2
.gitignore
vendored
2
.gitignore
vendored
@@ -32,3 +32,5 @@ google-services.json
|
|||||||
|
|
||||||
# Android Profiling
|
# Android Profiling
|
||||||
*.hprof
|
*.hprof
|
||||||
|
|
||||||
|
.kiro/
|
||||||
|
|||||||
0
MAINPAGE_REFACTORING_ANALYSIS.md
Normal file
0
MAINPAGE_REFACTORING_ANALYSIS.md
Normal file
@@ -31,6 +31,7 @@
|
|||||||
`keycode 120`,安卓官方([keycodes.h#349](https://android.googlesource.com/platform/frameworks/native/+/master/include/android/keycodes.h#349))的定义为
|
`keycode 120`,安卓官方([keycodes.h#349](https://android.googlesource.com/platform/frameworks/native/+/master/include/android/keycodes.h#349))的定义为
|
||||||
`System Request / Print Screen key.`,不同的厂商有不同的实现,在某些类原生(`AxionOS`) 上的行为是软重启
|
`System Request / Print Screen key.`,不同的厂商有不同的实现,在某些类原生(`AxionOS`) 上的行为是软重启
|
||||||
- I18N
|
- I18N
|
||||||
|
- More: [TODO.md](TODO.md)
|
||||||
|
|
||||||
## 建议搭配模块
|
## 建议搭配模块
|
||||||
|
|
||||||
|
|||||||
20
TODO.md
Normal file
20
TODO.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
## PARAMS
|
||||||
|
|
||||||
|
- orientation locking
|
||||||
|
- `-r, --record=file.mp4` 投屏时录制到文件 Record screen to file. The format is determined by the --record-format option if set, or by the file extension.
|
||||||
|
- `--record-format` 录制格式 Force recording format (mp4, mkv, m4a, mka, opus, aac, flac or wav).
|
||||||
|
|
||||||
|
## FEATURES
|
||||||
|
|
||||||
|
- 设置项连接设备后马上启用scrcpy会话
|
||||||
|
|
||||||
|
### LOWER PRIORITY
|
||||||
|
|
||||||
|
顺序无关
|
||||||
|
|
||||||
|
- 多配置切换
|
||||||
|
- [音频延迟](https://developer.android.com/ndk/guides/audio/audio-latency)
|
||||||
|
- 横屏布局
|
||||||
|
- 原生悬浮窗 [Petterpx/FloatingX](https://github.com/Petterpx/FloatingX)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.compose.compiler)
|
alias(libs.plugins.compose.compiler)
|
||||||
|
id("kotlin-parcelize")
|
||||||
}
|
}
|
||||||
|
|
||||||
val defaultAbiList = listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
val defaultAbiList = listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||||
@@ -23,8 +24,8 @@ android {
|
|||||||
applicationId = "io.github.miuzarte.scrcpyforandroid"
|
applicationId = "io.github.miuzarte.scrcpyforandroid"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 5
|
versionCode = 6
|
||||||
versionName = "0.0.5"
|
versionName = "0.1.0"
|
||||||
|
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
cmake {
|
cmake {
|
||||||
@@ -97,6 +98,8 @@ dependencies {
|
|||||||
implementation("org.bouncycastle:bcpkix-jdk18on:1.80")
|
implementation("org.bouncycastle:bcpkix-jdk18on:1.80")
|
||||||
implementation("org.conscrypt:conscrypt-android:2.5.2")
|
implementation("org.conscrypt:conscrypt-android:2.5.2")
|
||||||
implementation("sh.calvin.reorderable:reorderable:3.0.0")
|
implementation("sh.calvin.reorderable:reorderable:3.0.0")
|
||||||
|
implementation("androidx.datastore:datastore-preferences:1.2.1")
|
||||||
|
implementation(libs.androidx.compose.runtime)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
|||||||
@@ -4,15 +4,28 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import io.github.miuzarte.scrcpyforandroid.pages.MainPage
|
import io.github.miuzarte.scrcpyforandroid.pages.MainScreen
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// initialize settings singleton
|
||||||
|
Storage.init(applicationContext)
|
||||||
|
|
||||||
|
val migration = PreferenceMigration(applicationContext)
|
||||||
|
runBlocking {
|
||||||
|
if (migration.needsMigration())
|
||||||
|
migration.migrate(clearSharedPrefs = true)
|
||||||
|
}
|
||||||
|
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
MainPage()
|
MainScreen()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,35 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid
|
package io.github.miuzarte.scrcpyforandroid
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import androidx.core.net.toUri
|
|
||||||
import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder
|
import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder
|
||||||
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
|
import io.github.miuzarte.scrcpyforandroid.nativecore.PersistentVideoRenderer
|
||||||
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpyAudioPlayer
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||||
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpySessionManager
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
|
||||||
import java.io.File
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import java.time.LocalTime
|
import kotlinx.coroutines.sync.withLock
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.util.ArrayDeque
|
import java.util.ArrayDeque
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
import java.util.concurrent.CopyOnWriteArraySet
|
import java.util.concurrent.CopyOnWriteArraySet
|
||||||
import java.util.concurrent.ExecutionException
|
|
||||||
import java.util.concurrent.Executors
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Facade that centralizes ADB and scrcpy native operations.
|
* Facade that centralizes video rendering.
|
||||||
*
|
*
|
||||||
* Provides synchronous and asynchronous helpers that run on an internal
|
* Provides helpers for:
|
||||||
* single-thread executor to serialize access to the native session manager
|
* - Surface/Decoder management for video rendering
|
||||||
* and decoders. Callers should use the provided methods from UI code but
|
* - Video size and FPS monitoring
|
||||||
* not perform heavy work on the main thread directly.
|
|
||||||
*/
|
*/
|
||||||
class NativeCoreFacade(private val appContext: Context) {
|
class NativeCoreFacade private constructor() {
|
||||||
|
@Volatile
|
||||||
|
var session: Scrcpy.Session? = null
|
||||||
|
private set
|
||||||
|
|
||||||
private val adbService = NativeAdbService(appContext)
|
private val sessionLifecycleMutex = Mutex()
|
||||||
private val sessionManager = ScrcpySessionManager(adbService)
|
private val renderer = PersistentVideoRenderer()
|
||||||
private val executor = Executors.newSingleThreadExecutor()
|
private var activeSurfaceId: Int? = null
|
||||||
private val surfaceMap = ConcurrentHashMap<String, Surface>()
|
private var decoder: AnnexBDecoder? = null
|
||||||
private val surfaceIdentityMap = ConcurrentHashMap<String, Int>()
|
|
||||||
private val decoderMap = ConcurrentHashMap<String, AnnexBDecoder>()
|
|
||||||
private val videoSizeListeners = CopyOnWriteArraySet<(Int, Int) -> Unit>()
|
private val videoSizeListeners = CopyOnWriteArraySet<(Int, Int) -> Unit>()
|
||||||
private val videoFpsListeners = CopyOnWriteArraySet<(Float) -> Unit>()
|
private val videoFpsListeners = CopyOnWriteArraySet<(Float) -> Unit>()
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
@@ -47,383 +41,79 @@ class NativeCoreFacade(private val appContext: Context) {
|
|||||||
private var packetCount: Long = 0
|
private var packetCount: Long = 0
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var audioPlayer: ScrcpyAudioPlayer? = null
|
private var currentSessionInfo: Scrcpy.Session.SessionInfo? = null
|
||||||
|
|
||||||
@Volatile
|
suspend fun close() {
|
||||||
private var currentSessionInfo: ScrcpySessionInfo? = null
|
sessionLifecycleMutex.withLock {
|
||||||
|
releaseAllDecoders()
|
||||||
fun close() {
|
renderer.release()
|
||||||
releaseAllDecoders()
|
}
|
||||||
runCatching { sessionManager.stop() }
|
|
||||||
runCatching { adbService.close() }
|
|
||||||
executor.shutdown()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a rendering Surface for a given `tag`.
|
* Register the current rendering [surface].
|
||||||
*
|
*
|
||||||
* - If the surface is already known and the decoder is active, this is a no-op.
|
* - If the surface is already active and the decoder exists, this is a no-op.
|
||||||
* - If a decoder exists but cannot switch output surface, a new decoder is created
|
* - If a decoder exists but cannot switch output surface, a new decoder is created
|
||||||
* and bound to the supplied surface.
|
* and bound to the supplied surface.
|
||||||
* - This method must be called from the UI thread (it is called by the composable
|
|
||||||
* that owns the TextureView). Native decoder operations are performed synchronously
|
|
||||||
* on the UI thread only via the decoder API; heavy work happens inside the decoder.
|
|
||||||
*/
|
*/
|
||||||
fun registerVideoSurface(tag: String, surface: Surface) {
|
suspend fun attachVideoSurface(surface: Surface) {
|
||||||
val newId = System.identityHashCode(surface)
|
sessionLifecycleMutex.withLock {
|
||||||
val oldId = surfaceIdentityMap[tag]
|
if (!surface.isValid) {
|
||||||
if (oldId != null && oldId == newId && decoderMap.containsKey(tag)) {
|
Log.w(TAG, "attachVideoSurface(): skip invalid surface")
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
val newId = System.identityHashCode(surface)
|
||||||
|
if (activeSurfaceId == newId && decoder != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Log.i(TAG, "attachVideoSurface(): surfaceId=$newId oldSurfaceId=$activeSurfaceId")
|
||||||
|
activeSurfaceId = newId
|
||||||
|
renderer.attachDisplaySurface(surface)
|
||||||
|
val session = currentSessionInfo ?: return
|
||||||
|
val currentDecoder = decoder
|
||||||
|
if (currentDecoder != null) {
|
||||||
|
Log.i(TAG, "attachVideoSurface(): try switch decoder output to persistent surface")
|
||||||
|
val switched = currentDecoder.switchOutputSurface(renderer.getDecoderSurface())
|
||||||
|
Log.i(TAG, "attachVideoSurface(): switchOutputSurface success=$switched")
|
||||||
|
if (switched) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createOrReplaceDecoder(session)
|
||||||
}
|
}
|
||||||
createOrReplaceDecoder(tag, surface, session)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unregister the rendering Surface previously bound to `tag`.
|
* Unregister the active rendering [surface].
|
||||||
*
|
*
|
||||||
* - If a stale surface reference is supplied (identity mismatch), the request is ignored.
|
* - If a stale surface reference is supplied (identity mismatch), the request is ignored.
|
||||||
* - When there is no active session, the decoder for the tag is released immediately.
|
* - When [releaseDecoder] is false, only the active display target is cleared so a future
|
||||||
* - This protects the native decoder from feeding into a released Surface.
|
* surface can attempt to rebind via `setOutputSurface()`.
|
||||||
|
* - When [releaseDecoder] is true, the current decoder is also released because the backing
|
||||||
|
* surface is being destroyed for real.
|
||||||
*/
|
*/
|
||||||
fun unregisterVideoSurface(tag: String, surface: Surface? = null) {
|
suspend fun detachVideoSurface(surface: Surface? = null, releaseDecoder: Boolean = false) {
|
||||||
val currentId = surfaceIdentityMap[tag]
|
sessionLifecycleMutex.withLock {
|
||||||
val requestId = surface?.let { System.identityHashCode(it) }
|
val currentId = activeSurfaceId
|
||||||
if (requestId != null && currentId != null && requestId != currentId) {
|
val requestId = surface?.let { System.identityHashCode(it) }
|
||||||
Log.i(
|
if (requestId != null && currentId != null && requestId != currentId) {
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pair with a device over ADB pairing protocol.
|
|
||||||
* @return true on successful pairing, false otherwise.
|
|
||||||
*/
|
|
||||||
fun adbPair(host: String, port: Int, pairingCode: String): Boolean {
|
|
||||||
return ioCall { adbService.pair(host, port, pairingCode) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Discover an ADB pairing service (mDNS) and return its host:port.
|
|
||||||
* Returns null if no service is found within `timeoutMs`.
|
|
||||||
*/
|
|
||||||
fun adbDiscoverPairingService(
|
|
||||||
timeoutMs: Long = 12_000,
|
|
||||||
includeLanDevices: Boolean = true,
|
|
||||||
): Pair<String, Int>? {
|
|
||||||
return ioCall { adbService.discoverPairingService(timeoutMs, includeLanDevices) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Discover an ADB connect service for direct connection (mDNS).
|
|
||||||
*/
|
|
||||||
fun adbDiscoverConnectService(
|
|
||||||
timeoutMs: Long = 12_000,
|
|
||||||
includeLanDevices: Boolean = true,
|
|
||||||
): Pair<String, Int>? {
|
|
||||||
return ioCall { adbService.discoverConnectService(timeoutMs, includeLanDevices) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to an ADB server at the given host and port.
|
|
||||||
* Returns true on success.
|
|
||||||
*/
|
|
||||||
fun adbConnect(host: String, port: Int): Boolean = ioCall { adbService.connect(host, port) }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnect current ADB connection. Always returns true.
|
|
||||||
*/
|
|
||||||
fun adbDisconnect(): Boolean {
|
|
||||||
ioCall { adbService.disconnect() }
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check whether an ADB connection is currently established.
|
|
||||||
*/
|
|
||||||
fun adbIsConnected(): Boolean = ioCall { adbService.isConnected() }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a shell command over ADB and return its stdout as a string.
|
|
||||||
*/
|
|
||||||
fun adbShell(command: String): String = ioCall { adbService.shell(command) }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the local ADB key name used when generating or selecting key files.
|
|
||||||
*/
|
|
||||||
fun setAdbKeyName(name: String) {
|
|
||||||
adbService.keyName = name
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start a scrcpy session synchronously.
|
|
||||||
*
|
|
||||||
* - This method runs on the internal single-threaded [executor] via [ioCall], so
|
|
||||||
* callers block until the start completes. It handles server extraction, starting
|
|
||||||
* the session manager, creating decoders for any registered surfaces, and setting
|
|
||||||
* up audio playback when available.
|
|
||||||
* - After the session is established, `currentSessionInfo` is populated and
|
|
||||||
* bootstrap packets are reset so newly created decoders can be primed.
|
|
||||||
*/
|
|
||||||
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()
|
|
||||||
latestConfigPacket = null
|
|
||||||
}
|
|
||||||
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(
|
Log.i(
|
||||||
TAG,
|
TAG,
|
||||||
"scrcpyStart(): create audio player codecId=0x${
|
"detachVideoSurface(): skip stale request requestSurfaceId=$requestId currentSurfaceId=$currentId"
|
||||||
info.audioCodecId.toUInt().toString(16)
|
|
||||||
}"
|
|
||||||
)
|
)
|
||||||
val player = ScrcpyAudioPlayer(info.audioCodecId)
|
return
|
||||||
audioPlayer = player
|
|
||||||
sessionManager.attachAudioConsumer { packet ->
|
|
||||||
player.feedPacket(packet.data, packet.ptsUs, packet.isConfig)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.i(TAG, "scrcpyStart(): audio playback disabled for this session")
|
|
||||||
}
|
}
|
||||||
|
Log.i(
|
||||||
session
|
TAG,
|
||||||
}
|
"detachVideoSurface(): surfaceId=$requestId releaseDecoder=$releaseDecoder"
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop any running scrcpy session and clear all video/audio consumers.
|
|
||||||
*
|
|
||||||
* - Executes on the internal executor to keep scrcpy/session-manager operations
|
|
||||||
* serialized with other IO operations.
|
|
||||||
* - Releases decoders and audio players and resets session state.
|
|
||||||
*/
|
|
||||||
fun scrcpyStop(): Boolean {
|
|
||||||
ioCall {
|
|
||||||
releaseAllDecoders()
|
|
||||||
synchronized(bootstrapLock) {
|
|
||||||
bootstrapPackets.clear()
|
|
||||||
latestConfigPacket = null
|
|
||||||
}
|
|
||||||
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(
|
activeSurfaceId = null
|
||||||
videoEncoders = result.videoEncoders,
|
renderer.detachDisplaySurface(surface, releaseSurface = false)
|
||||||
audioEncoders = result.audioEncoders,
|
if (releaseDecoder) {
|
||||||
videoEncoderTypes = result.videoEncoderTypes,
|
Log.i(TAG, "detachVideoSurface(): releasing decoder with destroyed surface")
|
||||||
audioEncoderTypes = result.audioEncoderTypes,
|
decoder?.release()
|
||||||
rawOutput = result.rawOutput,
|
decoder = null
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -444,118 +134,80 @@ class NativeCoreFacade(private val appContext: Context) {
|
|||||||
videoFpsListeners.remove(listener)
|
videoFpsListeners.remove(listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun scrcpyBackOrScreenOn(action: Int = 0) {
|
suspend fun scrcpyBackOrTurnScreenOn(action: Int = 0) {
|
||||||
ioExecute {
|
session?.pressBackOrTurnScreenOn(action)
|
||||||
runCatching { sessionManager.pressBackOrScreenOn(action) }
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by Scrcpy.kt when a session starts.
|
||||||
|
* Sets up video decoders for registered surfaces.
|
||||||
|
*/
|
||||||
|
suspend fun onScrcpySessionStarted(
|
||||||
|
session: Scrcpy.Session.SessionInfo,
|
||||||
|
sessionMgr: Scrcpy.Session
|
||||||
|
) = sessionLifecycleMutex.withLock {
|
||||||
|
this.session = sessionMgr
|
||||||
|
currentSessionInfo = session
|
||||||
|
releaseAllDecoders()
|
||||||
|
synchronized(bootstrapLock) {
|
||||||
|
bootstrapPackets.clear()
|
||||||
|
latestConfigPacket = null
|
||||||
|
}
|
||||||
|
if (activeSurfaceId != null) {
|
||||||
|
Log.i(TAG, "onScrcpySessionStarted(): bind decoder to persistent surface")
|
||||||
|
createOrReplaceDecoder(session)
|
||||||
|
}
|
||||||
|
packetCount = 0
|
||||||
|
sessionMgr.attachVideoConsumer { packet ->
|
||||||
|
cacheBootstrapPacket(packet)
|
||||||
|
packetCount += 1
|
||||||
|
if (packetCount == 1L || packetCount % 120L == 0L) {
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoder=${decoder != null}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val currentDecoder = decoder ?: return@attachVideoConsumer
|
||||||
|
if (activeSurfaceId == null) return@attachVideoConsumer
|
||||||
|
runCatching {
|
||||||
|
currentDecoder.feedAnnexB(
|
||||||
|
packet.data,
|
||||||
|
packet.ptsUs,
|
||||||
|
packet.isKeyFrame,
|
||||||
|
packet.isConfig
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractAssetToCache(assetPath: String): File {
|
/**
|
||||||
val clean = assetPath.removePrefix("/")
|
* Called by Scrcpy.kt when a session stops.
|
||||||
val source = appContext.assets.open(clean)
|
* Cleans up decoders and resets state.
|
||||||
val outputFile = File(appContext.cacheDir, File(clean).name)
|
*/
|
||||||
source.use { input ->
|
suspend fun onScrcpySessionStopped() = sessionLifecycleMutex.withLock {
|
||||||
outputFile.outputStream().use { output -> input.copyTo(output) }
|
session = null
|
||||||
|
releaseAllDecoders()
|
||||||
|
synchronized(bootstrapLock) {
|
||||||
|
bootstrapPackets.clear()
|
||||||
|
latestConfigPacket = null
|
||||||
}
|
}
|
||||||
return outputFile
|
currentSessionInfo = null
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
companion object {
|
||||||
private const val TAG = "NativeCoreFacade"
|
private const val TAG = "NativeCoreFacade"
|
||||||
private const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v3.3.4"
|
|
||||||
private const val MAX_BOOTSTRAP_PACKETS = 90
|
private const val MAX_BOOTSTRAP_PACKETS = 90
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var instance: NativeCoreFacade? = null
|
private var instance: NativeCoreFacade? = null
|
||||||
|
|
||||||
|
// TODO ???
|
||||||
fun get(context: Context): NativeCoreFacade {
|
fun get(context: Context): NativeCoreFacade {
|
||||||
return instance ?: synchronized(this) {
|
return instance ?: synchronized(this) {
|
||||||
instance ?: NativeCoreFacade(context.applicationContext).also { instance = it }
|
instance ?: NativeCoreFacade().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(
|
private data class CachedPacket(
|
||||||
@@ -588,7 +240,7 @@ class NativeCoreFacade(private val appContext: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create or replace a decoder bound to `surface` for `session`.
|
* Create or replace the active decoder bound to [surface] for [session].
|
||||||
*
|
*
|
||||||
* - Chooses MIME type from `session.codec` and constructs an [AnnexBDecoder].
|
* - Chooses MIME type from `session.codec` and constructs an [AnnexBDecoder].
|
||||||
* - The decoder's `onOutputSizeChanged` callback publishes size changes to
|
* - The decoder's `onOutputSizeChanged` callback publishes size changes to
|
||||||
@@ -596,23 +248,27 @@ class NativeCoreFacade(private val appContext: Context) {
|
|||||||
* - Newly created decoders are fed with any cached bootstrap packets to allow
|
* - Newly created decoders are fed with any cached bootstrap packets to allow
|
||||||
* faster playback startup.
|
* faster playback startup.
|
||||||
*/
|
*/
|
||||||
private fun createOrReplaceDecoder(tag: String, surface: Surface, session: ScrcpySessionInfo) {
|
private fun createOrReplaceDecoder(session: Scrcpy.Session.SessionInfo) {
|
||||||
decoderMap.remove(tag)?.release()
|
val surface = renderer.getDecoderSurface()
|
||||||
val mime = when (session.codec.lowercase()) {
|
decoder?.release()
|
||||||
"h264" -> "video/avc"
|
decoder = null
|
||||||
"h265" -> "video/hevc"
|
|
||||||
"av1" -> "video/av01"
|
|
||||||
else -> "video/avc"
|
|
||||||
}
|
|
||||||
Log.i(
|
Log.i(
|
||||||
TAG,
|
TAG,
|
||||||
"createOrReplaceDecoder(): tag=$tag codec=$mime size=${session.width}x${session.height}"
|
"createOrReplaceDecoder(): " +
|
||||||
|
"codec=${session.codec?.string ?: "null"}, " +
|
||||||
|
"size=${session.width}x${session.height}, " +
|
||||||
|
"persistent=true"
|
||||||
)
|
)
|
||||||
val decoder = AnnexBDecoder(
|
val newDecoder = AnnexBDecoder(
|
||||||
width = session.width,
|
width = session.width,
|
||||||
height = session.height,
|
height = session.height,
|
||||||
outputSurface = surface,
|
outputSurface = surface,
|
||||||
mimeType = mime,
|
mimeType = when (session.codec) {
|
||||||
|
Codec.H264 -> "video/avc"
|
||||||
|
Codec.H265 -> "video/hevc"
|
||||||
|
Codec.AV1 -> "video/av01"
|
||||||
|
else -> "video/avc"
|
||||||
|
},
|
||||||
onOutputSizeChanged = { width, height ->
|
onOutputSizeChanged = { width, height ->
|
||||||
val current = currentSessionInfo
|
val current = currentSessionInfo
|
||||||
if (current == null || (current.width == width && current.height == height)) {
|
if (current == null || (current.width == width && current.height == height)) {
|
||||||
@@ -637,8 +293,8 @@ class NativeCoreFacade(private val appContext: Context) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
decoderMap[tag] = decoder
|
decoder = newDecoder
|
||||||
replayBootstrapPackets(decoder)
|
replayBootstrapPackets(newDecoder)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun replayBootstrapPackets(decoder: AnnexBDecoder) {
|
private fun replayBootstrapPackets(decoder: AnnexBDecoder) {
|
||||||
@@ -654,7 +310,7 @@ class NativeCoreFacade(private val appContext: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cacheBootstrapPacket(packet: ScrcpySessionManager.VideoPacket) {
|
private fun cacheBootstrapPacket(packet: Scrcpy.Session.VideoPacket) {
|
||||||
val cached = CachedPacket(
|
val cached = CachedPacket(
|
||||||
data = packet.data.copyOf(),
|
data = packet.data.copyOf(),
|
||||||
ptsUs = packet.ptsUs,
|
ptsUs = packet.ptsUs,
|
||||||
@@ -683,130 +339,8 @@ class NativeCoreFacade(private val appContext: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Attach a single consumer to the session manager to deliver incoming video packets
|
|
||||||
* to all active decoders.
|
|
||||||
*
|
|
||||||
* - Called when a session is active and at least one decoder exists. Packets are
|
|
||||||
* cached into [bootstrapPackets] to allow late-attaching decoders to catch up.
|
|
||||||
* - The consumer iterates [decoderMap] and feeds each decoder. Errors are
|
|
||||||
* isolated with `runCatching` so one codec failure doesn't stop others.
|
|
||||||
*/
|
|
||||||
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() {
|
private fun releaseAllDecoders() {
|
||||||
decoderMap.values.forEach { decoder ->
|
runCatching { decoder?.release() }
|
||||||
runCatching { decoder.release() }
|
decoder = null
|
||||||
}
|
|
||||||
decoderMap.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a blocking IO task on the internal single-thread executor and return its result.
|
|
||||||
*
|
|
||||||
* - This serializes access to native adb/session operations and unwraps
|
|
||||||
* ExecutionException to rethrow the underlying cause.
|
|
||||||
*/
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Submit a non-blocking IO task to the internal executor.
|
|
||||||
*
|
|
||||||
* - Use this for fire-and-forget native operations where the caller does not need
|
|
||||||
* a synchronous result (e.g. injecting input, toggling power, etc.).
|
|
||||||
*/
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid.constants
|
|
||||||
|
|
||||||
object AppDefaults {
|
|
||||||
const val EVENT_LOG_LINES = 512
|
|
||||||
const val ADB_PORT = 5555
|
|
||||||
|
|
||||||
// Devices
|
|
||||||
const val QUICK_CONNECT_INPUT = ""
|
|
||||||
|
|
||||||
const val PAIR_HOST = ""
|
|
||||||
const val PAIR_PORT = ""
|
|
||||||
const val PAIR_CODE = ""
|
|
||||||
|
|
||||||
const val AUDIO_ENABLED = true
|
|
||||||
const val AUDIO_CODEC = "opus"
|
|
||||||
const val AUDIO_BIT_RATE_KBPS = 128
|
|
||||||
const val AUDIO_BIT_RATE_INPUT = "128"
|
|
||||||
const val VIDEO_CODEC = "h264"
|
|
||||||
const val VIDEO_BIT_RATE_MBPS = 8f
|
|
||||||
const val VIDEO_BIT_RATE_INPUT = "8.0"
|
|
||||||
|
|
||||||
const val TURN_SCREEN_OFF = false
|
|
||||||
const val NO_CONTROL = false
|
|
||||||
const val NO_VIDEO = false
|
|
||||||
|
|
||||||
const val VIDEO_SOURCE_PRESET = "display"
|
|
||||||
const val DISPLAY_ID = ""
|
|
||||||
|
|
||||||
const val CAMERA_ID = ""
|
|
||||||
const val CAMERA_FACING_PRESET = ""
|
|
||||||
const val CAMERA_SIZE_PRESET = ""
|
|
||||||
const val CAMERA_SIZE_CUSTOM = ""
|
|
||||||
const val CAMERA_AR = ""
|
|
||||||
const val CAMERA_FPS = ""
|
|
||||||
const val CAMERA_HIGH_SPEED = false
|
|
||||||
|
|
||||||
const val AUDIO_SOURCE_PRESET = "output"
|
|
||||||
const val AUDIO_SOURCE_CUSTOM = ""
|
|
||||||
const val AUDIO_DUP = false
|
|
||||||
const val NO_AUDIO_PLAYBACK = false
|
|
||||||
const val REQUIRE_AUDIO = false
|
|
||||||
|
|
||||||
const val MAX_SIZE_INPUT = ""
|
|
||||||
const val MAX_FPS_INPUT = ""
|
|
||||||
|
|
||||||
const val VIDEO_ENCODER = ""
|
|
||||||
const val VIDEO_CODEC_OPTION = ""
|
|
||||||
const val AUDIO_ENCODER = ""
|
|
||||||
const val AUDIO_CODEC_OPTION = ""
|
|
||||||
|
|
||||||
const val NEW_DISPLAY_WIDTH = ""
|
|
||||||
const val NEW_DISPLAY_HEIGHT = ""
|
|
||||||
const val NEW_DISPLAY_DPI = ""
|
|
||||||
|
|
||||||
const val CROP_WIDTH = ""
|
|
||||||
const val CROP_HEIGHT = ""
|
|
||||||
const val CROP_X = ""
|
|
||||||
const val CROP_Y = ""
|
|
||||||
|
|
||||||
// Settings
|
|
||||||
const val THEME_BASE_INDEX = 0
|
|
||||||
const val MONET = false
|
|
||||||
|
|
||||||
const val FULLSCREEN_DEBUG_INFO = false
|
|
||||||
const val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = true
|
|
||||||
const val KEEP_SCREEN_ON_WHEN_STREAMING = false
|
|
||||||
const val DEVICE_PREVIEW_CARD_HEIGHT_DP = 320
|
|
||||||
const val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = true
|
|
||||||
const val VIRTUAL_BUTTONS_LAYOUT =
|
|
||||||
"more:1,app_switch:1,home:0,back:1,menu:0,notification:0,volume_up:0,volume_down:0,volume_mute:0,power:0,screenshot:0"
|
|
||||||
|
|
||||||
const val CUSTOM_SERVER_URI = ""
|
|
||||||
|
|
||||||
const val SERVER_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar"
|
|
||||||
const val SERVER_REMOTE_PATH_INPUT = ""
|
|
||||||
|
|
||||||
const val ADB_KEY_NAME = "scrcpy"
|
|
||||||
const val ADB_KEY_NAME_INPUT = ""
|
|
||||||
const val ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN = true
|
|
||||||
const val ADB_AUTO_RECONNECT_PAIRED_DEVICE = true
|
|
||||||
const val ADB_MDNS_LAN_DISCOVERY = true
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid.constants
|
|
||||||
|
|
||||||
object AppPreferenceKeys {
|
|
||||||
const val PREFS_NAME = "scrcpy_app_prefs"
|
|
||||||
const val NATIVE_ADB_KEY_PREFS_NAME = "nativecore_adb_rsa"
|
|
||||||
const val NATIVE_ADB_PRIVATE_KEY = "priv"
|
|
||||||
|
|
||||||
// Devices
|
|
||||||
const val QUICK_DEVICES = "quick_devices"
|
|
||||||
const val QUICK_CONNECT_INPUT = "quick_connect_input"
|
|
||||||
|
|
||||||
const val PAIR_HOST = "pair_host"
|
|
||||||
const val PAIR_PORT = "pair_port"
|
|
||||||
const val PAIR_CODE = "pair_code"
|
|
||||||
|
|
||||||
const val AUDIO_ENABLED = "audio_enabled"
|
|
||||||
const val AUDIO_CODEC = "audio_codec"
|
|
||||||
const val AUDIO_BIT_RATE_INPUT = "audio_bit_rate_input"
|
|
||||||
const val AUDIO_BIT_RATE_KBPS = "audio_bit_rate_kbps"
|
|
||||||
const val VIDEO_CODEC = "video_codec"
|
|
||||||
const val VIDEO_BIT_RATE_MBPS = "video_bit_rate_mbps"
|
|
||||||
const val VIDEO_BIT_RATE_INPUT = "video_bit_rate_input"
|
|
||||||
|
|
||||||
const val TURN_SCREEN_OFF = "turn_screen_off"
|
|
||||||
const val NO_CONTROL = "no_control"
|
|
||||||
const val NO_VIDEO = "no_video"
|
|
||||||
|
|
||||||
const val VIDEO_SOURCE_PRESET = "video_source_preset"
|
|
||||||
const val DISPLAY_ID = "display_id"
|
|
||||||
|
|
||||||
const val CAMERA_ID = "camera_id"
|
|
||||||
const val CAMERA_FACING_PRESET = "camera_facing_preset"
|
|
||||||
const val CAMERA_SIZE_PRESET = "camera_size_preset"
|
|
||||||
const val CAMERA_SIZE_CUSTOM = "camera_size_custom"
|
|
||||||
const val CAMERA_AR = "camera_ar"
|
|
||||||
const val CAMERA_FPS = "camera_fps"
|
|
||||||
const val CAMERA_HIGH_SPEED = "camera_high_speed"
|
|
||||||
|
|
||||||
const val AUDIO_SOURCE_PRESET = "audio_source_preset"
|
|
||||||
const val AUDIO_SOURCE_CUSTOM = "audio_source_custom"
|
|
||||||
const val AUDIO_DUP = "audio_dup"
|
|
||||||
const val NO_AUDIO_PLAYBACK = "no_audio_playback"
|
|
||||||
const val REQUIRE_AUDIO = "require_audio"
|
|
||||||
|
|
||||||
const val MAX_SIZE_INPUT = "max_size_input"
|
|
||||||
const val MAX_FPS_INPUT = "max_fps_input"
|
|
||||||
|
|
||||||
const val VIDEO_ENCODER = "video_encoder"
|
|
||||||
const val VIDEO_CODEC_OPTION = "video_codec_options"
|
|
||||||
const val AUDIO_ENCODER = "audio_encoder"
|
|
||||||
const val AUDIO_CODEC_OPTION = "audio_codec_options"
|
|
||||||
|
|
||||||
const val NEW_DISPLAY_WIDTH = "new_display_width"
|
|
||||||
const val NEW_DISPLAY_HEIGHT = "new_display_height"
|
|
||||||
const val NEW_DISPLAY_DPI = "new_display_dpi"
|
|
||||||
|
|
||||||
const val CROP_WIDTH = "crop_width"
|
|
||||||
const val CROP_HEIGHT = "crop_height"
|
|
||||||
const val CROP_X = "crop_x"
|
|
||||||
const val CROP_Y = "crop_y"
|
|
||||||
|
|
||||||
// Settings
|
|
||||||
const val THEME_BASE_INDEX = "theme_base_index"
|
|
||||||
const val MONET = "monet"
|
|
||||||
|
|
||||||
const val FULLSCREEN_DEBUG_INFO = "fullscreen_debug_info"
|
|
||||||
const val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = "show_fullscreen_virtual_buttons"
|
|
||||||
const val KEEP_SCREEN_ON_WHEN_STREAMING = "keep_screen_on_when_streaming"
|
|
||||||
const val DEVICE_PREVIEW_CARD_HEIGHT_DP = "device_preview_card_height_dp"
|
|
||||||
const val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = "preview_virtual_button_show_text"
|
|
||||||
const val VIRTUAL_BUTTONS_LAYOUT = "virtual_buttons_layout"
|
|
||||||
|
|
||||||
const val CUSTOM_SERVER_URI = "custom_server_uri"
|
|
||||||
|
|
||||||
const val SERVER_REMOTE_PATH = "server_remote_path"
|
|
||||||
|
|
||||||
const val ADB_KEY_NAME = "adb_key_name"
|
|
||||||
const val ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN = "adb_pairing_auto_discover_on_dialog_open"
|
|
||||||
const val ADB_AUTO_RECONNECT_PAIRED_DEVICE = "adb_auto_reconnect_paired_device"
|
|
||||||
const val ADB_MDNS_LAN_DISCOVERY = "adb_mdns_lan_discovery"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.constants
|
||||||
|
|
||||||
|
object Defaults {
|
||||||
|
const val ADB_PORT = 5555;
|
||||||
|
}
|
||||||
@@ -1,7 +1,63 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid.constants
|
package io.github.miuzarte.scrcpyforandroid.constants
|
||||||
|
|
||||||
object ScrcpyPresets {
|
/**
|
||||||
val MaxSize = listOf(0, 720, 1080, 1280, 1600, 1920, 2160, 2560, 3200, 3840)
|
* A generic preset class that holds a list of preset values and provides
|
||||||
val MaxFPS = listOf(0, 24, 30, 45, 60, 90, 120)
|
* methods to find the index of a value or its nearest match.
|
||||||
val AudioBitRate = listOf(32, 64, 96, 128, 160, 192, 256, 320, 384, 512)
|
*
|
||||||
|
* @param T The type of preset values (typically Int)
|
||||||
|
* @param values The list of preset values
|
||||||
|
*/
|
||||||
|
class Preset<T : Comparable<T>>(val values: List<T>) {
|
||||||
|
val lastIndex: Int get() = values.lastIndex
|
||||||
|
val size: Int get() = values.size
|
||||||
|
val indices: IntRange get() = values.indices
|
||||||
|
|
||||||
|
operator fun get(index: Int): T = values[index]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the index of the exact value or the nearest preset.
|
||||||
|
* For numeric types, finds the closest value by absolute difference.
|
||||||
|
*/
|
||||||
|
fun indexOfOrNearest(value: T): Int {
|
||||||
|
val exact = values.indexOf(value)
|
||||||
|
if (exact >= 0) return exact
|
||||||
|
|
||||||
|
// For numeric types, find nearest by comparing
|
||||||
|
return values.withIndex().minByOrNull { (_, preset) ->
|
||||||
|
when {
|
||||||
|
preset is Number && value is Number -> {
|
||||||
|
kotlin.math.abs(preset.toDouble() - value.toDouble())
|
||||||
|
}
|
||||||
|
else -> if (preset > value) 1.0 else -1.0
|
||||||
|
}
|
||||||
|
}?.index ?: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension function for Int presets to find index from Int value.
|
||||||
|
*/
|
||||||
|
fun Preset<Int>.indexOfOrNearest(raw: Int): Int {
|
||||||
|
val exact = values.indexOf(raw)
|
||||||
|
if (exact >= 0) return exact
|
||||||
|
val nearest = values.withIndex().minByOrNull { (_, preset) ->
|
||||||
|
kotlin.math.abs(preset - raw)
|
||||||
|
}
|
||||||
|
return nearest?.index ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension function for Int presets to find index from String value.
|
||||||
|
*/
|
||||||
|
fun Preset<Int>.indexOfOrNearest(raw: String): Int {
|
||||||
|
if (raw.isBlank()) return 0
|
||||||
|
val value = raw.toIntOrNull() ?: return 0
|
||||||
|
return indexOfOrNearest(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
object ScrcpyPresets {
|
||||||
|
val MaxSize = Preset(listOf(0, 720, 1080, 1280, 1600, 1920, 2160, 2560, 3200, 3840)) // px
|
||||||
|
val MaxFPS = Preset(listOf(0, 24, 30, 45, 60, 90, 120)) // fps
|
||||||
|
val AudioBitRate = Preset(listOf(0, 32, 64, 96, 128, 160, 192, 256, 320, 384, 512)) // Kbps
|
||||||
|
val CameraFps = Preset(listOf(0, 10, 15, 24, 30, 60, 120, 240, 480, 960)) // fps
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.constants
|
||||||
|
|
||||||
|
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
|
||||||
|
|
||||||
|
object ThemeModes {
|
||||||
|
data class Option(
|
||||||
|
val label: String,
|
||||||
|
val mode: ColorSchemeMode,
|
||||||
|
)
|
||||||
|
|
||||||
|
val baseOptions = listOf(
|
||||||
|
Option("跟随系统", ColorSchemeMode.System),
|
||||||
|
Option("浅色", ColorSchemeMode.Light),
|
||||||
|
Option("深色", ColorSchemeMode.Dark),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -16,8 +16,10 @@ object UiSpacing {
|
|||||||
val SectionTitleTop = 12.dp
|
val SectionTitleTop = 12.dp
|
||||||
val SectionTitleBottom = 6.dp
|
val SectionTitleBottom = 6.dp
|
||||||
val FieldLabelBottom = 4.dp
|
val FieldLabelBottom = 4.dp
|
||||||
val CardContent = 12.dp
|
val Content = 12.dp
|
||||||
|
val ContentVertical = 12.dp
|
||||||
|
val ContentHorizontal = 12.dp
|
||||||
val CardTitle = 16.dp
|
val CardTitle = 16.dp
|
||||||
val BottomContent = 64.dp
|
val PageBottom = 64.dp
|
||||||
val BottomSheetBottom = 16.dp
|
val SheetBottom = 16.dp
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,188 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid.models
|
package io.github.miuzarte.scrcpyforandroid.models
|
||||||
|
|
||||||
internal data class ConnectionTarget(
|
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
|
||||||
val host: String,
|
|
||||||
val port: Int,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
// Composable 用, 不可变 List
|
||||||
* A compact shortcut entry for quick-connect lists shown in the UI.
|
class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut> by devices {
|
||||||
* `online` indicates whether the device was reachable when last probed.
|
fun marshalToString(
|
||||||
*/
|
separator: String = DEFAULT_SEPARATOR,
|
||||||
internal data class DeviceShortcut(
|
): String = joinToString(separator) { it.marshalToString() }
|
||||||
val id: String,
|
|
||||||
val name: String,
|
companion object {
|
||||||
|
const val DEFAULT_SEPARATOR = "\n"
|
||||||
|
|
||||||
|
fun unmarshalFrom(
|
||||||
|
s: String,
|
||||||
|
separator: String = DEFAULT_SEPARATOR,
|
||||||
|
): DeviceShortcuts {
|
||||||
|
if (s.isBlank()) return DeviceShortcuts(emptyList())
|
||||||
|
val list = s.splitToSequence(separator)
|
||||||
|
.mapNotNull { DeviceShortcut.unmarshalFrom(it) }
|
||||||
|
.toList()
|
||||||
|
return DeviceShortcuts(list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getIndex(id: String) = devices.indexOfFirst { it.id == id }
|
||||||
|
private fun getIndex(host: String, port: Int) = devices.indexOfFirst {
|
||||||
|
it.host == host && it.port == port
|
||||||
|
}
|
||||||
|
|
||||||
|
fun get(id: String) = devices.firstOrNull { it.id == id }
|
||||||
|
fun get(host: String, port: Int) = devices.firstOrNull {
|
||||||
|
it.host == host && it.port == port
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(
|
||||||
|
id: String? = null,
|
||||||
|
host: String? = null,
|
||||||
|
port: Int? = null,
|
||||||
|
name: String? = null,
|
||||||
|
newPort: Int? = null,
|
||||||
|
updateNameOnlyWhenEmpty: Boolean = false,
|
||||||
|
): DeviceShortcuts {
|
||||||
|
val idx = if (id != null) getIndex(id)
|
||||||
|
else if (host != null && port != null) getIndex(host, port)
|
||||||
|
else -1
|
||||||
|
|
||||||
|
if (idx < 0) return this
|
||||||
|
val old = devices[idx]
|
||||||
|
val updateById = id != null
|
||||||
|
|
||||||
|
// 确定最终的属性值
|
||||||
|
val finalName = when {
|
||||||
|
name == null -> old.name
|
||||||
|
updateNameOnlyWhenEmpty && old.name.isNotBlank() -> old.name
|
||||||
|
else -> name
|
||||||
|
}
|
||||||
|
val finalHost = if (updateById) host ?: old.host else old.host
|
||||||
|
val finalPort = if (updateById) port ?: old.port else newPort ?: old.port
|
||||||
|
|
||||||
|
// 若无任何变化,返回原实例
|
||||||
|
if (finalName == old.name && finalHost == old.host && finalPort == old.port)
|
||||||
|
return this
|
||||||
|
|
||||||
|
val newList = devices.toMutableList().apply {
|
||||||
|
this[idx] = DeviceShortcut(
|
||||||
|
name = finalName,
|
||||||
|
host = finalHost,
|
||||||
|
port = finalPort,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return DeviceShortcuts(
|
||||||
|
if ((updateById && (finalHost != old.host || finalPort != old.port))
|
||||||
|
|| (newPort != null && newPort != old.port)
|
||||||
|
)
|
||||||
|
newList.distinctBy { it.id }
|
||||||
|
else newList
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun upsert(
|
||||||
|
shortcut: DeviceShortcut,
|
||||||
|
index: Int? = null,
|
||||||
|
): DeviceShortcuts {
|
||||||
|
val existingIdx = getIndex(shortcut.id)
|
||||||
|
val newList = devices.toMutableList()
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
newList[existingIdx] = shortcut
|
||||||
|
} else {
|
||||||
|
if (index != null) newList.add(index, shortcut)
|
||||||
|
else newList.add(shortcut)
|
||||||
|
}
|
||||||
|
return DeviceShortcuts(newList)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun move(fromIndex: Int, toIndex: Int): DeviceShortcuts {
|
||||||
|
if (fromIndex !in devices.indices || toIndex !in devices.indices) return this
|
||||||
|
if (fromIndex == toIndex) return this
|
||||||
|
val mutable = devices.toMutableList()
|
||||||
|
val item = mutable.removeAt(fromIndex)
|
||||||
|
// 如果目标位置在原位置之后,移除后列表长度减1,因此目标索引需减1
|
||||||
|
val target = if (toIndex > fromIndex) toIndex - 1 else toIndex
|
||||||
|
mutable.add(target, item)
|
||||||
|
return DeviceShortcuts(mutable)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除指定设备
|
||||||
|
fun remove(id: String) = DeviceShortcuts(devices.filterNot { it.id == id })
|
||||||
|
|
||||||
|
// 清空所有设备
|
||||||
|
fun clear() = DeviceShortcuts(emptyList())
|
||||||
|
|
||||||
|
// 复制当前实例
|
||||||
|
fun copy(devices: List<DeviceShortcut> = this.devices): DeviceShortcuts =
|
||||||
|
DeviceShortcuts(devices)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DeviceShortcut(
|
||||||
|
val name: String = "",
|
||||||
val host: String,
|
val host: String,
|
||||||
val port: Int,
|
val port: Int = Defaults.ADB_PORT,
|
||||||
val online: Boolean,
|
) {
|
||||||
)
|
val id: String get() = "$host:$port"
|
||||||
|
|
||||||
|
fun marshalToString(
|
||||||
|
separator: String = DEFAULT_SEPARATOR,
|
||||||
|
): String = listOf(
|
||||||
|
name.trim(), host.trim(), port.toString()
|
||||||
|
).joinToString(
|
||||||
|
separator = separator
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_SEPARATOR = "|"
|
||||||
|
fun unmarshalFrom(
|
||||||
|
s: String,
|
||||||
|
separator: String = DEFAULT_SEPARATOR,
|
||||||
|
): DeviceShortcut? {
|
||||||
|
val parts = s.split(separator, limit = 3)
|
||||||
|
return when (parts.size) {
|
||||||
|
3 -> {
|
||||||
|
val name = parts[0].trim()
|
||||||
|
val host = parts[1].trim()
|
||||||
|
val port = parts[2].trim().toIntOrNull() ?: Defaults.ADB_PORT
|
||||||
|
if (host.isNotBlank()) DeviceShortcut(
|
||||||
|
name = name,
|
||||||
|
host = host,
|
||||||
|
port = port,
|
||||||
|
)
|
||||||
|
else null
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ConnectionTarget(
|
||||||
|
val host: String,
|
||||||
|
val port: Int = Defaults.ADB_PORT,
|
||||||
|
) {
|
||||||
|
override fun toString(): String = "$host:$port"
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun unmarshalFrom(s: String): ConnectionTarget? {
|
||||||
|
val parts = s.split(":", limit = 2)
|
||||||
|
return when (parts.size) {
|
||||||
|
2 -> ConnectionTarget(
|
||||||
|
host = parts[0].trim(),
|
||||||
|
port = parts[1].trim().toIntOrNull() ?: Defaults.ADB_PORT,
|
||||||
|
)
|
||||||
|
|
||||||
|
1 -> ConnectionTarget(
|
||||||
|
host = parts[0].trim(),
|
||||||
|
port = Defaults.ADB_PORT,
|
||||||
|
)
|
||||||
|
|
||||||
|
0 -> ConnectionTarget(
|
||||||
|
host = s.trim(),
|
||||||
|
port = Defaults.ADB_PORT,
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.models
|
||||||
|
|
||||||
|
class ScrcpyOptions {
|
||||||
|
data class NewDisplay(val width: Int? = null, val height: Int? = null, val dpi: Int? = null) {
|
||||||
|
override fun toString() = buildString {
|
||||||
|
if (width != null && width > 0 && height != null && height > 0)
|
||||||
|
append("${width}x${height}")
|
||||||
|
if (dpi != null && dpi > 0)
|
||||||
|
append("/$dpi")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun parseFrom(width: String, height: String, dpi: String) =
|
||||||
|
NewDisplay(
|
||||||
|
width = width.toIntOrNull()?.takeIf { it > 0 },
|
||||||
|
height = height.toIntOrNull()?.takeIf { it > 0 },
|
||||||
|
dpi = dpi.toIntOrNull()?.takeIf { it > 0 },
|
||||||
|
)
|
||||||
|
|
||||||
|
fun parseFrom(input: String): NewDisplay {
|
||||||
|
// [<width>x<height>][/<dpi>]
|
||||||
|
|
||||||
|
val trimmed = input.trim()
|
||||||
|
if (trimmed.isEmpty()) return NewDisplay()
|
||||||
|
|
||||||
|
val slashIndex = trimmed.indexOf('/')
|
||||||
|
val sizePart = if (slashIndex >= 0) trimmed.substring(0, slashIndex) else trimmed
|
||||||
|
val dpiPart = if (slashIndex >= 0) trimmed.substring(slashIndex + 1) else ""
|
||||||
|
|
||||||
|
val xIndex = sizePart.indexOf('x')
|
||||||
|
var widthPart = ""
|
||||||
|
var heightPart = ""
|
||||||
|
if (xIndex >= 0) {
|
||||||
|
widthPart = sizePart.substring(0, xIndex)
|
||||||
|
heightPart = sizePart.substring(xIndex + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseFrom(widthPart, heightPart, dpiPart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Crop(
|
||||||
|
val width: Int? = null,
|
||||||
|
val height: Int? = null,
|
||||||
|
val x: Int? = null,
|
||||||
|
val y: Int? = null,
|
||||||
|
) {
|
||||||
|
override fun toString() =
|
||||||
|
if (width != null && width > 0
|
||||||
|
&& height != null && height > 0
|
||||||
|
&& x != null && x > 0
|
||||||
|
&& y != null && y > 0
|
||||||
|
) "$width:$height:$x:$y"
|
||||||
|
else ""
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun parseFrom(width: String, height: String, x: String, y: String) =
|
||||||
|
Crop(
|
||||||
|
width = width.toIntOrNull()?.takeIf { it > 0 },
|
||||||
|
height = height.toIntOrNull()?.takeIf { it > 0 },
|
||||||
|
x = x.toIntOrNull()?.takeIf { it > 0 },
|
||||||
|
y = y.toIntOrNull()?.takeIf { it > 0 },
|
||||||
|
)
|
||||||
|
|
||||||
|
fun parseFrom(input: String): Crop {
|
||||||
|
// width:height:x:y
|
||||||
|
val parts = input.split(':', limit = 4)
|
||||||
|
return if (parts.size >= 4)
|
||||||
|
parseFrom(parts[0], parts[1], parts[2], parts[3])
|
||||||
|
else Crop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,9 @@ class AnnexBDecoder(
|
|||||||
private var released = false
|
private var released = false
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
if (!outputSurface.isValid) {
|
||||||
|
throw IllegalStateException("Cannot initialize decoder: output surface is not valid")
|
||||||
|
}
|
||||||
val format = MediaFormat.createVideoFormat(mimeType, width, height)
|
val format = MediaFormat.createVideoFormat(mimeType, width, height)
|
||||||
if (sps != null) {
|
if (sps != null) {
|
||||||
format.setByteBuffer("csd-0", java.nio.ByteBuffer.wrap(sps))
|
format.setByteBuffer("csd-0", java.nio.ByteBuffer.wrap(sps))
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import android.content.Context
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.edit
|
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.AppPreferenceKeys
|
import kotlinx.coroutines.runBlocking
|
||||||
import java.io.BufferedInputStream
|
import java.io.BufferedInputStream
|
||||||
import java.io.Closeable
|
import java.io.Closeable
|
||||||
import java.io.EOFException
|
import java.io.EOFException
|
||||||
@@ -45,13 +45,13 @@ import kotlin.concurrent.thread
|
|||||||
*/
|
*/
|
||||||
internal class DirectAdbTransport(private val context: Context) {
|
internal class DirectAdbTransport(private val context: Context) {
|
||||||
|
|
||||||
private val keys: Pair<PrivateKey, ByteArray> by lazy { loadOrCreate() }
|
private val keys: Pair<PrivateKey, ByteArray> by lazy { runBlocking { loadOrCreate() } }
|
||||||
|
|
||||||
val privateKey: PrivateKey get() = keys.first
|
val privateKey: PrivateKey get() = keys.first
|
||||||
val publicKeyX509: ByteArray get() = keys.second
|
val publicKeyX509: ByteArray get() = keys.second
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
var keyName: String = AppDefaults.ADB_KEY_NAME
|
var keyName: String = AppSettings.ADB_KEY_NAME.defaultValue
|
||||||
|
|
||||||
fun connect(host: String, port: Int): DirectAdbConnection {
|
fun connect(host: String, port: Int): DirectAdbConnection {
|
||||||
Log.i(TAG, "connect(): opening direct adbd transport to $host:$port")
|
Log.i(TAG, "connect(): opening direct adbd transport to $host:$port")
|
||||||
@@ -60,7 +60,7 @@ internal class DirectAdbTransport(private val context: Context) {
|
|||||||
port,
|
port,
|
||||||
privateKey,
|
privateKey,
|
||||||
publicKeyX509,
|
publicKeyX509,
|
||||||
keyName.ifBlank { AppDefaults.ADB_KEY_NAME })
|
keyName.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue })
|
||||||
conn.handshake()
|
conn.handshake()
|
||||||
Log.i(TAG, "connect(): handshake success for $host:$port")
|
Log.i(TAG, "connect(): handshake success for $host:$port")
|
||||||
return conn
|
return conn
|
||||||
@@ -78,7 +78,7 @@ internal class DirectAdbTransport(private val context: Context) {
|
|||||||
|
|
||||||
val pairingKey = AdbPairingKey(
|
val pairingKey = AdbPairingKey(
|
||||||
privateKey = privateKey,
|
privateKey = privateKey,
|
||||||
alias = keyName.ifBlank { AppDefaults.ADB_KEY_NAME },
|
alias = keyName.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue },
|
||||||
)
|
)
|
||||||
return DirectAdbPairingClient(targetHost, port, targetCode, pairingKey).use {
|
return DirectAdbPairingClient(targetHost, port, targetCode, pairingKey).use {
|
||||||
it.start()
|
it.start()
|
||||||
@@ -102,39 +102,52 @@ internal class DirectAdbTransport(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load persisted RSA keypair from shared preferences, or generate a new one.
|
* Load persisted RSA keypair from DataStore, or generate a new one.
|
||||||
* Returns (privateKey, publicX509Bytes).
|
* Returns (privateKey, publicX509Bytes).
|
||||||
*/
|
*/
|
||||||
private fun loadOrCreate(): Pair<PrivateKey, ByteArray> {
|
private suspend fun loadOrCreate(
|
||||||
val prefs = context.getSharedPreferences(
|
forceNew: Boolean = false,
|
||||||
AppPreferenceKeys.NATIVE_ADB_KEY_PREFS_NAME,
|
): Pair<PrivateKey, ByteArray> {
|
||||||
Context.MODE_PRIVATE
|
val adbClientData = Storage.adbClientData
|
||||||
)
|
|
||||||
val privB64 = prefs.getString(AppPreferenceKeys.NATIVE_ADB_PRIVATE_KEY, null)
|
val privB64 = adbClientData.rsaPrivateKey.get()
|
||||||
if (privB64 != null) {
|
|
||||||
|
if (privB64.isNotBlank() && !forceNew) {
|
||||||
try {
|
try {
|
||||||
val kf = KeyFactory.getInstance("RSA")
|
val kf = KeyFactory.getInstance("RSA")
|
||||||
val priv =
|
val priv = kf.generatePrivate(
|
||||||
kf.generatePrivate(PKCS8EncodedKeySpec(Base64.decode(privB64, Base64.DEFAULT)))
|
PKCS8EncodedKeySpec(
|
||||||
|
Base64.decode(privB64, Base64.DEFAULT)
|
||||||
|
)
|
||||||
|
)
|
||||||
val pub = derivePublicX509(priv)
|
val pub = derivePublicX509(priv)
|
||||||
Log.i(TAG, "loadOrCreate(): loaded persisted RSA key pair, fp=${fingerprint(pub)}")
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"loadOrCreate(): loaded persisted RSA key pair from DataStore, " +
|
||||||
|
"fp=${fingerprint(pub)}"
|
||||||
|
)
|
||||||
return Pair(priv, pub)
|
return Pair(priv, pub)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "loadOrCreate(): failed to load persisted key, regenerating", e)
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"loadOrCreate(): failed to load persisted key from DataStore, regenerating",
|
||||||
|
e
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val kpg = KeyPairGenerator.getInstance("RSA")
|
val kpg = KeyPairGenerator.getInstance("RSA")
|
||||||
kpg.initialize(2048)
|
kpg.initialize(2048)
|
||||||
val kp = kpg.generateKeyPair()
|
val kp = kpg.generateKeyPair()
|
||||||
prefs.edit {
|
|
||||||
putString(
|
// 保存到 DataStore
|
||||||
AppPreferenceKeys.NATIVE_ADB_PRIVATE_KEY,
|
val privateKeyB64 = Base64.encodeToString(kp.private.encoded, Base64.NO_WRAP)
|
||||||
Base64.encodeToString(kp.private.encoded, Base64.NO_WRAP)
|
val publicKeyB64 = Base64.encodeToString(kp.public.encoded, Base64.NO_WRAP)
|
||||||
)
|
adbClientData.rsaPrivateKey.set(privateKeyB64)
|
||||||
}
|
adbClientData.rsaPublicKeyX509.set(publicKeyB64)
|
||||||
|
|
||||||
Log.i(
|
Log.i(
|
||||||
TAG,
|
TAG,
|
||||||
"loadOrCreate(): generated new RSA key pair, fp=${fingerprint(kp.public.encoded)}"
|
"loadOrCreate(): generated new RSA key pair and saved to DataStore, fp=${fingerprint(kp.public.encoded)}"
|
||||||
)
|
)
|
||||||
return Pair(kp.private, kp.public.encoded)
|
return Pair(kp.private, kp.public.encoded)
|
||||||
}
|
}
|
||||||
@@ -203,7 +216,7 @@ internal class DirectAdbConnection(
|
|||||||
val port: Int,
|
val port: Int,
|
||||||
private val privateKey: PrivateKey,
|
private val privateKey: PrivateKey,
|
||||||
private val publicKeyX509: ByteArray,
|
private val publicKeyX509: ByteArray,
|
||||||
private val keyName: String = AppDefaults.ADB_KEY_NAME,
|
private val keyName: String = AppSettings.ADB_KEY_NAME.defaultValue,
|
||||||
) : AutoCloseable {
|
) : AutoCloseable {
|
||||||
|
|
||||||
private val sha1DigestInfoPrefix = byteArrayOf(
|
private val sha1DigestInfoPrefix = byteArrayOf(
|
||||||
@@ -380,7 +393,12 @@ internal class DirectAdbConnection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isAlive(): Boolean = !closed && !socket.isClosed && socket.isConnected
|
fun isAlive(): Boolean {
|
||||||
|
val isClosed = socket.isClosed
|
||||||
|
val isConnected = socket.isConnected
|
||||||
|
Log.d(TAG, "isClose: $isClosed, isConnected: $isConnected")
|
||||||
|
return !closed && !isClosed && isConnected
|
||||||
|
}
|
||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
if (!closed) {
|
if (!closed) {
|
||||||
@@ -548,7 +566,7 @@ internal class DirectAdbConnection(
|
|||||||
* Logical ADB stream abstraction mapped to a local id. Provides blocking
|
* Logical ADB stream abstraction mapped to a local id. Provides blocking
|
||||||
* `InputStream`/`OutputStream` implementations and lifecycle helpers used by callers.
|
* `InputStream`/`OutputStream` implementations and lifecycle helpers used by callers.
|
||||||
*/
|
*/
|
||||||
internal class AdbSocketStream(
|
class AdbSocketStream(
|
||||||
val localId: Int,
|
val localId: Int,
|
||||||
private val sender: (cmd: Int, arg0: Int, arg1: Int, data: ByteArray) -> Unit,
|
private val sender: (cmd: Int, arg0: Int, arg1: Int, data: ByteArray) -> Unit,
|
||||||
) : Closeable {
|
) : Closeable {
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ internal class DirectAdbPairingClient(
|
|||||||
READY,
|
READY,
|
||||||
EXCHANGING_MSGS,
|
EXCHANGING_MSGS,
|
||||||
EXCHANGING_PEER_INFO,
|
EXCHANGING_PEER_INFO,
|
||||||
STOPPED,
|
STOPPED;
|
||||||
}
|
}
|
||||||
|
|
||||||
private lateinit var socket: Socket
|
private lateinit var socket: Socket
|
||||||
|
|||||||
@@ -2,18 +2,26 @@ package io.github.miuzarte.scrcpyforandroid.nativecore
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Higher-level ADB service that wraps `DirectAdbTransport` and provides
|
* Higher-level ADB service that wraps `DirectAdbTransport` and provides
|
||||||
* synchronized connect/disconnect/shell helpers for callers.
|
* coroutine-based connect/disconnect/shell helpers for callers.
|
||||||
*
|
*
|
||||||
* Methods are synchronized because the underlying transport is single-connection
|
* Methods use Mutex for thread-safety because the underlying transport is single-connection
|
||||||
* and accessed from the app's serialized IO executor.
|
* and may be accessed from multiple coroutines.
|
||||||
|
*
|
||||||
|
* All network operations are executed on Dispatchers.IO.
|
||||||
*/
|
*/
|
||||||
class NativeAdbService(appContext: Context) {
|
class NativeAdbService(appContext: Context) {
|
||||||
|
|
||||||
private val transport = DirectAdbTransport(appContext)
|
private val transport = DirectAdbTransport(appContext)
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var connection: DirectAdbConnection? = null
|
private var connection: DirectAdbConnection? = null
|
||||||
@@ -30,14 +38,13 @@ class NativeAdbService(appContext: Context) {
|
|||||||
transport.keyName = value
|
transport.keyName = value
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
suspend fun pair(host: String, port: Int, pairingCode: String): Boolean = mutex.withLock {
|
||||||
fun pair(host: String, port: Int, pairingCode: String): Boolean {
|
|
||||||
val h = host.trim()
|
val h = host.trim()
|
||||||
val code = pairingCode.trim()
|
val code = pairingCode.trim()
|
||||||
require(h.isNotBlank()) { "host is blank" }
|
require(h.isNotBlank()) { "host is blank" }
|
||||||
require(code.isNotBlank()) { "pairing code is blank" }
|
require(code.isNotBlank()) { "pairing code is blank" }
|
||||||
Log.i(TAG, "pair(): host=$h port=$port")
|
Log.i(TAG, "pair(): host=$h port=$port")
|
||||||
return try {
|
return@withLock try {
|
||||||
transport.pair(h, port, code)
|
transport.pair(h, port, code)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "pair(): failed host=$h port=$port", e)
|
Log.e(TAG, "pair(): failed host=$h port=$port", e)
|
||||||
@@ -46,12 +53,11 @@ class NativeAdbService(appContext: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
suspend fun discoverPairingService(
|
||||||
fun discoverPairingService(
|
|
||||||
timeoutMs: Long = 12_000,
|
timeoutMs: Long = 12_000,
|
||||||
includeLanDevices: Boolean = true
|
includeLanDevices: Boolean = true
|
||||||
): Pair<String, Int>? {
|
): Pair<String, Int>? = mutex.withLock {
|
||||||
return try {
|
return@withLock try {
|
||||||
transport.discoverPairingService(timeoutMs, includeLanDevices)
|
transport.discoverPairingService(timeoutMs, includeLanDevices)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "discoverPairingService(): failed", e)
|
Log.w(TAG, "discoverPairingService(): failed", e)
|
||||||
@@ -59,12 +65,11 @@ class NativeAdbService(appContext: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
suspend fun discoverConnectService(
|
||||||
fun discoverConnectService(
|
|
||||||
timeoutMs: Long = 12_000,
|
timeoutMs: Long = 12_000,
|
||||||
includeLanDevices: Boolean = true
|
includeLanDevices: Boolean = true
|
||||||
): Pair<String, Int>? {
|
): Pair<String, Int>? = mutex.withLock {
|
||||||
return try {
|
return@withLock try {
|
||||||
transport.discoverConnectService(timeoutMs, includeLanDevices)
|
transport.discoverConnectService(timeoutMs, includeLanDevices)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "discoverConnectService(): failed", e)
|
Log.w(TAG, "discoverConnectService(): failed", e)
|
||||||
@@ -77,63 +82,81 @@ class NativeAdbService(appContext: Context) {
|
|||||||
* same host:port it is reused; otherwise the previous connection is closed
|
* same host:port it is reused; otherwise the previous connection is closed
|
||||||
* before attempting the new connect.
|
* before attempting the new connect.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
suspend fun connect(
|
||||||
fun connect(host: String, port: Int): Boolean {
|
host: String,
|
||||||
Log.i(TAG, "connect(): host=$host port=$port")
|
port: Int,
|
||||||
val existing = connection
|
timeout: Duration = Duration.INFINITE,
|
||||||
if (existing != null && existing.isAlive() && connectedHost == host && connectedPort == port) {
|
) = withContext(Dispatchers.IO) {
|
||||||
return true
|
mutex.withLock {
|
||||||
}
|
Log.i(TAG, "connect(): host=$host port=$port")
|
||||||
disconnect()
|
|
||||||
try {
|
if (connection != null
|
||||||
val conn = transport.connect(host, port)
|
&& connection!!.isAlive()
|
||||||
connection = conn
|
&& connectedHost == host
|
||||||
connectedHost = host
|
&& connectedPort == port
|
||||||
connectedPort = port
|
) {
|
||||||
return true
|
return@withLock
|
||||||
} catch (e: Exception) {
|
}
|
||||||
Log.e(TAG, "connect(): failed host=$host port=$port", e)
|
disconnectInternal()
|
||||||
val detail = e.message ?: "${e.javaClass.simpleName} (no message)"
|
|
||||||
throw IllegalStateException("ADB connect failed to $host:$port -> $detail", e)
|
try {
|
||||||
|
val conn = withTimeout(timeout) { transport.connect(host, port) }
|
||||||
|
connection = conn
|
||||||
|
connectedHost = host
|
||||||
|
connectedPort = port
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Close the current ADB connection immediately.
|
* Close the current ADB connection immediately.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
suspend fun disconnect() = withContext(Dispatchers.IO) {
|
||||||
fun disconnect() {
|
mutex.withLock {
|
||||||
|
disconnectInternal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun isConnected(): Boolean = mutex.withLock {
|
||||||
|
connection?.isAlive() == true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a shell command on the connected device and return stdout text.
|
||||||
|
*/
|
||||||
|
suspend fun shell(command: String): String = mutex.withLock {
|
||||||
|
val response = requireConnection().shell(command)
|
||||||
|
Log.d(TAG, "command: $command, response: $response")
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun openShellStream(command: String): AdbSocketStream = mutex.withLock {
|
||||||
|
requireConnection().openStream("shell:$command")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun push(localPath: Path, remotePath: String) = mutex.withLock {
|
||||||
|
requireConnection().push(localPath.toFile().readBytes(), remotePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun openAbstractSocket(name: String): AdbSocketStream = mutex.withLock {
|
||||||
|
requireConnection().openStream("localabstract:$name")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun close() {
|
||||||
|
disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disconnectInternal() {
|
||||||
runCatching { connection?.close() }
|
runCatching { connection?.close() }
|
||||||
connection = null
|
connection = null
|
||||||
connectedHost = null
|
connectedHost = null
|
||||||
connectedPort = null
|
connectedPort = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun isConnected(): Boolean = connection?.isAlive() == true
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a shell command on the connected device and return stdout text.
|
|
||||||
*/
|
|
||||||
@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 {
|
private fun requireConnection(): DirectAdbConnection {
|
||||||
return connection?.takeIf { it.isAlive() }
|
return connection?.takeIf { it.isAlive() }
|
||||||
?: throw IllegalStateException("ADB not connected")
|
?: throw IllegalStateException("ADB not connected")
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.nativecore
|
||||||
|
|
||||||
|
import android.graphics.SurfaceTexture
|
||||||
|
import android.opengl.EGL14
|
||||||
|
import android.opengl.EGLConfig
|
||||||
|
import android.opengl.EGLContext
|
||||||
|
import android.opengl.EGLDisplay
|
||||||
|
import android.opengl.EGLSurface
|
||||||
|
import android.opengl.GLES11Ext
|
||||||
|
import android.opengl.GLES20
|
||||||
|
import android.opengl.Matrix
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.HandlerThread
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.Surface
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoder always renders into a persistent SurfaceTexture-backed Surface.
|
||||||
|
* UI surfaces are display-only targets fed from that persistent texture via EGL.
|
||||||
|
*/
|
||||||
|
class PersistentVideoRenderer {
|
||||||
|
private val tag = "PersistentVideoRenderer"
|
||||||
|
private val renderThread = HandlerThread("PersistentVideoRenderer").apply { start() }
|
||||||
|
private val handler = Handler(renderThread.looper)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var initialized = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var released = false
|
||||||
|
|
||||||
|
private var eglDisplay: EGLDisplay = EGL14.EGL_NO_DISPLAY
|
||||||
|
private var eglContext: EGLContext = EGL14.EGL_NO_CONTEXT
|
||||||
|
private var eglConfig: EGLConfig? = null
|
||||||
|
private var eglPbufferSurface: EGLSurface = EGL14.EGL_NO_SURFACE
|
||||||
|
private var displayEglSurface: EGLSurface = EGL14.EGL_NO_SURFACE
|
||||||
|
private var displaySurface: Surface? = null
|
||||||
|
private var displaySurfaceId: Int? = null
|
||||||
|
|
||||||
|
private var oesTextureId = 0
|
||||||
|
private var decoderSurfaceTexture: SurfaceTexture? = null
|
||||||
|
private var decoderSurface: Surface? = null
|
||||||
|
private val stMatrix = FloatArray(16)
|
||||||
|
private val mvpMatrix = FloatArray(16)
|
||||||
|
private val frameAvailableCount = AtomicLong(0)
|
||||||
|
private val frameConsumedCount = AtomicLong(0)
|
||||||
|
private val frameRenderedCount = AtomicLong(0)
|
||||||
|
|
||||||
|
private var program = 0
|
||||||
|
private var positionHandle = 0
|
||||||
|
private var texCoordHandle = 0
|
||||||
|
private var mvpMatrixHandle = 0
|
||||||
|
private var stMatrixHandle = 0
|
||||||
|
private var samplerHandle = 0
|
||||||
|
|
||||||
|
private val initLock = Object()
|
||||||
|
|
||||||
|
fun getDecoderSurface(): Surface {
|
||||||
|
ensureInitialized()
|
||||||
|
synchronized(initLock) {
|
||||||
|
return requireNotNull(decoderSurface) { "decoderSurface not initialized" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun attachDisplaySurface(surface: Surface) {
|
||||||
|
ensureInitialized()
|
||||||
|
val newId = System.identityHashCode(surface)
|
||||||
|
if (displaySurfaceId == newId) return
|
||||||
|
Log.i(tag, "attachDisplaySurface(): request surfaceId=$newId old=${displaySurfaceId}")
|
||||||
|
handler.post {
|
||||||
|
if (released || !surface.isValid) return@post
|
||||||
|
releaseDisplaySurfaceLocked()
|
||||||
|
displaySurface = surface
|
||||||
|
displaySurfaceId = newId
|
||||||
|
displayEglSurface = EGL14.eglCreateWindowSurface(
|
||||||
|
eglDisplay,
|
||||||
|
eglConfig,
|
||||||
|
surface,
|
||||||
|
intArrayOf(EGL14.EGL_NONE),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
Log.i(tag, "attachDisplaySurface(): attached surfaceId=$newId")
|
||||||
|
drawFrame()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detachDisplaySurface(surface: Surface? = null, releaseSurface: Boolean = false) {
|
||||||
|
val requestId = surface?.let { System.identityHashCode(it) }
|
||||||
|
Log.i(tag, "detachDisplaySurface(): request surfaceId=$requestId releaseSurface=$releaseSurface current=${displaySurfaceId}")
|
||||||
|
handler.post {
|
||||||
|
if (released) return@post
|
||||||
|
if (requestId != null && requestId != displaySurfaceId) return@post
|
||||||
|
releaseDisplaySurfaceLocked()
|
||||||
|
if (releaseSurface) {
|
||||||
|
runCatching { surface?.release() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun release() {
|
||||||
|
handler.post {
|
||||||
|
if (released) return@post
|
||||||
|
released = true
|
||||||
|
releaseDisplaySurfaceLocked()
|
||||||
|
runCatching { decoderSurface?.release() }
|
||||||
|
decoderSurface = null
|
||||||
|
runCatching { decoderSurfaceTexture?.release() }
|
||||||
|
decoderSurfaceTexture = null
|
||||||
|
if (program != 0) {
|
||||||
|
GLES20.glDeleteProgram(program)
|
||||||
|
program = 0
|
||||||
|
}
|
||||||
|
if (oesTextureId != 0) {
|
||||||
|
GLES20.glDeleteTextures(1, intArrayOf(oesTextureId), 0)
|
||||||
|
oesTextureId = 0
|
||||||
|
}
|
||||||
|
if (eglDisplay !== EGL14.EGL_NO_DISPLAY) {
|
||||||
|
EGL14.eglMakeCurrent(
|
||||||
|
eglDisplay,
|
||||||
|
EGL14.EGL_NO_SURFACE,
|
||||||
|
EGL14.EGL_NO_SURFACE,
|
||||||
|
EGL14.EGL_NO_CONTEXT
|
||||||
|
)
|
||||||
|
if (eglPbufferSurface != EGL14.EGL_NO_SURFACE) {
|
||||||
|
EGL14.eglDestroySurface(eglDisplay, eglPbufferSurface)
|
||||||
|
}
|
||||||
|
EGL14.eglDestroyContext(eglDisplay, eglContext)
|
||||||
|
EGL14.eglTerminate(eglDisplay)
|
||||||
|
}
|
||||||
|
eglDisplay = EGL14.EGL_NO_DISPLAY
|
||||||
|
eglContext = EGL14.EGL_NO_CONTEXT
|
||||||
|
eglPbufferSurface = EGL14.EGL_NO_SURFACE
|
||||||
|
eglConfig = null
|
||||||
|
renderThread.quitSafely()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureInitialized() {
|
||||||
|
if (initialized) return
|
||||||
|
synchronized(initLock) {
|
||||||
|
if (initialized) return
|
||||||
|
val latch = java.util.concurrent.CountDownLatch(1)
|
||||||
|
handler.post {
|
||||||
|
initializeLocked()
|
||||||
|
initialized = true
|
||||||
|
latch.countDown()
|
||||||
|
}
|
||||||
|
latch.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initializeLocked() {
|
||||||
|
eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
|
||||||
|
check(eglDisplay != EGL14.EGL_NO_DISPLAY)
|
||||||
|
val version = IntArray(2)
|
||||||
|
check(EGL14.eglInitialize(eglDisplay, version, 0, version, 1))
|
||||||
|
|
||||||
|
val configs = arrayOfNulls<EGLConfig>(1)
|
||||||
|
val numConfigs = IntArray(1)
|
||||||
|
val attribs = intArrayOf(
|
||||||
|
EGL14.EGL_RENDERABLE_TYPE, 4,
|
||||||
|
EGL14.EGL_RED_SIZE, 8,
|
||||||
|
EGL14.EGL_GREEN_SIZE, 8,
|
||||||
|
EGL14.EGL_BLUE_SIZE, 8,
|
||||||
|
EGL14.EGL_ALPHA_SIZE, 8,
|
||||||
|
EGL14.EGL_NONE
|
||||||
|
)
|
||||||
|
check(EGL14.eglChooseConfig(eglDisplay, attribs, 0, configs, 0, 1, numConfigs, 0))
|
||||||
|
eglConfig = configs[0]
|
||||||
|
eglContext = EGL14.eglCreateContext(
|
||||||
|
eglDisplay,
|
||||||
|
eglConfig,
|
||||||
|
EGL14.EGL_NO_CONTEXT,
|
||||||
|
intArrayOf(0x3098, 2, EGL14.EGL_NONE),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
check(eglContext != EGL14.EGL_NO_CONTEXT)
|
||||||
|
eglPbufferSurface = EGL14.eglCreatePbufferSurface(
|
||||||
|
eglDisplay,
|
||||||
|
eglConfig,
|
||||||
|
intArrayOf(EGL14.EGL_WIDTH, 1, EGL14.EGL_HEIGHT, 1, EGL14.EGL_NONE),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
check(eglPbufferSurface != EGL14.EGL_NO_SURFACE)
|
||||||
|
check(EGL14.eglMakeCurrent(eglDisplay, eglPbufferSurface, eglPbufferSurface, eglContext))
|
||||||
|
|
||||||
|
oesTextureId = createExternalTexture()
|
||||||
|
decoderSurfaceTexture = SurfaceTexture(oesTextureId).apply {
|
||||||
|
setOnFrameAvailableListener({
|
||||||
|
val n = frameAvailableCount.incrementAndGet()
|
||||||
|
if (n == 1L || n % 120L == 0L) {
|
||||||
|
Log.i(tag, "onFrameAvailable(): available=$n consumed=${frameConsumedCount.get()} rendered=${frameRenderedCount.get()} display=${displaySurfaceId != null}")
|
||||||
|
}
|
||||||
|
drawFrame()
|
||||||
|
}, handler)
|
||||||
|
}
|
||||||
|
decoderSurface = Surface(decoderSurfaceTexture)
|
||||||
|
Log.i(tag, "initializeLocked(): decoder surface created")
|
||||||
|
|
||||||
|
Matrix.setIdentityM(stMatrix, 0)
|
||||||
|
Matrix.setIdentityM(mvpMatrix, 0)
|
||||||
|
Matrix.rotateM(mvpMatrix, 0, 180f, 0f, 0f, 1f)
|
||||||
|
Matrix.scaleM(mvpMatrix, 0, -1f, 1f, 1f)
|
||||||
|
program = createProgram(VERTEX_SHADER, FRAGMENT_SHADER)
|
||||||
|
positionHandle = GLES20.glGetAttribLocation(program, "aPosition")
|
||||||
|
texCoordHandle = GLES20.glGetAttribLocation(program, "aTexCoord")
|
||||||
|
mvpMatrixHandle = GLES20.glGetUniformLocation(program, "uMvpMatrix")
|
||||||
|
stMatrixHandle = GLES20.glGetUniformLocation(program, "uStMatrix")
|
||||||
|
samplerHandle = GLES20.glGetUniformLocation(program, "sTexture")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawFrame() {
|
||||||
|
if (released) return
|
||||||
|
val surfaceTexture = decoderSurfaceTexture ?: return
|
||||||
|
if (eglPbufferSurface == EGL14.EGL_NO_SURFACE) return
|
||||||
|
|
||||||
|
// Always consume decoder frames on the persistent context, even if there is no
|
||||||
|
// visible output surface right now. Otherwise the producer side can stall.
|
||||||
|
EGL14.eglMakeCurrent(eglDisplay, eglPbufferSurface, eglPbufferSurface, eglContext)
|
||||||
|
runCatching { surfaceTexture.updateTexImage() }
|
||||||
|
.onSuccess {
|
||||||
|
val consumed = frameConsumedCount.incrementAndGet()
|
||||||
|
if (consumed == 1L || consumed % 120L == 0L) {
|
||||||
|
Log.i(tag, "drawFrame(): consumed=$consumed available=${frameAvailableCount.get()} rendered=${frameRenderedCount.get()} display=${displaySurfaceId != null}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onFailure { Log.w(tag, "updateTexImage failed", it) }
|
||||||
|
surfaceTexture.getTransformMatrix(stMatrix)
|
||||||
|
|
||||||
|
if (displayEglSurface == EGL14.EGL_NO_SURFACE) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val width = IntArray(1)
|
||||||
|
val height = IntArray(1)
|
||||||
|
EGL14.eglQuerySurface(eglDisplay, displayEglSurface, EGL14.EGL_WIDTH, width, 0)
|
||||||
|
EGL14.eglQuerySurface(eglDisplay, displayEglSurface, EGL14.EGL_HEIGHT, height, 0)
|
||||||
|
EGL14.eglMakeCurrent(eglDisplay, displayEglSurface, displayEglSurface, eglContext)
|
||||||
|
GLES20.glViewport(0, 0, width[0].coerceAtLeast(1), height[0].coerceAtLeast(1))
|
||||||
|
GLES20.glClearColor(0f, 0f, 0f, 1f)
|
||||||
|
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
|
||||||
|
GLES20.glUseProgram(program)
|
||||||
|
GLES20.glUniformMatrix4fv(mvpMatrixHandle, 1, false, mvpMatrix, 0)
|
||||||
|
GLES20.glUniformMatrix4fv(stMatrixHandle, 1, false, stMatrix, 0)
|
||||||
|
GLES20.glUniform1i(samplerHandle, 0)
|
||||||
|
|
||||||
|
VERTICES.position(0)
|
||||||
|
GLES20.glVertexAttribPointer(positionHandle, 2, GLES20.GL_FLOAT, false, 16, VERTICES)
|
||||||
|
GLES20.glEnableVertexAttribArray(positionHandle)
|
||||||
|
VERTICES.position(2)
|
||||||
|
GLES20.glVertexAttribPointer(texCoordHandle, 2, GLES20.GL_FLOAT, false, 16, VERTICES)
|
||||||
|
GLES20.glEnableVertexAttribArray(texCoordHandle)
|
||||||
|
|
||||||
|
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
||||||
|
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, oesTextureId)
|
||||||
|
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
|
||||||
|
EGL14.eglSwapBuffers(eglDisplay, displayEglSurface)
|
||||||
|
val rendered = frameRenderedCount.incrementAndGet()
|
||||||
|
if (rendered == 1L || rendered % 120L == 0L) {
|
||||||
|
Log.i(tag, "drawFrame(): rendered=$rendered consumed=${frameConsumedCount.get()} available=${frameAvailableCount.get()} viewport=${width[0]}x${height[0]}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseDisplaySurfaceLocked() {
|
||||||
|
if (displayEglSurface != EGL14.EGL_NO_SURFACE) {
|
||||||
|
EGL14.eglDestroySurface(eglDisplay, displayEglSurface)
|
||||||
|
displayEglSurface = EGL14.EGL_NO_SURFACE
|
||||||
|
}
|
||||||
|
if (displaySurfaceId != null) {
|
||||||
|
Log.i(tag, "releaseDisplaySurfaceLocked(): surfaceId=$displaySurfaceId")
|
||||||
|
}
|
||||||
|
displaySurface = null
|
||||||
|
displaySurfaceId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createExternalTexture(): Int {
|
||||||
|
val textures = IntArray(1)
|
||||||
|
GLES20.glGenTextures(1, textures, 0)
|
||||||
|
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textures[0])
|
||||||
|
GLES20.glTexParameteri(
|
||||||
|
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
|
||||||
|
GLES20.GL_TEXTURE_MIN_FILTER,
|
||||||
|
GLES20.GL_LINEAR
|
||||||
|
)
|
||||||
|
GLES20.glTexParameteri(
|
||||||
|
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
|
||||||
|
GLES20.GL_TEXTURE_MAG_FILTER,
|
||||||
|
GLES20.GL_LINEAR
|
||||||
|
)
|
||||||
|
GLES20.glTexParameteri(
|
||||||
|
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
|
||||||
|
GLES20.GL_TEXTURE_WRAP_S,
|
||||||
|
GLES20.GL_CLAMP_TO_EDGE
|
||||||
|
)
|
||||||
|
GLES20.glTexParameteri(
|
||||||
|
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
|
||||||
|
GLES20.GL_TEXTURE_WRAP_T,
|
||||||
|
GLES20.GL_CLAMP_TO_EDGE
|
||||||
|
)
|
||||||
|
return textures[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createProgram(vertexShader: String, fragmentShader: String): Int {
|
||||||
|
val vertex = compileShader(GLES20.GL_VERTEX_SHADER, vertexShader)
|
||||||
|
val fragment = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentShader)
|
||||||
|
return GLES20.glCreateProgram().also { program ->
|
||||||
|
GLES20.glAttachShader(program, vertex)
|
||||||
|
GLES20.glAttachShader(program, fragment)
|
||||||
|
GLES20.glLinkProgram(program)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compileShader(type: Int, source: String): Int {
|
||||||
|
return GLES20.glCreateShader(type).also { shader ->
|
||||||
|
GLES20.glShaderSource(shader, source)
|
||||||
|
GLES20.glCompileShader(shader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val VERTICES = java.nio.ByteBuffer.allocateDirect(4 * 4 * 4)
|
||||||
|
.order(java.nio.ByteOrder.nativeOrder())
|
||||||
|
.asFloatBuffer()
|
||||||
|
.apply {
|
||||||
|
put(
|
||||||
|
floatArrayOf(
|
||||||
|
-1f, -1f, 0f, 1f,
|
||||||
|
1f, -1f, 1f, 1f,
|
||||||
|
-1f, 1f, 0f, 0f,
|
||||||
|
1f, 1f, 1f, 0f,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
position(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val VERTEX_SHADER = """
|
||||||
|
attribute vec4 aPosition;
|
||||||
|
attribute vec4 aTexCoord;
|
||||||
|
uniform mat4 uMvpMatrix;
|
||||||
|
uniform mat4 uStMatrix;
|
||||||
|
varying vec2 vTexCoord;
|
||||||
|
void main() {
|
||||||
|
gl_Position = uMvpMatrix * aPosition;
|
||||||
|
vTexCoord = (uStMatrix * aTexCoord).xy;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
private const val FRAGMENT_SHADER = """
|
||||||
|
#extension GL_OES_EGL_image_external : require
|
||||||
|
precision mediump float;
|
||||||
|
varying vec2 vTexCoord;
|
||||||
|
uniform samplerExternalOES sTexture;
|
||||||
|
void main() {
|
||||||
|
gl_FragColor = texture2D(sTexture, vTexCoord);
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import android.media.AudioTrack
|
|||||||
import android.media.MediaCodec
|
import android.media.MediaCodec
|
||||||
import android.media.MediaFormat
|
import android.media.MediaFormat
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
@@ -42,9 +43,9 @@ class ScrcpyAudioPlayer(private val codecId: Int) {
|
|||||||
}"
|
}"
|
||||||
)
|
)
|
||||||
when (codecId) {
|
when (codecId) {
|
||||||
AUDIO_CODEC_OPUS -> prepareOpus(data)
|
Codec.OPUS.id -> prepareOpus(data)
|
||||||
AUDIO_CODEC_AAC -> prepareAac(data)
|
Codec.AAC.id -> prepareAac(data)
|
||||||
AUDIO_CODEC_FLAC -> prepareFlac(data)
|
Codec.FLAC.id -> prepareFlac(data)
|
||||||
// RAW has no config packet
|
// RAW has no config packet
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -55,7 +56,7 @@ class ScrcpyAudioPlayer(private val codecId: Int) {
|
|||||||
Log.i(TAG, "feedPacket(): packets=$packetCount prepared=$prepared size=${data.size}")
|
Log.i(TAG, "feedPacket(): packets=$packetCount prepared=$prepared size=${data.size}")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (codecId == AUDIO_CODEC_RAW) {
|
if (codecId == Codec.RAW.id) {
|
||||||
ensureRawAudioTrack()?.write(data, 0, data.size, AudioTrack.WRITE_NON_BLOCKING)
|
ensureRawAudioTrack()?.write(data, 0, data.size, AudioTrack.WRITE_NON_BLOCKING)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -212,10 +213,6 @@ class ScrcpyAudioPlayer(private val codecId: Int) {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "ScrcpyAudioPlayer"
|
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 SAMPLE_RATE = 48000
|
||||||
private const val CHANNELS = 2
|
private const val CHANNELS = 2
|
||||||
private const val CODEC_TIMEOUT_US = 10_000L
|
private const val CODEC_TIMEOUT_US = 10_000L
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,684 +0,0 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid.pages
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
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.runtime.rememberCoroutineScope
|
|
||||||
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 kotlinx.coroutines.launch
|
|
||||||
import top.yukonga.miuix.kmp.basic.Card
|
|
||||||
import top.yukonga.miuix.kmp.basic.ScrollBehavior
|
|
||||||
import top.yukonga.miuix.kmp.basic.SnackbarHostState
|
|
||||||
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
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
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 -> ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,10 +8,15 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -20,56 +25,78 @@ import androidx.core.view.WindowCompat
|
|||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
||||||
import io.github.miuzarte.scrcpyforandroid.ScrcpySessionInfo
|
|
||||||
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
|
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen
|
import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
|
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar
|
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||||
|
|
||||||
data class FullscreenControlLaunch(
|
|
||||||
val deviceName: String,
|
|
||||||
val width: Int,
|
|
||||||
val height: Int,
|
|
||||||
val codec: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FullscreenControlPage(
|
fun FullscreenControlScreen(
|
||||||
launch: FullscreenControlLaunch,
|
onBack: () -> Unit,
|
||||||
|
session: Scrcpy.Session.SessionInfo,
|
||||||
nativeCore: NativeCoreFacade,
|
nativeCore: NativeCoreFacade,
|
||||||
virtualButtonsLayout: String,
|
|
||||||
showDebugInfo: Boolean,
|
|
||||||
showVirtualButtons: Boolean,
|
|
||||||
onVideoSizeChanged: (width: Int, height: Int) -> Unit,
|
onVideoSizeChanged: (width: Int, height: Int) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
// Disable predictive back handler temporarily to avoid decoding issues.
|
// Disable predictive back handler temporarily to avoid decoding issues.
|
||||||
BackHandler(enabled = true, onBack = onDismiss)
|
BackHandler(enabled = true, onBack = onBack)
|
||||||
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
val haptics = rememberAppHaptics()
|
val haptics = rememberAppHaptics()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||||
|
|
||||||
val activity = remember(context) { context as? Activity }
|
val activity = remember(context) { context as? Activity }
|
||||||
val virtualButtonLayout = remember(virtualButtonsLayout) {
|
|
||||||
VirtualButtonActions.splitLayout(VirtualButtonActions.parseStoredLayout(virtualButtonsLayout))
|
val asBundleShared by appSettings.bundleState.collectAsState()
|
||||||
|
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
|
||||||
|
var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) }
|
||||||
|
val asBundleLatest by rememberUpdatedState(asBundle)
|
||||||
|
LaunchedEffect(asBundleShared) {
|
||||||
|
if (asBundle != asBundleShared) {
|
||||||
|
asBundle = asBundleShared
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val bar = remember(virtualButtonLayout) {
|
LaunchedEffect(asBundle) {
|
||||||
|
delay(Settings.BUNDLE_SAVE_DELAY)
|
||||||
|
if (asBundle != asBundleSharedLatest) {
|
||||||
|
appSettings.saveBundle(asBundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
taskScope.launch {
|
||||||
|
appSettings.saveBundle(asBundleLatest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val buttonItems = remember(asBundle.virtualButtonsLayout) {
|
||||||
|
VirtualButtonActions.splitLayout(
|
||||||
|
VirtualButtonActions.parseStoredLayout(asBundle.virtualButtonsLayout)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val fullscreenDebugInfo = asBundle.fullscreenDebugInfo
|
||||||
|
val showFullscreenVirtualButtons = asBundle.showFullscreenVirtualButtons
|
||||||
|
|
||||||
|
val bar = remember(buttonItems) {
|
||||||
VirtualButtonBar(
|
VirtualButtonBar(
|
||||||
outsideActions = virtualButtonLayout.first,
|
outsideActions = buttonItems.first,
|
||||||
moreActions = virtualButtonLayout.second,
|
moreActions = buttonItems.second,
|
||||||
)
|
|
||||||
}
|
|
||||||
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) }
|
var currentFps by remember { mutableFloatStateOf(0f) }
|
||||||
|
|
||||||
DisposableEffect(activity) {
|
DisposableEffect(activity) {
|
||||||
@@ -94,7 +121,6 @@ fun FullscreenControlPage(
|
|||||||
|
|
||||||
DisposableEffect(nativeCore) {
|
DisposableEffect(nativeCore) {
|
||||||
val listener: (Int, Int) -> Unit = { w, h ->
|
val listener: (Int, Int) -> Unit = { w, h ->
|
||||||
session = session.copy(width = w, height = h)
|
|
||||||
onVideoSizeChanged(w, h)
|
onVideoSizeChanged(w, h)
|
||||||
}
|
}
|
||||||
nativeCore.addVideoSizeListener(listener)
|
nativeCore.addVideoSizeListener(listener)
|
||||||
@@ -113,9 +139,15 @@ fun FullscreenControlPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendKeycode(keycode: Int) {
|
suspend fun sendKeycode(keycode: Int) {
|
||||||
nativeCore.scrcpyInjectKeycode(0, keycode)
|
runCatching {
|
||||||
nativeCore.scrcpyInjectKeycode(1, keycode)
|
withContext(Dispatchers.IO) {
|
||||||
|
nativeCore.session?.injectKeycode(0, keycode)
|
||||||
|
nativeCore.session?.injectKeycode(1, keycode)
|
||||||
|
}
|
||||||
|
}.onFailure { e ->
|
||||||
|
android.util.Log.w("FullscreenControlPage", "sendKeycode failed for keycode=$keycode", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(contentWindowInsets = WindowInsets(0, 0, 0, 0)) { contentPadding ->
|
Scaffold(contentWindowInsets = WindowInsets(0, 0, 0, 0)) { contentPadding ->
|
||||||
@@ -127,30 +159,34 @@ fun FullscreenControlPage(
|
|||||||
FullscreenControlScreen(
|
FullscreenControlScreen(
|
||||||
session = session,
|
session = session,
|
||||||
nativeCore = nativeCore,
|
nativeCore = nativeCore,
|
||||||
onDismiss = onDismiss,
|
onDismiss = onBack,
|
||||||
showDebugInfo = showDebugInfo,
|
showDebugInfo = fullscreenDebugInfo,
|
||||||
currentFps = currentFps,
|
currentFps = currentFps,
|
||||||
enableBackHandler = false,
|
enableBackHandler = false,
|
||||||
onInjectTouch = { action, pointerId, x, y, pressure, buttons ->
|
onInjectTouch = { action, pointerId, x, y, pressure, buttons ->
|
||||||
nativeCore.scrcpyInjectTouch(
|
withContext(Dispatchers.IO) {
|
||||||
action = action,
|
nativeCore.session?.injectTouch(
|
||||||
pointerId = pointerId,
|
action = action,
|
||||||
x = x,
|
pointerId = pointerId,
|
||||||
y = y,
|
x = x,
|
||||||
screenWidth = session.width,
|
y = y,
|
||||||
screenHeight = session.height,
|
screenWidth = session.width,
|
||||||
pressure = pressure,
|
screenHeight = session.height,
|
||||||
actionButton = 0,
|
pressure = pressure,
|
||||||
buttons = buttons,
|
actionButton = 0,
|
||||||
)
|
buttons = buttons,
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if (showVirtualButtons) {
|
if (showFullscreenVirtualButtons) {
|
||||||
bar.Fullscreen(
|
bar.Fullscreen(
|
||||||
modifier = Modifier.align(Alignment.BottomCenter),
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
onAction = { action ->
|
onAction = { action ->
|
||||||
action.keycode?.let(::sendKeycode)
|
action.keycode?.let {
|
||||||
|
sendKeycode(it)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,876 +0,0 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid.pages
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.ActivityInfo
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.SystemClock
|
|
||||||
import android.view.WindowManager
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
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.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
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.rounded.Devices
|
|
||||||
import androidx.compose.material.icons.rounded.MoreVert
|
|
||||||
import androidx.compose.material.icons.rounded.Settings
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
|
||||||
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.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 kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
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.Rounded.Devices),
|
|
||||||
Settings(title = "设置", label = "设置", icon = Icons.Rounded.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 activity = remember(context) { context as? Activity }
|
|
||||||
val initialOrientation = remember(activity) {
|
|
||||||
activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
|
||||||
}
|
|
||||||
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 audioEnabled by rememberSaveable { mutableStateOf(initialSettings.audioEnabled) }
|
|
||||||
var audioCodec by rememberSaveable { mutableStateOf(initialSettings.audioCodec) }
|
|
||||||
var videoCodec by rememberSaveable { mutableStateOf(initialSettings.videoCodec) }
|
|
||||||
var themeBaseIndex by rememberSaveable { mutableIntStateOf(initialSettings.themeBaseIndex) }
|
|
||||||
var monetEnabled by rememberSaveable { mutableStateOf(initialSettings.monetEnabled) }
|
|
||||||
var fullscreenDebugInfoEnabled by rememberSaveable { mutableStateOf(initialSettings.fullscreenDebugInfoEnabled) }
|
|
||||||
var showFullscreenVirtualButtons by rememberSaveable { mutableStateOf(initialSettings.showFullscreenVirtualButtons) }
|
|
||||||
var showPreviewVirtualButtonText by rememberSaveable { mutableStateOf(initialSettings.showPreviewVirtualButtonText) }
|
|
||||||
var keepScreenOnWhenStreamingEnabled by rememberSaveable { mutableStateOf(initialSettings.keepScreenOnWhenStreamingEnabled) }
|
|
||||||
var devicePreviewCardHeightDp by rememberSaveable { mutableIntStateOf(initialSettings.devicePreviewCardHeightDp) }
|
|
||||||
var virtualButtonsLayout by rememberSaveable { mutableStateOf(initialSettings.virtualButtonsLayout) }
|
|
||||||
var customServerUri by rememberSaveable { mutableStateOf(initialSettings.customServerUri) }
|
|
||||||
var serverRemotePath by rememberSaveable { mutableStateOf(initialSettings.serverRemotePath) }
|
|
||||||
var adbKeyName by rememberSaveable { mutableStateOf(initialSettings.adbKeyName) }
|
|
||||||
var adbPairingAutoDiscoverOnDialogOpen by rememberSaveable {
|
|
||||||
mutableStateOf(initialSettings.adbPairingAutoDiscoverOnDialogOpen)
|
|
||||||
}
|
|
||||||
var adbAutoReconnectPairedDevice by rememberSaveable {
|
|
||||||
mutableStateOf(initialSettings.adbAutoReconnectPairedDevice)
|
|
||||||
}
|
|
||||||
var adbMdnsLanDiscoveryEnabled by rememberSaveable {
|
|
||||||
mutableStateOf(initialSettings.adbMdnsLanDiscoveryEnabled)
|
|
||||||
}
|
|
||||||
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.cameraAr) }
|
|
||||||
var cameraFpsInput by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraFps) }
|
|
||||||
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) }
|
|
||||||
var lastExitBackPressAtMs by rememberSaveable { mutableLongStateOf(0L) }
|
|
||||||
var fullscreenOrientation by rememberSaveable {
|
|
||||||
mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)
|
|
||||||
}
|
|
||||||
val themeMode = resolveThemeMode(themeBaseIndex, monetEnabled)
|
|
||||||
val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) }
|
|
||||||
|
|
||||||
// Restore system orientation when MainPage leaves composition.
|
|
||||||
DisposableEffect(activity) {
|
|
||||||
onDispose {
|
|
||||||
activity?.requestedOrientation = initialOrientation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep-screen-on is controlled globally, so fullscreen and preview share the same behavior.
|
|
||||||
DisposableEffect(activity, keepScreenOnWhenStreamingEnabled, sessionStarted) {
|
|
||||||
val window = activity?.window
|
|
||||||
val shouldKeepScreenOn = keepScreenOnWhenStreamingEnabled && sessionStarted
|
|
||||||
if (window != null && shouldKeepScreenOn) {
|
|
||||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
|
||||||
}
|
|
||||||
onDispose {
|
|
||||||
if (window != null && shouldKeepScreenOn) {
|
|
||||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fullscreen route can force orientation based on stream ratio; all other routes are portrait.
|
|
||||||
LaunchedEffect(activity, currentRootScreen, fullscreenOrientation) {
|
|
||||||
val targetOrientation = when (currentRootScreen) {
|
|
||||||
is RootScreen.Fullscreen -> fullscreenOrientation
|
|
||||||
else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
|
||||||
}
|
|
||||||
activity?.requestedOrientation = targetOrientation
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(
|
|
||||||
audioEnabled,
|
|
||||||
audioCodec,
|
|
||||||
videoCodec,
|
|
||||||
themeBaseIndex,
|
|
||||||
monetEnabled,
|
|
||||||
fullscreenDebugInfoEnabled,
|
|
||||||
showFullscreenVirtualButtons,
|
|
||||||
showPreviewVirtualButtonText,
|
|
||||||
keepScreenOnWhenStreamingEnabled,
|
|
||||||
devicePreviewCardHeightDp,
|
|
||||||
virtualButtonsLayout,
|
|
||||||
customServerUri,
|
|
||||||
serverRemotePath,
|
|
||||||
adbKeyName,
|
|
||||||
adbPairingAutoDiscoverOnDialogOpen,
|
|
||||||
adbAutoReconnectPairedDevice,
|
|
||||||
adbMdnsLanDiscoveryEnabled,
|
|
||||||
) {
|
|
||||||
saveMainSettings(
|
|
||||||
context,
|
|
||||||
MainSettings(
|
|
||||||
audioEnabled = audioEnabled,
|
|
||||||
audioCodec = audioCodec,
|
|
||||||
videoCodec = videoCodec,
|
|
||||||
themeBaseIndex = themeBaseIndex,
|
|
||||||
monetEnabled = monetEnabled,
|
|
||||||
fullscreenDebugInfoEnabled = fullscreenDebugInfoEnabled,
|
|
||||||
showFullscreenVirtualButtons = showFullscreenVirtualButtons,
|
|
||||||
showPreviewVirtualButtonText = showPreviewVirtualButtonText,
|
|
||||||
keepScreenOnWhenStreamingEnabled = keepScreenOnWhenStreamingEnabled,
|
|
||||||
devicePreviewCardHeightDp = devicePreviewCardHeightDp,
|
|
||||||
virtualButtonsLayout = virtualButtonsLayout,
|
|
||||||
customServerUri = customServerUri,
|
|
||||||
serverRemotePath = serverRemotePath,
|
|
||||||
adbKeyName = adbKeyName,
|
|
||||||
adbPairingAutoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen,
|
|
||||||
adbAutoReconnectPairedDevice = adbAutoReconnectPairedDevice,
|
|
||||||
adbMdnsLanDiscoveryEnabled = adbMdnsLanDiscoveryEnabled,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(adbKeyName) {
|
|
||||||
nativeCore.setAdbKeyName(adbKeyName.ifBlank { AppDefaults.ADB_KEY_NAME })
|
|
||||||
}
|
|
||||||
|
|
||||||
fun popRoot() {
|
|
||||||
if (rootBackStack.size > 1) {
|
|
||||||
rootBackStack.removeAt(rootBackStack.lastIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unified back behavior:
|
|
||||||
// 1) pop inner route
|
|
||||||
// 2) switch tab back to Device
|
|
||||||
// 3) double-back to exit and disconnect adb/scrcpy
|
|
||||||
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.PAGE_SWITCH_DAMPING_RATIO,
|
|
||||||
stiffness = UiMotion.PAGE_SWITCH_STIFFNESS,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val now = SystemClock.elapsedRealtime()
|
|
||||||
if (now - lastExitBackPressAtMs > 2_000L) {
|
|
||||||
lastExitBackPressAtMs = now
|
|
||||||
Toast.makeText(context, "再按一次返回键退出", Toast.LENGTH_SHORT).show()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lastExitBackPressAtMs = 0L
|
|
||||||
scope.launch {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
runCatching { nativeCore.scrcpyStop() }
|
|
||||||
runCatching { nativeCore.adbDisconnect() }
|
|
||||||
}
|
|
||||||
activity?.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val canNavigateBack = rootBackStack.size > 1 ||
|
|
||||||
pagerState.currentPage != MainTabDestination.Device.ordinal
|
|
||||||
|
|
||||||
BackHandler(enabled = currentRootScreen !is RootScreen.Fullscreen) {
|
|
||||||
handleBackNavigation()
|
|
||||||
}
|
|
||||||
|
|
||||||
PredictiveBackHandler(
|
|
||||||
enabled = canNavigateBack && currentRootScreen !is RootScreen.Fullscreen
|
|
||||||
) { 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.PAGE_SWITCH_DAMPING_RATIO,
|
|
||||||
stiffness = UiMotion.PAGE_SWITCH_STIFFNESS,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
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.Rounded.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,
|
|
||||||
virtualButtonsLayout = virtualButtonsLayout,
|
|
||||||
showPreviewVirtualButtonText = showPreviewVirtualButtonText,
|
|
||||||
previewCardHeightDp = devicePreviewCardHeightDp,
|
|
||||||
themeBaseIndex = themeBaseIndex,
|
|
||||||
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 ->
|
|
||||||
fullscreenOrientation =
|
|
||||||
if (session.width >= session.height) {
|
|
||||||
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
|
||||||
} else {
|
|
||||||
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
|
||||||
}
|
|
||||||
rootBackStack.add(
|
|
||||||
RootScreen.Fullscreen(
|
|
||||||
launch = FullscreenControlLaunch(
|
|
||||||
deviceName = session.deviceName,
|
|
||||||
width = session.width,
|
|
||||||
height = session.height,
|
|
||||||
codec = session.codec,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
adbPairingAutoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen,
|
|
||||||
adbAutoReconnectPairedDevice = adbAutoReconnectPairedDevice,
|
|
||||||
adbMdnsLanDiscoveryEnabled = adbMdnsLanDiscoveryEnabled,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
},
|
|
||||||
onOpenReorderDevices = {
|
|
||||||
openReorderDevicesAction?.invoke()
|
|
||||||
},
|
|
||||||
onOpenVirtualButtonOrder = {
|
|
||||||
rootBackStack.add(RootScreen.VirtualButtonOrder)
|
|
||||||
},
|
|
||||||
showFullscreenVirtualButtons = showFullscreenVirtualButtons,
|
|
||||||
onShowFullscreenVirtualButtonsChange = {
|
|
||||||
showFullscreenVirtualButtons = it
|
|
||||||
},
|
|
||||||
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 },
|
|
||||||
adbPairingAutoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen,
|
|
||||||
onAdbPairingAutoDiscoverOnDialogOpenChange = {
|
|
||||||
adbPairingAutoDiscoverOnDialogOpen = it
|
|
||||||
},
|
|
||||||
adbAutoReconnectPairedDevice = adbAutoReconnectPairedDevice,
|
|
||||||
onAdbAutoReconnectPairedDeviceChange = {
|
|
||||||
adbAutoReconnectPairedDevice = 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,
|
|
||||||
layoutString = virtualButtonsLayout,
|
|
||||||
onLayoutChange = { layout ->
|
|
||||||
virtualButtonsLayout = layout
|
|
||||||
},
|
|
||||||
showPreviewText = showPreviewVirtualButtonText,
|
|
||||||
onShowPreviewTextChange = { showPreviewVirtualButtonText = it },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
entry<RootScreen.Fullscreen> { screen ->
|
|
||||||
FullscreenControlPage(
|
|
||||||
launch = screen.launch,
|
|
||||||
nativeCore = nativeCore,
|
|
||||||
virtualButtonsLayout = virtualButtonsLayout,
|
|
||||||
showDebugInfo = fullscreenDebugInfoEnabled,
|
|
||||||
showVirtualButtons = showFullscreenVirtualButtons,
|
|
||||||
onVideoSizeChanged = { width, height ->
|
|
||||||
fullscreenOrientation = if (width >= height) {
|
|
||||||
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
|
||||||
} else {
|
|
||||||
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
|
||||||
}
|
|
||||||
},
|
|
||||||
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,431 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.pages
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ActivityInfo
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
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.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.PagerState
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Devices
|
||||||
|
import androidx.compose.material.icons.rounded.Settings
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
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.platform.LocalContext
|
||||||
|
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.ThemeModes
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.constants.UiMotion
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
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.Scaffold
|
||||||
|
import top.yukonga.miuix.kmp.basic.SnackbarHost
|
||||||
|
import top.yukonga.miuix.kmp.basic.SnackbarHostState
|
||||||
|
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
|
import top.yukonga.miuix.kmp.theme.ThemeController
|
||||||
|
|
||||||
|
private enum class MainBottomTabDestination(
|
||||||
|
val label: String,
|
||||||
|
val icon: ImageVector,
|
||||||
|
) {
|
||||||
|
Device(label = "设备", icon = Icons.Rounded.Devices),
|
||||||
|
Settings(label = "设置", icon = Icons.Rounded.Settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface RootScreen : NavKey {
|
||||||
|
data object Home : RootScreen
|
||||||
|
data object Advanced : RootScreen
|
||||||
|
data object VirtualButtonOrder : RootScreen
|
||||||
|
data class Fullscreen(val session: Scrcpy.Session.SessionInfo) : RootScreen
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainScreen() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||||
|
|
||||||
|
val activity = remember(context) { context as? Activity }
|
||||||
|
val initialOrientation = remember(activity) {
|
||||||
|
activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||||
|
}
|
||||||
|
DisposableEffect(activity) {
|
||||||
|
onDispose {
|
||||||
|
activity?.requestedOrientation = initialOrientation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val nativeCore = remember(appContext) {
|
||||||
|
NativeCoreFacade.get(appContext)
|
||||||
|
}
|
||||||
|
val adbService = remember(appContext) {
|
||||||
|
NativeAdbService(appContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
val snackHostState = remember { SnackbarHostState() }
|
||||||
|
val snackbarController = remember(scope, snackHostState) {
|
||||||
|
SnackbarController(
|
||||||
|
scope = scope,
|
||||||
|
hostState = snackHostState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val saveableStateHolder = rememberSaveableStateHolder()
|
||||||
|
val tabs = remember { MainBottomTabDestination.entries }
|
||||||
|
val pagerState = rememberPagerState(
|
||||||
|
initialPage = MainBottomTabDestination.Device.ordinal,
|
||||||
|
pageCount = { tabs.size })
|
||||||
|
val currentTab = tabs[pagerState.currentPage]
|
||||||
|
val rootBackStack = remember { mutableStateListOf<NavKey>(RootScreen.Home) }
|
||||||
|
val currentRootScreen = rootBackStack.lastOrNull() as? RootScreen ?: RootScreen.Home
|
||||||
|
val devicesPageScrollBehavior = MiuixScrollBehavior(
|
||||||
|
canScroll = { currentTab == MainBottomTabDestination.Device })
|
||||||
|
val settingsPageScrollBehavior = MiuixScrollBehavior(
|
||||||
|
canScroll = { currentTab == MainBottomTabDestination.Settings })
|
||||||
|
val advancedPageScrollBehavior = MiuixScrollBehavior(
|
||||||
|
canScroll = {
|
||||||
|
when (currentRootScreen) {
|
||||||
|
is RootScreen.Advanced -> true
|
||||||
|
is RootScreen.VirtualButtonOrder -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
fun popRoot() {
|
||||||
|
if (rootBackStack.size > 1)
|
||||||
|
rootBackStack.removeAt(rootBackStack.lastIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unified back behavior:
|
||||||
|
// 1) pop inner route
|
||||||
|
// 2) switch tab back to Device
|
||||||
|
// 3) double-back to exit and disconnect adb/scrcpy
|
||||||
|
var lastExitBackPressAtMs by rememberSaveable { mutableLongStateOf(0L) }
|
||||||
|
|
||||||
|
val asBundleShared by appSettings.bundleState.collectAsState()
|
||||||
|
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
|
||||||
|
var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) }
|
||||||
|
val asBundleLatest by rememberUpdatedState(asBundle)
|
||||||
|
LaunchedEffect(asBundleShared) {
|
||||||
|
if (asBundle != asBundleShared) {
|
||||||
|
asBundle = asBundleShared
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(asBundle) {
|
||||||
|
delay(Settings.BUNDLE_SAVE_DELAY)
|
||||||
|
if (asBundle != asBundleSharedLatest) {
|
||||||
|
appSettings.saveBundle(asBundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
taskScope.launch {
|
||||||
|
appSettings.saveBundle(asBundleLatest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val qdBundleShared by quickDevices.bundleState.collectAsState()
|
||||||
|
val qdBundleSharedLatest by rememberUpdatedState(qdBundleShared)
|
||||||
|
var qdBundle by rememberSaveable(qdBundleShared) { mutableStateOf(qdBundleShared) }
|
||||||
|
val qdBundleLatest by rememberUpdatedState(qdBundle)
|
||||||
|
LaunchedEffect(qdBundleShared) {
|
||||||
|
if (qdBundle != qdBundleShared) {
|
||||||
|
qdBundle = qdBundleShared
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(qdBundle) {
|
||||||
|
delay(Settings.BUNDLE_SAVE_DELAY)
|
||||||
|
if (qdBundle != qdBundleSharedLatest) {
|
||||||
|
quickDevices.saveBundle(qdBundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
taskScope.launch {
|
||||||
|
quickDevices.saveBundle(qdBundleLatest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val customServerUri = asBundle.customServerUri
|
||||||
|
.ifBlank { null }
|
||||||
|
val customServerVersion = asBundle.customServerVersion
|
||||||
|
.ifBlank { Scrcpy.DEFAULT_SERVER_VERSION }
|
||||||
|
val serverRemotePath = asBundle.serverRemotePath
|
||||||
|
.ifBlank { AppSettings.SERVER_REMOTE_PATH.defaultValue }
|
||||||
|
val scrcpy = remember(
|
||||||
|
appContext,
|
||||||
|
adbService,
|
||||||
|
customServerUri,
|
||||||
|
customServerVersion,
|
||||||
|
serverRemotePath,
|
||||||
|
) {
|
||||||
|
Scrcpy(
|
||||||
|
appContext = appContext,
|
||||||
|
adbService = adbService,
|
||||||
|
customServerUri = customServerUri,
|
||||||
|
serverVersion = customServerVersion,
|
||||||
|
serverRemotePath = serverRemotePath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleBackNavigation() {
|
||||||
|
if (rootBackStack.size > 1) {
|
||||||
|
popRoot()
|
||||||
|
} else if (pagerState.currentPage != MainBottomTabDestination.Device.ordinal) {
|
||||||
|
scope.launch {
|
||||||
|
pagerState.animateScrollToPage(
|
||||||
|
page = MainBottomTabDestination.Device.ordinal,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = UiMotion.PAGE_SWITCH_DAMPING_RATIO,
|
||||||
|
stiffness = UiMotion.PAGE_SWITCH_STIFFNESS,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
if (now - lastExitBackPressAtMs > 2_000L) {
|
||||||
|
lastExitBackPressAtMs = now
|
||||||
|
Toast.makeText(context, "再按一次返回键退出", Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastExitBackPressAtMs = 0L
|
||||||
|
scope.launch {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
runCatching { scrcpy.stop() }
|
||||||
|
runCatching { adbService.disconnect() }
|
||||||
|
}
|
||||||
|
activity?.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val canNavigateBack = rootBackStack.size > 1
|
||||||
|
|| pagerState.currentPage != MainBottomTabDestination.Device.ordinal
|
||||||
|
|
||||||
|
BackHandler(enabled = currentRootScreen !is RootScreen.Fullscreen) {
|
||||||
|
handleBackNavigation()
|
||||||
|
}
|
||||||
|
|
||||||
|
PredictiveBackHandler(
|
||||||
|
enabled = canNavigateBack && currentRootScreen !is RootScreen.Fullscreen
|
||||||
|
) { progress ->
|
||||||
|
try {
|
||||||
|
progress.collect { }
|
||||||
|
handleBackNavigation()
|
||||||
|
} catch (_: CancellationException) {
|
||||||
|
// Gesture was cancelled by the system/user.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var showReorderDevices by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var fullscreenOrientation by rememberSaveable {
|
||||||
|
mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fullscreen route can force orientation based on stream ratio; all other routes are portrait.
|
||||||
|
LaunchedEffect(activity, currentRootScreen, fullscreenOrientation) {
|
||||||
|
val targetOrientation = when (currentRootScreen) {
|
||||||
|
is RootScreen.Fullscreen -> fullscreenOrientation
|
||||||
|
else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||||
|
}
|
||||||
|
activity?.requestedOrientation = targetOrientation
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(asBundle.adbKeyName) {
|
||||||
|
adbService.keyName = asBundle.adbKeyName.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue }
|
||||||
|
}
|
||||||
|
val picker = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.OpenDocument()
|
||||||
|
) { uri: Uri? ->
|
||||||
|
if (uri == null) return@rememberLauncherForActivityResult
|
||||||
|
runCatching {
|
||||||
|
context.contentResolver.takePersistableUriPermission(
|
||||||
|
uri,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
)
|
||||||
|
}
|
||||||
|
asBundle = asBundle.copy(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.PAGE_SWITCH_DAMPING_RATIO,
|
||||||
|
stiffness = UiMotion.PAGE_SWITCH_STIFFNESS,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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) {
|
||||||
|
MainBottomTabDestination.Device -> DeviceTabScreen(
|
||||||
|
nativeCore = nativeCore,
|
||||||
|
adbService = adbService,
|
||||||
|
scrcpy = scrcpy,
|
||||||
|
snackbar = snackbarController,
|
||||||
|
scrollBehavior = devicesPageScrollBehavior,
|
||||||
|
onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) },
|
||||||
|
onOpenReorderDevices = { showReorderDevices = true },
|
||||||
|
onOpenAdvancedPage = { rootBackStack.add(RootScreen.Advanced) },
|
||||||
|
onOpenFullscreenPage = { session ->
|
||||||
|
fullscreenOrientation =
|
||||||
|
if (session.width >= session.height) {
|
||||||
|
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||||
|
} else {
|
||||||
|
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||||
|
}
|
||||||
|
rootBackStack.add(
|
||||||
|
RootScreen.Fullscreen(session),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
MainBottomTabDestination.Settings -> SettingsScreen(
|
||||||
|
scrollBehavior = settingsPageScrollBehavior,
|
||||||
|
snackbar = snackbarController,
|
||||||
|
onOpenReorderDevices = { showReorderDevices = true },
|
||||||
|
onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) },
|
||||||
|
onPickServer = {
|
||||||
|
picker.launch(
|
||||||
|
arrayOf(
|
||||||
|
"application/java-archive",
|
||||||
|
"application/octet-stream",
|
||||||
|
"*/*"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ReorderDevicesScreen(
|
||||||
|
show = showReorderDevices,
|
||||||
|
onDismissRequest = { showReorderDevices = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry(RootScreen.Advanced) {
|
||||||
|
ScrcpyAllOptionsScreen(
|
||||||
|
onBack = ::popRoot,
|
||||||
|
scrollBehavior = advancedPageScrollBehavior,
|
||||||
|
snackbar = snackbarController,
|
||||||
|
scrcpy = scrcpy,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry(RootScreen.VirtualButtonOrder) {
|
||||||
|
VirtualButtonOrderScreen(
|
||||||
|
onBack = ::popRoot,
|
||||||
|
scrollBehavior = advancedPageScrollBehavior,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry<RootScreen.Fullscreen> { screen ->
|
||||||
|
FullscreenControlScreen(
|
||||||
|
onBack = ::popRoot,
|
||||||
|
session = screen.session,
|
||||||
|
nativeCore = nativeCore,
|
||||||
|
onVideoSizeChanged = { width, height ->
|
||||||
|
fullscreenOrientation =
|
||||||
|
if (width >= height) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||||
|
else ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val rootEntries = rememberDecoratedNavEntries(
|
||||||
|
backStack = rootBackStack,
|
||||||
|
entryProvider = rootEntryProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
val themeMode = when (asBundle.themeBaseIndex.coerceIn(0, ThemeModes.baseOptions.lastIndex)) {
|
||||||
|
1 -> if (!asBundle.monet) ColorSchemeMode.Light else ColorSchemeMode.MonetLight
|
||||||
|
2 -> if (!asBundle.monet) ColorSchemeMode.Dark else ColorSchemeMode.MonetDark
|
||||||
|
else -> if (!asBundle.monet) ColorSchemeMode.System else ColorSchemeMode.MonetSystem
|
||||||
|
}
|
||||||
|
val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) }
|
||||||
|
|
||||||
|
MiuixTheme(controller = themeController) {
|
||||||
|
NavDisplay(
|
||||||
|
entries = rootEntries,
|
||||||
|
onBack = ::popRoot,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.pages
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import top.yukonga.miuix.kmp.extra.SuperBottomSheet
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ReorderDevicesScreen(
|
||||||
|
show: Boolean,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||||
|
|
||||||
|
val qdBundleShared by quickDevices.bundleState.collectAsState()
|
||||||
|
val qdBundleSharedLatest by rememberUpdatedState(qdBundleShared)
|
||||||
|
var qdBundle by rememberSaveable(qdBundleShared) { mutableStateOf(qdBundleShared) }
|
||||||
|
val qdBundleLatest by rememberUpdatedState(qdBundle)
|
||||||
|
LaunchedEffect(qdBundleShared) {
|
||||||
|
if (qdBundle != qdBundleShared) {
|
||||||
|
qdBundle = qdBundleShared
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(qdBundle) {
|
||||||
|
delay(Settings.BUNDLE_SAVE_DELAY)
|
||||||
|
if (qdBundle != qdBundleSharedLatest) {
|
||||||
|
quickDevices.saveBundle(qdBundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
taskScope.launch {
|
||||||
|
quickDevices.saveBundle(qdBundleLatest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var savedShortcuts by remember {
|
||||||
|
mutableStateOf(DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList))
|
||||||
|
}
|
||||||
|
LaunchedEffect(qdBundle.quickDevicesList) {
|
||||||
|
savedShortcuts = DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList)
|
||||||
|
}
|
||||||
|
LaunchedEffect(savedShortcuts) {
|
||||||
|
val serialized = savedShortcuts.marshalToString()
|
||||||
|
if (serialized != qdBundle.quickDevicesList) {
|
||||||
|
qdBundle = qdBundle.copy(quickDevicesList = serialized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SuperBottomSheet(
|
||||||
|
show = show,
|
||||||
|
title = "快速设备排序",
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
) {
|
||||||
|
ReorderableList(
|
||||||
|
itemsProvider = {
|
||||||
|
savedShortcuts.map { device ->
|
||||||
|
ReorderableList.Item(
|
||||||
|
id = device.id,
|
||||||
|
title = device.name.ifBlank { device.host },
|
||||||
|
subtitle = "${device.host}:${device.port}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettle = { fromIndex, toIndex ->
|
||||||
|
savedShortcuts = savedShortcuts.move(fromIndex, toIndex)
|
||||||
|
},
|
||||||
|
).invoke()
|
||||||
|
Spacer(Modifier.height(UiSpacing.SheetBottom))
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,272 +0,0 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid.pages
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
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.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.rounded.Clear
|
|
||||||
import androidx.compose.material.icons.rounded.FileOpen
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
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.SuperArrow
|
|
||||||
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,
|
|
||||||
showFullscreenVirtualButtons: Boolean,
|
|
||||||
onOpenReorderDevices: () -> Unit,
|
|
||||||
onOpenVirtualButtonOrder: () -> Unit,
|
|
||||||
onShowFullscreenVirtualButtonsChange: (Boolean) -> Unit,
|
|
||||||
customServerUri: String?,
|
|
||||||
onPickServer: () -> Unit,
|
|
||||||
onClearServer: () -> Unit,
|
|
||||||
serverRemotePath: String,
|
|
||||||
onServerRemotePathChange: (String) -> Unit,
|
|
||||||
adbKeyName: String,
|
|
||||||
onAdbKeyNameChange: (String) -> Unit,
|
|
||||||
adbPairingAutoDiscoverOnDialogOpen: Boolean,
|
|
||||||
onAdbPairingAutoDiscoverOnDialogOpenChange: (Boolean) -> Unit,
|
|
||||||
adbAutoReconnectPairedDevice: Boolean,
|
|
||||||
onAdbAutoReconnectPairedDeviceChange: (Boolean) -> Unit,
|
|
||||||
scrollBehavior: ScrollBehavior,
|
|
||||||
) {
|
|
||||||
val baseModeItems = THEME_BASE_OPTIONS.map { it.label }
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
// 设置
|
|
||||||
AppPageLazyColumn(
|
|
||||||
contentPadding = contentPadding,
|
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
) {
|
|
||||||
item {
|
|
||||||
SectionSmallTitle("主题")
|
|
||||||
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("投屏")
|
|
||||||
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)) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
SuperArrow(
|
|
||||||
title = "快速设备排序",
|
|
||||||
summary = "手动排序设备页的快速设备",
|
|
||||||
onClick = onOpenReorderDevices,
|
|
||||||
)
|
|
||||||
SuperArrow(
|
|
||||||
title = "虚拟按钮排序",
|
|
||||||
summary = "手动排序预览/全屏时的虚拟按钮,并选择哪些按钮展示在外",
|
|
||||||
onClick = onOpenVirtualButtonOrder,
|
|
||||||
)
|
|
||||||
SuperSwitch(
|
|
||||||
title = "全屏显示虚拟按钮",
|
|
||||||
summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮",
|
|
||||||
checked = showFullscreenVirtualButtons,
|
|
||||||
onCheckedChange = onShowFullscreenVirtualButtonsChange,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
SectionSmallTitle("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.Rounded.Clear, contentDescription = "清空")
|
|
||||||
}
|
|
||||||
IconButton(onClick = onPickServer) {
|
|
||||||
Icon(Icons.Rounded.FileOpen, 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.SERVER_REMOTE_PATH,
|
|
||||||
useLabelAsPlaceholder = true,
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = UiSpacing.CardContent)
|
|
||||||
.padding(bottom = UiSpacing.CardContent),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
SectionSmallTitle("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.ADB_KEY_NAME,
|
|
||||||
useLabelAsPlaceholder = true,
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = UiSpacing.CardContent)
|
|
||||||
.padding(bottom = UiSpacing.CardContent),
|
|
||||||
)
|
|
||||||
SuperSwitch(
|
|
||||||
title = "配对时自动启用发现服务",
|
|
||||||
summary = "打开配对弹窗后自动搜索可用配对端口",
|
|
||||||
checked = adbPairingAutoDiscoverOnDialogOpen,
|
|
||||||
onCheckedChange = onAdbPairingAutoDiscoverOnDialogOpenChange,
|
|
||||||
)
|
|
||||||
SuperSwitch(
|
|
||||||
title = "自动重连已配对设备",
|
|
||||||
summary = "自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘)",
|
|
||||||
checked = adbAutoReconnectPairedDevice,
|
|
||||||
onCheckedChange = onAdbAutoReconnectPairedDeviceChange,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
SectionSmallTitle("关于")
|
|
||||||
Card {
|
|
||||||
SuperArrow(
|
|
||||||
title = "前往仓库",
|
|
||||||
summary = "github.com/Miuzarte/ScrcpyForAndroid",
|
|
||||||
onClick = {
|
|
||||||
val intent = Intent(
|
|
||||||
Intent.ACTION_VIEW,
|
|
||||||
"https://github.com/Miuzarte/ScrcpyForAndroid".toUri()
|
|
||||||
)
|
|
||||||
context.startActivity(intent)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: 放进 [AppPageLazyColumn] 里
|
|
||||||
item { Spacer(Modifier.height(UiSpacing.BottomContent)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.pages
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Process
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
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.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Clear
|
||||||
|
import androidx.compose.material.icons.rounded.FileOpen
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
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.Scaffold
|
||||||
|
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.basic.TopAppBar
|
||||||
|
import top.yukonga.miuix.kmp.extra.SuperArrow
|
||||||
|
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
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
scrollBehavior: ScrollBehavior,
|
||||||
|
snackbar: SnackbarController,
|
||||||
|
onOpenReorderDevices: () -> Unit,
|
||||||
|
onOpenVirtualButtonOrder: () -> Unit,
|
||||||
|
onPickServer: () -> Unit,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = "设置",
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { pagePadding ->
|
||||||
|
SettingsPage(
|
||||||
|
contentPadding = pagePadding,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
snackbar = snackbar,
|
||||||
|
onOpenReorderDevices = onOpenReorderDevices,
|
||||||
|
onOpenVirtualButtonOrder = onOpenVirtualButtonOrder,
|
||||||
|
onPickServer = onPickServer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsPage(
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
scrollBehavior: ScrollBehavior,
|
||||||
|
snackbar: SnackbarController,
|
||||||
|
onOpenReorderDevices: () -> Unit,
|
||||||
|
onOpenVirtualButtonOrder: () -> Unit,
|
||||||
|
onPickServer: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
var needMigration by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
needMigration = PreferenceMigration(appContext).needsMigration()
|
||||||
|
}
|
||||||
|
|
||||||
|
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val asBundleShared by appSettings.bundleState.collectAsState()
|
||||||
|
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
|
||||||
|
var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) }
|
||||||
|
val asBundleLatest by rememberUpdatedState(asBundle)
|
||||||
|
LaunchedEffect(asBundleShared) {
|
||||||
|
if (asBundle != asBundleShared) {
|
||||||
|
asBundle = asBundleShared
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(asBundle) {
|
||||||
|
delay(Settings.BUNDLE_SAVE_DELAY)
|
||||||
|
if (asBundle != asBundleSharedLatest) {
|
||||||
|
appSettings.saveBundle(asBundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
taskScope.launch {
|
||||||
|
appSettings.saveBundle(asBundleLatest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val themeItems = rememberSaveable { ThemeModes.baseOptions.map { it.label } }
|
||||||
|
|
||||||
|
val customServerVersionShowInput = rememberSaveable(asBundle.customServerUri) {
|
||||||
|
asBundle.customServerUri.isNotBlank()
|
||||||
|
}
|
||||||
|
var customServerVersionInput by rememberSaveable(asBundle.customServerVersion) {
|
||||||
|
mutableStateOf(asBundle.customServerVersion)
|
||||||
|
}
|
||||||
|
var serverRemotePathInput by rememberSaveable(asBundle.serverRemotePath) {
|
||||||
|
mutableStateOf(
|
||||||
|
if (asBundle.serverRemotePath == AppSettings.SERVER_REMOTE_PATH.defaultValue) ""
|
||||||
|
else asBundle.serverRemotePath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var adbKeyNameInput by rememberSaveable(asBundle.adbKeyName) {
|
||||||
|
mutableStateOf(
|
||||||
|
if (asBundle.adbKeyName == AppSettings.ADB_KEY_NAME.defaultValue) ""
|
||||||
|
else asBundle.adbKeyName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = contentPadding,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
SectionSmallTitle("主题", showLeadingSpacer = false)
|
||||||
|
Card {
|
||||||
|
SuperDropdown(
|
||||||
|
title = "外观模式",
|
||||||
|
summary = ThemeModes.baseOptions
|
||||||
|
.getOrNull(asBundle.themeBaseIndex.coerceIn(0, ThemeModes.baseOptions.lastIndex))
|
||||||
|
?.label
|
||||||
|
?: "跟随系统",
|
||||||
|
items = themeItems,
|
||||||
|
selectedIndex = asBundle.themeBaseIndex.coerceIn(0, ThemeModes.baseOptions.lastIndex),
|
||||||
|
onSelectedIndexChange = {
|
||||||
|
asBundle = asBundle.copy(themeBaseIndex = it)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
SuperSwitch(
|
||||||
|
title = "Monet",
|
||||||
|
summary = "开启后使用 Monet 动态配色",
|
||||||
|
checked = asBundle.monet,
|
||||||
|
onCheckedChange = {
|
||||||
|
asBundle = asBundle.copy(monet = it)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SectionSmallTitle("投屏")
|
||||||
|
Card {
|
||||||
|
SuperSwitch(
|
||||||
|
title = "启用调试信息",
|
||||||
|
summary = "在全屏界面悬浮显示分辨率、帧率和触点信息",
|
||||||
|
checked = asBundle.fullscreenDebugInfo,
|
||||||
|
onCheckedChange = {
|
||||||
|
asBundle = asBundle.copy(fullscreenDebugInfo = it)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
SuperSlider(
|
||||||
|
title = "预览卡高度",
|
||||||
|
summary = "设备页预览卡高度",
|
||||||
|
value = asBundle.devicePreviewCardHeightDp.toFloat(),
|
||||||
|
onValueChange = {
|
||||||
|
asBundle = asBundle.copy(
|
||||||
|
devicePreviewCardHeightDp = it.roundToInt().coerceAtLeast(120)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
valueRange = 160f..600f,
|
||||||
|
steps = 600 - 160 - 2,
|
||||||
|
unit = "dp",
|
||||||
|
displayFormatter = { it.roundToInt().toString() },
|
||||||
|
inputInitialValue = asBundle.devicePreviewCardHeightDp.toString(),
|
||||||
|
inputFilter = { it.filter(Char::isDigit) },
|
||||||
|
inputValueRange = 120f..UShort.MAX_VALUE.toFloat(),
|
||||||
|
onInputConfirm = { input ->
|
||||||
|
input.toIntOrNull()?.let {
|
||||||
|
asBundle = asBundle.copy(
|
||||||
|
devicePreviewCardHeightDp = it.coerceAtLeast(120)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
SuperArrow(
|
||||||
|
title = "快速设备排序",
|
||||||
|
summary = "手动排序设备页的快速设备",
|
||||||
|
onClick = onOpenReorderDevices,
|
||||||
|
)
|
||||||
|
SuperArrow(
|
||||||
|
title = "虚拟按钮排序",
|
||||||
|
summary = "手动排序预览/全屏时的虚拟按钮,并选择哪些按钮展示在外",
|
||||||
|
onClick = onOpenVirtualButtonOrder,
|
||||||
|
)
|
||||||
|
SuperSwitch(
|
||||||
|
title = "全屏显示虚拟按钮",
|
||||||
|
summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮",
|
||||||
|
checked = asBundle.showFullscreenVirtualButtons,
|
||||||
|
onCheckedChange = {
|
||||||
|
asBundle = asBundle.copy(showFullscreenVirtualButtons = it)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SectionSmallTitle("scrcpy-server", showLeadingSpacer = false)
|
||||||
|
Card {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(vertical = UiSpacing.Large),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = UiSpacing.Large),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "自定义 binary",
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
TextField(
|
||||||
|
value = asBundle.customServerUri,
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = Scrcpy.DEFAULT_SERVER_ASSET_NAME,
|
||||||
|
useLabelAsPlaceholder = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
trailingIcon = {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(end = UiSpacing.Medium),
|
||||||
|
) {
|
||||||
|
if (asBundle.customServerUri.isNotBlank())
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
asBundle = asBundle.copy(
|
||||||
|
customServerUri = "",
|
||||||
|
customServerVersion = "",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.Clear,
|
||||||
|
contentDescription = "清空",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onPickServer) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.FileOpen,
|
||||||
|
contentDescription = "选择文件",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AnimatedVisibility(customServerVersionShowInput) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = UiSpacing.Large),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "自定义 binary version",
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
SuperTextField(
|
||||||
|
value = customServerVersionInput,
|
||||||
|
onValueChange = { customServerVersionInput = it },
|
||||||
|
onFocusLost = {
|
||||||
|
if (customServerVersionInput == AppSettings.CUSTOM_SERVER_VERSION.defaultValue)
|
||||||
|
customServerVersionInput = ""
|
||||||
|
asBundle = asBundle.copy(
|
||||||
|
customServerVersion = customServerVersionInput
|
||||||
|
.ifBlank { AppSettings.CUSTOM_SERVER_VERSION.defaultValue }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = Scrcpy.DEFAULT_SERVER_VERSION,
|
||||||
|
useLabelAsPlaceholder = true,
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = UiSpacing.Large),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Remote Path",
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
SuperTextField(
|
||||||
|
value = serverRemotePathInput,
|
||||||
|
onValueChange = { serverRemotePathInput = it },
|
||||||
|
onFocusLost = {
|
||||||
|
if (serverRemotePathInput == AppSettings.SERVER_REMOTE_PATH.defaultValue)
|
||||||
|
serverRemotePathInput = ""
|
||||||
|
asBundle = asBundle.copy(
|
||||||
|
serverRemotePath = serverRemotePathInput
|
||||||
|
.ifBlank { AppSettings.SERVER_REMOTE_PATH.defaultValue }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = Scrcpy.DEFAULT_REMOTE_PATH,
|
||||||
|
useLabelAsPlaceholder = true,
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SectionSmallTitle("ADB", showLeadingSpacer = false)
|
||||||
|
Card {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(vertical = UiSpacing.Large),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = UiSpacing.Large),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "自定义 ADB 密钥名",
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
SuperTextField(
|
||||||
|
value = adbKeyNameInput,
|
||||||
|
onValueChange = { adbKeyNameInput = it },
|
||||||
|
onFocusLost = {
|
||||||
|
if (adbKeyNameInput == AppSettings.ADB_KEY_NAME.defaultValue)
|
||||||
|
adbKeyNameInput = ""
|
||||||
|
asBundle = asBundle.copy(
|
||||||
|
adbKeyName = adbKeyNameInput
|
||||||
|
.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = AppSettings.ADB_KEY_NAME.defaultValue,
|
||||||
|
useLabelAsPlaceholder = true,
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SuperSwitch(
|
||||||
|
title = "配对时自动启用发现服务",
|
||||||
|
summary = "打开配对弹窗后自动搜索可用配对端口",
|
||||||
|
checked = asBundle.adbPairingAutoDiscoverOnDialogOpen,
|
||||||
|
onCheckedChange = {
|
||||||
|
asBundle = asBundle.copy(
|
||||||
|
adbPairingAutoDiscoverOnDialogOpen = it
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
SuperSwitch(
|
||||||
|
title = "自动重连已配对设备",
|
||||||
|
summary = "自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘)",
|
||||||
|
checked = asBundle.adbAutoReconnectPairedDevice,
|
||||||
|
onCheckedChange = {
|
||||||
|
asBundle = asBundle.copy(
|
||||||
|
adbAutoReconnectPairedDevice = it
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needMigration) item {
|
||||||
|
// 这部分应该不会显示出来,
|
||||||
|
// 应用启动时就会执行迁移与旧数据的删除
|
||||||
|
SectionSmallTitle("应用", showLeadingSpacer = false)
|
||||||
|
Card {
|
||||||
|
SuperArrow(
|
||||||
|
title = "恢复旧版本配置",
|
||||||
|
summary = "从旧版本的 SharedPreferences 恢复至 DataStore",
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
val migration = PreferenceMigration(appContext)
|
||||||
|
migration.migrate(clearSharedPrefs = false)
|
||||||
|
snackbar.show("迁移完成,应用将重启")
|
||||||
|
|
||||||
|
delay(1000)
|
||||||
|
|
||||||
|
val intent = context.packageManager.getLaunchIntentForPackage(
|
||||||
|
context.packageName
|
||||||
|
)
|
||||||
|
intent?.apply {
|
||||||
|
addFlags(
|
||||||
|
Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
|
)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
|
||||||
|
Process.killProcess(Process.myPid())
|
||||||
|
exitProcess(0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SectionSmallTitle("关于", showLeadingSpacer = false)
|
||||||
|
Card {
|
||||||
|
SuperArrow(
|
||||||
|
title = "前往仓库",
|
||||||
|
summary = "github.com/Miuzarte/ScrcpyForAndroid",
|
||||||
|
onClick = {
|
||||||
|
context.startActivity(
|
||||||
|
Intent(
|
||||||
|
Intent.ACTION_VIEW,
|
||||||
|
"https://github.com/Miuzarte/ScrcpyForAndroid".toUri(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item { Spacer(Modifier.height(UiSpacing.PageBottom)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid.pages
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
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 io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
|
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
|
|
||||||
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.extra.SuperSwitch
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun VirtualButtonOrderPage(
|
|
||||||
contentPadding: PaddingValues,
|
|
||||||
scrollBehavior: ScrollBehavior,
|
|
||||||
layoutString: String,
|
|
||||||
onLayoutChange: (String) -> Unit,
|
|
||||||
showPreviewText: Boolean,
|
|
||||||
onShowPreviewTextChange: (Boolean) -> Unit,
|
|
||||||
) {
|
|
||||||
var buttonItems by remember(layoutString) {
|
|
||||||
mutableStateOf(VirtualButtonActions.parseStoredLayout(layoutString))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun emitChanges() {
|
|
||||||
onLayoutChange(VirtualButtonActions.encodeStoredLayout(buttonItems))
|
|
||||||
}
|
|
||||||
|
|
||||||
AppPageLazyColumn(
|
|
||||||
contentPadding = contentPadding,
|
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
) {
|
|
||||||
// 按钮显示文本开关
|
|
||||||
item {
|
|
||||||
Card {
|
|
||||||
SuperSwitch(
|
|
||||||
title = "按钮显示文本",
|
|
||||||
summary = "超过3个建议关闭,只对预览卡下方的虚拟按钮生效",
|
|
||||||
checked = showPreviewText,
|
|
||||||
onCheckedChange = onShowPreviewTextChange,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
|
||||||
ReorderableList(
|
|
||||||
itemsProvider = {
|
|
||||||
buttonItems.map { item ->
|
|
||||||
val action = item.action
|
|
||||||
ReorderableList.Item(
|
|
||||||
id = action.id,
|
|
||||||
icon = action.icon,
|
|
||||||
title = if (action.keycode == null) action.title else "${action.title} (${action.keycode})",
|
|
||||||
subtitle = if (item.showOutside) "显示在外部" else "显示在更多菜单内",
|
|
||||||
checked = item.showOutside,
|
|
||||||
checkboxEnabled = action != VirtualButtonAction.MORE,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
orientation = ReorderableList.Orientation.Column,
|
|
||||||
onSettle = { fromIndex, toIndex ->
|
|
||||||
buttonItems = buttonItems.toMutableList().apply {
|
|
||||||
add(toIndex, removeAt(fromIndex))
|
|
||||||
}
|
|
||||||
emitChanges()
|
|
||||||
},
|
|
||||||
showCheckbox = true,
|
|
||||||
onCheckboxChange = { id, checked ->
|
|
||||||
buttonItems = buttonItems.map { item ->
|
|
||||||
if (item.action.id == id) {
|
|
||||||
item.copy(showOutside = checked)
|
|
||||||
} else {
|
|
||||||
item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
emitChanges()
|
|
||||||
},
|
|
||||||
)()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.pages
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
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.Scaffold
|
||||||
|
import top.yukonga.miuix.kmp.basic.ScrollBehavior
|
||||||
|
import top.yukonga.miuix.kmp.basic.TopAppBar
|
||||||
|
import top.yukonga.miuix.kmp.extra.SuperSwitch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun VirtualButtonOrderScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
scrollBehavior: ScrollBehavior,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = "虚拟按钮排序",
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "返回"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { pagePadding ->
|
||||||
|
VirtualButtonOrderPage(
|
||||||
|
contentPadding = pagePadding,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun VirtualButtonOrderPage(
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
scrollBehavior: ScrollBehavior,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||||
|
|
||||||
|
val asBundleShared by appSettings.bundleState.collectAsState()
|
||||||
|
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
|
||||||
|
var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) }
|
||||||
|
val asBundleLatest by rememberUpdatedState(asBundle)
|
||||||
|
LaunchedEffect(asBundleShared) {
|
||||||
|
if (asBundle != asBundleShared) {
|
||||||
|
asBundle = asBundleShared
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(asBundle) {
|
||||||
|
delay(Settings.BUNDLE_SAVE_DELAY)
|
||||||
|
if (asBundle != asBundleSharedLatest) {
|
||||||
|
appSettings.saveBundle(asBundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
taskScope.launch {
|
||||||
|
appSettings.saveBundle(asBundleLatest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buttonItems by remember(asBundle.virtualButtonsLayout) {
|
||||||
|
mutableStateOf(VirtualButtonActions.parseStoredLayout(asBundle.virtualButtonsLayout))
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = contentPadding,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
) {
|
||||||
|
// 按钮显示文本开关
|
||||||
|
item {
|
||||||
|
Card {
|
||||||
|
SuperSwitch(
|
||||||
|
title = "按钮显示文本",
|
||||||
|
summary = "超过3个建议关闭,只对预览卡下方的虚拟按钮生效",
|
||||||
|
checked = asBundle.previewVirtualButtonShowText,
|
||||||
|
onCheckedChange = {
|
||||||
|
asBundle = asBundle.copy(previewVirtualButtonShowText = it)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
ReorderableList(
|
||||||
|
itemsProvider = {
|
||||||
|
buttonItems.map { item ->
|
||||||
|
val action = item.action
|
||||||
|
ReorderableList.Item(
|
||||||
|
id = action.id,
|
||||||
|
icon = action.icon,
|
||||||
|
title = if (action.keycode == null) action.title else "${action.title} (${action.keycode})",
|
||||||
|
subtitle = if (item.showOutside) "显示在外部" else "显示在更多菜单内",
|
||||||
|
checked = item.showOutside,
|
||||||
|
checkboxEnabled = action != VirtualButtonAction.MORE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orientation = ReorderableList.Orientation.Column,
|
||||||
|
onSettle = { fromIndex, toIndex ->
|
||||||
|
buttonItems = buttonItems.toMutableList().apply {
|
||||||
|
add(toIndex, removeAt(fromIndex))
|
||||||
|
}
|
||||||
|
asBundle = asBundle.copy(
|
||||||
|
virtualButtonsLayout = VirtualButtonActions.encodeStoredLayout(buttonItems)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
showCheckbox = true,
|
||||||
|
onCheckboxChange = { id, checked ->
|
||||||
|
buttonItems = buttonItems.map { item ->
|
||||||
|
if (item.action.id == id) {
|
||||||
|
item.copy(showOutside = checked)
|
||||||
|
} else {
|
||||||
|
item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
asBundle = asBundle.copy(
|
||||||
|
virtualButtonsLayout = VirtualButtonActions.encodeStoredLayout(buttonItems)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ import top.yukonga.miuix.kmp.basic.ScrollBehavior
|
|||||||
import top.yukonga.miuix.kmp.utils.overScrollVertical
|
import top.yukonga.miuix.kmp.utils.overScrollVertical
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppPageLazyColumn(
|
fun LazyColumn(
|
||||||
contentPadding: PaddingValues,
|
contentPadding: PaddingValues,
|
||||||
scrollBehavior: ScrollBehavior,
|
scrollBehavior: ScrollBehavior,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -32,18 +32,16 @@ fun AppPageLazyColumn(
|
|||||||
content: LazyListScope.() -> Unit,
|
content: LazyListScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val focusClearModifier = if (clearFocusOnTap) {
|
|
||||||
Modifier.pointerInput(Unit) {
|
|
||||||
detectTapGestures(onTap = { focusManager.clearFocus() })
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.then(focusClearModifier)
|
.then(
|
||||||
|
if (clearFocusOnTap)
|
||||||
|
Modifier.pointerInput(Unit) {
|
||||||
|
detectTapGestures(onTap = { focusManager.clearFocus() })
|
||||||
|
} else Modifier
|
||||||
|
)
|
||||||
.overScrollVertical()
|
.overScrollVertical()
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||||
.padding(contentPadding),
|
.padding(contentPadding),
|
||||||
@@ -2,28 +2,30 @@ package io.github.miuzarte.scrcpyforandroid.scaffolds
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
import androidx.compose.ui.unit.dp
|
||||||
import top.yukonga.miuix.kmp.basic.ButtonDefaults
|
import top.yukonga.miuix.kmp.basic.ButtonDefaults
|
||||||
import top.yukonga.miuix.kmp.basic.Slider
|
import top.yukonga.miuix.kmp.basic.Slider
|
||||||
import top.yukonga.miuix.kmp.basic.Text
|
import top.yukonga.miuix.kmp.basic.Text
|
||||||
import top.yukonga.miuix.kmp.basic.TextButton
|
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.SuperArrow
|
||||||
import top.yukonga.miuix.kmp.extra.SuperDialog
|
import top.yukonga.miuix.kmp.extra.SuperDialog
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SuperSlide(
|
fun SuperSlider(
|
||||||
title: String,
|
title: String,
|
||||||
summary: String,
|
summary: String,
|
||||||
value: Float,
|
value: Float,
|
||||||
@@ -39,7 +41,9 @@ fun SuperSlide(
|
|||||||
displayFormatter: (Float) -> String = { it.toInt().toString() },
|
displayFormatter: (Float) -> String = { it.toInt().toString() },
|
||||||
displayText: String? = null,
|
displayText: String? = null,
|
||||||
inputTitle: String = title,
|
inputTitle: String = title,
|
||||||
inputHint: String = unit,
|
inputSummary: String = summary,
|
||||||
|
inputLabel: String = unit,
|
||||||
|
useLabelAsPlaceholder: Boolean = false,
|
||||||
inputInitialValue: String = displayFormatter(value),
|
inputInitialValue: String = displayFormatter(value),
|
||||||
inputFilter: (String) -> String = { text -> text.filter { it.isDigit() || it == '.' } },
|
inputFilter: (String) -> String = { text -> text.filter { it.isDigit() || it == '.' } },
|
||||||
inputValueRange: ClosedFloatingPointRange<Float>? = null,
|
inputValueRange: ClosedFloatingPointRange<Float>? = null,
|
||||||
@@ -59,7 +63,8 @@ fun SuperSlide(
|
|||||||
endActions = {
|
endActions = {
|
||||||
val isZeroState = value == 0f && zeroStateText != null
|
val isZeroState = value == 0f && zeroStateText != null
|
||||||
val valueText =
|
val valueText =
|
||||||
if (isZeroState) zeroStateText else (displayText ?: displayFormatter(value))
|
if (isZeroState) zeroStateText
|
||||||
|
else (displayText ?: displayFormatter(value))
|
||||||
val shouldShowUnit = unit.isNotBlank() && (!isZeroState || showUnitWhenZeroState)
|
val shouldShowUnit = unit.isNotBlank() && (!isZeroState || showUnitWhenZeroState)
|
||||||
val text = if (shouldShowUnit) "$valueText $unit" else valueText
|
val text = if (shouldShowUnit) "$valueText $unit" else valueText
|
||||||
Text(
|
Text(
|
||||||
@@ -82,60 +87,81 @@ fun SuperSlide(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if (showInputDialog) {
|
SliderInputDialog(
|
||||||
var valueText by remember(inputInitialValue) { mutableStateOf(inputInitialValue) }
|
showDialog = showInputDialog,
|
||||||
val activeInputRange = inputValueRange ?: valueRange
|
title = inputTitle,
|
||||||
SuperDialog(
|
summary = inputSummary,
|
||||||
show = true,
|
label = inputLabel,
|
||||||
onDismissRequest = {
|
useLabelAsPlaceholder = useLabelAsPlaceholder,
|
||||||
showInputDialog = false
|
initialValue = inputInitialValue,
|
||||||
holdArrow = false
|
inputFilter = inputFilter,
|
||||||
},
|
inputValueRange = inputValueRange ?: valueRange,
|
||||||
) {
|
onDismissRequest = { showInputDialog = false },
|
||||||
Row(
|
onDismissFinished = { holdArrow = false },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
onConfirm = { input ->
|
||||||
horizontalArrangement = Arrangement.Center,
|
onInputConfirm(input)
|
||||||
) {
|
showInputDialog = false
|
||||||
Text(text = inputTitle)
|
},
|
||||||
}
|
)
|
||||||
TextField(
|
}
|
||||||
value = valueText,
|
|
||||||
onValueChange = { valueText = inputFilter(it) },
|
@Composable
|
||||||
label = inputHint,
|
private fun SliderInputDialog(
|
||||||
singleLine = true,
|
showDialog: Boolean,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
title: String,
|
||||||
modifier = Modifier
|
summary: String,
|
||||||
.fillMaxWidth()
|
label: String = "",
|
||||||
.padding(top = UiSpacing.Large),
|
useLabelAsPlaceholder: Boolean = false,
|
||||||
|
initialValue: String,
|
||||||
|
inputFilter: (String) -> String,
|
||||||
|
inputValueRange: ClosedFloatingPointRange<Float>,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onDismissFinished: () -> Unit,
|
||||||
|
onConfirm: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
SuperDialog(
|
||||||
|
show = showDialog,
|
||||||
|
title = title,
|
||||||
|
summary = summary,
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
onDismissFinished = onDismissFinished,
|
||||||
|
content = {
|
||||||
|
var text by rememberSaveable(initialValue) { mutableStateOf(initialValue) }
|
||||||
|
|
||||||
|
SuperTextField(
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp),
|
||||||
|
value = text,
|
||||||
|
label = label,
|
||||||
|
useLabelAsPlaceholder = useLabelAsPlaceholder,
|
||||||
|
maxLines = 1,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Number,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
text = inputFilter(newValue)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
Row(horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = UiSpacing.Large),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
|
|
||||||
) {
|
|
||||||
TextButton(
|
TextButton(
|
||||||
text = "取消",
|
text = "取消",
|
||||||
|
onClick = onDismissRequest,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
onClick = {
|
|
||||||
showInputDialog = false
|
|
||||||
holdArrow = false
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.width(20.dp))
|
||||||
TextButton(
|
TextButton(
|
||||||
text = "确定",
|
text = "确定",
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
onClick = {
|
onClick = {
|
||||||
val inputValue = valueText.trim().toFloatOrNull()
|
val inputValue = text.toFloatOrNull() ?: 0f
|
||||||
if (inputValue != null && inputValue >= activeInputRange.start && inputValue <= activeInputRange.endInclusive) {
|
if (inputValue >= inputValueRange.start && inputValue <= inputValueRange.endInclusive) {
|
||||||
onInputConfirm(valueText.trim())
|
onConfirm(text.trim())
|
||||||
showInputDialog = false
|
|
||||||
holdArrow = false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
colors = ButtonDefaults.textButtonColorsPrimary(),
|
colors = ButtonDefaults.textButtonColorsPrimary(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.scaffolds
|
||||||
|
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
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.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import top.yukonga.miuix.kmp.basic.TextField
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapped [TextField] component with state change callbacks.
|
||||||
|
*
|
||||||
|
* @param value The text to be displayed in the text field.
|
||||||
|
* @param onValueChange The callback to be called when the value changes.
|
||||||
|
* @param modifier The modifier to be applied to the [TextField].
|
||||||
|
* @param label The label to be displayed when the [TextField] is empty.
|
||||||
|
* @param enabled Whether the [TextField] is enabled.
|
||||||
|
* @param readOnly Whether the [TextField] is read-only.
|
||||||
|
* @param singleLine Whether the text field is single line.
|
||||||
|
* @param maxLines The maximum number of lines allowed to be displayed.
|
||||||
|
* @param minLines The minimum number of lines allowed to be displayed.
|
||||||
|
* @param useLabelAsPlaceholder Whether to use the label as a placeholder.
|
||||||
|
* @param keyboardOptions The keyboard options to be applied to the [TextField].
|
||||||
|
* @param keyboardActions The keyboard actions to be applied to the [TextField].
|
||||||
|
* @param visualTransformation The visual transformation to be applied to the [TextField].
|
||||||
|
* @param onFocusGained The callback to be called when the text field gains focus.
|
||||||
|
* @param onFocusLost The callback to be called when the text field loses focus.
|
||||||
|
* @param insideMargin The margin inside the [TextField].
|
||||||
|
* @param backgroundColor The background color of the [TextField].
|
||||||
|
* @param cornerRadius The corner radius of the [TextField].
|
||||||
|
* @param labelColor The color of the label.
|
||||||
|
* @param borderColor The color of the border when the [TextField] is focused.
|
||||||
|
* @param textStyle The text style to be applied to the [TextField].
|
||||||
|
* @param cursorBrush The brush to be used for the cursor.
|
||||||
|
* @param leadingIcon The leading icon to be displayed in the [TextField].
|
||||||
|
* @param trailingIcon The trailing icon to be displayed in the [TextField].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SuperTextField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
label: String = "",
|
||||||
|
enabled: Boolean = true,
|
||||||
|
readOnly: Boolean = false,
|
||||||
|
singleLine: Boolean = false,
|
||||||
|
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
|
||||||
|
minLines: Int = 1,
|
||||||
|
useLabelAsPlaceholder: Boolean = false,
|
||||||
|
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||||
|
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||||
|
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||||
|
onFocusGained: (() -> Unit)? = null,
|
||||||
|
onFocusLost: (() -> Unit)? = null,
|
||||||
|
insideMargin: DpSize = DpSize(16.dp, 16.dp),
|
||||||
|
backgroundColor: Color = MiuixTheme.colorScheme.secondaryContainer,
|
||||||
|
cornerRadius: Dp = 16.dp,
|
||||||
|
labelColor: Color = MiuixTheme.colorScheme.onSecondaryContainer,
|
||||||
|
borderColor: Color = MiuixTheme.colorScheme.primary,
|
||||||
|
textStyle: TextStyle = MiuixTheme.textStyles.main,
|
||||||
|
cursorBrush: Brush = SolidColor(MiuixTheme.colorScheme.primary),
|
||||||
|
leadingIcon: @Composable (() -> Unit)? = null,
|
||||||
|
trailingIcon: @Composable (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
|
||||||
|
// 监听焦点状态变化并触发回调
|
||||||
|
LaunchedEffect(isFocused) {
|
||||||
|
if (isFocused) onFocusGained?.invoke()
|
||||||
|
else onFocusLost?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
modifier = modifier
|
||||||
|
.onFocusChanged { focusState ->
|
||||||
|
isFocused = focusState.isFocused
|
||||||
|
}
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
label = label,
|
||||||
|
enabled = enabled,
|
||||||
|
readOnly = readOnly,
|
||||||
|
singleLine = singleLine,
|
||||||
|
maxLines = maxLines,
|
||||||
|
minLines = minLines,
|
||||||
|
useLabelAsPlaceholder = useLabelAsPlaceholder,
|
||||||
|
keyboardOptions = keyboardOptions,
|
||||||
|
keyboardActions = keyboardActions,
|
||||||
|
visualTransformation = visualTransformation,
|
||||||
|
insideMargin = insideMargin,
|
||||||
|
backgroundColor = backgroundColor,
|
||||||
|
cornerRadius = cornerRadius,
|
||||||
|
labelColor = labelColor,
|
||||||
|
borderColor = borderColor,
|
||||||
|
textStyle = textStyle,
|
||||||
|
cursorBrush = cursorBrush,
|
||||||
|
leadingIcon = leadingIcon,
|
||||||
|
trailingIcon = trailingIcon,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,565 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.scrcpy
|
||||||
|
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.AudioSource
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.DisplayImePolicy
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.LogLevel
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Orientation
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.OrientationLock
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
|
||||||
|
|
||||||
|
// TODO: 配置切换
|
||||||
|
|
||||||
|
data class ClientOptions(
|
||||||
|
// var serial: String = "", // to server
|
||||||
|
|
||||||
|
// --crop=width:height:x:y
|
||||||
|
var crop: String = "", // to server
|
||||||
|
|
||||||
|
// TODO: implement
|
||||||
|
// --record
|
||||||
|
var recordFilename: String = "",
|
||||||
|
|
||||||
|
// var windowTitle: String = "",
|
||||||
|
// var pushTarget: String = "",
|
||||||
|
// var renderDriver: String = "",
|
||||||
|
|
||||||
|
// --video-codec-options
|
||||||
|
var videoCodecOptions: String = "", // to server
|
||||||
|
// --audio-codec-options
|
||||||
|
var audioCodecOptions: String = "", // to server
|
||||||
|
// --video-encoder
|
||||||
|
var videoEncoder: String = "", // to server
|
||||||
|
// --audio-encoder
|
||||||
|
var audioEncoder: String = "", // to server
|
||||||
|
|
||||||
|
// --camera-id
|
||||||
|
var cameraId: String = "", // to server
|
||||||
|
// --camera-size
|
||||||
|
var cameraSize: String = "", // to server
|
||||||
|
// --camera-ar
|
||||||
|
var cameraAr: String = "", // to server
|
||||||
|
// --camera-fps
|
||||||
|
var cameraFps: UShort = 0u, // to server
|
||||||
|
|
||||||
|
var logLevel: LogLevel = LogLevel.INFO, // to server
|
||||||
|
|
||||||
|
// --video-codec
|
||||||
|
var videoCodec: Codec = Codec.H264, // to server
|
||||||
|
// --audio-codec
|
||||||
|
var audioCodec: Codec = Codec.OPUS, // to server
|
||||||
|
// --video-source
|
||||||
|
var videoSource: VideoSource = VideoSource.DISPLAY, // to server
|
||||||
|
// --audio-source
|
||||||
|
var audioSource: AudioSource = AudioSource.AUTO, // to server
|
||||||
|
|
||||||
|
// --record-format
|
||||||
|
var recordFormat: RecordFormat = RecordFormat.AUTO,
|
||||||
|
|
||||||
|
// var keyboardInputMode: KeyboardInputMode,
|
||||||
|
// var mouseInputMode: MouseInputMode,
|
||||||
|
// var gamepadInputMode: GamepadInputMode,
|
||||||
|
|
||||||
|
// sc_mouse_bindings(pri, sec)
|
||||||
|
// var mouseBindings: MouseBindings,
|
||||||
|
|
||||||
|
// --camera-facing
|
||||||
|
var cameraFacing: CameraFacing = CameraFacing.ANY, // to server
|
||||||
|
|
||||||
|
// var portRange: PortRange, // sc_port_range(first, last) // to server
|
||||||
|
// var tunnelHost: UInt, // to server
|
||||||
|
// var tunnelPort: UShort, // to server
|
||||||
|
|
||||||
|
// OR of enum sc_shortcut_mod values
|
||||||
|
// var shortcutMods: ShortcutMod,
|
||||||
|
|
||||||
|
// --max-size
|
||||||
|
var maxSize: UShort = 0u, // to server
|
||||||
|
|
||||||
|
// --video-bit-rate
|
||||||
|
var videoBitRate: Int = 0, // to server
|
||||||
|
// --audio-bit-rate
|
||||||
|
var audioBitRate: Int = 0, // to server
|
||||||
|
|
||||||
|
// --max-fps
|
||||||
|
var maxFps: String = "", // float to be parsed by the server
|
||||||
|
var angle: String = "", // float to be parsed by the server
|
||||||
|
|
||||||
|
// --capture-orientation=.*
|
||||||
|
var captureOrientation: Orientation = Orientation.ORIENT_0, // to server
|
||||||
|
// --capture-orientation=@.*
|
||||||
|
var captureOrientationLock: OrientationLock = OrientationLock.UNLOCKED, // to server
|
||||||
|
|
||||||
|
// --display-orientation
|
||||||
|
var displayOrientation: Orientation = Orientation.ORIENT_0,
|
||||||
|
// --record-orientation
|
||||||
|
var recordOrientation: Orientation = Orientation.ORIENT_0,
|
||||||
|
|
||||||
|
// --display-ime-policy
|
||||||
|
var displayImePolicy: DisplayImePolicy = DisplayImePolicy.UNDEFINED, // to server
|
||||||
|
|
||||||
|
// var windowX: Short,
|
||||||
|
// var windowY: Short,
|
||||||
|
// var windowWidth: UShort,
|
||||||
|
// var windowHeight: UShort,
|
||||||
|
|
||||||
|
// --display-id
|
||||||
|
// -1 for empty text field
|
||||||
|
var displayId: Int = -1, // to server
|
||||||
|
|
||||||
|
// var videoBuffer: Tick,
|
||||||
|
// var audioBuffer: Tick,
|
||||||
|
// var audioOutputBuffer: Tick,
|
||||||
|
// var timeLimit: Tick,
|
||||||
|
|
||||||
|
// --screen-off-timeout
|
||||||
|
var screenOffTimeout: Tick = Tick(-1), // to server
|
||||||
|
|
||||||
|
// var otg: Boolean,
|
||||||
|
|
||||||
|
// --show-touches
|
||||||
|
var showTouches: Boolean = false, // to server
|
||||||
|
// 开始后立即进入全屏
|
||||||
|
// --fullscreen
|
||||||
|
var fullscreen: Boolean = false,
|
||||||
|
|
||||||
|
// var alwaysOnTop: Boolean,
|
||||||
|
|
||||||
|
// --no-control
|
||||||
|
var control: Boolean = true, // to server
|
||||||
|
// --no-playback
|
||||||
|
// --no-video-playback
|
||||||
|
var videoPlayback: Boolean = true,
|
||||||
|
// --no-playback
|
||||||
|
// --no-audio-playback
|
||||||
|
var audioPlayback: Boolean = true,
|
||||||
|
// --turn-screen-off
|
||||||
|
var turnScreenOff: Boolean = false,
|
||||||
|
|
||||||
|
// [sc_key_inject_mode]
|
||||||
|
// var keyInjectMode: KeyInjectMode,
|
||||||
|
|
||||||
|
// var windowBorderless: Boolean,
|
||||||
|
// var mipmaps: Boolean,
|
||||||
|
|
||||||
|
// --stay-awake
|
||||||
|
var stayAwake: Boolean = false, // to server
|
||||||
|
|
||||||
|
// --force-adb-forward
|
||||||
|
// var forceAdbForward: Boolean, // to server
|
||||||
|
|
||||||
|
// --disable-screensaver
|
||||||
|
var disableScreensaver: Boolean = false,
|
||||||
|
|
||||||
|
// var forwardKeyRepeat: Boolean,
|
||||||
|
// var legacyPaste: Boolean,
|
||||||
|
|
||||||
|
// --power-off-on-close
|
||||||
|
var powerOffOnClose: Boolean = false, // to server
|
||||||
|
|
||||||
|
// --no-clipboard-autosync
|
||||||
|
// var clipboardAutosync: Boolean = true, // to server
|
||||||
|
|
||||||
|
// --no-downsize-on-error
|
||||||
|
// var downsizeOnError: Boolean = true, // to server
|
||||||
|
|
||||||
|
// var tcpip: Boolean, // to server
|
||||||
|
// var tcpipDst: String = "", // to server
|
||||||
|
// var selectUsb: Boolean, // to server
|
||||||
|
// var selectTcpip: Boolean, // to server
|
||||||
|
|
||||||
|
// --no-cleanup
|
||||||
|
var cleanUp: Boolean = true, // to server
|
||||||
|
|
||||||
|
// var startFpsCounter: Boolean,
|
||||||
|
|
||||||
|
// --no-power-on
|
||||||
|
var powerOn: Boolean = true, // to server
|
||||||
|
// --no-video
|
||||||
|
var video: Boolean = true, // to server
|
||||||
|
// --no-audio
|
||||||
|
var audio: Boolean = true, // to server
|
||||||
|
// --require-audio
|
||||||
|
var requireAudio: Boolean = false,
|
||||||
|
|
||||||
|
// 结束 scrcpy 后断开 adb 连接
|
||||||
|
// --kill-adb-on-close
|
||||||
|
var killAdbOnClose: Boolean = false, // to server but client side
|
||||||
|
// --camera-high-speed
|
||||||
|
var cameraHighSpeed: Boolean = false, // to server
|
||||||
|
|
||||||
|
var list: ListOptions = ListOptions.NULL, // to server
|
||||||
|
|
||||||
|
// --no-window
|
||||||
|
// var window: Boolean,
|
||||||
|
|
||||||
|
// --no-mouse-hover
|
||||||
|
// var mouseHover: Boolean = true,
|
||||||
|
|
||||||
|
// --audio-dup
|
||||||
|
var audioDup: Boolean = false, // to server
|
||||||
|
// --new-display=[<width>x<height>][/<dpi>]
|
||||||
|
var newDisplay: String = "", // to server
|
||||||
|
// --start-app
|
||||||
|
var startApp: String = "",
|
||||||
|
|
||||||
|
// --no-vd-destroy-content
|
||||||
|
var vdDestroyContent: Boolean = true, // to server
|
||||||
|
// --no-vd-system-decorations
|
||||||
|
var vdSystemDecorations: Boolean = true, // to server
|
||||||
|
) {
|
||||||
|
enum class RecordFormat(val string: String) {
|
||||||
|
AUTO("auto"), // ignore
|
||||||
|
MP4("mp4"),
|
||||||
|
MKV("mkv"),
|
||||||
|
M4A("m4a"),
|
||||||
|
MKA("mka"),
|
||||||
|
OPUS("opus"),
|
||||||
|
AAC("aac"),
|
||||||
|
FLAC("flac"),
|
||||||
|
WAV("wav");
|
||||||
|
|
||||||
|
fun isAudioOnly(): Boolean = when (this) {
|
||||||
|
M4A, MKA, OPUS, AAC, FLAC, WAV -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fix(): ClientOptions {
|
||||||
|
when(videoSource) {
|
||||||
|
VideoSource.DISPLAY -> {
|
||||||
|
cameraId = ""
|
||||||
|
cameraFacing = CameraFacing.ANY
|
||||||
|
cameraSize = ""
|
||||||
|
cameraAr = ""
|
||||||
|
cameraFps = 0u
|
||||||
|
cameraHighSpeed = false
|
||||||
|
}
|
||||||
|
VideoSource.CAMERA -> {
|
||||||
|
displayId = 0
|
||||||
|
maxSize = 0u
|
||||||
|
maxFps = ""
|
||||||
|
newDisplay = ""
|
||||||
|
crop = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validate(): ClientOptions {
|
||||||
|
if (!video) {
|
||||||
|
videoPlayback = false
|
||||||
|
powerOn = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!audio) {
|
||||||
|
audioPlayback = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (video && !videoPlayback && recordFilename.isBlank()) {
|
||||||
|
video = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audio && !audioPlayback && recordFilename.isBlank()) {
|
||||||
|
audio = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!video && !audio && !control) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"nothing to do"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
requireAudio = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newDisplay.isNotBlank()) {
|
||||||
|
if (videoSource != VideoSource.DISPLAY) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"--new-display is only available with --video-source=display"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"--new-display is incompatible with --no-video"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoSource == VideoSource.CAMERA) {
|
||||||
|
if (displayId > 0) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"--display-id is only available with --video-source=display"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayImePolicy != DisplayImePolicy.UNDEFINED) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"--display-ime-policy is only available with --video-source=display"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cameraId.isNotBlank() && cameraFacing != CameraFacing.ANY) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Cannot specify both --camera-id and --camera-facing"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cameraSize.isNotBlank()) {
|
||||||
|
if (maxSize > 0u) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Cannot specify both --camera-size and -m/--max-size"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cameraAr.isNotBlank()) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"--camera-high-speed requires an explicit --camera-fps value"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cameraHighSpeed && cameraFps > 0u) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"--camera-high-speed requires an explicit --camera-fps value"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control) {
|
||||||
|
control = false
|
||||||
|
}
|
||||||
|
} else if (cameraId.isNotBlank()
|
||||||
|
|| cameraAr.isNotBlank()
|
||||||
|
|| cameraFacing != CameraFacing.ANY
|
||||||
|
|| cameraFps > 0u
|
||||||
|
|| cameraHighSpeed
|
||||||
|
|| cameraSize.isNotBlank()
|
||||||
|
) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Camera options are only available with --video-source=camera"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayId > 0 && newDisplay.isNotBlank()) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Cannot specify both --display-id and --new-display"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayImePolicy != DisplayImePolicy.UNDEFINED
|
||||||
|
&& displayId == 0 && newDisplay.isBlank()
|
||||||
|
) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"--display-ime-policy is only supported on a secondary display"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audio && audioSource == AudioSource.AUTO) {
|
||||||
|
// Select the audio source according to the video source
|
||||||
|
audioSource = if (videoSource == VideoSource.DISPLAY) {
|
||||||
|
if (audioDup) {
|
||||||
|
AudioSource.PLAYBACK
|
||||||
|
} else {
|
||||||
|
AudioSource.OUTPUT
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
AudioSource.MIC
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioDup) {
|
||||||
|
if (!audio) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"--audio-dup not supported if audio is disabled"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioSource != AudioSource.PLAYBACK) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"--audio-dup is specific to --audio-source=playback"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordFormat != RecordFormat.AUTO && recordFilename.isNotBlank()) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Record format specified without recording"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordFilename.isNotBlank()) {
|
||||||
|
if (!video && !audio) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Video and audio disabled, nothing to record"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordFormat == RecordFormat.AUTO) {
|
||||||
|
// TODO: guess from recordFilename
|
||||||
|
recordFormat = RecordFormat.MP4
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordOrientation != Orientation.ORIENT_0) {
|
||||||
|
if (recordOrientation.isMirror()) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Record orientation only supports rotation, " +
|
||||||
|
"not flipping: ${recordOrientation.string}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (video && recordFormat.isAudioOnly()) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Audio container does not support video stream"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordFormat == RecordFormat.OPUS && audioCodec != Codec.OPUS) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Recording to OPUS file requires an OPUS audio stream " +
|
||||||
|
"(try with --audio-codec=opus)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordFormat == RecordFormat.AAC && audioCodec != Codec.AAC) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Recording to AAC file requires an AAC audio stream " +
|
||||||
|
"(try with --audio-codec=aac)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordFormat == RecordFormat.FLAC && audioCodec != Codec.FLAC) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Recording to FLAC file requires an FLAC audio stream " +
|
||||||
|
"(try with --audio-codec=flac)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordFormat == RecordFormat.WAV && audioCodec != Codec.RAW) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Recording to WAV file requires an RAW audio stream " +
|
||||||
|
"(try with --audio-codec=raw)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((recordFormat == RecordFormat.MP4 || recordFormat == RecordFormat.M4A)
|
||||||
|
&& audioCodec == Codec.RAW
|
||||||
|
) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Recording to MP4 container does not support RAW audio"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
if (audioCodec == Codec.FLAC && audioBitRate > 0u) {
|
||||||
|
// "--audio-bit-rate is ignored for FLAC audio codec"
|
||||||
|
// audioBitRate
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
if (audioCodec == Codec.RAW) {
|
||||||
|
if (audioBitRate > 0u) {
|
||||||
|
// "--audio-bit-rate is ignored for raw audio codec"
|
||||||
|
// audioBitRate
|
||||||
|
}
|
||||||
|
if (audioCodecOptions.isNotBlank()) {
|
||||||
|
// "--audio-codec-options is ignored for raw audio codec"
|
||||||
|
// audioCodecOptions
|
||||||
|
}
|
||||||
|
if (audioEncoder.isNotBlank()) {
|
||||||
|
// "--audio-encoder is ignored for raw audio codec"
|
||||||
|
// audioEncoder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!control) {
|
||||||
|
if (turnScreenOff) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Cannot request to turn screen off if control is disabled"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (stayAwake) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Cannot request to stay awake if control is disabled"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showTouches) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Cannot request to show touches if control is disabled"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (powerOffOnClose) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Cannot request power off on close if control is disabled"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (startApp.isNotBlank()) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Cannot start an Android app if control is disabled"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toServerParams(scid: UInt): ServerParams {
|
||||||
|
return ServerParams(
|
||||||
|
scid = scid,
|
||||||
|
|
||||||
|
logLevel = logLevel,
|
||||||
|
videoCodec = videoCodec,
|
||||||
|
audioCodec = audioCodec,
|
||||||
|
videoSource = videoSource,
|
||||||
|
audioSource = audioSource,
|
||||||
|
cameraFacing = cameraFacing,
|
||||||
|
crop = crop,
|
||||||
|
|
||||||
|
maxSize = maxSize,
|
||||||
|
videoBitRate = videoBitRate,
|
||||||
|
audioBitRate = audioBitRate,
|
||||||
|
maxFps = maxFps,
|
||||||
|
angle = angle,
|
||||||
|
screenOffTimeout = screenOffTimeout,
|
||||||
|
captureOrientation = captureOrientation,
|
||||||
|
captureOrientationLock = captureOrientationLock,
|
||||||
|
control = control,
|
||||||
|
displayId = displayId,
|
||||||
|
newDisplay = newDisplay,
|
||||||
|
displayImePolicy = displayImePolicy,
|
||||||
|
video = video,
|
||||||
|
audio = audio,
|
||||||
|
audioDup = audioDup,
|
||||||
|
showTouches = showTouches,
|
||||||
|
stayAwake = stayAwake,
|
||||||
|
videoCodecOptions = videoCodecOptions,
|
||||||
|
audioCodecOptions = audioCodecOptions,
|
||||||
|
videoEncoder = videoEncoder,
|
||||||
|
audioEncoder = audioEncoder,
|
||||||
|
cameraId = cameraId,
|
||||||
|
cameraSize = cameraSize,
|
||||||
|
cameraAr = cameraAr,
|
||||||
|
cameraFps = cameraFps,
|
||||||
|
|
||||||
|
powerOffOnClose = powerOffOnClose,
|
||||||
|
|
||||||
|
cleanUp = cleanUp,
|
||||||
|
powerOn = powerOn,
|
||||||
|
|
||||||
|
// killAdbOnClose == killAdbOnClose, // client side
|
||||||
|
|
||||||
|
cameraHighSpeed = cameraHighSpeed,
|
||||||
|
vdDestroyContent = vdDestroyContent,
|
||||||
|
vdSystemDecorations = vdSystemDecorations,
|
||||||
|
list = list,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,288 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.scrcpy
|
||||||
|
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.AudioSource
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.DisplayImePolicy
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.LogLevel
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Orientation
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.OrientationLock
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
|
||||||
|
|
||||||
|
// 启动 scrcpy 前直接继承自 [ClientOptions],
|
||||||
|
// 不需要默认值
|
||||||
|
data class ServerParams(
|
||||||
|
var scid: UInt, // (random uint32) & 0x7FFFFFFF
|
||||||
|
|
||||||
|
// var reqSerial: String,
|
||||||
|
|
||||||
|
var logLevel: LogLevel,
|
||||||
|
|
||||||
|
var videoCodec: Codec,
|
||||||
|
var audioCodec: Codec,
|
||||||
|
var videoSource: VideoSource,
|
||||||
|
var audioSource: AudioSource,
|
||||||
|
|
||||||
|
var cameraFacing: CameraFacing,
|
||||||
|
|
||||||
|
var crop: String,
|
||||||
|
|
||||||
|
var videoCodecOptions: String,
|
||||||
|
var audioCodecOptions: String,
|
||||||
|
var videoEncoder: String,
|
||||||
|
var audioEncoder: String,
|
||||||
|
|
||||||
|
var cameraId: String,
|
||||||
|
var cameraSize: String,
|
||||||
|
var cameraAr: String, // aspect ratio
|
||||||
|
var cameraFps: UShort,
|
||||||
|
|
||||||
|
// var portRange: PortRange, // sc_port_range(first, last)
|
||||||
|
// var tunnelHost: UInt,
|
||||||
|
// var tunnelPort: UShort,
|
||||||
|
|
||||||
|
var maxSize: UShort,
|
||||||
|
|
||||||
|
var videoBitRate: Int,
|
||||||
|
var audioBitRate: Int,
|
||||||
|
|
||||||
|
var maxFps: String, // float to be parsed by the server
|
||||||
|
var angle: String, // float to be parsed by the server
|
||||||
|
|
||||||
|
var screenOffTimeout: Tick,
|
||||||
|
|
||||||
|
var captureOrientation: Orientation,
|
||||||
|
var captureOrientationLock: OrientationLock,
|
||||||
|
|
||||||
|
var control: Boolean,
|
||||||
|
|
||||||
|
var displayId: Int,
|
||||||
|
var newDisplay: String,
|
||||||
|
|
||||||
|
var displayImePolicy: DisplayImePolicy,
|
||||||
|
|
||||||
|
var video: Boolean,
|
||||||
|
var audio: Boolean,
|
||||||
|
var audioDup: Boolean,
|
||||||
|
var showTouches: Boolean,
|
||||||
|
var stayAwake: Boolean,
|
||||||
|
|
||||||
|
// var forceAdbForward: Boolean,
|
||||||
|
|
||||||
|
var powerOffOnClose: Boolean,
|
||||||
|
|
||||||
|
// var downsizeOnError: Boolean,
|
||||||
|
// var tcpip: Boolean,
|
||||||
|
// var tcpipDst: String,
|
||||||
|
// var selectUsb: Boolean,
|
||||||
|
// var selectTcpip: Boolean,
|
||||||
|
|
||||||
|
var cleanUp: Boolean,
|
||||||
|
var powerOn: Boolean,
|
||||||
|
|
||||||
|
// var killAdbOnClose: Boolean,
|
||||||
|
|
||||||
|
var cameraHighSpeed: Boolean,
|
||||||
|
|
||||||
|
var vdDestroyContent: Boolean,
|
||||||
|
var vdSystemDecorations: Boolean,
|
||||||
|
|
||||||
|
var list: ListOptions,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val SEPARATOR: String = " "
|
||||||
|
const val ILLEGAL_CHARACTER_SET: String = " ;'\"*$?&`#\\|<>[]{}()!~\r\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validate(str: String) {
|
||||||
|
// forbid special shell characters
|
||||||
|
if (str.any { it in ILLEGAL_CHARACTER_SET }) {
|
||||||
|
throw IllegalArgumentException("Invalid server param: [$str]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun build(vararg extraArgs: String): String {
|
||||||
|
return (extraArgs.toList() + this.toList()).joinToString(SEPARATOR)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toList(simplify: Boolean = false): MutableList<String> {
|
||||||
|
val cmd = mutableListOf<String>()
|
||||||
|
|
||||||
|
if (!simplify) {
|
||||||
|
cmd.add("scid=${scid.toString(16)}")
|
||||||
|
cmd.add("log_level=${logLevel.string}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
cmd.add("video=false")
|
||||||
|
}
|
||||||
|
if (videoBitRate > 0) {
|
||||||
|
cmd.add("video_bit_rate=$videoBitRate")
|
||||||
|
}
|
||||||
|
if (!audio) {
|
||||||
|
cmd.add("audio=false")
|
||||||
|
}
|
||||||
|
if (audioBitRate > 0) {
|
||||||
|
if (!audioCodec.isLossless) {
|
||||||
|
// 比官方实现多个判断编解码器类型
|
||||||
|
cmd.add("audio_bit_rate=$audioBitRate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (videoCodec != Codec.H264) {
|
||||||
|
cmd.add("video_codec=${videoCodec.string}")
|
||||||
|
}
|
||||||
|
if (audioCodec != Codec.OPUS) {
|
||||||
|
cmd.add("audio_codec=${audioCodec.string}")
|
||||||
|
}
|
||||||
|
if (videoSource != VideoSource.DISPLAY) {
|
||||||
|
cmd.add("video_source=${videoSource.string}")
|
||||||
|
}
|
||||||
|
// audio_source (default: output, only add if different and audio is enabled)
|
||||||
|
// Note: AUTO should have been resolved by ClientOptions.validate()
|
||||||
|
if (audioSource != AudioSource.OUTPUT && audio) {
|
||||||
|
cmd.add("audio_source=${audioSource.string}")
|
||||||
|
}
|
||||||
|
if (audioDup) {
|
||||||
|
cmd.add("audio_dup=true")
|
||||||
|
}
|
||||||
|
if (maxSize > 0u) {
|
||||||
|
cmd.add("max_size=$maxSize")
|
||||||
|
}
|
||||||
|
if (maxFps.isNotBlank()) {
|
||||||
|
validate(maxFps)
|
||||||
|
cmd.add("max_fps=${maxFps.trim()}")
|
||||||
|
}
|
||||||
|
if (angle.isNotBlank()) {
|
||||||
|
validate(angle)
|
||||||
|
cmd.add("angle=${angle.trim()}")
|
||||||
|
}
|
||||||
|
if (captureOrientationLock != OrientationLock.UNLOCKED
|
||||||
|
|| captureOrientation != Orientation.ORIENT_0
|
||||||
|
) {
|
||||||
|
when (captureOrientationLock) {
|
||||||
|
OrientationLock.LOCKED_INITIAL ->
|
||||||
|
cmd.add("capture_orientation=@")
|
||||||
|
|
||||||
|
OrientationLock.LOCKED_VALUE ->
|
||||||
|
cmd.add("capture_orientation=@${captureOrientation.string}")
|
||||||
|
|
||||||
|
OrientationLock.UNLOCKED ->
|
||||||
|
cmd.add("capture_orientation=${captureOrientation.string}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// always true cause we are using adb wireless
|
||||||
|
cmd.add("tunnel_forward=true")
|
||||||
|
if (crop.isNotBlank()) {
|
||||||
|
validate(crop)
|
||||||
|
cmd.add("crop=${crop.trim()}")
|
||||||
|
}
|
||||||
|
if (!control) {
|
||||||
|
// By default, control is true
|
||||||
|
cmd.add("control=false")
|
||||||
|
}
|
||||||
|
if (displayId >= 0) {
|
||||||
|
cmd.add("display_id=$displayId")
|
||||||
|
}
|
||||||
|
if (cameraId.isNotBlank()) {
|
||||||
|
validate(cameraId)
|
||||||
|
cmd.add("camera_id=${cameraId.trim()}")
|
||||||
|
}
|
||||||
|
if (cameraSize.isNotBlank()) {
|
||||||
|
validate(cameraSize)
|
||||||
|
cmd.add("camera_size=${cameraSize.trim()}")
|
||||||
|
}
|
||||||
|
if (cameraFacing != CameraFacing.ANY) {
|
||||||
|
cmd.add("camera_facing=${cameraFacing.string}")
|
||||||
|
}
|
||||||
|
if (cameraAr.isNotBlank()) {
|
||||||
|
validate(cameraAr)
|
||||||
|
cmd.add("camera_ar=${cameraAr.trim()}")
|
||||||
|
}
|
||||||
|
if (cameraFps > 0u) {
|
||||||
|
cmd.add("camera_fps=$cameraFps")
|
||||||
|
}
|
||||||
|
if (cameraHighSpeed) {
|
||||||
|
cmd.add("camera_high_speed=true")
|
||||||
|
}
|
||||||
|
if (showTouches) {
|
||||||
|
cmd.add("show_touches=true")
|
||||||
|
}
|
||||||
|
if (stayAwake) {
|
||||||
|
cmd.add("stay_awake=true")
|
||||||
|
}
|
||||||
|
if (screenOffTimeout.value != -1L) {
|
||||||
|
require(screenOffTimeout >= 0) {
|
||||||
|
"screen_off_timeout must be >= 0"
|
||||||
|
}
|
||||||
|
cmd.add("screen_off_timeout=${screenOffTimeout.toMs()}")
|
||||||
|
}
|
||||||
|
if (videoCodecOptions.isNotBlank()) {
|
||||||
|
validate(videoCodecOptions)
|
||||||
|
cmd.add("video_codec_options=${videoCodecOptions.trim()}")
|
||||||
|
}
|
||||||
|
if (audioCodecOptions.isNotBlank()) {
|
||||||
|
validate(audioCodecOptions)
|
||||||
|
cmd.add("audio_codec_options=${audioCodecOptions.trim()}")
|
||||||
|
}
|
||||||
|
if (videoEncoder.isNotBlank()) {
|
||||||
|
validate(videoEncoder)
|
||||||
|
cmd.add("video_encoder=${videoEncoder.trim()}")
|
||||||
|
}
|
||||||
|
if (audioEncoder.isNotBlank()) {
|
||||||
|
validate(audioEncoder)
|
||||||
|
cmd.add("audio_encoder=${audioEncoder.trim()}")
|
||||||
|
}
|
||||||
|
if (powerOffOnClose) {
|
||||||
|
cmd.add("power_off_on_close=true")
|
||||||
|
}
|
||||||
|
// not implemented
|
||||||
|
val clipBoardAutosync = false
|
||||||
|
if (!clipBoardAutosync) {
|
||||||
|
cmd.add("clipboard_autosync=false")
|
||||||
|
}
|
||||||
|
// not implemented
|
||||||
|
val downsizeOnError = false
|
||||||
|
if (!downsizeOnError) {
|
||||||
|
cmd.add("downsize_on_error=false")
|
||||||
|
}
|
||||||
|
if (!cleanUp) {
|
||||||
|
// By default, cleanup is true
|
||||||
|
cmd.add("cleanup=false")
|
||||||
|
}
|
||||||
|
if (!powerOn) {
|
||||||
|
// By default, power_on is true
|
||||||
|
cmd.add("power_on=false")
|
||||||
|
}
|
||||||
|
if (newDisplay.isNotBlank()) {
|
||||||
|
validate(newDisplay)
|
||||||
|
cmd.add("new_display=${newDisplay.trim()}")
|
||||||
|
}
|
||||||
|
if (displayImePolicy != DisplayImePolicy.UNDEFINED) {
|
||||||
|
cmd.add("display_ime_policy=${displayImePolicy.string}")
|
||||||
|
}
|
||||||
|
if (!vdDestroyContent) {
|
||||||
|
cmd.add("vd_destroy_content=false")
|
||||||
|
}
|
||||||
|
if (!vdSystemDecorations) {
|
||||||
|
cmd.add("vd_system_decorations=false")
|
||||||
|
}
|
||||||
|
if (list has ListOptions.ENCODERS) {
|
||||||
|
cmd.add("list_encoders=true")
|
||||||
|
}
|
||||||
|
if (list has ListOptions.DISPLAYS) {
|
||||||
|
cmd.add("list_displays=true")
|
||||||
|
}
|
||||||
|
if (list has ListOptions.CAMERAS) {
|
||||||
|
cmd.add("list_cameras=true")
|
||||||
|
}
|
||||||
|
if (list has ListOptions.CAMERA_SIZES) {
|
||||||
|
cmd.add("list_camera_sizes=true")
|
||||||
|
}
|
||||||
|
if (list has ListOptions.APPS) {
|
||||||
|
cmd.add("list_apps=true")
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.scrcpy
|
||||||
|
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.microseconds
|
||||||
|
|
||||||
|
/*
|
||||||
|
参考官方实现尽量统一行为
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Shared {
|
||||||
|
@JvmInline
|
||||||
|
value class Tick(val value: Long) {
|
||||||
|
fun toNs(): Long = value * 1000
|
||||||
|
fun toUs(): Long = value
|
||||||
|
fun toMs(): Long = value / 1000
|
||||||
|
fun toSec(): Long = value / 1_000_000
|
||||||
|
fun toSecDouble(): Double = value / 1_000_000.0
|
||||||
|
|
||||||
|
operator fun plus(other: Tick): Tick = Tick(value + other.value)
|
||||||
|
operator fun minus(other: Tick): Tick = Tick(value - other.value)
|
||||||
|
operator fun times(scale: Long): Tick = Tick(value * scale)
|
||||||
|
operator fun div(scale: Long): Tick = Tick(value / scale)
|
||||||
|
operator fun compareTo(other: Tick): Int = value.compareTo(other.value)
|
||||||
|
operator fun compareTo(other: Long): Int = value.compareTo(other)
|
||||||
|
operator fun compareTo(other: Int): Int = value.compareTo(other.toLong())
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val FREQ = 1_000_000 // 微秒/秒
|
||||||
|
fun fromNs(ns: Long): Tick = Tick(ns / 1000)
|
||||||
|
fun fromUs(us: Long): Tick = Tick(us)
|
||||||
|
fun fromMs(ms: Long): Tick = Tick(ms * 1000)
|
||||||
|
fun fromSec(sec: Long): Tick = Tick(sec * 1_000_000)
|
||||||
|
fun fromSecDouble(sec: Double): Tick = Tick((sec * 1_000_000).toLong())
|
||||||
|
fun fromDuration(duration: Duration): Tick = Tick(duration.inWholeMicroseconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toDuration(): Duration = value.microseconds
|
||||||
|
fun Duration.toTick(): Tick = Tick(this.inWholeMicroseconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ListOptions(val value: Int) {
|
||||||
|
NULL(0x0),
|
||||||
|
ENCODERS(0x1), // --list-encoders
|
||||||
|
DISPLAYS(0x2), // --list-displays
|
||||||
|
CAMERAS(0x4), // --list-cameras
|
||||||
|
CAMERA_SIZES(0x8), // --list-camera-sizes
|
||||||
|
APPS(0x10); // --list-apps
|
||||||
|
|
||||||
|
infix fun or(other: ListOptions) = value or other.value
|
||||||
|
infix fun and(other: ListOptions) = value and other.value
|
||||||
|
infix fun has(other: ListOptions) = this and other != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class EncoderType(val s: String) {
|
||||||
|
HARDWARE("hw"),
|
||||||
|
SOFTWARE("sw"),
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class LogLevel(val string: String) {
|
||||||
|
VERBOSE("verbose"),
|
||||||
|
DEBUG("debug"),
|
||||||
|
INFO("info"),
|
||||||
|
WARN("warn"),
|
||||||
|
ERROR("error");
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Codec(
|
||||||
|
val string: String,
|
||||||
|
val displayName: String,
|
||||||
|
val type: Type,
|
||||||
|
val id: Int,
|
||||||
|
val isLossless: Boolean,
|
||||||
|
) {
|
||||||
|
H264("h264", "H.264", Type.VIDEO, 0x68323634, false), // default, ignore when passing
|
||||||
|
H265("h265", "H.265", Type.VIDEO, 0x68323635, false),
|
||||||
|
AV1("av1", "AV1", Type.VIDEO, 0x00617631, false),
|
||||||
|
|
||||||
|
OPUS("opus", "Opus", Type.AUDIO, 0x6f707573, false), // default, ignore when passing
|
||||||
|
AAC("aac", "AAC", Type.AUDIO, 0x00616163, false),
|
||||||
|
FLAC("flac", "FLAC", Type.AUDIO, 0x666c6163, true),
|
||||||
|
RAW("raw", "RAW", Type.AUDIO, 0x00726177, true); // wav raw
|
||||||
|
|
||||||
|
enum class Type { VIDEO, AUDIO }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val VIDEO = entries.filter { it.type == Type.VIDEO }
|
||||||
|
val AUDIO = entries.filter { it.type == Type.AUDIO }
|
||||||
|
|
||||||
|
fun fromString(value: String, type: Type = Type.VIDEO) =
|
||||||
|
entries.find { it.string.equals(value, ignoreCase = true) }
|
||||||
|
?: when (type) {
|
||||||
|
Type.VIDEO -> H264
|
||||||
|
Type.AUDIO -> OPUS
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fromId(value: Int, type: Type? = null): Codec? =
|
||||||
|
entries.find { it.id == value && (type == null || it.type == type) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class VideoSource(val string: String) {
|
||||||
|
DISPLAY("display"), // default, ignore when passing
|
||||||
|
CAMERA("camera");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromString(value: String) =
|
||||||
|
entries.find { it.string.equals(value, ignoreCase = true) }
|
||||||
|
?: DISPLAY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AudioSource(val string: String) {
|
||||||
|
AUTO("auto"), // OUTPUT for video DISPLAY, MIC for video CAMERA
|
||||||
|
OUTPUT("output"),
|
||||||
|
MIC("mic"),
|
||||||
|
PLAYBACK("playback"),
|
||||||
|
MIC_UNPROCESSED("mic-unprocessed"),
|
||||||
|
MIC_CAMCORDER("mic-camcorder"),
|
||||||
|
MIC_VOICE_RECOGNITION("mic-voice-recognition"),
|
||||||
|
MIC_VOICE_COMMUNICATION("mic-voice-communication"),
|
||||||
|
VOICE_CALL("voice-call"),
|
||||||
|
VOICE_CALL_UPLINK("voice-call-uplink"),
|
||||||
|
VOICE_CALL_DOWNLINK("voice-call-downlink"),
|
||||||
|
VOICE_PERFORMANCE("voice-performance");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromString(value: String) =
|
||||||
|
entries.find { it.string.equals(value, ignoreCase = true) }
|
||||||
|
?: AUTO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class CameraFacing(val string: String) {
|
||||||
|
ANY("any"), // default, ignore when passing
|
||||||
|
FRONT("front"),
|
||||||
|
BACK("back"),
|
||||||
|
EXTERNAL("external");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromString(value: String) =
|
||||||
|
entries.find { it.string.equals(value, ignoreCase = true) }
|
||||||
|
?: ANY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Orientation(val string: String) {
|
||||||
|
ORIENT_0("0"),
|
||||||
|
ORIENT_90("90"),
|
||||||
|
ORIENT_180("180"),
|
||||||
|
ORIENT_270("270"),
|
||||||
|
FLIP_0("flip0"),
|
||||||
|
FLIP_90("flip90"),
|
||||||
|
FLIP_180("flip180"),
|
||||||
|
FLIP_270("flip270");
|
||||||
|
|
||||||
|
fun isMirror(): Boolean = ordinal and 4 != 0
|
||||||
|
fun isSwap(): Boolean = ordinal and 1 != 0
|
||||||
|
fun getRotation(): Orientation {
|
||||||
|
return when (ordinal and 3) {
|
||||||
|
0 -> ORIENT_0
|
||||||
|
1 -> ORIENT_90
|
||||||
|
2 -> ORIENT_180
|
||||||
|
3 -> ORIENT_270
|
||||||
|
else -> error("Invalid rotation value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromInt(value: Int): Orientation {
|
||||||
|
return entries.getOrNull(value)
|
||||||
|
?: error("Invalid orientation value: $value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class OrientationLock(val string: String) {
|
||||||
|
UNLOCKED("unlocked"), // ignore
|
||||||
|
LOCKED_VALUE("locked_value"), // "@${orientation.string}"
|
||||||
|
LOCKED_INITIAL("locked_initial"); // "@"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class DisplayImePolicy(val string: String) {
|
||||||
|
UNDEFINED("undefined"), // default, ignore when passing
|
||||||
|
LOCAL("local"),
|
||||||
|
FALLBACK("fallback"),
|
||||||
|
HIDE("hide");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.scrcpy
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
class TouchEventHandler(
|
||||||
|
private val coroutineScope: CoroutineScope,
|
||||||
|
private val session: Scrcpy.Session.SessionInfo,
|
||||||
|
private val touchAreaSize: IntSize,
|
||||||
|
private val activePointerIds: LinkedHashSet<Int>,
|
||||||
|
private val activePointerPositions: LinkedHashMap<Int, Offset>,
|
||||||
|
private val activePointerDevicePositions: LinkedHashMap<Int, Pair<Int, Int>>,
|
||||||
|
private val pointerLabels: LinkedHashMap<Int, Int>,
|
||||||
|
private var nextPointerLabel: Int,
|
||||||
|
private val onInjectTouch: suspend (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit,
|
||||||
|
private val onActiveTouchCountChanged: (Int) -> Unit,
|
||||||
|
private val onActiveTouchDebugChanged: (String) -> Unit,
|
||||||
|
private val onNextPointerLabelChanged: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val FULLSCREEN_TOUCH_LOG_TAG = "FullscreenTouch"
|
||||||
|
}
|
||||||
|
|
||||||
|
private object UiMotionActions {
|
||||||
|
const val DOWN = 0
|
||||||
|
const val UP = 1
|
||||||
|
const val MOVE = 2
|
||||||
|
const val CANCEL = 3
|
||||||
|
const val POINTER_DOWN = 5
|
||||||
|
const val POINTER_UP = 6
|
||||||
|
}
|
||||||
|
|
||||||
|
private val eventPointerIds = HashSet<Int>(10)
|
||||||
|
private val eventPositions = HashMap<Int, Offset>(10)
|
||||||
|
private val eventPressures = HashMap<Int, Float>(10)
|
||||||
|
private val justPressedPointerIds = HashSet<Int>(10)
|
||||||
|
|
||||||
|
fun handleMotionEvent(event: MotionEvent): Boolean {
|
||||||
|
if (touchAreaSize.width == 0 || touchAreaSize.height == 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
val bounds = calculateContentBounds()
|
||||||
|
|
||||||
|
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
||||||
|
return handleCancelAction(bounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
extractEventData(event)
|
||||||
|
handleDisappearedPointers(eventPointerIds, bounds)
|
||||||
|
|
||||||
|
val endedPointerId = getEndedPointerId(event)
|
||||||
|
handlePointerDown(event, endedPointerId, bounds)
|
||||||
|
handlePointerMove(event, endedPointerId, bounds)
|
||||||
|
handlePointerUp(endedPointerId, bounds)
|
||||||
|
|
||||||
|
onActiveTouchCountChanged(activePointerIds.size)
|
||||||
|
refreshTouchDebug()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ContentBounds(
|
||||||
|
val width: Float,
|
||||||
|
val height: Float,
|
||||||
|
val left: Float,
|
||||||
|
val top: Float,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun calculateContentBounds(): ContentBounds {
|
||||||
|
val sessionAspect = if (session.height == 0) {
|
||||||
|
16f / 9f
|
||||||
|
} else {
|
||||||
|
session.width.toFloat() / session.height.toFloat()
|
||||||
|
}
|
||||||
|
val containerWidth = touchAreaSize.width.toFloat()
|
||||||
|
val containerHeight = touchAreaSize.height.toFloat()
|
||||||
|
val containerAspect = containerWidth / containerHeight
|
||||||
|
|
||||||
|
val contentWidth: Float
|
||||||
|
val contentHeight: Float
|
||||||
|
if (sessionAspect > containerAspect) {
|
||||||
|
contentWidth = containerWidth
|
||||||
|
contentHeight = containerWidth / sessionAspect
|
||||||
|
} else {
|
||||||
|
contentHeight = containerHeight
|
||||||
|
contentWidth = containerHeight * sessionAspect
|
||||||
|
}
|
||||||
|
val contentLeft = (containerWidth - contentWidth) / 2f
|
||||||
|
val contentTop = (containerHeight - contentHeight) / 2f
|
||||||
|
|
||||||
|
return ContentBounds(contentWidth, contentHeight, contentLeft, contentTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isInsideContent(rawX: Float, rawY: Float, bounds: ContentBounds): Boolean {
|
||||||
|
return rawX in bounds.left..(bounds.left + bounds.width) &&
|
||||||
|
rawY in bounds.top..(bounds.top + bounds.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapToDevice(rawX: Float, rawY: Float, bounds: ContentBounds): Pair<Int, Int> {
|
||||||
|
val normalizedX = ((rawX - bounds.left) / bounds.width).coerceIn(0f, 1f)
|
||||||
|
val normalizedY = ((rawY - bounds.top) / bounds.height).coerceIn(0f, 1f)
|
||||||
|
val x = (normalizedX * (session.width - 1).coerceAtLeast(0)).roundToInt()
|
||||||
|
.coerceIn(0, (session.width - 1).coerceAtLeast(0))
|
||||||
|
val y = (normalizedY * (session.height - 1).coerceAtLeast(0)).roundToInt()
|
||||||
|
.coerceIn(0, (session.height - 1).coerceAtLeast(0))
|
||||||
|
return x to y
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPointerLabel(pointerId: Int): Int {
|
||||||
|
val existing = pointerLabels[pointerId]
|
||||||
|
if (existing != null) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
val assigned = nextPointerLabel
|
||||||
|
nextPointerLabel += 1
|
||||||
|
onNextPointerLabelChanged(nextPointerLabel)
|
||||||
|
pointerLabels[pointerId] = assigned
|
||||||
|
return assigned
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshTouchDebug() {
|
||||||
|
if (activePointerIds.isEmpty()) {
|
||||||
|
onActiveTouchDebugChanged("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val debug = activePointerIds
|
||||||
|
.sortedBy { getPointerLabel(it) }
|
||||||
|
.joinToString(separator = "\n") { pointerId ->
|
||||||
|
val label = getPointerLabel(pointerId)
|
||||||
|
val pos = activePointerDevicePositions[pointerId]
|
||||||
|
if (pos == null) {
|
||||||
|
"#$label(id=$pointerId):?"
|
||||||
|
} else {
|
||||||
|
"#$label(id=$pointerId):${pos.first},${pos.second}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onActiveTouchDebugChanged(debug)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releasePointer(pointerId: Int, bounds: ContentBounds) {
|
||||||
|
if (!activePointerIds.contains(pointerId)) return
|
||||||
|
val pos = activePointerPositions[pointerId] ?: Offset.Zero
|
||||||
|
val (x, y) = mapToDevice(pos.x, pos.y, bounds)
|
||||||
|
coroutineScope.launch {
|
||||||
|
runCatching {
|
||||||
|
onInjectTouch(UiMotionActions.UP, pointerId.toLong(), x, y, 0f, 0)
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.w(FULLSCREEN_TOUCH_LOG_TAG, "releasePointer failed for pointerId=$pointerId", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activePointerIds -= pointerId
|
||||||
|
activePointerPositions.remove(pointerId)
|
||||||
|
activePointerDevicePositions.remove(pointerId)
|
||||||
|
pointerLabels.remove(pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleCancelAction(bounds: ContentBounds): Boolean {
|
||||||
|
val toCancel = activePointerIds.toList()
|
||||||
|
for (pointerId in toCancel) {
|
||||||
|
releasePointer(pointerId, bounds)
|
||||||
|
}
|
||||||
|
onActiveTouchCountChanged(activePointerIds.size)
|
||||||
|
refreshTouchDebug()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractEventData(event: MotionEvent) {
|
||||||
|
eventPointerIds.clear()
|
||||||
|
eventPositions.clear()
|
||||||
|
eventPressures.clear()
|
||||||
|
for (i in 0 until event.pointerCount) {
|
||||||
|
val pointerId = event.getPointerId(i)
|
||||||
|
eventPointerIds += pointerId
|
||||||
|
eventPositions[pointerId] = Offset(event.getX(i), event.getY(i))
|
||||||
|
eventPressures[pointerId] = event.getPressure(i).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDisappearedPointers(eventPointerIds: Set<Int>, bounds: ContentBounds) {
|
||||||
|
val disappearedPointers = activePointerIds.filter { it !in eventPointerIds }
|
||||||
|
for (pointerId in disappearedPointers) {
|
||||||
|
releasePointer(pointerId, bounds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getEndedPointerId(event: MotionEvent): Int? {
|
||||||
|
return when (event.actionMasked) {
|
||||||
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> event.getPointerId(event.actionIndex)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handlePointerDown(
|
||||||
|
event: MotionEvent,
|
||||||
|
endedPointerId: Int?,
|
||||||
|
bounds: ContentBounds,
|
||||||
|
) {
|
||||||
|
justPressedPointerIds.clear()
|
||||||
|
for (i in 0 until event.pointerCount) {
|
||||||
|
val pointerId = event.getPointerId(i)
|
||||||
|
if (pointerId == endedPointerId) continue
|
||||||
|
val raw = eventPositions[pointerId] ?: continue
|
||||||
|
val pressure = eventPressures[pointerId] ?: 0f
|
||||||
|
if (!activePointerIds.contains(pointerId)) {
|
||||||
|
if (!isInsideContent(raw.x, raw.y, bounds)) continue
|
||||||
|
val (x, y) = mapToDevice(raw.x, raw.y, bounds)
|
||||||
|
activePointerIds += pointerId
|
||||||
|
activePointerPositions[pointerId] = raw
|
||||||
|
activePointerDevicePositions[pointerId] = x to y
|
||||||
|
justPressedPointerIds += pointerId
|
||||||
|
coroutineScope.launch {
|
||||||
|
runCatching {
|
||||||
|
onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, pressure, 0)
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.w(
|
||||||
|
FULLSCREEN_TOUCH_LOG_TAG,
|
||||||
|
"handlePointerDown failed for pointerId=$pointerId",
|
||||||
|
e
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handlePointerMove(
|
||||||
|
event: MotionEvent,
|
||||||
|
endedPointerId: Int?,
|
||||||
|
bounds: ContentBounds,
|
||||||
|
) {
|
||||||
|
for (i in 0 until event.pointerCount) {
|
||||||
|
val pointerId = event.getPointerId(i)
|
||||||
|
if (!activePointerIds.contains(pointerId)) continue
|
||||||
|
if (pointerId == endedPointerId) continue
|
||||||
|
if (pointerId in justPressedPointerIds) continue
|
||||||
|
val raw = eventPositions[pointerId] ?: continue
|
||||||
|
val pressure = eventPressures[pointerId] ?: 0f
|
||||||
|
activePointerPositions[pointerId] = raw
|
||||||
|
val (x, y) = mapToDevice(raw.x, raw.y, bounds)
|
||||||
|
activePointerDevicePositions[pointerId] = x to y
|
||||||
|
coroutineScope.launch {
|
||||||
|
runCatching {
|
||||||
|
onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, pressure, 0)
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.w(
|
||||||
|
FULLSCREEN_TOUCH_LOG_TAG,
|
||||||
|
"handlePointerMove failed for pointerId=$pointerId",
|
||||||
|
e
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handlePointerUp(
|
||||||
|
endedPointerId: Int?,
|
||||||
|
bounds: ContentBounds,
|
||||||
|
) {
|
||||||
|
if (endedPointerId != null) {
|
||||||
|
val endPos = eventPositions[endedPointerId]
|
||||||
|
if (endPos != null) {
|
||||||
|
activePointerPositions[endedPointerId] = endPos
|
||||||
|
}
|
||||||
|
releasePointer(endedPointerId, bounds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,528 +0,0 @@
|
|||||||
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 audioEnabled: Boolean = AppDefaults.AUDIO_ENABLED,
|
|
||||||
val audioCodec: String = AppDefaults.AUDIO_CODEC,
|
|
||||||
val videoCodec: String = AppDefaults.VIDEO_CODEC,
|
|
||||||
val themeBaseIndex: Int = AppDefaults.THEME_BASE_INDEX,
|
|
||||||
val monetEnabled: Boolean = AppDefaults.MONET,
|
|
||||||
val fullscreenDebugInfoEnabled: Boolean = AppDefaults.FULLSCREEN_DEBUG_INFO,
|
|
||||||
val showFullscreenVirtualButtons: Boolean = AppDefaults.SHOW_FULLSCREEN_VIRTUAL_BUTTONS,
|
|
||||||
val showPreviewVirtualButtonText: Boolean = AppDefaults.PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT,
|
|
||||||
val keepScreenOnWhenStreamingEnabled: Boolean = AppDefaults.KEEP_SCREEN_ON_WHEN_STREAMING,
|
|
||||||
val devicePreviewCardHeightDp: Int = AppDefaults.DEVICE_PREVIEW_CARD_HEIGHT_DP,
|
|
||||||
val virtualButtonsLayout: String = AppDefaults.VIRTUAL_BUTTONS_LAYOUT,
|
|
||||||
val customServerUri: String? = AppDefaults.CUSTOM_SERVER_URI,
|
|
||||||
val serverRemotePath: String = AppDefaults.SERVER_REMOTE_PATH_INPUT,
|
|
||||||
val adbKeyName: String = AppDefaults.ADB_KEY_NAME_INPUT,
|
|
||||||
val adbPairingAutoDiscoverOnDialogOpen: Boolean =
|
|
||||||
AppDefaults.ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN,
|
|
||||||
val adbAutoReconnectPairedDevice: Boolean = AppDefaults.ADB_AUTO_RECONNECT_PAIRED_DEVICE,
|
|
||||||
val adbMdnsLanDiscoveryEnabled: Boolean = AppDefaults.ADB_MDNS_LAN_DISCOVERY,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal data class DevicePageSettings(
|
|
||||||
val quickConnectInput: String = AppDefaults.QUICK_CONNECT_INPUT,
|
|
||||||
val pairHost: String = AppDefaults.PAIR_HOST,
|
|
||||||
val pairPort: String = AppDefaults.PAIR_PORT,
|
|
||||||
val pairCode: String = AppDefaults.PAIR_CODE,
|
|
||||||
val audioBitRateKbps: Int = AppDefaults.AUDIO_BIT_RATE_KBPS,
|
|
||||||
val audioBitRateInput: String = AppDefaults.AUDIO_BIT_RATE_INPUT,
|
|
||||||
val videoBitRateMbps: Float = AppDefaults.VIDEO_BIT_RATE_MBPS,
|
|
||||||
val videoBitRateInput: String = AppDefaults.VIDEO_BIT_RATE_INPUT,
|
|
||||||
val turnScreenOff: Boolean = AppDefaults.TURN_SCREEN_OFF,
|
|
||||||
val noControl: Boolean = AppDefaults.NO_CONTROL,
|
|
||||||
val noVideo: Boolean = AppDefaults.NO_VIDEO,
|
|
||||||
val videoSourcePreset: String = AppDefaults.VIDEO_SOURCE_PRESET,
|
|
||||||
val displayIdInput: String = AppDefaults.DISPLAY_ID,
|
|
||||||
val cameraIdInput: String = AppDefaults.CAMERA_ID,
|
|
||||||
val cameraFacingPreset: String = AppDefaults.CAMERA_FACING_PRESET,
|
|
||||||
val cameraSizePreset: String = AppDefaults.CAMERA_SIZE_PRESET,
|
|
||||||
val cameraSizeCustom: String = AppDefaults.CAMERA_SIZE_CUSTOM,
|
|
||||||
val cameraAr: String = AppDefaults.CAMERA_AR,
|
|
||||||
val cameraFps: String = AppDefaults.CAMERA_FPS,
|
|
||||||
val cameraHighSpeed: Boolean = AppDefaults.CAMERA_HIGH_SPEED,
|
|
||||||
val audioSourcePreset: String = AppDefaults.AUDIO_SOURCE_PRESET,
|
|
||||||
val audioSourceCustom: String = AppDefaults.AUDIO_SOURCE_CUSTOM,
|
|
||||||
val audioDup: Boolean = AppDefaults.AUDIO_DUP,
|
|
||||||
val noAudioPlayback: Boolean = AppDefaults.NO_AUDIO_PLAYBACK,
|
|
||||||
val requireAudio: Boolean = AppDefaults.REQUIRE_AUDIO,
|
|
||||||
val maxSizeInput: String = AppDefaults.MAX_SIZE_INPUT,
|
|
||||||
val maxFpsInput: String = AppDefaults.MAX_FPS_INPUT,
|
|
||||||
val videoEncoder: String = AppDefaults.VIDEO_ENCODER,
|
|
||||||
val videoCodecOptions: String = AppDefaults.VIDEO_CODEC_OPTION,
|
|
||||||
val audioEncoder: String = AppDefaults.AUDIO_ENCODER,
|
|
||||||
val audioCodecOptions: String = AppDefaults.AUDIO_CODEC_OPTION,
|
|
||||||
val newDisplayWidth: String = AppDefaults.NEW_DISPLAY_WIDTH,
|
|
||||||
val newDisplayHeight: String = AppDefaults.NEW_DISPLAY_HEIGHT,
|
|
||||||
val newDisplayDpi: String = AppDefaults.NEW_DISPLAY_DPI,
|
|
||||||
val cropWidth: String = AppDefaults.CROP_WIDTH,
|
|
||||||
val cropHeight: String = AppDefaults.CROP_HEIGHT,
|
|
||||||
val cropX: String = AppDefaults.CROP_X,
|
|
||||||
val cropY: String = AppDefaults.CROP_Y,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal fun loadMainSettings(context: Context): MainSettings {
|
|
||||||
val prefs = context.getSharedPreferences(
|
|
||||||
AppPreferenceKeys.PREFS_NAME,
|
|
||||||
Context.MODE_PRIVATE,
|
|
||||||
)
|
|
||||||
return MainSettings(
|
|
||||||
audioEnabled = prefs.getBoolean(
|
|
||||||
AppPreferenceKeys.AUDIO_ENABLED,
|
|
||||||
AppDefaults.AUDIO_ENABLED,
|
|
||||||
),
|
|
||||||
audioCodec = prefs.getString(
|
|
||||||
AppPreferenceKeys.AUDIO_CODEC,
|
|
||||||
AppDefaults.AUDIO_CODEC,
|
|
||||||
).orEmpty().ifBlank { AppDefaults.AUDIO_CODEC },
|
|
||||||
videoCodec = prefs.getString(
|
|
||||||
AppPreferenceKeys.VIDEO_CODEC,
|
|
||||||
AppDefaults.VIDEO_CODEC,
|
|
||||||
).orEmpty().ifBlank { AppDefaults.VIDEO_CODEC },
|
|
||||||
themeBaseIndex = prefs.getInt(
|
|
||||||
AppPreferenceKeys.THEME_BASE_INDEX,
|
|
||||||
AppDefaults.THEME_BASE_INDEX,
|
|
||||||
),
|
|
||||||
monetEnabled = prefs.getBoolean(
|
|
||||||
AppPreferenceKeys.MONET,
|
|
||||||
AppDefaults.MONET,
|
|
||||||
),
|
|
||||||
fullscreenDebugInfoEnabled = prefs.getBoolean(
|
|
||||||
AppPreferenceKeys.FULLSCREEN_DEBUG_INFO,
|
|
||||||
AppDefaults.FULLSCREEN_DEBUG_INFO,
|
|
||||||
),
|
|
||||||
showFullscreenVirtualButtons = prefs.getBoolean(
|
|
||||||
AppPreferenceKeys.SHOW_FULLSCREEN_VIRTUAL_BUTTONS,
|
|
||||||
AppDefaults.SHOW_FULLSCREEN_VIRTUAL_BUTTONS,
|
|
||||||
),
|
|
||||||
showPreviewVirtualButtonText = prefs.getBoolean(
|
|
||||||
AppPreferenceKeys.PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT,
|
|
||||||
AppDefaults.PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT,
|
|
||||||
),
|
|
||||||
keepScreenOnWhenStreamingEnabled = prefs.getBoolean(
|
|
||||||
AppPreferenceKeys.KEEP_SCREEN_ON_WHEN_STREAMING,
|
|
||||||
AppDefaults.KEEP_SCREEN_ON_WHEN_STREAMING,
|
|
||||||
),
|
|
||||||
devicePreviewCardHeightDp = prefs.getInt(
|
|
||||||
AppPreferenceKeys.DEVICE_PREVIEW_CARD_HEIGHT_DP,
|
|
||||||
AppDefaults.DEVICE_PREVIEW_CARD_HEIGHT_DP,
|
|
||||||
).coerceAtLeast(120),
|
|
||||||
virtualButtonsLayout = prefs.getString(
|
|
||||||
AppPreferenceKeys.VIRTUAL_BUTTONS_LAYOUT,
|
|
||||||
AppDefaults.VIRTUAL_BUTTONS_LAYOUT,
|
|
||||||
).orEmpty().ifBlank { AppDefaults.VIRTUAL_BUTTONS_LAYOUT },
|
|
||||||
customServerUri = prefs.getString(
|
|
||||||
AppPreferenceKeys.CUSTOM_SERVER_URI,
|
|
||||||
AppDefaults.CUSTOM_SERVER_URI
|
|
||||||
).orEmpty().ifBlank { null },
|
|
||||||
serverRemotePath = prefs.getString(
|
|
||||||
AppPreferenceKeys.SERVER_REMOTE_PATH,
|
|
||||||
AppDefaults.SERVER_REMOTE_PATH_INPUT,
|
|
||||||
).orEmpty(),
|
|
||||||
adbKeyName = prefs.getString(
|
|
||||||
AppPreferenceKeys.ADB_KEY_NAME,
|
|
||||||
AppDefaults.ADB_KEY_NAME_INPUT,
|
|
||||||
).orEmpty(),
|
|
||||||
adbPairingAutoDiscoverOnDialogOpen = prefs.getBoolean(
|
|
||||||
AppPreferenceKeys.ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN,
|
|
||||||
AppDefaults.ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN,
|
|
||||||
),
|
|
||||||
adbAutoReconnectPairedDevice = prefs.getBoolean(
|
|
||||||
AppPreferenceKeys.ADB_AUTO_RECONNECT_PAIRED_DEVICE,
|
|
||||||
AppDefaults.ADB_AUTO_RECONNECT_PAIRED_DEVICE,
|
|
||||||
),
|
|
||||||
adbMdnsLanDiscoveryEnabled = prefs.getBoolean(
|
|
||||||
AppPreferenceKeys.ADB_MDNS_LAN_DISCOVERY,
|
|
||||||
AppDefaults.ADB_MDNS_LAN_DISCOVERY,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun saveMainSettings(context: Context, settings: MainSettings) {
|
|
||||||
context.getSharedPreferences(
|
|
||||||
AppPreferenceKeys.PREFS_NAME,
|
|
||||||
Context.MODE_PRIVATE,
|
|
||||||
).edit {
|
|
||||||
putBoolean(
|
|
||||||
AppPreferenceKeys.AUDIO_ENABLED,
|
|
||||||
settings.audioEnabled,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.AUDIO_CODEC,
|
|
||||||
settings.audioCodec,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.VIDEO_CODEC,
|
|
||||||
settings.videoCodec,
|
|
||||||
)
|
|
||||||
.putInt(
|
|
||||||
AppPreferenceKeys.THEME_BASE_INDEX,
|
|
||||||
settings.themeBaseIndex,
|
|
||||||
)
|
|
||||||
.putBoolean(
|
|
||||||
AppPreferenceKeys.MONET,
|
|
||||||
settings.monetEnabled,
|
|
||||||
)
|
|
||||||
.putBoolean(
|
|
||||||
AppPreferenceKeys.FULLSCREEN_DEBUG_INFO,
|
|
||||||
settings.fullscreenDebugInfoEnabled,
|
|
||||||
)
|
|
||||||
.putBoolean(
|
|
||||||
AppPreferenceKeys.SHOW_FULLSCREEN_VIRTUAL_BUTTONS,
|
|
||||||
settings.showFullscreenVirtualButtons,
|
|
||||||
)
|
|
||||||
.putBoolean(
|
|
||||||
AppPreferenceKeys.PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT,
|
|
||||||
settings.showPreviewVirtualButtonText,
|
|
||||||
)
|
|
||||||
.putBoolean(
|
|
||||||
AppPreferenceKeys.KEEP_SCREEN_ON_WHEN_STREAMING,
|
|
||||||
settings.keepScreenOnWhenStreamingEnabled,
|
|
||||||
)
|
|
||||||
.putInt(
|
|
||||||
AppPreferenceKeys.DEVICE_PREVIEW_CARD_HEIGHT_DP,
|
|
||||||
settings.devicePreviewCardHeightDp.coerceAtLeast(120),
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.VIRTUAL_BUTTONS_LAYOUT,
|
|
||||||
settings.virtualButtonsLayout,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.CUSTOM_SERVER_URI,
|
|
||||||
settings.customServerUri,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.SERVER_REMOTE_PATH,
|
|
||||||
settings.serverRemotePath,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.ADB_KEY_NAME,
|
|
||||||
settings.adbKeyName,
|
|
||||||
)
|
|
||||||
.putBoolean(
|
|
||||||
AppPreferenceKeys.ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN,
|
|
||||||
settings.adbPairingAutoDiscoverOnDialogOpen,
|
|
||||||
)
|
|
||||||
.putBoolean(
|
|
||||||
AppPreferenceKeys.ADB_AUTO_RECONNECT_PAIRED_DEVICE,
|
|
||||||
settings.adbAutoReconnectPairedDevice,
|
|
||||||
)
|
|
||||||
.putBoolean(
|
|
||||||
AppPreferenceKeys.ADB_MDNS_LAN_DISCOVERY,
|
|
||||||
settings.adbMdnsLanDiscoveryEnabled,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun loadDevicePageSettings(context: Context): DevicePageSettings {
|
|
||||||
val prefs = context.getSharedPreferences(
|
|
||||||
AppPreferenceKeys.PREFS_NAME,
|
|
||||||
Context.MODE_PRIVATE,
|
|
||||||
)
|
|
||||||
val audioBitRateKbps = prefs.getInt(
|
|
||||||
AppPreferenceKeys.AUDIO_BIT_RATE_KBPS,
|
|
||||||
AppDefaults.AUDIO_BIT_RATE_KBPS,
|
|
||||||
)
|
|
||||||
return DevicePageSettings(
|
|
||||||
quickConnectInput = prefs.getString(
|
|
||||||
AppPreferenceKeys.QUICK_CONNECT_INPUT,
|
|
||||||
AppDefaults.QUICK_CONNECT_INPUT,
|
|
||||||
).orEmpty(),
|
|
||||||
pairHost = AppDefaults.PAIR_HOST,
|
|
||||||
pairPort = AppDefaults.PAIR_PORT,
|
|
||||||
pairCode = AppDefaults.PAIR_CODE,
|
|
||||||
audioBitRateKbps = audioBitRateKbps,
|
|
||||||
audioBitRateInput = prefs.getString(
|
|
||||||
AppPreferenceKeys.AUDIO_BIT_RATE_INPUT,
|
|
||||||
AppDefaults.AUDIO_BIT_RATE_INPUT,
|
|
||||||
).orEmpty().ifBlank { audioBitRateKbps.toString() },
|
|
||||||
videoBitRateMbps = prefs.getFloat(
|
|
||||||
AppPreferenceKeys.VIDEO_BIT_RATE_MBPS,
|
|
||||||
AppDefaults.VIDEO_BIT_RATE_MBPS,
|
|
||||||
),
|
|
||||||
videoBitRateInput = prefs.getString(
|
|
||||||
AppPreferenceKeys.VIDEO_BIT_RATE_INPUT,
|
|
||||||
AppDefaults.VIDEO_BIT_RATE_INPUT
|
|
||||||
).orEmpty().ifBlank { AppDefaults.VIDEO_BIT_RATE_INPUT },
|
|
||||||
turnScreenOff = prefs.getBoolean(
|
|
||||||
AppPreferenceKeys.TURN_SCREEN_OFF,
|
|
||||||
AppDefaults.TURN_SCREEN_OFF,
|
|
||||||
),
|
|
||||||
noControl = prefs.getBoolean(
|
|
||||||
AppPreferenceKeys.NO_CONTROL,
|
|
||||||
AppDefaults.NO_CONTROL,
|
|
||||||
),
|
|
||||||
noVideo = prefs.getBoolean(
|
|
||||||
AppPreferenceKeys.NO_VIDEO,
|
|
||||||
AppDefaults.NO_VIDEO,
|
|
||||||
),
|
|
||||||
videoSourcePreset = prefs.getString(
|
|
||||||
AppPreferenceKeys.VIDEO_SOURCE_PRESET,
|
|
||||||
AppDefaults.VIDEO_SOURCE_PRESET,
|
|
||||||
).orEmpty().ifBlank { AppDefaults.VIDEO_SOURCE_PRESET },
|
|
||||||
displayIdInput = prefs.getString(
|
|
||||||
AppPreferenceKeys.DISPLAY_ID,
|
|
||||||
AppDefaults.DISPLAY_ID,
|
|
||||||
)
|
|
||||||
.orEmpty(),
|
|
||||||
cameraIdInput = prefs.getString(
|
|
||||||
AppPreferenceKeys.CAMERA_ID,
|
|
||||||
AppDefaults.CAMERA_ID,
|
|
||||||
)
|
|
||||||
.orEmpty(),
|
|
||||||
cameraFacingPreset = prefs.getString(
|
|
||||||
AppPreferenceKeys.CAMERA_FACING_PRESET,
|
|
||||||
AppDefaults.CAMERA_FACING_PRESET,
|
|
||||||
).orEmpty(),
|
|
||||||
cameraSizePreset = prefs.getString(
|
|
||||||
AppPreferenceKeys.CAMERA_SIZE_PRESET,
|
|
||||||
AppDefaults.CAMERA_SIZE_PRESET,
|
|
||||||
).orEmpty(),
|
|
||||||
cameraSizeCustom = prefs.getString(
|
|
||||||
AppPreferenceKeys.CAMERA_SIZE_CUSTOM,
|
|
||||||
AppDefaults.CAMERA_SIZE_CUSTOM,
|
|
||||||
).orEmpty(),
|
|
||||||
cameraAr = prefs.getString(
|
|
||||||
AppPreferenceKeys.CAMERA_AR,
|
|
||||||
AppDefaults.CAMERA_AR,
|
|
||||||
).orEmpty(),
|
|
||||||
cameraFps = prefs.getString(
|
|
||||||
AppPreferenceKeys.CAMERA_FPS,
|
|
||||||
AppDefaults.CAMERA_FPS,
|
|
||||||
).orEmpty(),
|
|
||||||
cameraHighSpeed = prefs.getBoolean(
|
|
||||||
AppPreferenceKeys.CAMERA_HIGH_SPEED,
|
|
||||||
AppDefaults.CAMERA_HIGH_SPEED,
|
|
||||||
),
|
|
||||||
audioSourcePreset = prefs.getString(
|
|
||||||
AppPreferenceKeys.AUDIO_SOURCE_PRESET,
|
|
||||||
AppDefaults.AUDIO_SOURCE_PRESET,
|
|
||||||
).orEmpty().ifBlank { AppDefaults.AUDIO_SOURCE_PRESET },
|
|
||||||
audioSourceCustom = prefs.getString(
|
|
||||||
AppPreferenceKeys.AUDIO_SOURCE_CUSTOM,
|
|
||||||
AppDefaults.AUDIO_SOURCE_CUSTOM,
|
|
||||||
).orEmpty(),
|
|
||||||
audioDup = prefs.getBoolean(
|
|
||||||
AppPreferenceKeys.AUDIO_DUP,
|
|
||||||
AppDefaults.AUDIO_DUP,
|
|
||||||
),
|
|
||||||
noAudioPlayback = prefs.getBoolean(
|
|
||||||
AppPreferenceKeys.NO_AUDIO_PLAYBACK,
|
|
||||||
AppDefaults.NO_AUDIO_PLAYBACK,
|
|
||||||
),
|
|
||||||
requireAudio = prefs.getBoolean(
|
|
||||||
AppPreferenceKeys.REQUIRE_AUDIO,
|
|
||||||
AppDefaults.REQUIRE_AUDIO,
|
|
||||||
),
|
|
||||||
maxSizeInput = prefs.getString(
|
|
||||||
AppPreferenceKeys.MAX_SIZE_INPUT,
|
|
||||||
AppDefaults.MAX_SIZE_INPUT,
|
|
||||||
)
|
|
||||||
.orEmpty(),
|
|
||||||
maxFpsInput = prefs.getString(
|
|
||||||
AppPreferenceKeys.MAX_FPS_INPUT,
|
|
||||||
AppDefaults.MAX_FPS_INPUT,
|
|
||||||
)
|
|
||||||
.orEmpty(),
|
|
||||||
videoEncoder = prefs.getString(
|
|
||||||
AppPreferenceKeys.VIDEO_ENCODER,
|
|
||||||
AppDefaults.VIDEO_ENCODER,
|
|
||||||
)
|
|
||||||
.orEmpty(),
|
|
||||||
videoCodecOptions = prefs.getString(
|
|
||||||
AppPreferenceKeys.VIDEO_CODEC_OPTION,
|
|
||||||
AppDefaults.VIDEO_CODEC_OPTION,
|
|
||||||
).orEmpty(),
|
|
||||||
audioEncoder = prefs.getString(
|
|
||||||
AppPreferenceKeys.AUDIO_ENCODER,
|
|
||||||
AppDefaults.AUDIO_ENCODER,
|
|
||||||
).orEmpty(),
|
|
||||||
audioCodecOptions = prefs.getString(
|
|
||||||
AppPreferenceKeys.AUDIO_CODEC_OPTION,
|
|
||||||
AppDefaults.AUDIO_CODEC_OPTION,
|
|
||||||
).orEmpty(),
|
|
||||||
newDisplayWidth = prefs.getString(
|
|
||||||
AppPreferenceKeys.NEW_DISPLAY_WIDTH,
|
|
||||||
AppDefaults.NEW_DISPLAY_WIDTH,
|
|
||||||
).orEmpty(),
|
|
||||||
newDisplayHeight = prefs.getString(
|
|
||||||
AppPreferenceKeys.NEW_DISPLAY_HEIGHT,
|
|
||||||
AppDefaults.NEW_DISPLAY_HEIGHT,
|
|
||||||
).orEmpty(),
|
|
||||||
newDisplayDpi = prefs.getString(
|
|
||||||
AppPreferenceKeys.NEW_DISPLAY_DPI,
|
|
||||||
AppDefaults.NEW_DISPLAY_DPI,
|
|
||||||
).orEmpty(),
|
|
||||||
cropWidth = prefs.getString(
|
|
||||||
AppPreferenceKeys.CROP_WIDTH,
|
|
||||||
AppDefaults.CROP_WIDTH,
|
|
||||||
).orEmpty(),
|
|
||||||
cropHeight = prefs.getString(
|
|
||||||
AppPreferenceKeys.CROP_HEIGHT,
|
|
||||||
AppDefaults.CROP_HEIGHT,
|
|
||||||
).orEmpty(),
|
|
||||||
cropX = prefs.getString(
|
|
||||||
AppPreferenceKeys.CROP_X,
|
|
||||||
AppDefaults.CROP_X,
|
|
||||||
).orEmpty(),
|
|
||||||
cropY = prefs.getString(
|
|
||||||
AppPreferenceKeys.CROP_Y,
|
|
||||||
AppDefaults.CROP_Y,
|
|
||||||
).orEmpty(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun saveDevicePageSettings(context: Context, settings: DevicePageSettings) {
|
|
||||||
context.getSharedPreferences(AppPreferenceKeys.PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
.edit {
|
|
||||||
remove(AppPreferenceKeys.PAIR_HOST)
|
|
||||||
.remove(AppPreferenceKeys.PAIR_PORT)
|
|
||||||
.remove(AppPreferenceKeys.PAIR_CODE)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.QUICK_CONNECT_INPUT,
|
|
||||||
settings.quickConnectInput,
|
|
||||||
)
|
|
||||||
.putInt(
|
|
||||||
AppPreferenceKeys.AUDIO_BIT_RATE_KBPS,
|
|
||||||
settings.audioBitRateKbps,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.AUDIO_BIT_RATE_INPUT,
|
|
||||||
settings.audioBitRateInput,
|
|
||||||
)
|
|
||||||
.putFloat(
|
|
||||||
AppPreferenceKeys.VIDEO_BIT_RATE_MBPS,
|
|
||||||
settings.videoBitRateMbps,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.VIDEO_BIT_RATE_INPUT,
|
|
||||||
settings.videoBitRateInput,
|
|
||||||
)
|
|
||||||
.putBoolean(
|
|
||||||
AppPreferenceKeys.TURN_SCREEN_OFF,
|
|
||||||
settings.turnScreenOff,
|
|
||||||
)
|
|
||||||
.putBoolean(
|
|
||||||
AppPreferenceKeys.NO_CONTROL,
|
|
||||||
settings.noControl,
|
|
||||||
)
|
|
||||||
.putBoolean(
|
|
||||||
AppPreferenceKeys.NO_VIDEO,
|
|
||||||
settings.noVideo,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.VIDEO_SOURCE_PRESET,
|
|
||||||
settings.videoSourcePreset,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.DISPLAY_ID,
|
|
||||||
settings.displayIdInput,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.CAMERA_ID,
|
|
||||||
settings.cameraIdInput,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.CAMERA_FACING_PRESET,
|
|
||||||
settings.cameraFacingPreset,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.CAMERA_SIZE_PRESET,
|
|
||||||
settings.cameraSizePreset,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.CAMERA_SIZE_CUSTOM,
|
|
||||||
settings.cameraSizeCustom,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.CAMERA_AR,
|
|
||||||
settings.cameraAr,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.CAMERA_FPS,
|
|
||||||
settings.cameraFps,
|
|
||||||
)
|
|
||||||
.putBoolean(
|
|
||||||
AppPreferenceKeys.CAMERA_HIGH_SPEED,
|
|
||||||
settings.cameraHighSpeed,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.AUDIO_SOURCE_PRESET,
|
|
||||||
settings.audioSourcePreset,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.AUDIO_SOURCE_CUSTOM,
|
|
||||||
settings.audioSourceCustom,
|
|
||||||
)
|
|
||||||
.putBoolean(
|
|
||||||
AppPreferenceKeys.AUDIO_DUP,
|
|
||||||
settings.audioDup,
|
|
||||||
)
|
|
||||||
.putBoolean(
|
|
||||||
AppPreferenceKeys.NO_AUDIO_PLAYBACK,
|
|
||||||
settings.noAudioPlayback,
|
|
||||||
)
|
|
||||||
.putBoolean(
|
|
||||||
AppPreferenceKeys.REQUIRE_AUDIO,
|
|
||||||
settings.requireAudio,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.MAX_SIZE_INPUT,
|
|
||||||
settings.maxSizeInput,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.MAX_FPS_INPUT,
|
|
||||||
settings.maxFpsInput,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.VIDEO_ENCODER,
|
|
||||||
settings.videoEncoder,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.VIDEO_CODEC_OPTION,
|
|
||||||
settings.videoCodecOptions,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.AUDIO_ENCODER,
|
|
||||||
settings.audioEncoder,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.AUDIO_CODEC_OPTION,
|
|
||||||
settings.audioCodecOptions,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.NEW_DISPLAY_WIDTH,
|
|
||||||
settings.newDisplayWidth,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.NEW_DISPLAY_HEIGHT,
|
|
||||||
settings.newDisplayHeight,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.NEW_DISPLAY_DPI,
|
|
||||||
settings.newDisplayDpi,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.CROP_WIDTH,
|
|
||||||
settings.cropWidth,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.CROP_HEIGHT,
|
|
||||||
settings.cropHeight,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.CROP_X,
|
|
||||||
settings.cropX,
|
|
||||||
)
|
|
||||||
.putString(
|
|
||||||
AppPreferenceKeys.CROP_Y,
|
|
||||||
settings.cropY,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid.services
|
package io.github.miuzarte.scrcpyforandroid.services
|
||||||
|
|
||||||
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
internal data class ConnectedDeviceInfo(
|
internal data class ConnectedDeviceInfo(
|
||||||
val model: String,
|
val model: String,
|
||||||
@@ -16,34 +18,26 @@ internal data class ConnectedDeviceInfo(
|
|||||||
* Fetch basic device properties from an already-connected device.
|
* Fetch basic device properties from an already-connected device.
|
||||||
*
|
*
|
||||||
* Notes:
|
* Notes:
|
||||||
* - This function issues multiple `adb shell getprop` commands via [nativeCore.adbShell].
|
* - This function issues multiple `shell getprop` commands via [adbService.shell].
|
||||||
* Each call may block on native I/O, so callers should execute this on the dedicated
|
* Each call may block on native I/O, so it should be called from a coroutine context.
|
||||||
* ADB worker dispatcher rather than the UI thread.
|
|
||||||
* - Returns a lightweight [ConnectedDeviceInfo] structure with commonly-used properties.
|
* - Returns a lightweight [ConnectedDeviceInfo] structure with commonly-used properties.
|
||||||
*/
|
*/
|
||||||
internal fun fetchConnectedDeviceInfo(
|
internal suspend fun fetchConnectedDeviceInfo(
|
||||||
nativeCore: NativeCoreFacade,
|
adbService: NativeAdbService,
|
||||||
host: String,
|
host: String,
|
||||||
port: Int
|
port: Int
|
||||||
): ConnectedDeviceInfo {
|
): ConnectedDeviceInfo = withContext(Dispatchers.IO) {
|
||||||
fun prop(name: String): String =
|
suspend fun prop(name: String): String = runCatching {
|
||||||
runCatching { nativeCore.adbShell("getprop $name").trim() }.getOrDefault("")
|
adbService.shell("getprop $name").trim()
|
||||||
|
}.getOrDefault("")
|
||||||
|
|
||||||
val model = prop("ro.product.model")
|
ConnectedDeviceInfo(
|
||||||
val serial = prop("ro.serialno")
|
model = prop("ro.product.model").ifBlank { "$host:$port" },
|
||||||
val manufacturer = prop("ro.product.manufacturer")
|
serial = prop("ro.serialno"),
|
||||||
val brand = prop("ro.product.brand")
|
manufacturer = prop("ro.product.manufacturer"),
|
||||||
val device = prop("ro.product.device")
|
brand = prop("ro.product.brand"),
|
||||||
val androidRelease = prop("ro.build.version.release")
|
device = prop("ro.product.device"),
|
||||||
val sdkInt = prop("ro.build.version.sdk").toIntOrNull() ?: -1
|
androidRelease = prop("ro.build.version.release"),
|
||||||
|
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,72 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.services
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global singleton for event logging.
|
||||||
|
*
|
||||||
|
* Manages event logs with timestamp formatting and automatic log rotation.
|
||||||
|
* Logs are stored in a thread-safe SnapshotStateList for Compose integration.
|
||||||
|
*/
|
||||||
|
object EventLogger {
|
||||||
|
private const val LOG_TAG = "EventLogger"
|
||||||
|
|
||||||
|
const val MAX_LINES = 512
|
||||||
|
|
||||||
|
private val _eventLog: SnapshotStateList<String> = mutableStateListOf()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only access to the event log list.
|
||||||
|
*/
|
||||||
|
val eventLog: List<String> get() = _eventLog
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an event with timestamp and optional error.
|
||||||
|
*
|
||||||
|
* @param message The log message
|
||||||
|
* @param level Log level (Log.INFO, Log.ERROR, Log.WARN, Log.DEBUG)
|
||||||
|
* @param error Optional throwable for error logging
|
||||||
|
*/
|
||||||
|
fun logEvent(message: String, level: Int = Log.INFO, error: Throwable? = null) {
|
||||||
|
val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
|
||||||
|
_eventLog.add(0, "[$timestamp] $message")
|
||||||
|
|
||||||
|
// Rotate logs if exceeds max size
|
||||||
|
if (_eventLog.size > MAX_LINES) {
|
||||||
|
_eventLog.removeRange(MAX_LINES, _eventLog.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log to Android logcat
|
||||||
|
when (level) {
|
||||||
|
Log.ERROR -> if (error != null) Log.e(LOG_TAG, message, error)
|
||||||
|
else Log.e(LOG_TAG, message)
|
||||||
|
|
||||||
|
Log.WARN -> if (error != null) Log.w(LOG_TAG, message, error)
|
||||||
|
else Log.w(LOG_TAG, message)
|
||||||
|
|
||||||
|
Log.DEBUG -> if (error != null) Log.d(LOG_TAG, message, error)
|
||||||
|
else Log.d(LOG_TAG, message)
|
||||||
|
|
||||||
|
else -> if (error != null) Log.i(LOG_TAG, message, error)
|
||||||
|
else Log.i(LOG_TAG, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all event logs.
|
||||||
|
*/
|
||||||
|
fun clearLogs() {
|
||||||
|
_eventLog.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there are any logs.
|
||||||
|
*/
|
||||||
|
fun hasLogs(): Boolean = _eventLog.isNotEmpty()
|
||||||
|
}
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
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.PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
.getString(AppPreferenceKeys.QUICK_DEVICES, "")
|
|
||||||
.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.ADB_PORT
|
|
||||||
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.ADB_PORT.toString()).trim()
|
|
||||||
.toIntOrNull() ?: AppDefaults.ADB_PORT
|
|
||||||
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.PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
.edit {
|
|
||||||
putString(AppPreferenceKeys.QUICK_DEVICES, 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.ADB_PORT.toString()).trim().toIntOrNull()
|
|
||||||
?: AppDefaults.ADB_PORT
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun replaceQuickDevicePort(
|
|
||||||
context: Context,
|
|
||||||
quickDevices: MutableList<DeviceShortcut>,
|
|
||||||
host: String,
|
|
||||||
oldPort: Int,
|
|
||||||
newPort: Int,
|
|
||||||
online: Boolean,
|
|
||||||
) {
|
|
||||||
val idx = quickDevices.indexOfFirst { it.host == host && it.port == oldPort }
|
|
||||||
if (idx < 0) return
|
|
||||||
|
|
||||||
val old = quickDevices[idx]
|
|
||||||
val updated = old.copy(
|
|
||||||
id = "$host:$newPort",
|
|
||||||
port = newPort,
|
|
||||||
online = online,
|
|
||||||
)
|
|
||||||
|
|
||||||
quickDevices[idx] = updated
|
|
||||||
val dedup = quickDevices.distinctBy { it.id }
|
|
||||||
quickDevices.clear()
|
|
||||||
quickDevices.addAll(dedup)
|
|
||||||
saveQuickDevices(context, quickDevices)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.services
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import top.yukonga.miuix.kmp.basic.SnackbarDuration
|
||||||
|
import top.yukonga.miuix.kmp.basic.SnackbarHostState
|
||||||
|
|
||||||
|
class SnackbarController(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
val hostState: SnackbarHostState,
|
||||||
|
val defaultActionLabel: String? = null,
|
||||||
|
val defaultWithDismissAction: Boolean? = null,
|
||||||
|
val defaultDuration: SnackbarDuration? = null,
|
||||||
|
) {
|
||||||
|
fun show(
|
||||||
|
message: String,
|
||||||
|
actionLabel: String? = defaultActionLabel,
|
||||||
|
withDismissAction: Boolean = defaultWithDismissAction ?: true,
|
||||||
|
duration: SnackbarDuration = defaultDuration ?: SnackbarDuration.Short,
|
||||||
|
scope: CoroutineScope = this.scope,
|
||||||
|
) {
|
||||||
|
scope.launch {
|
||||||
|
hostState.showSnackbar(
|
||||||
|
message = message,
|
||||||
|
actionLabel = actionLabel,
|
||||||
|
withDismissAction = withDismissAction,
|
||||||
|
duration = duration,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.storage
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
class AdbClientData(context: Context) : Settings(context, "AdbClient") {
|
||||||
|
companion object {
|
||||||
|
val RSA_PRIVATE_KEY = Pair(
|
||||||
|
stringPreferencesKey("rsa_private_key"),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
val RSA_PUBLIC_KEY_X509 = Pair(
|
||||||
|
stringPreferencesKey("rsa_public_key_x509"),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val rsaPrivateKey by setting(RSA_PRIVATE_KEY)
|
||||||
|
val rsaPublicKeyX509 by setting(RSA_PUBLIC_KEY_X509)
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class Bundle(
|
||||||
|
val rsaPrivateKey: String,
|
||||||
|
val rsaPublicKeyX509: String,
|
||||||
|
) : Parcelable {
|
||||||
|
}
|
||||||
|
|
||||||
|
private val bundleFields = arrayOf(
|
||||||
|
bundleField(RSA_PRIVATE_KEY) { bundle: Bundle -> bundle.rsaPrivateKey },
|
||||||
|
bundleField(RSA_PUBLIC_KEY_X509) { bundle: Bundle -> bundle.rsaPublicKeyX509 },
|
||||||
|
)
|
||||||
|
|
||||||
|
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
|
||||||
|
|
||||||
|
private fun bundleFromPreferences(preferences: Preferences) = Bundle(
|
||||||
|
rsaPrivateKey = preferences.read(RSA_PRIVATE_KEY),
|
||||||
|
rsaPublicKeyX509 = preferences.read(RSA_PUBLIC_KEY_X509),
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun loadBundle() = loadBundle(::bundleFromPreferences)
|
||||||
|
|
||||||
|
suspend fun saveBundle(new: Bundle) = saveBundle(bundleState.value, new, bundleFields)
|
||||||
|
|
||||||
|
suspend fun updateBundle(transform: (Bundle) -> Bundle) {
|
||||||
|
saveBundle(transform(bundleState.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.storage
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
class AppSettings(context: Context) : Settings(context, "AppSettings") {
|
||||||
|
companion object {
|
||||||
|
val THEME_BASE_INDEX = Pair(
|
||||||
|
intPreferencesKey("theme_base_index"),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
val MONET = Pair(
|
||||||
|
booleanPreferencesKey("monet"),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
val FULLSCREEN_DEBUG_INFO = Pair(
|
||||||
|
booleanPreferencesKey("fullscreen_debug_info"),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = Pair(
|
||||||
|
booleanPreferencesKey("show_fullscreen_virtual_buttons"),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair(
|
||||||
|
intPreferencesKey("device_preview_card_height_dp"),
|
||||||
|
320
|
||||||
|
)
|
||||||
|
val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = Pair(
|
||||||
|
booleanPreferencesKey("preview_virtual_button_show_text"),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
val VIRTUAL_BUTTONS_LAYOUT = Pair(
|
||||||
|
stringPreferencesKey("virtual_buttons_layout"),
|
||||||
|
"more:1,app_switch:1,home:0,back:1,menu:0,notification:0,volume_up:0,volume_down:0,volume_mute:0,power:0,screenshot:0"
|
||||||
|
)
|
||||||
|
val CUSTOM_SERVER_URI = Pair(
|
||||||
|
stringPreferencesKey("custom_server_uri"),
|
||||||
|
""
|
||||||
|
)
|
||||||
|
val CUSTOM_SERVER_VERSION = Pair(
|
||||||
|
stringPreferencesKey("custom_server_version"),
|
||||||
|
""
|
||||||
|
)
|
||||||
|
val SERVER_REMOTE_PATH = Pair(
|
||||||
|
stringPreferencesKey("server_remote_path"),
|
||||||
|
Scrcpy.DEFAULT_REMOTE_PATH,
|
||||||
|
)
|
||||||
|
val ADB_KEY_NAME = Pair(
|
||||||
|
stringPreferencesKey("adb_key_name"),
|
||||||
|
"scrcpy"
|
||||||
|
)
|
||||||
|
val ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN = Pair(
|
||||||
|
booleanPreferencesKey("adb_pairing_auto_discover_on_dialog_open"),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
val ADB_AUTO_RECONNECT_PAIRED_DEVICE = Pair(
|
||||||
|
booleanPreferencesKey("adb_auto_reconnect_paired_device"),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
val ADB_MDNS_LAN_DISCOVERY = Pair(
|
||||||
|
booleanPreferencesKey("adb_mdns_lan_discovery"),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme Settings
|
||||||
|
val themeBaseIndex by setting(THEME_BASE_INDEX)
|
||||||
|
val monet by setting(MONET)
|
||||||
|
|
||||||
|
// Scrcpy Settings
|
||||||
|
val fullscreenDebugInfo by setting(FULLSCREEN_DEBUG_INFO)
|
||||||
|
val showFullscreenVirtualButtons by setting(SHOW_FULLSCREEN_VIRTUAL_BUTTONS)
|
||||||
|
val devicePreviewCardHeightDp by setting(DEVICE_PREVIEW_CARD_HEIGHT_DP)
|
||||||
|
val previewVirtualButtonShowText by setting(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT)
|
||||||
|
val virtualButtonsLayout by setting(VIRTUAL_BUTTONS_LAYOUT)
|
||||||
|
|
||||||
|
// Scrcpy Server Settings
|
||||||
|
val customServerUri by setting(CUSTOM_SERVER_URI)
|
||||||
|
val customServerVersion by setting(CUSTOM_SERVER_VERSION)
|
||||||
|
val serverRemotePath by setting(SERVER_REMOTE_PATH)
|
||||||
|
|
||||||
|
// ADB Settings
|
||||||
|
val adbKeyName by setting(ADB_KEY_NAME)
|
||||||
|
val adbPairingAutoDiscoverOnDialogOpen by setting(ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN)
|
||||||
|
val adbAutoReconnectPairedDevice by setting(ADB_AUTO_RECONNECT_PAIRED_DEVICE)
|
||||||
|
val adbMdnsLanDiscovery by setting(ADB_MDNS_LAN_DISCOVERY)
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class Bundle(
|
||||||
|
val themeBaseIndex: Int,
|
||||||
|
val monet: Boolean,
|
||||||
|
val fullscreenDebugInfo: Boolean,
|
||||||
|
val showFullscreenVirtualButtons: Boolean,
|
||||||
|
val devicePreviewCardHeightDp: Int,
|
||||||
|
val previewVirtualButtonShowText: Boolean,
|
||||||
|
val virtualButtonsLayout: String,
|
||||||
|
val customServerUri: String,
|
||||||
|
val customServerVersion: String,
|
||||||
|
val serverRemotePath: String,
|
||||||
|
val adbKeyName: String,
|
||||||
|
val adbPairingAutoDiscoverOnDialogOpen: Boolean,
|
||||||
|
val adbAutoReconnectPairedDevice: Boolean,
|
||||||
|
val adbMdnsLanDiscovery: Boolean,
|
||||||
|
) : Parcelable {
|
||||||
|
}
|
||||||
|
|
||||||
|
private val bundleFields = arrayOf(
|
||||||
|
bundleField(THEME_BASE_INDEX) { bundle: Bundle -> bundle.themeBaseIndex },
|
||||||
|
bundleField(MONET) { bundle: Bundle -> bundle.monet },
|
||||||
|
bundleField(FULLSCREEN_DEBUG_INFO) { bundle: Bundle -> bundle.fullscreenDebugInfo },
|
||||||
|
bundleField(SHOW_FULLSCREEN_VIRTUAL_BUTTONS) { bundle: Bundle -> bundle.showFullscreenVirtualButtons },
|
||||||
|
bundleField(DEVICE_PREVIEW_CARD_HEIGHT_DP) { bundle: Bundle -> bundle.devicePreviewCardHeightDp },
|
||||||
|
bundleField(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) { bundle: Bundle -> bundle.previewVirtualButtonShowText },
|
||||||
|
bundleField(VIRTUAL_BUTTONS_LAYOUT) { bundle: Bundle -> bundle.virtualButtonsLayout },
|
||||||
|
bundleField(CUSTOM_SERVER_URI) { bundle: Bundle -> bundle.customServerUri },
|
||||||
|
bundleField(CUSTOM_SERVER_VERSION) { bundle: Bundle -> bundle.customServerVersion },
|
||||||
|
bundleField(SERVER_REMOTE_PATH) { bundle: Bundle -> bundle.serverRemotePath },
|
||||||
|
bundleField(ADB_KEY_NAME) { bundle: Bundle -> bundle.adbKeyName },
|
||||||
|
bundleField(ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN) { bundle: Bundle -> bundle.adbPairingAutoDiscoverOnDialogOpen },
|
||||||
|
bundleField(ADB_AUTO_RECONNECT_PAIRED_DEVICE) { bundle: Bundle -> bundle.adbAutoReconnectPairedDevice },
|
||||||
|
bundleField(ADB_MDNS_LAN_DISCOVERY) { bundle: Bundle -> bundle.adbMdnsLanDiscovery },
|
||||||
|
)
|
||||||
|
|
||||||
|
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
|
||||||
|
|
||||||
|
private fun bundleFromPreferences(preferences: Preferences) = Bundle(
|
||||||
|
themeBaseIndex = preferences.read(THEME_BASE_INDEX),
|
||||||
|
monet = preferences.read(MONET),
|
||||||
|
fullscreenDebugInfo = preferences.read(FULLSCREEN_DEBUG_INFO),
|
||||||
|
showFullscreenVirtualButtons = preferences.read(SHOW_FULLSCREEN_VIRTUAL_BUTTONS),
|
||||||
|
devicePreviewCardHeightDp = preferences.read(DEVICE_PREVIEW_CARD_HEIGHT_DP),
|
||||||
|
previewVirtualButtonShowText = preferences.read(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT),
|
||||||
|
virtualButtonsLayout = preferences.read(VIRTUAL_BUTTONS_LAYOUT),
|
||||||
|
customServerUri = preferences.read(CUSTOM_SERVER_URI),
|
||||||
|
customServerVersion = preferences.read(CUSTOM_SERVER_VERSION),
|
||||||
|
serverRemotePath = preferences.read(SERVER_REMOTE_PATH),
|
||||||
|
adbKeyName = preferences.read(ADB_KEY_NAME),
|
||||||
|
adbPairingAutoDiscoverOnDialogOpen = preferences.read(
|
||||||
|
ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN
|
||||||
|
),
|
||||||
|
adbAutoReconnectPairedDevice = preferences.read(ADB_AUTO_RECONNECT_PAIRED_DEVICE),
|
||||||
|
adbMdnsLanDiscovery = preferences.read(ADB_MDNS_LAN_DISCOVERY),
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun loadBundle() = loadBundle(::bundleFromPreferences)
|
||||||
|
|
||||||
|
suspend fun saveBundle(new: Bundle) = saveBundle(bundleState.value, new, bundleFields)
|
||||||
|
|
||||||
|
suspend fun updateBundle(transform: (Bundle) -> Bundle) {
|
||||||
|
saveBundle(transform(bundleState.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO?
|
||||||
|
// fun validate(): Boolean = true
|
||||||
|
}
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.storage
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
private const val TAG = "PreferenceMigration"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从旧的 SharedPreferences 迁移到新的 DataStore
|
||||||
|
*/
|
||||||
|
class PreferenceMigration(private val appContext: Context) {
|
||||||
|
private val appSharedPrefs: SharedPreferences by lazy {
|
||||||
|
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val sharedPrefs: SharedPreferences by lazy {
|
||||||
|
appContext.getSharedPreferences("nativecore_adb_rsa", Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否需要迁移(SharedPreferences 是否包含数据)
|
||||||
|
*/
|
||||||
|
suspend fun needsMigration(): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
appSharedPrefs.all.isNotEmpty() || sharedPrefs.all.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行完整迁移
|
||||||
|
*/
|
||||||
|
suspend fun migrate(
|
||||||
|
clearSharedPrefs: Boolean = false,
|
||||||
|
) = withContext(Dispatchers.IO) {
|
||||||
|
if (!needsMigration()) {
|
||||||
|
Log.i(TAG, "No data to migrate, skipping")
|
||||||
|
return@withContext
|
||||||
|
} else {
|
||||||
|
val appList = appSharedPrefs.all.entries.joinToString { (k, v) -> "\"$k\" to $v" }
|
||||||
|
val adbList = sharedPrefs.all.entries.joinToString { (k, v) -> "\"$k\" to $v" }
|
||||||
|
Log.d(TAG, "Migrating appSharedPrefs ($appList)")
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "Starting migration from SharedPreferences to DataStore")
|
||||||
|
|
||||||
|
// 迁移 AppSettings
|
||||||
|
migrateAppSettings()
|
||||||
|
|
||||||
|
// 迁移 ScrcpyOptions
|
||||||
|
migrateScrcpyOptions()
|
||||||
|
|
||||||
|
// 迁移 QuickDevices
|
||||||
|
migrateQuickDevices()
|
||||||
|
|
||||||
|
// 迁移 ADB 密钥
|
||||||
|
migrateAdbClientData()
|
||||||
|
|
||||||
|
// 清空 SharedPreferences
|
||||||
|
if (clearSharedPrefs) {
|
||||||
|
appSharedPrefs.edit { clear() }
|
||||||
|
sharedPrefs.edit { clear() }
|
||||||
|
Log.d(TAG, "SharedPreferences cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(
|
||||||
|
TAG, "Migration completed successfully" +
|
||||||
|
" and SharedPreferences ${if (clearSharedPrefs) "" else "is not "}cleared"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 迁移应用设置
|
||||||
|
*/
|
||||||
|
private suspend fun migrateAppSettings() {
|
||||||
|
val appSettings = Storage.appSettings
|
||||||
|
|
||||||
|
// Theme Settings
|
||||||
|
migrateInt(
|
||||||
|
THEME_BASE_INDEX,
|
||||||
|
AppSettings.THEME_BASE_INDEX.defaultValue,
|
||||||
|
appSettings.themeBaseIndex,
|
||||||
|
)
|
||||||
|
migrateBoolean(
|
||||||
|
MONET,
|
||||||
|
AppSettings.MONET.defaultValue,
|
||||||
|
appSettings.monet,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scrcpy Settings
|
||||||
|
migrateBoolean(
|
||||||
|
FULLSCREEN_DEBUG_INFO,
|
||||||
|
AppSettings.FULLSCREEN_DEBUG_INFO.defaultValue,
|
||||||
|
appSettings.fullscreenDebugInfo,
|
||||||
|
)
|
||||||
|
migrateBoolean(
|
||||||
|
SHOW_FULLSCREEN_VIRTUAL_BUTTONS,
|
||||||
|
AppSettings.SHOW_FULLSCREEN_VIRTUAL_BUTTONS.defaultValue,
|
||||||
|
appSettings.showFullscreenVirtualButtons,
|
||||||
|
)
|
||||||
|
migrateInt(
|
||||||
|
DEVICE_PREVIEW_CARD_HEIGHT_DP,
|
||||||
|
AppSettings.DEVICE_PREVIEW_CARD_HEIGHT_DP.defaultValue,
|
||||||
|
appSettings.devicePreviewCardHeightDp,
|
||||||
|
)
|
||||||
|
migrateBoolean(
|
||||||
|
PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT,
|
||||||
|
AppSettings.PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT.defaultValue,
|
||||||
|
appSettings.previewVirtualButtonShowText,
|
||||||
|
)
|
||||||
|
migrateString(
|
||||||
|
VIRTUAL_BUTTONS_LAYOUT,
|
||||||
|
AppSettings.VIRTUAL_BUTTONS_LAYOUT.defaultValue,
|
||||||
|
appSettings.virtualButtonsLayout,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scrcpy Server Settings
|
||||||
|
migrateString(
|
||||||
|
CUSTOM_SERVER_URI,
|
||||||
|
AppSettings.CUSTOM_SERVER_URI.defaultValue,
|
||||||
|
appSettings.customServerUri,
|
||||||
|
)
|
||||||
|
migrateString(
|
||||||
|
SERVER_REMOTE_PATH,
|
||||||
|
AppSettings.SERVER_REMOTE_PATH.defaultValue,
|
||||||
|
appSettings.serverRemotePath,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ADB Settings
|
||||||
|
migrateString(
|
||||||
|
ADB_KEY_NAME,
|
||||||
|
AppSettings.ADB_KEY_NAME.defaultValue,
|
||||||
|
appSettings.adbKeyName,
|
||||||
|
)
|
||||||
|
migrateBoolean(
|
||||||
|
ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN,
|
||||||
|
AppSettings.ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN.defaultValue,
|
||||||
|
appSettings.adbPairingAutoDiscoverOnDialogOpen,
|
||||||
|
)
|
||||||
|
migrateBoolean(
|
||||||
|
ADB_AUTO_RECONNECT_PAIRED_DEVICE,
|
||||||
|
AppSettings.ADB_AUTO_RECONNECT_PAIRED_DEVICE.defaultValue,
|
||||||
|
appSettings.adbAutoReconnectPairedDevice,
|
||||||
|
)
|
||||||
|
migrateBoolean(
|
||||||
|
ADB_MDNS_LAN_DISCOVERY,
|
||||||
|
AppSettings.ADB_MDNS_LAN_DISCOVERY.defaultValue,
|
||||||
|
appSettings.adbMdnsLanDiscovery,
|
||||||
|
)
|
||||||
|
|
||||||
|
Log.d(TAG, "AppSettings migration completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 迁移 Scrcpy 选项
|
||||||
|
*/
|
||||||
|
private suspend fun migrateScrcpyOptions() {
|
||||||
|
val scrcpyOptions = Storage.scrcpyOptions
|
||||||
|
|
||||||
|
// Audio & Video Codecs
|
||||||
|
migrateString(
|
||||||
|
AUDIO_CODEC,
|
||||||
|
ScrcpyOptions.AUDIO_CODEC.defaultValue,
|
||||||
|
scrcpyOptions.audioCodec,
|
||||||
|
)
|
||||||
|
migrateString(
|
||||||
|
VIDEO_CODEC,
|
||||||
|
ScrcpyOptions.VIDEO_CODEC.defaultValue,
|
||||||
|
scrcpyOptions.videoCodec,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bit Rates
|
||||||
|
val audioBitRateKbps = sharedPrefs.getInt(
|
||||||
|
AUDIO_BIT_RATE_KBPS,
|
||||||
|
ScrcpyOptions.AUDIO_BIT_RATE.defaultValue / 1_000,
|
||||||
|
)
|
||||||
|
scrcpyOptions.audioBitRate.set(audioBitRateKbps * 1_000) // Convert to bps
|
||||||
|
|
||||||
|
val videoBitRateMbps = sharedPrefs.getFloat(
|
||||||
|
VIDEO_BIT_RATE_MBPS,
|
||||||
|
(ScrcpyOptions.VIDEO_BIT_RATE.defaultValue / 1_000_000).toFloat(),
|
||||||
|
)
|
||||||
|
scrcpyOptions.videoBitRate.set((videoBitRateMbps * 1_000_000).toInt())
|
||||||
|
|
||||||
|
// Control Options
|
||||||
|
migrateBoolean(
|
||||||
|
TURN_SCREEN_OFF,
|
||||||
|
ScrcpyOptions.TURN_SCREEN_OFF.defaultValue,
|
||||||
|
scrcpyOptions.turnScreenOff,
|
||||||
|
)
|
||||||
|
migrateBoolean(
|
||||||
|
NO_CONTROL,
|
||||||
|
!ScrcpyOptions.CONTROL.defaultValue,
|
||||||
|
) { value ->
|
||||||
|
scrcpyOptions.control.set(!value) // Invert logic
|
||||||
|
}
|
||||||
|
migrateBoolean(
|
||||||
|
NO_VIDEO,
|
||||||
|
!ScrcpyOptions.VIDEO.defaultValue,
|
||||||
|
) { value ->
|
||||||
|
scrcpyOptions.video.set(!value) // Invert logic
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video Source
|
||||||
|
val videoSourcePreset = sharedPrefs.getString(
|
||||||
|
VIDEO_SOURCE_PRESET,
|
||||||
|
ScrcpyOptions.VIDEO_SOURCE.defaultValue,
|
||||||
|
).orEmpty().ifBlank { ScrcpyOptions.VIDEO_SOURCE.defaultValue }
|
||||||
|
scrcpyOptions.videoSource.set(videoSourcePreset)
|
||||||
|
|
||||||
|
migrateString(
|
||||||
|
DISPLAY_ID,
|
||||||
|
ScrcpyOptions.DISPLAY_ID.defaultValue.toString(),
|
||||||
|
) { value ->
|
||||||
|
value.toIntOrNull()?.let { scrcpyOptions.displayId.set(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera Settings
|
||||||
|
migrateString(
|
||||||
|
CAMERA_ID,
|
||||||
|
ScrcpyOptions.CAMERA_ID.defaultValue,
|
||||||
|
scrcpyOptions.cameraId,
|
||||||
|
)
|
||||||
|
migrateString(
|
||||||
|
CAMERA_FACING_PRESET,
|
||||||
|
ScrcpyOptions.CAMERA_FACING.defaultValue,
|
||||||
|
scrcpyOptions.cameraFacing,
|
||||||
|
)
|
||||||
|
migrateString(
|
||||||
|
CAMERA_SIZE_PRESET,
|
||||||
|
ScrcpyOptions.CAMERA_SIZE.defaultValue,
|
||||||
|
) { value ->
|
||||||
|
if (value == "custom") {
|
||||||
|
val customSize = sharedPrefs.getString(
|
||||||
|
CAMERA_SIZE_CUSTOM,
|
||||||
|
ScrcpyOptions.CAMERA_SIZE_CUSTOM.defaultValue,
|
||||||
|
).orEmpty()
|
||||||
|
scrcpyOptions.cameraSizeCustom.set(customSize)
|
||||||
|
scrcpyOptions.cameraSizeUseCustom.set(true)
|
||||||
|
} else {
|
||||||
|
scrcpyOptions.cameraSize.set(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
migrateString(
|
||||||
|
CAMERA_AR,
|
||||||
|
ScrcpyOptions.CAMERA_AR.defaultValue,
|
||||||
|
scrcpyOptions.cameraAr,
|
||||||
|
)
|
||||||
|
migrateString(
|
||||||
|
CAMERA_FPS,
|
||||||
|
ScrcpyOptions.CAMERA_FPS.defaultValue.toString(),
|
||||||
|
) { value ->
|
||||||
|
value.toIntOrNull()?.let { scrcpyOptions.cameraFps.set(it) }
|
||||||
|
}
|
||||||
|
migrateBoolean(
|
||||||
|
CAMERA_HIGH_SPEED,
|
||||||
|
ScrcpyOptions.CAMERA_HIGH_SPEED.defaultValue,
|
||||||
|
scrcpyOptions.cameraHighSpeed,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Audio Source
|
||||||
|
val audioSourcePreset = sharedPrefs.getString(
|
||||||
|
AUDIO_SOURCE_PRESET,
|
||||||
|
"auto",
|
||||||
|
).orEmpty().ifBlank { "auto" }
|
||||||
|
|
||||||
|
if (audioSourcePreset == "custom") {
|
||||||
|
val customSource = sharedPrefs.getString(
|
||||||
|
AUDIO_SOURCE_CUSTOM,
|
||||||
|
ScrcpyOptions.AUDIO_SOURCE.defaultValue,
|
||||||
|
).orEmpty()
|
||||||
|
scrcpyOptions.audioSource.set(customSource)
|
||||||
|
} else {
|
||||||
|
scrcpyOptions.audioSource.set(audioSourcePreset)
|
||||||
|
}
|
||||||
|
|
||||||
|
migrateBoolean(
|
||||||
|
AUDIO_DUP,
|
||||||
|
ScrcpyOptions.AUDIO_DUP.defaultValue,
|
||||||
|
scrcpyOptions.audioDup,
|
||||||
|
)
|
||||||
|
migrateBoolean(
|
||||||
|
NO_AUDIO_PLAYBACK,
|
||||||
|
!ScrcpyOptions.AUDIO_PLAYBACK.defaultValue,
|
||||||
|
) { value ->
|
||||||
|
scrcpyOptions.audioPlayback.set(!value) // Invert logic
|
||||||
|
}
|
||||||
|
migrateBoolean(
|
||||||
|
REQUIRE_AUDIO,
|
||||||
|
ScrcpyOptions.REQUIRE_AUDIO.defaultValue,
|
||||||
|
scrcpyOptions.requireAudio,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Max Size & FPS
|
||||||
|
migrateString(
|
||||||
|
MAX_SIZE_INPUT,
|
||||||
|
ScrcpyOptions.MAX_SIZE.defaultValue.toString(),
|
||||||
|
) { value ->
|
||||||
|
value.toIntOrNull()?.let { scrcpyOptions.maxSize.set(it) }
|
||||||
|
}
|
||||||
|
migrateString(
|
||||||
|
MAX_FPS_INPUT,
|
||||||
|
ScrcpyOptions.MAX_FPS.defaultValue,
|
||||||
|
scrcpyOptions.maxFps,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Encoders & Codec Options
|
||||||
|
migrateString(
|
||||||
|
VIDEO_ENCODER,
|
||||||
|
ScrcpyOptions.VIDEO_ENCODER.defaultValue,
|
||||||
|
scrcpyOptions.videoEncoder,
|
||||||
|
)
|
||||||
|
migrateString(
|
||||||
|
VIDEO_CODEC_OPTION,
|
||||||
|
ScrcpyOptions.VIDEO_CODEC_OPTIONS.defaultValue,
|
||||||
|
scrcpyOptions.videoCodecOptions,
|
||||||
|
)
|
||||||
|
migrateString(
|
||||||
|
AUDIO_ENCODER,
|
||||||
|
ScrcpyOptions.AUDIO_ENCODER.defaultValue,
|
||||||
|
scrcpyOptions.audioEncoder,
|
||||||
|
)
|
||||||
|
migrateString(
|
||||||
|
AUDIO_CODEC_OPTION,
|
||||||
|
ScrcpyOptions.AUDIO_CODEC_OPTIONS.defaultValue,
|
||||||
|
scrcpyOptions.audioCodecOptions,
|
||||||
|
)
|
||||||
|
|
||||||
|
// New Display
|
||||||
|
val newDisplayWidth = sharedPrefs.getString(
|
||||||
|
NEW_DISPLAY_WIDTH,
|
||||||
|
"",
|
||||||
|
).orEmpty()
|
||||||
|
val newDisplayHeight = sharedPrefs.getString(
|
||||||
|
NEW_DISPLAY_HEIGHT,
|
||||||
|
"",
|
||||||
|
).orEmpty()
|
||||||
|
val newDisplayDpi = sharedPrefs.getString(
|
||||||
|
NEW_DISPLAY_DPI,
|
||||||
|
"",
|
||||||
|
).orEmpty()
|
||||||
|
|
||||||
|
if (newDisplayWidth.isNotBlank() && newDisplayHeight.isNotBlank()) {
|
||||||
|
val newDisplay = if (newDisplayDpi.isNotBlank()) {
|
||||||
|
"${newDisplayWidth}x${newDisplayHeight}/${newDisplayDpi}"
|
||||||
|
} else {
|
||||||
|
"${newDisplayWidth}x${newDisplayHeight}"
|
||||||
|
}
|
||||||
|
scrcpyOptions.newDisplay.set(newDisplay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crop
|
||||||
|
val cropWidth = sharedPrefs.getString(
|
||||||
|
CROP_WIDTH,
|
||||||
|
"",
|
||||||
|
).orEmpty()
|
||||||
|
val cropHeight = sharedPrefs.getString(
|
||||||
|
CROP_HEIGHT,
|
||||||
|
"",
|
||||||
|
).orEmpty()
|
||||||
|
val cropX = sharedPrefs.getString(
|
||||||
|
CROP_X,
|
||||||
|
"",
|
||||||
|
).orEmpty()
|
||||||
|
val cropY = sharedPrefs.getString(
|
||||||
|
CROP_Y,
|
||||||
|
"",
|
||||||
|
).orEmpty()
|
||||||
|
|
||||||
|
if (cropWidth.isNotBlank() && cropHeight.isNotBlank()
|
||||||
|
&& cropX.isNotBlank() && cropY.isNotBlank()
|
||||||
|
) {
|
||||||
|
scrcpyOptions.crop.set("${cropWidth}:${cropHeight}:${cropX}:${cropY}")
|
||||||
|
}
|
||||||
|
|
||||||
|
migrateBoolean(
|
||||||
|
AUDIO_ENABLED,
|
||||||
|
ScrcpyOptions.AUDIO.defaultValue,
|
||||||
|
scrcpyOptions.audio,
|
||||||
|
)
|
||||||
|
|
||||||
|
Log.d(TAG, "ScrcpyOptions migration completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 迁移快速设备列表
|
||||||
|
*/
|
||||||
|
private suspend fun migrateQuickDevices() {
|
||||||
|
val quickDevices = Storage.quickDevices
|
||||||
|
|
||||||
|
// Migrate quick devices list
|
||||||
|
val quickDevicesRaw = appSharedPrefs.getString(
|
||||||
|
QUICK_DEVICES,
|
||||||
|
""
|
||||||
|
).orEmpty()
|
||||||
|
if (quickDevicesRaw.isNotBlank()) {
|
||||||
|
quickDevices.quickDevicesList.set(quickDevicesRaw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate quick connect input
|
||||||
|
migrateString(
|
||||||
|
QUICK_CONNECT_INPUT,
|
||||||
|
QuickDevices.QUICK_CONNECT_INPUT.defaultValue,
|
||||||
|
quickDevices.quickConnectInput,
|
||||||
|
)
|
||||||
|
|
||||||
|
Log.d(TAG, "QuickDevices migration completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 迁移 ADB 客户端数据(RSA 密钥)
|
||||||
|
*/
|
||||||
|
private suspend fun migrateAdbClientData() {
|
||||||
|
val adbClientData = Storage.adbClientData
|
||||||
|
|
||||||
|
// 迁移 RSA 私钥
|
||||||
|
val privKey = sharedPrefs.getString("priv", null)
|
||||||
|
if (privKey != null) {
|
||||||
|
adbClientData.rsaPrivateKey.set(privKey)
|
||||||
|
Log.d(TAG, "ADB RSA private key migrated")
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "AdbClientData migration completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods for different data types
|
||||||
|
|
||||||
|
private suspend fun migrateString(
|
||||||
|
key: String,
|
||||||
|
defaultValue: String,
|
||||||
|
settingProperty: Settings.SettingProperty<String>
|
||||||
|
) {
|
||||||
|
val value = appSharedPrefs.getString(key, defaultValue)
|
||||||
|
.orEmpty()
|
||||||
|
.ifBlank { defaultValue }
|
||||||
|
settingProperty.set(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun migrateString(
|
||||||
|
key: String,
|
||||||
|
defaultValue: String,
|
||||||
|
action: suspend (String) -> Unit
|
||||||
|
) {
|
||||||
|
val value = appSharedPrefs.getString(key, defaultValue)
|
||||||
|
.orEmpty()
|
||||||
|
.ifBlank { defaultValue }
|
||||||
|
action(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun migrateInt(
|
||||||
|
key: String,
|
||||||
|
defaultValue: Int,
|
||||||
|
settingProperty: Settings.SettingProperty<Int>
|
||||||
|
) {
|
||||||
|
val value = appSharedPrefs.getInt(key, defaultValue)
|
||||||
|
settingProperty.set(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun migrateInt(
|
||||||
|
key: String,
|
||||||
|
defaultValue: Int,
|
||||||
|
action: suspend (Int) -> Unit
|
||||||
|
) {
|
||||||
|
val value = appSharedPrefs.getInt(key, defaultValue)
|
||||||
|
action(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun migrateBoolean(
|
||||||
|
key: String,
|
||||||
|
defaultValue: Boolean,
|
||||||
|
settingProperty: Settings.SettingProperty<Boolean>
|
||||||
|
) {
|
||||||
|
val value = appSharedPrefs.getBoolean(key, defaultValue)
|
||||||
|
settingProperty.set(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun migrateBoolean(
|
||||||
|
key: String,
|
||||||
|
defaultValue: Boolean,
|
||||||
|
action: suspend (Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
val value = appSharedPrefs.getBoolean(key, defaultValue)
|
||||||
|
action(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val PREFS_NAME = "scrcpy_app_prefs"
|
||||||
|
|
||||||
|
// Devices
|
||||||
|
const val QUICK_DEVICES = "quick_devices"
|
||||||
|
const val QUICK_CONNECT_INPUT = "quick_connect_input"
|
||||||
|
|
||||||
|
const val PAIR_HOST = "pair_host"
|
||||||
|
const val PAIR_PORT = "pair_port"
|
||||||
|
const val PAIR_CODE = "pair_code"
|
||||||
|
|
||||||
|
const val AUDIO_ENABLED = "audio_enabled"
|
||||||
|
const val AUDIO_CODEC = "audio_codec"
|
||||||
|
const val AUDIO_BIT_RATE_INPUT = "audio_bit_rate_input"
|
||||||
|
const val AUDIO_BIT_RATE_KBPS = "audio_bit_rate_kbps"
|
||||||
|
const val VIDEO_CODEC = "video_codec"
|
||||||
|
const val VIDEO_BIT_RATE_MBPS = "video_bit_rate_mbps"
|
||||||
|
const val VIDEO_BIT_RATE_INPUT = "video_bit_rate_input"
|
||||||
|
|
||||||
|
const val TURN_SCREEN_OFF = "turn_screen_off"
|
||||||
|
const val NO_CONTROL = "no_control"
|
||||||
|
const val NO_VIDEO = "no_video"
|
||||||
|
|
||||||
|
const val VIDEO_SOURCE_PRESET = "video_source_preset"
|
||||||
|
const val DISPLAY_ID = "display_id"
|
||||||
|
|
||||||
|
const val CAMERA_ID = "camera_id"
|
||||||
|
const val CAMERA_FACING_PRESET = "camera_facing_preset"
|
||||||
|
const val CAMERA_SIZE_PRESET = "camera_size_preset"
|
||||||
|
const val CAMERA_SIZE_CUSTOM = "camera_size_custom"
|
||||||
|
const val CAMERA_AR = "camera_ar"
|
||||||
|
const val CAMERA_FPS = "camera_fps"
|
||||||
|
const val CAMERA_HIGH_SPEED = "camera_high_speed"
|
||||||
|
|
||||||
|
const val AUDIO_SOURCE_PRESET = "audio_source_preset"
|
||||||
|
const val AUDIO_SOURCE_CUSTOM = "audio_source_custom"
|
||||||
|
const val AUDIO_DUP = "audio_dup"
|
||||||
|
const val NO_AUDIO_PLAYBACK = "no_audio_playback"
|
||||||
|
const val REQUIRE_AUDIO = "require_audio"
|
||||||
|
|
||||||
|
const val MAX_SIZE_INPUT = "max_size_input"
|
||||||
|
const val MAX_FPS_INPUT = "max_fps_input"
|
||||||
|
|
||||||
|
const val VIDEO_ENCODER = "video_encoder"
|
||||||
|
const val VIDEO_CODEC_OPTION = "video_codec_options"
|
||||||
|
const val AUDIO_ENCODER = "audio_encoder"
|
||||||
|
const val AUDIO_CODEC_OPTION = "audio_codec_options"
|
||||||
|
|
||||||
|
const val NEW_DISPLAY_WIDTH = "new_display_width"
|
||||||
|
const val NEW_DISPLAY_HEIGHT = "new_display_height"
|
||||||
|
const val NEW_DISPLAY_DPI = "new_display_dpi"
|
||||||
|
|
||||||
|
const val CROP_WIDTH = "crop_width"
|
||||||
|
const val CROP_HEIGHT = "crop_height"
|
||||||
|
const val CROP_X = "crop_x"
|
||||||
|
const val CROP_Y = "crop_y"
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
const val THEME_BASE_INDEX = "theme_base_index"
|
||||||
|
const val MONET = "monet"
|
||||||
|
|
||||||
|
const val FULLSCREEN_DEBUG_INFO = "fullscreen_debug_info"
|
||||||
|
const val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = "show_fullscreen_virtual_buttons"
|
||||||
|
const val DEVICE_PREVIEW_CARD_HEIGHT_DP = "device_preview_card_height_dp"
|
||||||
|
const val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = "preview_virtual_button_show_text"
|
||||||
|
const val VIRTUAL_BUTTONS_LAYOUT = "virtual_buttons_layout"
|
||||||
|
|
||||||
|
const val CUSTOM_SERVER_URI = "custom_server_uri"
|
||||||
|
|
||||||
|
const val SERVER_REMOTE_PATH = "server_remote_path"
|
||||||
|
|
||||||
|
const val ADB_KEY_NAME = "adb_key_name"
|
||||||
|
const val ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN =
|
||||||
|
"adb_pairing_auto_discover_on_dialog_open"
|
||||||
|
const val ADB_AUTO_RECONNECT_PAIRED_DEVICE = "adb_auto_reconnect_paired_device"
|
||||||
|
const val ADB_MDNS_LAN_DISCOVERY = "adb_mdns_lan_discovery"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.storage
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
class QuickDevices(context: Context) : Settings(context, "QuickDevices") {
|
||||||
|
companion object {
|
||||||
|
val QUICK_DEVICES_LIST = Pair(
|
||||||
|
stringPreferencesKey("quick_devices_list"),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
val QUICK_CONNECT_INPUT = Pair(
|
||||||
|
stringPreferencesKey("quick_connect_input"),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val quickDevicesList by setting(QUICK_DEVICES_LIST)
|
||||||
|
val quickConnectInput by setting(QUICK_CONNECT_INPUT)
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class Bundle(
|
||||||
|
val quickDevicesList: String,
|
||||||
|
val quickConnectInput: String,
|
||||||
|
) : Parcelable {
|
||||||
|
}
|
||||||
|
|
||||||
|
private val bundleFields = arrayOf(
|
||||||
|
bundleField(QUICK_DEVICES_LIST) { bundle: Bundle -> bundle.quickDevicesList },
|
||||||
|
bundleField(QUICK_CONNECT_INPUT) { bundle: Bundle -> bundle.quickConnectInput },
|
||||||
|
)
|
||||||
|
|
||||||
|
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
|
||||||
|
|
||||||
|
private fun bundleFromPreferences(preferences: Preferences) = Bundle(
|
||||||
|
quickDevicesList = preferences.read(QUICK_DEVICES_LIST),
|
||||||
|
quickConnectInput = preferences.read(QUICK_CONNECT_INPUT),
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun loadBundle() = loadBundle(::bundleFromPreferences)
|
||||||
|
|
||||||
|
suspend fun saveBundle(new: Bundle) = saveBundle(bundleState.value, new, bundleFields)
|
||||||
|
|
||||||
|
suspend fun updateBundle(transform: (Bundle) -> Bundle) {
|
||||||
|
saveBundle(transform(bundleState.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,540 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.storage
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.AudioSource
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.DisplayImePolicy
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.LogLevel
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Orientation
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.OrientationLock
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
|
||||||
|
companion object {
|
||||||
|
val CROP = Pair(
|
||||||
|
stringPreferencesKey("crop"),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
val RECORD_FILENAME = Pair(
|
||||||
|
stringPreferencesKey("record_filename"),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
val VIDEO_CODEC_OPTIONS = Pair(
|
||||||
|
stringPreferencesKey("video_codec_options"),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
val AUDIO_CODEC_OPTIONS = Pair(
|
||||||
|
stringPreferencesKey("audio_codec_options"),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
val VIDEO_ENCODER = Pair(
|
||||||
|
stringPreferencesKey("video_encoder"),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
val AUDIO_ENCODER = Pair(
|
||||||
|
stringPreferencesKey("audio_encoder"),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
val CAMERA_ID = Pair(
|
||||||
|
stringPreferencesKey("camera_id"),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
val CAMERA_SIZE = Pair(
|
||||||
|
stringPreferencesKey("camera_size"),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
val CAMERA_SIZE_CUSTOM = Pair(
|
||||||
|
stringPreferencesKey("camera_size_custom"),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
val CAMERA_SIZE_USE_CUSTOM = Pair(
|
||||||
|
booleanPreferencesKey("camera_size_use_custom"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
val CAMERA_AR = Pair(
|
||||||
|
stringPreferencesKey("camera_ar"),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
val CAMERA_FPS = Pair(
|
||||||
|
intPreferencesKey("camera_fps"),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
val LOG_LEVEL = Pair(
|
||||||
|
stringPreferencesKey("log_level"),
|
||||||
|
"info",
|
||||||
|
)
|
||||||
|
val VIDEO_CODEC = Pair(
|
||||||
|
stringPreferencesKey("video_codec"),
|
||||||
|
"h264",
|
||||||
|
)
|
||||||
|
val AUDIO_CODEC = Pair(
|
||||||
|
stringPreferencesKey("audio_codec"),
|
||||||
|
"opus",
|
||||||
|
)
|
||||||
|
val VIDEO_SOURCE = Pair(
|
||||||
|
stringPreferencesKey("video_source"),
|
||||||
|
"display",
|
||||||
|
)
|
||||||
|
val AUDIO_SOURCE = Pair(
|
||||||
|
stringPreferencesKey("audio_source"),
|
||||||
|
"output",
|
||||||
|
)
|
||||||
|
val RECORD_FORMAT = Pair(
|
||||||
|
stringPreferencesKey("record_format"),
|
||||||
|
"auto",
|
||||||
|
)
|
||||||
|
val CAMERA_FACING = Pair(
|
||||||
|
stringPreferencesKey("camera_facing"),
|
||||||
|
"any",
|
||||||
|
)
|
||||||
|
val MAX_SIZE = Pair(
|
||||||
|
intPreferencesKey("max_size"),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
val VIDEO_BIT_RATE = Pair(
|
||||||
|
intPreferencesKey("video_bit_rate"),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
val AUDIO_BIT_RATE = Pair(
|
||||||
|
intPreferencesKey("audio_bit_rate"),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
val MAX_FPS = Pair(
|
||||||
|
stringPreferencesKey("max_fps"),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
val ANGLE = Pair(
|
||||||
|
stringPreferencesKey("angle"),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
val CAPTURE_ORIENTATION = Pair(
|
||||||
|
intPreferencesKey("capture_orientation"),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
val CAPTURE_ORIENTATION_LOCK = Pair(
|
||||||
|
stringPreferencesKey("capture_orientation_lock"),
|
||||||
|
"unlocked",
|
||||||
|
)
|
||||||
|
val DISPLAY_ORIENTATION = Pair(
|
||||||
|
intPreferencesKey("display_orientation"),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
val RECORD_ORIENTATION = Pair(
|
||||||
|
intPreferencesKey("record_orientation"),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
val DISPLAY_IME_POLICY = Pair(
|
||||||
|
stringPreferencesKey("display_ime_policy"),
|
||||||
|
"undefined",
|
||||||
|
)
|
||||||
|
val DISPLAY_ID = Pair(
|
||||||
|
intPreferencesKey("display_id"),
|
||||||
|
-1, // undefined
|
||||||
|
)
|
||||||
|
val SCREEN_OFF_TIMEOUT = Pair(
|
||||||
|
longPreferencesKey("screen_off_timeout"),
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
val SHOW_TOUCHES = Pair(
|
||||||
|
booleanPreferencesKey("show_touches"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
val FULLSCREEN = Pair(
|
||||||
|
booleanPreferencesKey("fullscreen"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
val CONTROL = Pair(
|
||||||
|
booleanPreferencesKey("control"),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
val VIDEO_PLAYBACK = Pair(
|
||||||
|
booleanPreferencesKey("video_playback"),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
val AUDIO_PLAYBACK = Pair(
|
||||||
|
booleanPreferencesKey("audio_playback"),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
val TURN_SCREEN_OFF = Pair(
|
||||||
|
booleanPreferencesKey("turn_screen_off"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
val STAY_AWAKE = Pair(
|
||||||
|
booleanPreferencesKey("stay_awake"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
val DISABLE_SCREENSAVER = Pair(
|
||||||
|
booleanPreferencesKey("disable_screensaver"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
val POWER_OFF_ON_CLOSE = Pair(
|
||||||
|
booleanPreferencesKey("power_off_on_close"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
val CLEANUP = Pair(
|
||||||
|
booleanPreferencesKey("cleanup"),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
val POWER_ON = Pair(
|
||||||
|
booleanPreferencesKey("power_on"),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
val VIDEO = Pair(
|
||||||
|
booleanPreferencesKey("video"),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
val AUDIO = Pair(
|
||||||
|
booleanPreferencesKey("audio"),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
val REQUIRE_AUDIO = Pair(
|
||||||
|
booleanPreferencesKey("require_audio"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
val KILL_ADB_ON_CLOSE = Pair(
|
||||||
|
booleanPreferencesKey("kill_adb_on_close"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
val CAMERA_HIGH_SPEED = Pair(
|
||||||
|
booleanPreferencesKey("camera_high_speed"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
val LIST = Pair(
|
||||||
|
stringPreferencesKey("list"),
|
||||||
|
"null",
|
||||||
|
)
|
||||||
|
val AUDIO_DUP = Pair(
|
||||||
|
booleanPreferencesKey("audio_dup"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
val NEW_DISPLAY = Pair(
|
||||||
|
stringPreferencesKey("new_display"),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
val START_APP = Pair(
|
||||||
|
stringPreferencesKey("start_app"),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
val VD_DESTROY_CONTENT = Pair(
|
||||||
|
booleanPreferencesKey("vd_destroy_content"),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
val VD_SYSTEM_DECORATIONS = Pair(
|
||||||
|
booleanPreferencesKey("vd_system_decorations"),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val crop by setting(CROP)
|
||||||
|
val recordFilename by setting(RECORD_FILENAME)
|
||||||
|
val videoCodecOptions by setting(VIDEO_CODEC_OPTIONS)
|
||||||
|
val audioCodecOptions by setting(AUDIO_CODEC_OPTIONS)
|
||||||
|
val videoEncoder by setting(VIDEO_ENCODER)
|
||||||
|
val audioEncoder by setting(AUDIO_ENCODER)
|
||||||
|
val cameraId by setting(CAMERA_ID)
|
||||||
|
val cameraSize by setting(CAMERA_SIZE)
|
||||||
|
val cameraSizeCustom by setting(CAMERA_SIZE_CUSTOM)
|
||||||
|
val cameraSizeUseCustom by setting(CAMERA_SIZE_USE_CUSTOM)
|
||||||
|
val cameraAr by setting(CAMERA_AR)
|
||||||
|
val cameraFps by setting(CAMERA_FPS)
|
||||||
|
val logLevel by setting(LOG_LEVEL)
|
||||||
|
val videoCodec by setting(VIDEO_CODEC)
|
||||||
|
val audioCodec by setting(AUDIO_CODEC)
|
||||||
|
val videoSource by setting(VIDEO_SOURCE)
|
||||||
|
val audioSource by setting(AUDIO_SOURCE)
|
||||||
|
val recordFormat by setting(RECORD_FORMAT)
|
||||||
|
val cameraFacing by setting(CAMERA_FACING)
|
||||||
|
val maxSize by setting(MAX_SIZE)
|
||||||
|
val videoBitRate by setting(VIDEO_BIT_RATE)
|
||||||
|
val audioBitRate by setting(AUDIO_BIT_RATE)
|
||||||
|
val maxFps by setting(MAX_FPS)
|
||||||
|
val angle by setting(ANGLE)
|
||||||
|
val captureOrientation by setting(CAPTURE_ORIENTATION)
|
||||||
|
val captureOrientationLock by setting(CAPTURE_ORIENTATION_LOCK)
|
||||||
|
val displayOrientation by setting(DISPLAY_ORIENTATION)
|
||||||
|
val recordOrientation by setting(RECORD_ORIENTATION)
|
||||||
|
val displayImePolicy by setting(DISPLAY_IME_POLICY)
|
||||||
|
val displayId by setting(DISPLAY_ID)
|
||||||
|
val screenOffTimeout by setting(SCREEN_OFF_TIMEOUT)
|
||||||
|
val showTouches by setting(SHOW_TOUCHES)
|
||||||
|
val fullscreen by setting(FULLSCREEN)
|
||||||
|
val control by setting(CONTROL)
|
||||||
|
val videoPlayback by setting(VIDEO_PLAYBACK)
|
||||||
|
val audioPlayback by setting(AUDIO_PLAYBACK)
|
||||||
|
val turnScreenOff by setting(TURN_SCREEN_OFF)
|
||||||
|
val stayAwake by setting(STAY_AWAKE)
|
||||||
|
val disableScreensaver by setting(DISABLE_SCREENSAVER)
|
||||||
|
val powerOffOnClose by setting(POWER_OFF_ON_CLOSE)
|
||||||
|
val cleanup by setting(CLEANUP)
|
||||||
|
val powerOn by setting(POWER_ON)
|
||||||
|
val video by setting(VIDEO)
|
||||||
|
val audio by setting(AUDIO)
|
||||||
|
val requireAudio by setting(REQUIRE_AUDIO)
|
||||||
|
val killAdbOnClose by setting(KILL_ADB_ON_CLOSE)
|
||||||
|
val cameraHighSpeed by setting(CAMERA_HIGH_SPEED)
|
||||||
|
val list by setting(LIST)
|
||||||
|
val audioDup by setting(AUDIO_DUP)
|
||||||
|
val newDisplay by setting(NEW_DISPLAY)
|
||||||
|
val startApp by setting(START_APP)
|
||||||
|
val vdDestroyContent by setting(VD_DESTROY_CONTENT)
|
||||||
|
val vdSystemDecorations by setting(VD_SYSTEM_DECORATIONS)
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class Bundle(
|
||||||
|
val crop: String,
|
||||||
|
val recordFilename: String,
|
||||||
|
val videoCodecOptions: String,
|
||||||
|
val audioCodecOptions: String,
|
||||||
|
val videoEncoder: String,
|
||||||
|
val audioEncoder: String,
|
||||||
|
val cameraId: String,
|
||||||
|
val cameraSize: String,
|
||||||
|
val cameraSizeCustom: String,
|
||||||
|
val cameraSizeUseCustom: Boolean,
|
||||||
|
val cameraAr: String,
|
||||||
|
val cameraFps: Int,
|
||||||
|
val logLevel: String,
|
||||||
|
val videoCodec: String,
|
||||||
|
val audioCodec: String,
|
||||||
|
val videoSource: String,
|
||||||
|
val audioSource: String,
|
||||||
|
val recordFormat: String,
|
||||||
|
val cameraFacing: String,
|
||||||
|
val maxSize: Int,
|
||||||
|
val videoBitRate: Int,
|
||||||
|
val audioBitRate: Int,
|
||||||
|
val maxFps: String,
|
||||||
|
val angle: String,
|
||||||
|
val captureOrientation: Int,
|
||||||
|
val captureOrientationLock: String,
|
||||||
|
val displayOrientation: Int,
|
||||||
|
val recordOrientation: Int,
|
||||||
|
val displayImePolicy: String,
|
||||||
|
val displayId: Int,
|
||||||
|
val screenOffTimeout: Long,
|
||||||
|
val showTouches: Boolean,
|
||||||
|
val fullscreen: Boolean,
|
||||||
|
val control: Boolean,
|
||||||
|
val videoPlayback: Boolean,
|
||||||
|
val audioPlayback: Boolean,
|
||||||
|
val turnScreenOff: Boolean,
|
||||||
|
val stayAwake: Boolean,
|
||||||
|
val disableScreensaver: Boolean,
|
||||||
|
val powerOffOnClose: Boolean,
|
||||||
|
val cleanup: Boolean,
|
||||||
|
val powerOn: Boolean,
|
||||||
|
val video: Boolean,
|
||||||
|
val audio: Boolean,
|
||||||
|
val requireAudio: Boolean,
|
||||||
|
val killAdbOnClose: Boolean,
|
||||||
|
val cameraHighSpeed: Boolean,
|
||||||
|
val list: String,
|
||||||
|
val audioDup: Boolean,
|
||||||
|
val newDisplay: String,
|
||||||
|
val startApp: String,
|
||||||
|
val vdDestroyContent: Boolean,
|
||||||
|
val vdSystemDecorations: Boolean
|
||||||
|
) : Parcelable {
|
||||||
|
}
|
||||||
|
|
||||||
|
private val bundleFields = arrayOf(
|
||||||
|
bundleField(CROP) { bundle: Bundle -> bundle.crop },
|
||||||
|
bundleField(RECORD_FILENAME) { bundle: Bundle -> bundle.recordFilename },
|
||||||
|
bundleField(VIDEO_CODEC_OPTIONS) { bundle: Bundle -> bundle.videoCodecOptions },
|
||||||
|
bundleField(AUDIO_CODEC_OPTIONS) { bundle: Bundle -> bundle.audioCodecOptions },
|
||||||
|
bundleField(VIDEO_ENCODER) { bundle: Bundle -> bundle.videoEncoder },
|
||||||
|
bundleField(AUDIO_ENCODER) { bundle: Bundle -> bundle.audioEncoder },
|
||||||
|
bundleField(CAMERA_ID) { bundle: Bundle -> bundle.cameraId },
|
||||||
|
bundleField(CAMERA_SIZE) { bundle: Bundle -> bundle.cameraSize },
|
||||||
|
bundleField(CAMERA_SIZE_CUSTOM) { bundle: Bundle -> bundle.cameraSizeCustom },
|
||||||
|
bundleField(CAMERA_SIZE_USE_CUSTOM) { bundle: Bundle -> bundle.cameraSizeUseCustom },
|
||||||
|
bundleField(CAMERA_AR) { bundle: Bundle -> bundle.cameraAr },
|
||||||
|
bundleField(CAMERA_FPS) { bundle: Bundle -> bundle.cameraFps },
|
||||||
|
bundleField(LOG_LEVEL) { bundle: Bundle -> bundle.logLevel },
|
||||||
|
bundleField(VIDEO_CODEC) { bundle: Bundle -> bundle.videoCodec },
|
||||||
|
bundleField(AUDIO_CODEC) { bundle: Bundle -> bundle.audioCodec },
|
||||||
|
bundleField(VIDEO_SOURCE) { bundle: Bundle -> bundle.videoSource },
|
||||||
|
bundleField(AUDIO_SOURCE) { bundle: Bundle -> bundle.audioSource },
|
||||||
|
bundleField(RECORD_FORMAT) { bundle: Bundle -> bundle.recordFormat },
|
||||||
|
bundleField(CAMERA_FACING) { bundle: Bundle -> bundle.cameraFacing },
|
||||||
|
bundleField(MAX_SIZE) { bundle: Bundle -> bundle.maxSize },
|
||||||
|
bundleField(VIDEO_BIT_RATE) { bundle: Bundle -> bundle.videoBitRate },
|
||||||
|
bundleField(AUDIO_BIT_RATE) { bundle: Bundle -> bundle.audioBitRate },
|
||||||
|
bundleField(MAX_FPS) { bundle: Bundle -> bundle.maxFps },
|
||||||
|
bundleField(ANGLE) { bundle: Bundle -> bundle.angle },
|
||||||
|
bundleField(CAPTURE_ORIENTATION) { bundle: Bundle -> bundle.captureOrientation },
|
||||||
|
bundleField(CAPTURE_ORIENTATION_LOCK) { bundle: Bundle -> bundle.captureOrientationLock },
|
||||||
|
bundleField(DISPLAY_ORIENTATION) { bundle: Bundle -> bundle.displayOrientation },
|
||||||
|
bundleField(RECORD_ORIENTATION) { bundle: Bundle -> bundle.recordOrientation },
|
||||||
|
bundleField(DISPLAY_IME_POLICY) { bundle: Bundle -> bundle.displayImePolicy },
|
||||||
|
bundleField(DISPLAY_ID) { bundle: Bundle -> bundle.displayId },
|
||||||
|
bundleField(SCREEN_OFF_TIMEOUT) { bundle: Bundle -> bundle.screenOffTimeout },
|
||||||
|
bundleField(SHOW_TOUCHES) { bundle: Bundle -> bundle.showTouches },
|
||||||
|
bundleField(FULLSCREEN) { bundle: Bundle -> bundle.fullscreen },
|
||||||
|
bundleField(CONTROL) { bundle: Bundle -> bundle.control },
|
||||||
|
bundleField(VIDEO_PLAYBACK) { bundle: Bundle -> bundle.videoPlayback },
|
||||||
|
bundleField(AUDIO_PLAYBACK) { bundle: Bundle -> bundle.audioPlayback },
|
||||||
|
bundleField(TURN_SCREEN_OFF) { bundle: Bundle -> bundle.turnScreenOff },
|
||||||
|
bundleField(STAY_AWAKE) { bundle: Bundle -> bundle.stayAwake },
|
||||||
|
bundleField(DISABLE_SCREENSAVER) { bundle: Bundle -> bundle.disableScreensaver },
|
||||||
|
bundleField(POWER_OFF_ON_CLOSE) { bundle: Bundle -> bundle.powerOffOnClose },
|
||||||
|
bundleField(CLEANUP) { bundle: Bundle -> bundle.cleanup },
|
||||||
|
bundleField(POWER_ON) { bundle: Bundle -> bundle.powerOn },
|
||||||
|
bundleField(VIDEO) { bundle: Bundle -> bundle.video },
|
||||||
|
bundleField(AUDIO) { bundle: Bundle -> bundle.audio },
|
||||||
|
bundleField(REQUIRE_AUDIO) { bundle: Bundle -> bundle.requireAudio },
|
||||||
|
bundleField(KILL_ADB_ON_CLOSE) { bundle: Bundle -> bundle.killAdbOnClose },
|
||||||
|
bundleField(CAMERA_HIGH_SPEED) { bundle: Bundle -> bundle.cameraHighSpeed },
|
||||||
|
bundleField(LIST) { bundle: Bundle -> bundle.list },
|
||||||
|
bundleField(AUDIO_DUP) { bundle: Bundle -> bundle.audioDup },
|
||||||
|
bundleField(NEW_DISPLAY) { bundle: Bundle -> bundle.newDisplay },
|
||||||
|
bundleField(START_APP) { bundle: Bundle -> bundle.startApp },
|
||||||
|
bundleField(VD_DESTROY_CONTENT) { bundle: Bundle -> bundle.vdDestroyContent },
|
||||||
|
bundleField(VD_SYSTEM_DECORATIONS) { bundle: Bundle -> bundle.vdSystemDecorations },
|
||||||
|
)
|
||||||
|
|
||||||
|
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
|
||||||
|
|
||||||
|
private fun bundleFromPreferences(preferences: Preferences) = Bundle(
|
||||||
|
crop = preferences.read(CROP),
|
||||||
|
recordFilename = preferences.read(RECORD_FILENAME),
|
||||||
|
videoCodecOptions = preferences.read(VIDEO_CODEC_OPTIONS),
|
||||||
|
audioCodecOptions = preferences.read(AUDIO_CODEC_OPTIONS),
|
||||||
|
videoEncoder = preferences.read(VIDEO_ENCODER),
|
||||||
|
audioEncoder = preferences.read(AUDIO_ENCODER),
|
||||||
|
cameraId = preferences.read(CAMERA_ID),
|
||||||
|
cameraSize = preferences.read(CAMERA_SIZE),
|
||||||
|
cameraSizeCustom = preferences.read(CAMERA_SIZE_CUSTOM),
|
||||||
|
cameraSizeUseCustom = preferences.read(CAMERA_SIZE_USE_CUSTOM),
|
||||||
|
cameraAr = preferences.read(CAMERA_AR),
|
||||||
|
cameraFps = preferences.read(CAMERA_FPS),
|
||||||
|
logLevel = preferences.read(LOG_LEVEL),
|
||||||
|
videoCodec = preferences.read(VIDEO_CODEC),
|
||||||
|
audioCodec = preferences.read(AUDIO_CODEC),
|
||||||
|
videoSource = preferences.read(VIDEO_SOURCE),
|
||||||
|
audioSource = preferences.read(AUDIO_SOURCE),
|
||||||
|
recordFormat = preferences.read(RECORD_FORMAT),
|
||||||
|
cameraFacing = preferences.read(CAMERA_FACING),
|
||||||
|
maxSize = preferences.read(MAX_SIZE),
|
||||||
|
videoBitRate = preferences.read(VIDEO_BIT_RATE),
|
||||||
|
audioBitRate = preferences.read(AUDIO_BIT_RATE),
|
||||||
|
maxFps = preferences.read(MAX_FPS),
|
||||||
|
angle = preferences.read(ANGLE),
|
||||||
|
captureOrientation = preferences.read(CAPTURE_ORIENTATION),
|
||||||
|
captureOrientationLock = preferences.read(CAPTURE_ORIENTATION_LOCK),
|
||||||
|
displayOrientation = preferences.read(DISPLAY_ORIENTATION),
|
||||||
|
recordOrientation = preferences.read(RECORD_ORIENTATION),
|
||||||
|
displayImePolicy = preferences.read(DISPLAY_IME_POLICY),
|
||||||
|
displayId = preferences.read(DISPLAY_ID),
|
||||||
|
screenOffTimeout = preferences.read(SCREEN_OFF_TIMEOUT),
|
||||||
|
showTouches = preferences.read(SHOW_TOUCHES),
|
||||||
|
fullscreen = preferences.read(FULLSCREEN),
|
||||||
|
control = preferences.read(CONTROL),
|
||||||
|
videoPlayback = preferences.read(VIDEO_PLAYBACK),
|
||||||
|
audioPlayback = preferences.read(AUDIO_PLAYBACK),
|
||||||
|
turnScreenOff = preferences.read(TURN_SCREEN_OFF),
|
||||||
|
stayAwake = preferences.read(STAY_AWAKE),
|
||||||
|
disableScreensaver = preferences.read(DISABLE_SCREENSAVER),
|
||||||
|
powerOffOnClose = preferences.read(POWER_OFF_ON_CLOSE),
|
||||||
|
cleanup = preferences.read(CLEANUP),
|
||||||
|
powerOn = preferences.read(POWER_ON),
|
||||||
|
video = preferences.read(VIDEO),
|
||||||
|
audio = preferences.read(AUDIO),
|
||||||
|
requireAudio = preferences.read(REQUIRE_AUDIO),
|
||||||
|
killAdbOnClose = preferences.read(KILL_ADB_ON_CLOSE),
|
||||||
|
cameraHighSpeed = preferences.read(CAMERA_HIGH_SPEED),
|
||||||
|
list = preferences.read(LIST),
|
||||||
|
audioDup = preferences.read(AUDIO_DUP),
|
||||||
|
newDisplay = preferences.read(NEW_DISPLAY),
|
||||||
|
startApp = preferences.read(START_APP),
|
||||||
|
vdDestroyContent = preferences.read(VD_DESTROY_CONTENT),
|
||||||
|
vdSystemDecorations = preferences.read(VD_SYSTEM_DECORATIONS),
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun loadBundle() = loadBundle(::bundleFromPreferences)
|
||||||
|
|
||||||
|
suspend fun saveBundle(new: Bundle) = saveBundle(bundleState.value, new, bundleFields)
|
||||||
|
|
||||||
|
suspend fun updateBundle(transform: (Bundle) -> Bundle) {
|
||||||
|
saveBundle(transform(bundleState.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validate(): Boolean = runBlocking {
|
||||||
|
runCatching {
|
||||||
|
toClientOptions().validate()
|
||||||
|
true
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 处理空值
|
||||||
|
fun toClientOptions() = toClientOptions(bundleState.value)
|
||||||
|
|
||||||
|
fun toClientOptions(bundle: Bundle) = ClientOptions(
|
||||||
|
crop = bundle.crop,
|
||||||
|
recordFilename = bundle.recordFilename,
|
||||||
|
videoCodecOptions = bundle.videoCodecOptions,
|
||||||
|
audioCodecOptions = bundle.audioCodecOptions,
|
||||||
|
videoEncoder = bundle.videoEncoder,
|
||||||
|
audioEncoder = bundle.audioEncoder,
|
||||||
|
cameraId = bundle.cameraId,
|
||||||
|
cameraSize = if (!bundle.cameraSizeUseCustom) bundle.cameraSize else bundle.cameraSizeCustom,
|
||||||
|
cameraAr = bundle.cameraAr,
|
||||||
|
cameraFps = bundle.cameraFps.toUShort(),
|
||||||
|
logLevel = LogLevel.valueOf(bundle.logLevel.uppercase()),
|
||||||
|
videoCodec = Codec.fromString(bundle.videoCodec),
|
||||||
|
audioCodec = Codec.fromString(bundle.audioCodec),
|
||||||
|
videoSource = VideoSource.fromString(bundle.videoSource),
|
||||||
|
audioSource = AudioSource.fromString(bundle.audioSource),
|
||||||
|
recordFormat = ClientOptions.RecordFormat.valueOf(bundle.recordFormat.uppercase()),
|
||||||
|
cameraFacing = CameraFacing.fromString(bundle.cameraFacing),
|
||||||
|
maxSize = bundle.maxSize.toUShort(),
|
||||||
|
videoBitRate = bundle.videoBitRate,
|
||||||
|
audioBitRate = bundle.audioBitRate,
|
||||||
|
maxFps = bundle.maxFps,
|
||||||
|
angle = bundle.angle,
|
||||||
|
captureOrientation = Orientation.fromInt(bundle.captureOrientation),
|
||||||
|
captureOrientationLock = OrientationLock.valueOf(
|
||||||
|
bundle.captureOrientationLock.uppercase()
|
||||||
|
),
|
||||||
|
displayOrientation = Orientation.fromInt(bundle.displayOrientation),
|
||||||
|
recordOrientation = Orientation.fromInt(bundle.recordOrientation),
|
||||||
|
displayImePolicy = DisplayImePolicy.valueOf(bundle.displayImePolicy.uppercase()),
|
||||||
|
displayId = bundle.displayId,
|
||||||
|
screenOffTimeout = Tick(bundle.screenOffTimeout),
|
||||||
|
showTouches = bundle.showTouches,
|
||||||
|
fullscreen = bundle.fullscreen,
|
||||||
|
control = bundle.control,
|
||||||
|
videoPlayback = bundle.videoPlayback,
|
||||||
|
audioPlayback = bundle.audioPlayback,
|
||||||
|
turnScreenOff = bundle.turnScreenOff,
|
||||||
|
stayAwake = bundle.stayAwake,
|
||||||
|
disableScreensaver = bundle.disableScreensaver,
|
||||||
|
powerOffOnClose = bundle.powerOffOnClose,
|
||||||
|
cleanUp = bundle.cleanup,
|
||||||
|
powerOn = bundle.powerOn,
|
||||||
|
video = bundle.video,
|
||||||
|
audio = bundle.audio,
|
||||||
|
requireAudio = bundle.requireAudio,
|
||||||
|
killAdbOnClose = bundle.killAdbOnClose,
|
||||||
|
cameraHighSpeed = bundle.cameraHighSpeed,
|
||||||
|
list = ListOptions.valueOf(bundle.list.uppercase()),
|
||||||
|
audioDup = bundle.audioDup,
|
||||||
|
newDisplay = bundle.newDisplay,
|
||||||
|
startApp = bundle.startApp,
|
||||||
|
vdDestroyContent = bundle.vdDestroyContent,
|
||||||
|
vdSystemDecorations = bundle.vdSystemDecorations
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.storage
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.emptyPreferences
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlin.reflect.KProperty
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
private const val TAG = "Settings"
|
||||||
|
|
||||||
|
abstract class Settings(
|
||||||
|
context: Context,
|
||||||
|
private val name: String,
|
||||||
|
corruptionHandler: ReplaceFileCorruptionHandler<Preferences>? =
|
||||||
|
ReplaceFileCorruptionHandler {
|
||||||
|
Log.e(TAG, "Preferences corrupted, resetting.", it)
|
||||||
|
emptyPreferences()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
private val settingsScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
|
data class Pair<T>(
|
||||||
|
val key: Preferences.Key<T>,
|
||||||
|
val defaultValue: T,
|
||||||
|
) {
|
||||||
|
val name: String get() = key.name
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun <T> Preferences.read(pair: Pair<T>): T =
|
||||||
|
this[pair.key] ?: pair.defaultValue
|
||||||
|
|
||||||
|
protected interface BundleField<B> {
|
||||||
|
suspend fun persist(settings: Settings, current: B, new: B)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun <B, T> bundleField(pair: Pair<T>, selector: (B) -> T): BundleField<B> =
|
||||||
|
object : BundleField<B> {
|
||||||
|
override suspend fun persist(settings: Settings, current: B, new: B) {
|
||||||
|
val currentValue = selector(current)
|
||||||
|
val newValue = selector(new)
|
||||||
|
if (currentValue != newValue) {
|
||||||
|
settings.setValue(pair, newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置项委托类,自动提供 get/set/observe/asState/asMutableState 方法
|
||||||
|
*/
|
||||||
|
inner class SettingProperty<T>(
|
||||||
|
val pair: Pair<T>
|
||||||
|
) {
|
||||||
|
// 创建时注册自身
|
||||||
|
init {
|
||||||
|
registerProperty(pair.name, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
operator fun getValue(thisRef: Any?, property: KProperty<*>): SettingProperty<T> = this
|
||||||
|
|
||||||
|
suspend fun isDefaultValue() = getValue(pair) == pair.defaultValue
|
||||||
|
|
||||||
|
suspend fun get(): T = getValue(pair)
|
||||||
|
|
||||||
|
suspend fun set(value: T) = setValue(pair, value)
|
||||||
|
|
||||||
|
fun observe(): Flow<T> = this@Settings.observe(pair)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun asState(): State<T> = this@Settings.asState(pair)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun asMutableState(): MutableState<T> = this@Settings.asMutableState(pair)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册表, 用于遍历
|
||||||
|
private val propertyRegistry = mutableMapOf<String, SettingProperty<*>>()
|
||||||
|
|
||||||
|
private fun <T> registerProperty(name: String, property: SettingProperty<T>) {
|
||||||
|
propertyRegistry[name] = property
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun getAllProperties(): Map<String, SettingProperty<*>> = propertyRegistry.toMap()
|
||||||
|
|
||||||
|
// 为 Context 添加扩展委托属性,确保 DataStore 单例
|
||||||
|
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
|
||||||
|
name = this.name,
|
||||||
|
corruptionHandler = corruptionHandler,
|
||||||
|
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
)
|
||||||
|
|
||||||
|
// 对外暴露的 DataStore 实例
|
||||||
|
protected val dataStore: DataStore<Preferences> = context.dataStore
|
||||||
|
|
||||||
|
protected fun <T> setting(pair: Pair<T>) = SettingProperty(pair)
|
||||||
|
|
||||||
|
protected fun <B> createBundleState(reader: (Preferences) -> B): StateFlow<B> =
|
||||||
|
dataStore.data
|
||||||
|
.map(reader)
|
||||||
|
.stateIn(
|
||||||
|
scope = settingsScope,
|
||||||
|
started = SharingStarted.Eagerly,
|
||||||
|
initialValue = runBlocking { loadBundle(reader) }
|
||||||
|
)
|
||||||
|
|
||||||
|
protected suspend fun <B> loadBundle(reader: (Preferences) -> B): B =
|
||||||
|
reader(dataStore.data.first())
|
||||||
|
|
||||||
|
protected suspend fun <B> saveBundle(
|
||||||
|
current: B,
|
||||||
|
new: B,
|
||||||
|
fields: Array<out BundleField<B>>,
|
||||||
|
) {
|
||||||
|
for (field in fields) {
|
||||||
|
field.persist(this, current, new)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected suspend fun <T> getValue(pair: Pair<T>): T =
|
||||||
|
dataStore.data.first()[pair.key] ?: pair.defaultValue
|
||||||
|
|
||||||
|
protected suspend fun <T> setValue(pair: Pair<T>, value: T) =
|
||||||
|
dataStore.edit { preferences -> preferences[pair.key] = value }
|
||||||
|
|
||||||
|
protected fun <T> observe(pair: Pair<T>): Flow<T> =
|
||||||
|
dataStore.data.map { preferences -> preferences[pair.key] ?: pair.defaultValue }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
protected fun <T> asState(pair: Pair<T>): State<T> =
|
||||||
|
observe(pair).collectAsState(initial = pair.defaultValue)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
protected fun <T> asMutableState(pair: Pair<T>): MutableState<T> {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val state = asState(pair)
|
||||||
|
|
||||||
|
return rememberSaveable(state.value) {
|
||||||
|
object : MutableState<T> {
|
||||||
|
override var value: T
|
||||||
|
get() = state.value
|
||||||
|
set(newValue) {
|
||||||
|
scope.launch {
|
||||||
|
setValue(pair, newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun component1(): T = value
|
||||||
|
override fun component2(): (T) -> Unit = { value = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val BUNDLE_SAVE_DELAY = 100.milliseconds
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.storage
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
// settings singleton
|
||||||
|
object Storage {
|
||||||
|
private lateinit var appContext: Context
|
||||||
|
|
||||||
|
fun init(context: Context) {
|
||||||
|
appContext = context.applicationContext
|
||||||
|
}
|
||||||
|
|
||||||
|
val appSettings: AppSettings by lazy { AppSettings(appContext) }
|
||||||
|
val quickDevices: QuickDevices by lazy { QuickDevices(appContext) }
|
||||||
|
val scrcpyOptions: ScrcpyOptions by lazy { ScrcpyOptions(appContext) }
|
||||||
|
val adbClientData: AdbClientData by lazy { AdbClientData(appContext) }
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,7 @@ class ReorderableList(
|
|||||||
private val showCheckbox: Boolean = false,
|
private val showCheckbox: Boolean = false,
|
||||||
private val onCheckboxChange: ((String, Boolean) -> Unit)? = null,
|
private val onCheckboxChange: ((String, Boolean) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
enum class Orientation { Column, Row }
|
enum class Orientation { Column, Row; }
|
||||||
|
|
||||||
data class Item(
|
data class Item(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -78,44 +78,40 @@ class ReorderableList(
|
|||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Small)
|
||||||
) {
|
) {
|
||||||
if (item.icon != null) {
|
if (item.icon != null) Icon(
|
||||||
Icon(
|
imageVector = item.icon,
|
||||||
item.icon,
|
contentDescription = item.title
|
||||||
contentDescription = item.title
|
)
|
||||||
)
|
|
||||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
|
||||||
}
|
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
text = item.title,
|
text = item.title,
|
||||||
color = MiuixTheme.colorScheme.onSurface,
|
color = MiuixTheme.colorScheme.onSurface,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
if (item.subtitle.isNotBlank()) {
|
if (item.subtitle.isNotBlank()) Text(
|
||||||
Text(
|
text = item.subtitle,
|
||||||
text = item.subtitle,
|
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
fontSize = 13.sp,
|
||||||
fontSize = 13.sp,
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Small)
|
||||||
) {
|
) {
|
||||||
if (showCheckbox) {
|
if (showCheckbox) Checkbox(
|
||||||
Checkbox(
|
state = if (item.checked) ToggleableState.On else ToggleableState.Off,
|
||||||
state = if (item.checked) ToggleableState.On else ToggleableState.Off,
|
onClick = {
|
||||||
onClick = {
|
onCheckboxChange?.invoke(item.id, !item.checked)
|
||||||
onCheckboxChange?.invoke(item.id, !item.checked)
|
},
|
||||||
},
|
enabled = item.checkboxEnabled
|
||||||
enabled = item.checkboxEnabled
|
)
|
||||||
)
|
|
||||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
|
||||||
}
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {},
|
onClick = {
|
||||||
|
haptics.contextClick()
|
||||||
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.draggableHandle(
|
.draggableHandle(
|
||||||
onDragStarted = {
|
onDragStarted = {
|
||||||
@@ -193,7 +189,7 @@ class ReorderableList(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.padding(UiSpacing.CardContent))
|
Spacer(Modifier.padding(UiSpacing.ContentVertical))
|
||||||
Text(
|
Text(
|
||||||
text = item.title,
|
text = item.title,
|
||||||
color = MiuixTheme.colorScheme.onSurface,
|
color = MiuixTheme.colorScheme.onSurface,
|
||||||
|
|||||||
@@ -25,15 +25,17 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
|
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.UiAndroidKeycodes
|
import io.github.miuzarte.scrcpyforandroid.constants.UiAndroidKeycodes
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||||
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
|
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import top.yukonga.miuix.kmp.basic.Button
|
import top.yukonga.miuix.kmp.basic.Button
|
||||||
import top.yukonga.miuix.kmp.basic.ButtonDefaults
|
import top.yukonga.miuix.kmp.basic.ButtonDefaults
|
||||||
import top.yukonga.miuix.kmp.basic.Icon
|
import top.yukonga.miuix.kmp.basic.Icon
|
||||||
@@ -118,7 +120,7 @@ enum class VirtualButtonAction(
|
|||||||
"截图",
|
"截图",
|
||||||
Icons.Rounded.Screenshot,
|
Icons.Rounded.Screenshot,
|
||||||
UiAndroidKeycodes.SYSRQ
|
UiAndroidKeycodes.SYSRQ
|
||||||
),
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
data class VirtualButtonItem(
|
data class VirtualButtonItem(
|
||||||
@@ -133,7 +135,7 @@ object VirtualButtonActions {
|
|||||||
|
|
||||||
fun parseStoredLayout(raw: String): List<VirtualButtonItem> {
|
fun parseStoredLayout(raw: String): List<VirtualButtonItem> {
|
||||||
if (raw.isBlank())
|
if (raw.isBlank())
|
||||||
return parseStoredLayout(AppDefaults.VIRTUAL_BUTTONS_LAYOUT)
|
return parseStoredLayout(AppSettings.VIRTUAL_BUTTONS_LAYOUT.defaultValue)
|
||||||
|
|
||||||
return raw.split(',').mapNotNull { item ->
|
return raw.split(',').mapNotNull { item ->
|
||||||
val parts = item.trim().split(':')
|
val parts = item.trim().split(':')
|
||||||
@@ -230,9 +232,10 @@ class VirtualButtonBar(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Fullscreen(
|
fun Fullscreen(
|
||||||
onAction: (VirtualButtonAction) -> Unit,
|
onAction: suspend (VirtualButtonAction) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val haptics = rememberAppHaptics()
|
val haptics = rememberAppHaptics()
|
||||||
var showMorePopup by remember { mutableStateOf(false) }
|
var showMorePopup by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -248,7 +251,9 @@ class VirtualButtonBar(
|
|||||||
if (action == VirtualButtonAction.MORE) {
|
if (action == VirtualButtonAction.MORE) {
|
||||||
showMorePopup = true
|
showMorePopup = true
|
||||||
} else {
|
} else {
|
||||||
onAction(action)
|
scope.launch {
|
||||||
|
onAction(action)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -284,9 +289,10 @@ class VirtualButtonBar(
|
|||||||
show: Boolean,
|
show: Boolean,
|
||||||
moreActions: List<VirtualButtonAction>,
|
moreActions: List<VirtualButtonAction>,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onAction: (VirtualButtonAction) -> Unit,
|
onAction: suspend (VirtualButtonAction) -> Unit,
|
||||||
renderInRootScaffold: Boolean,
|
renderInRootScaffold: Boolean,
|
||||||
) {
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val haptics = rememberAppHaptics()
|
val haptics = rememberAppHaptics()
|
||||||
val spinnerItems = remember(moreActions) {
|
val spinnerItems = remember(moreActions) {
|
||||||
moreActions.map { action ->
|
moreActions.map { action ->
|
||||||
@@ -296,7 +302,7 @@ class VirtualButtonBar(
|
|||||||
action.icon,
|
action.icon,
|
||||||
contentDescription = action.title,
|
contentDescription = action.title,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(end = UiSpacing.CardContent),
|
.padding(end = UiSpacing.ContentVertical),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
title = action.title,
|
title = action.title,
|
||||||
@@ -323,7 +329,9 @@ class VirtualButtonBar(
|
|||||||
dialogMode = false,
|
dialogMode = false,
|
||||||
onSelectedIndexChange = { selectedIdx ->
|
onSelectedIndexChange = { selectedIdx ->
|
||||||
haptics.confirm()
|
haptics.confirm()
|
||||||
onAction(moreActions[selectedIdx])
|
scope.launch {
|
||||||
|
onAction(moreActions[selectedIdx])
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ androidxJunit = "1.3.0"
|
|||||||
espressoCore = "3.7.0"
|
espressoCore = "3.7.0"
|
||||||
miuix = "0.8.7"
|
miuix = "0.8.7"
|
||||||
material = "1.13.0"
|
material = "1.13.0"
|
||||||
|
runtime = "1.10.5"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -32,6 +33,7 @@ material = { group = "com.google.android.material", name = "material", version.r
|
|||||||
miuix = { group = "top.yukonga.miuix.kmp", name = "miuix", version.ref = "miuix" }
|
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-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" }
|
miuix-navigation3-ui = { group = "top.yukonga.miuix.kmp", name = "miuix-navigation3-ui", version.ref = "miuix" }
|
||||||
|
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "runtime" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
Reference in New Issue
Block a user