From c567ed1bbbd7af548a62053988d8f1cbbfad9ed1 Mon Sep 17 00:00:00 2001 From: Miuzarte <982809597@qq.com> Date: Thu, 26 Mar 2026 00:56:02 +0800 Subject: [PATCH 1/8] wip: refactoring codebase --- .gitignore | 2 + README.md | 1 + TODO.md | 38 + .../scrcpyforandroid/NativeCoreFacade.kt | 652 +++--------------- .../nativecore/DirectAdbClient.kt | 2 +- .../nativecore/DirectAdbPairingClient.kt | 2 +- .../nativecore/NativeAdbService.kt | 111 +-- .../nativecore/ScrcpySessionManager.kt | 447 +++--------- .../scrcpyforandroid/pages/DevicePage.kt | 344 +++++---- .../pages/FullscreenControlPage.kt | 14 +- .../scrcpyforandroid/pages/MainPage.kt | 19 +- .../scrcpyforandroid/scrcpy/ClientOptions.kt | 549 +++++++++++++++ .../scrcpyforandroid/scrcpy/Scrcpy.kt | 424 ++++++++++++ .../scrcpy/ScrcpyUsageExample.kt | 0 .../scrcpyforandroid/scrcpy/ServerParams.kt | 283 ++++++++ .../scrcpyforandroid/scrcpy/Shared.kt | 159 +++++ .../services/DeviceConnectionLogic.kt | 35 +- .../scrcpyforandroid/services/EventLogger.kt | 70 ++ .../scrcpyforandroid/widgets/DeviceWidgets.kt | 437 +++++++----- .../widgets/ReorderableList.kt | 2 +- .../widgets/VirtualButtons.kt | 18 +- 21 files changed, 2269 insertions(+), 1340 deletions(-) create mode 100644 TODO.md create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ScrcpyUsageExample.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/EventLogger.kt diff --git a/.gitignore b/.gitignore index e5cbb64..a77f58c 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ google-services.json # Android Profiling *.hprof + +.kiro/ diff --git a/README.md b/README.md index 029ba02..d2d0faf 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ `keycode 120`,安卓官方([keycodes.h#349](https://android.googlesource.com/platform/frameworks/native/+/master/include/android/keycodes.h#349))的定义为 `System Request / Print Screen key.`,不同的厂商有不同的实现,在某些类原生(`AxionOS`) 上的行为是软重启 - I18N +- More: [TODO.md](TODO.md) ## 建议搭配模块 diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..6847934 --- /dev/null +++ b/TODO.md @@ -0,0 +1,38 @@ +# TODO + +## CURRENT + +- Refactoring + +## BUGS + +- Figure out is virtual display destroyed at the end of scrcpy session + +## WIDGETS + +- `SuperTextField` Click to pop a dialog with custom notes/summary + +## 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). +- `-t, --show-touches` 显示受控机的物理触控 Enable "show touches" on start, restore the initial value on exit. It only shows physical touches (not clicks from scrcpy). +- `--no-power-on` 开始投屏时不唤醒屏幕 Do not power on the device on start. +- `--power-off-on-close` 结束投屏时息屏 Turn the device screen off when closing scrcpy. +- `--disable-screensaver` 投屏时禁用自动息屏 Disable screensaver while scrcpy is running. +- `--screen-off-timeout=seconds` 投屏过程中的息屏时间 Set the screen off timeout while scrcpy is running (restore the initial value on exit). +- `--require-audio` This option makes scrcpy fail if audio is enabled but does not work. +- `--no-vd-destroy-content` 投屏结束关闭虚拟显示器时不结束进程 Disable virtual display "destroy content on removal" flag. With this option, when the virtual display is closed, the running apps are moved to the main display rather than being destroyed. +- `--no-vd-system-decorations` Disable virtual display system decorations flag. + +## FEATURES + +- 设置项连接设备后马上启用scrcpy会话 + +### LOWER PRIORITY + +顺序无关 + +- 横屏布局 +- 原生悬浮窗 diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt index f69196a..8842f36 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt @@ -1,38 +1,31 @@ package io.github.miuzarte.scrcpyforandroid import android.content.Context -import android.net.Uri import android.os.Handler import android.os.Looper import android.util.Log import android.view.Surface -import androidx.core.net.toUri import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpyAudioPlayer import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpySessionManager -import java.io.File -import java.time.LocalTime -import java.time.format.DateTimeFormatter +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.util.ArrayDeque import java.util.concurrent.ConcurrentHashMap 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 and ADB operations. * - * Provides synchronous and asynchronous helpers that run on an internal - * single-thread executor to serialize access to the native session manager - * and decoders. Callers should use the provided methods from UI code but - * not perform heavy work on the main thread directly. + * Provides helpers for: + * - ADB operations (all suspend functions) + * - Surface/Decoder management for video rendering + * - Control input injection (all suspend functions) */ class NativeCoreFacade(private val appContext: Context) { - - private val adbService = NativeAdbService(appContext) - private val sessionManager = ScrcpySessionManager(adbService) - private val executor = Executors.newSingleThreadExecutor() + val sessionManager = ScrcpySessionManager(NativeAdbService(appContext)) + private val sessionLifecycleMutex = Mutex() private val surfaceMap = ConcurrentHashMap() private val surfaceIdentityMap = ConcurrentHashMap() private val decoderMap = ConcurrentHashMap() @@ -52,11 +45,10 @@ class NativeCoreFacade(private val appContext: Context) { @Volatile private var currentSessionInfo: ScrcpySessionInfo? = null - fun close() { - releaseAllDecoders() - runCatching { sessionManager.stop() } - runCatching { adbService.close() } - executor.shutdown() + suspend fun close() { + sessionLifecycleMutex.withLock { + releaseAllDecoders() + } } /** @@ -69,29 +61,30 @@ class NativeCoreFacade(private val appContext: Context) { * 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) { - val newId = System.identityHashCode(surface) - val oldId = surfaceIdentityMap[tag] - if (oldId != null && oldId == newId && decoderMap.containsKey(tag)) { - return - } - Log.i(TAG, "registerVideoSurface(): tag=$tag surfaceId=$newId oldSurfaceId=$oldId") - surfaceMap[tag] = surface - surfaceIdentityMap[tag] = newId - val session = currentSessionInfo ?: return - ensureVideoConsumerAttached() - val decoder = decoderMap[tag] - if (decoder != null) { - val switched = decoder.switchOutputSurface(surface) - Log.i(TAG, "registerVideoSurface(): switchOutputSurface tag=$tag success=$switched") - if (switched) { + suspend fun registerVideoSurface(tag: String, surface: Surface) { + sessionLifecycleMutex.withLock { + val newId = System.identityHashCode(surface) + val oldId = surfaceIdentityMap[tag] + if (oldId != null && oldId == newId && decoderMap.containsKey(tag)) { return } + Log.i(TAG, "registerVideoSurface(): tag=$tag surfaceId=$newId oldSurfaceId=$oldId") + surfaceMap[tag] = surface + surfaceIdentityMap[tag] = newId + val session = currentSessionInfo ?: return + // ensureVideoConsumerAttached() + val decoder = decoderMap[tag] + if (decoder != null) { + val switched = decoder.switchOutputSurface(surface) + Log.i(TAG, "registerVideoSurface(): switchOutputSurface tag=$tag success=$switched") + if (switched) { + return + } + } + createOrReplaceDecoder(tag, surface, session) } - createOrReplaceDecoder(tag, surface, session) } - /** * Unregister the rendering Surface previously bound to `tag`. * @@ -99,331 +92,22 @@ class NativeCoreFacade(private val appContext: Context) { * - When there is no active session, the decoder for the tag is released immediately. * - This protects the native decoder from feeding into a released Surface. */ - fun unregisterVideoSurface(tag: String, surface: Surface? = null) { - val currentId = surfaceIdentityMap[tag] - val requestId = surface?.let { System.identityHashCode(it) } - if (requestId != null && currentId != null && requestId != currentId) { - Log.i( - TAG, - "unregisterVideoSurface(): skip stale request tag=$tag requestSurfaceId=$requestId currentSurfaceId=$currentId" - ) - return - } - Log.i(TAG, "unregisterVideoSurface(): tag=$tag surfaceId=$requestId") - surfaceMap.remove(tag) - surfaceIdentityMap.remove(tag) - if (currentSessionInfo == null) { - decoderMap.remove(tag)?.release() - } - } - - /** - * 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? { - 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? { - 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) { + suspend fun unregisterVideoSurface(tag: String, surface: Surface? = null) { + sessionLifecycleMutex.withLock { + val currentId = surfaceIdentityMap[tag] + val requestId = surface?.let { System.identityHashCode(it) } + if (requestId != null && currentId != null && requestId != currentId) { Log.i( TAG, - "scrcpyStart(): create audio player codecId=0x${ - info.audioCodecId.toUInt().toString(16) - }" + "unregisterVideoSurface(): skip stale request tag=$tag requestSurfaceId=$requestId currentSurfaceId=$currentId" ) - val player = ScrcpyAudioPlayer(info.audioCodecId) - audioPlayer = player - sessionManager.attachAudioConsumer { packet -> - player.feedPacket(packet.data, packet.ptsUs, packet.isConfig) - } - } else { - Log.i(TAG, "scrcpyStart(): audio playback disabled for this session") + return } - - session - } - } - - /** - * 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( - videoEncoders = result.videoEncoders, - audioEncoders = result.audioEncoders, - videoEncoderTypes = result.videoEncoderTypes, - audioEncoderTypes = result.audioEncoderTypes, - rawOutput = result.rawOutput, - ) - } - } - - fun scrcpyListCameraSizes( - customServerUri: String?, - remotePath: String, - serverVersion: String = "3.3.4" - ): ScrcpyCameraSizeLists { - return ioCall { - val serverJar = if (customServerUri.isNullOrBlank()) { - extractAssetToCache(DEFAULT_SERVER_ASSET) - } else { - extractUriToCache(customServerUri.toUri()) - } - val result = sessionManager.listCameraSizes( - serverJarPath = serverJar.toPath(), - options = ScrcpySessionManager.ScrcpyStartOptions( - serverVersion = serverVersion, - serverRemotePath = remotePath, - ), - ) - ScrcpyCameraSizeLists( - sizes = result.sizes, - rawOutput = result.rawOutput, - ) - } - } - - fun scrcpyIsStarted(): Boolean = ioCall { sessionManager.isStarted() } - - fun getLastScrcpyServerCommand(): String? = ioCall { sessionManager.getLastServerCommand() } - - fun scrcpyInjectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) { - ioExecute { - runCatching { sessionManager.injectKeycode(action, keycode, repeat, metaState) } - } - } - - fun scrcpyInjectText(text: String) { - ioExecute { - runCatching { sessionManager.injectText(text) } - } - } - - fun scrcpyInjectTouch( - action: Int, - x: Int, - y: Int, - screenWidth: Int, - screenHeight: Int, - pressure: Float, - pointerId: Long = 0L, - actionButton: Int = 1, - buttons: Int = 1, - ) { - ioExecute { - runCatching { - sessionManager.injectTouch( - action = action, - pointerId = pointerId, - x = x, - y = y, - screenWidth = screenWidth, - screenHeight = screenHeight, - pressure = pressure, - actionButton = actionButton, - buttons = buttons, - ) - } - } - } - - fun scrcpyInjectScroll( - x: Int, - y: Int, - screenWidth: Int, - screenHeight: Int, - hScroll: Float, - vScroll: Float, - buttons: Int = 0, - ) { - ioExecute { - runCatching { - sessionManager.injectScroll( - x, - y, - screenWidth, - screenHeight, - hScroll, - vScroll, - buttons - ) + Log.i(TAG, "unregisterVideoSurface(): tag=$tag surfaceId=$requestId") + surfaceMap.remove(tag) + surfaceIdentityMap.remove(tag) + if (currentSessionInfo == null) { + decoderMap.remove(tag)?.release() } } } @@ -444,35 +128,73 @@ class NativeCoreFacade(private val appContext: Context) { videoFpsListeners.remove(listener) } - fun scrcpyBackOrScreenOn(action: Int = 0) { - ioExecute { - runCatching { sessionManager.pressBackOrScreenOn(action) } + suspend fun scrcpyBackOrScreenOn(action: Int = 0) { + sessionManager.pressBackOrScreenOn(action) + } + + /** + * Called by Scrcpy.kt when a session starts. + * Sets up video decoders for registered surfaces. + */ + suspend fun onScrcpySessionStarted( + session: ScrcpySessionInfo, + sessionMgr: ScrcpySessionManager + ) = sessionLifecycleMutex.withLock { + currentSessionInfo = session + releaseAllDecoders() + synchronized(bootstrapLock) { + bootstrapPackets.clear() + latestConfigPacket = null + } + + surfaceMap.forEach { (tag, surface) -> + Log.i(TAG, "onScrcpySessionStarted(): bind decoder to tag=$tag") + createOrReplaceDecoder(tag, surface, session) + } + packetCount = 0 + // if (!request.noVideo) { + // ensureVideoConsumerAttached() + // } + 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} 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 extractAssetToCache(assetPath: String): File { - val clean = assetPath.removePrefix("/") - val source = appContext.assets.open(clean) - val outputFile = File(appContext.cacheDir, File(clean).name) - source.use { input -> - outputFile.outputStream().use { output -> input.copyTo(output) } + /** + * Called by Scrcpy.kt when a session stops. + * Cleans up decoders and resets state. + */ + suspend fun onScrcpySessionStopped() = sessionLifecycleMutex.withLock { + releaseAllDecoders() + synchronized(bootstrapLock) { + bootstrapPackets.clear() + latestConfigPacket = null } - return outputFile - } - - private fun extractUriToCache(uri: Uri): File { - val fileName = "custom-scrcpy-server.jar" - val outputFile = File(appContext.cacheDir, fileName) - appContext.contentResolver.openInputStream(uri).use { input -> - requireNotNull(input) { "Unable to open selected server URI" } - outputFile.outputStream().use { output -> input.copyTo(output) } - } - return outputFile + currentSessionInfo = null } companion object { private const val TAG = "NativeCoreFacade" - private const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v3.3.4" private const val MAX_BOOTSTRAP_PACKETS = 90 @Volatile @@ -484,78 +206,6 @@ class NativeCoreFacade(private val appContext: Context) { } } - 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( @@ -692,8 +342,9 @@ class NativeCoreFacade(private val appContext: Context) { * - 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 -> + @Deprecated("TODO: Determine if this is really unnecessary") + private suspend fun ensureVideoConsumerAttached(sessionMgr: ScrcpySessionManager) { + sessionMgr.attachVideoConsumer { packet -> cacheBootstrapPacket(packet) packetCount += 1 if (packetCount == 1L || packetCount % 120L == 0L) { @@ -718,7 +369,6 @@ class NativeCoreFacade(private val appContext: Context) { } } - private fun releaseAllDecoders() { decoderMap.values.forEach { decoder -> runCatching { decoder.release() } @@ -726,87 +376,11 @@ class NativeCoreFacade(private val appContext: Context) { 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 ioCall(task: () -> T): T { - return try { - executor.submit { task() }.get() - } catch (e: ExecutionException) { - val cause = e.cause - if (cause is Exception) { - throw cause - } - throw RuntimeException(cause ?: e) - } - } - - /** - * 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 ScrcpySessionInfo( + val width: Int, + val height: Int, + val deviceName: String, + val codec: String, + val controlEnabled: Boolean, + ) } - -data class ScrcpyStartRequest( - val serverAsset: String, - val customServerUri: String?, - val serverVersion: String, - val serverRemotePath: String, - val maxSize: Int, - val videoBitRate: Int, - val videoCodec: String = "h264", - val audio: Boolean = true, - val audioCodec: String = "opus", - val audioBitRate: Int = 128_000, - val maxFps: Float = 0f, - val noControl: Boolean = false, - val videoEncoder: String = "", - val videoCodecOptions: String = "", - val audioEncoder: String = "", - val audioCodecOptions: String = "", - val audioDup: Boolean = false, - val audioSource: String = "", - val videoSource: String = "display", - val cameraId: String = "", - val cameraFacing: String = "", - val cameraSize: String = "", - val cameraAr: String = "", - val cameraFps: Int = 0, - val cameraHighSpeed: Boolean = false, - val noAudioPlayback: Boolean = false, - val requireAudio: Boolean = false, - val turnScreenOff: Boolean = false, - val noVideo: Boolean = false, - val newDisplay: String = "", - val displayId: Int? = null, - val crop: String = "", -) - -data class ScrcpyEncoderLists( - val videoEncoders: List, - val audioEncoders: List, - val videoEncoderTypes: Map = emptyMap(), - val audioEncoderTypes: Map = emptyMap(), - val rawOutput: String = "", -) - -data class ScrcpyCameraSizeLists( - val sizes: List, - val rawOutput: String = "", -) - -data class ScrcpySessionInfo( - val width: Int, - val height: Int, - val deviceName: String, - val codec: String, - val controlEnabled: Boolean, -) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt index ed0806c..e19972c 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt @@ -548,7 +548,7 @@ internal class DirectAdbConnection( * Logical ADB stream abstraction mapped to a local id. Provides blocking * `InputStream`/`OutputStream` implementations and lifecycle helpers used by callers. */ -internal class AdbSocketStream( +class AdbSocketStream( val localId: Int, private val sender: (cmd: Int, arg0: Int, arg1: Int, data: ByteArray) -> Unit, ) : Closeable { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbPairingClient.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbPairingClient.kt index c3690d1..4cd8c9f 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbPairingClient.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbPairingClient.kt @@ -143,7 +143,7 @@ internal class DirectAdbPairingClient( READY, EXCHANGING_MSGS, EXCHANGING_PEER_INFO, - STOPPED, + STOPPED; } private lateinit var socket: Socket diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt index b8270e8..a3b9510 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt @@ -2,18 +2,22 @@ package io.github.miuzarte.scrcpyforandroid.nativecore import android.content.Context import android.util.Log +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout import java.nio.file.Path +import kotlin.time.Duration /** * 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 - * and accessed from the app's serialized IO executor. + * Methods use Mutex for thread-safety because the underlying transport is single-connection + * and may be accessed from multiple coroutines. */ class NativeAdbService(appContext: Context) { - private val transport = DirectAdbTransport(appContext) + private val mutex = Mutex() @Volatile private var connection: DirectAdbConnection? = null @@ -30,14 +34,13 @@ class NativeAdbService(appContext: Context) { transport.keyName = value } - @Synchronized - fun pair(host: String, port: Int, pairingCode: String): Boolean { + suspend fun pair(host: String, port: Int, pairingCode: String): Boolean = mutex.withLock { val h = host.trim() val code = pairingCode.trim() require(h.isNotBlank()) { "host is blank" } require(code.isNotBlank()) { "pairing code is blank" } Log.i(TAG, "pair(): host=$h port=$port") - return try { + return@withLock try { transport.pair(h, port, code) } catch (e: Exception) { Log.e(TAG, "pair(): failed host=$h port=$port", e) @@ -46,12 +49,11 @@ class NativeAdbService(appContext: Context) { } } - @Synchronized - fun discoverPairingService( + suspend fun discoverPairingService( timeoutMs: Long = 12_000, includeLanDevices: Boolean = true - ): Pair? { - return try { + ): Pair? = mutex.withLock { + return@withLock try { transport.discoverPairingService(timeoutMs, includeLanDevices) } catch (e: Exception) { Log.w(TAG, "discoverPairingService(): failed", e) @@ -59,12 +61,11 @@ class NativeAdbService(appContext: Context) { } } - @Synchronized - fun discoverConnectService( + suspend fun discoverConnectService( timeoutMs: Long = 12_000, includeLanDevices: Boolean = true - ): Pair? { - return try { + ): Pair? = mutex.withLock { + return@withLock try { transport.discoverConnectService(timeoutMs, includeLanDevices) } catch (e: Exception) { Log.w(TAG, "discoverConnectService(): failed", e) @@ -77,20 +78,27 @@ class NativeAdbService(appContext: Context) { * same host:port it is reused; otherwise the previous connection is closed * before attempting the new connect. */ - @Synchronized - fun connect(host: String, port: Int): Boolean { + suspend fun connect( + host: String, + port: Int, + timeout: Duration = Duration.INFINITE, + ) = mutex.withLock { Log.i(TAG, "connect(): host=$host port=$port") - val existing = connection - if (existing != null && existing.isAlive() && connectedHost == host && connectedPort == port) { - return true + + if (connection != null + && connection!!.isAlive() + && connectedHost == host + && connectedPort == port + ) { + return@withLock } - disconnect() + disconnectInternal() + try { - val conn = transport.connect(host, port) + val conn = withTimeout(timeout) { transport.connect(host, port) } connection = conn connectedHost = host connectedPort = port - return true } catch (e: Exception) { Log.e(TAG, "connect(): failed host=$host port=$port", e) val detail = e.message ?: "${e.javaClass.simpleName} (no message)" @@ -101,39 +109,42 @@ class NativeAdbService(appContext: Context) { /** * Close the current ADB connection immediately. */ - @Synchronized - fun disconnect() { + suspend 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 { + requireConnection().shell(command) + } + + 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() } connection = null connectedHost = 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 { return connection?.takeIf { it.isAlive() } ?: throw IllegalStateException("ADB not connected") diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpySessionManager.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpySessionManager.kt index 62930c6..b23287d 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpySessionManager.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpySessionManager.kt @@ -2,6 +2,10 @@ package io.github.miuzarte.scrcpyforandroid.nativecore import android.util.Log import android.view.KeyEvent +import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions +import io.github.miuzarte.scrcpyforandroid.scrcpy.ServerParams +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.io.BufferedInputStream import java.io.BufferedReader import java.io.DataInputStream @@ -15,6 +19,8 @@ import kotlin.concurrent.thread import kotlin.math.roundToInt class ScrcpySessionManager(private val adbService: NativeAdbService) { + private val mutex = Mutex() + @Volatile private var activeSession: ActiveSession? = null @@ -45,29 +51,27 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { * - Initializes an [ActiveSession] which holds socket streams and reader threads. * * Threading notes: - * - This is synchronized to avoid concurrent starts/stops. + * - Uses Mutex for thread-safety to avoid concurrent starts/stops. * - It may block while interacting with adb; callers should execute it off the UI - * thread when appropriate. The facade uses an executor to serialize such calls. + * thread when appropriate. */ - @Synchronized - fun start(serverJarPath: Path, options: ScrcpyStartOptions): SessionInfo { - stop() - synchronized(this) { - serverLogBuffer.clear() - } - val targetPath = options.serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH } - val scid = random.nextInt(Int.MAX_VALUE) - val socketName = socketNameFor(scid) + suspend fun start( + serverJarPath: Path, + serverCommand: String, + scid: UInt, + options: ClientOptions, + ): SessionInfo = mutex.withLock { + stopInternal() + serverLogBuffer.clear() + val socketName = socketNameFor(scid.toInt()) try { - adbService.push(serverJarPath, targetPath) - val cmd = buildServerCommand(targetPath, scid, options) - lastServerCommand = cmd + lastServerCommand = serverCommand Log.i( TAG, - "start(): socket=$socketName codec=${options.videoCodec} audio=${options.audio} audioCodec=${options.audioCodec}" + "start(): socket=$socketName codec=${options.videoCodec.string} audio=${options.audio} audioCodec=${options.audioCodec.string}" ) - val serverStream = adbService.openShellStream(cmd) + val serverStream = adbService.openShellStream(serverCommand) val serverLogThread = startServerLogThread(serverStream, socketName) Thread.sleep(SERVER_BOOT_DELAY_MS) @@ -118,7 +122,8 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { } val deviceName = readDeviceName(firstInput) - val audioCodecId = if (options.audio) audioCodecIdFromName(options.audioCodec) else 0 + val audioCodecId = + if (options.audio) audioCodecIdFromName(options.audioCodec.string) else 0 val codecId: Int val width: Int val height: Int @@ -175,8 +180,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { * - The reader thread stops when the session ends or the socket is closed. * - Consumers should be resilient to occasional dropped packets or reader errors. */ - @Synchronized - fun attachVideoConsumer(consumer: (VideoPacket) -> Unit) { + suspend fun attachVideoConsumer(consumer: (VideoPacket) -> Unit): Unit = mutex.withLock { val session = activeSession ?: throw IllegalStateException("scrcpy session not started") val vInput = session.videoInput ?: return val vStream = session.videoStream ?: return @@ -224,8 +228,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { } } - @Synchronized - fun clearVideoConsumer() { + suspend fun clearVideoConsumer() = mutex.withLock { videoConsumer = null } @@ -237,8 +240,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { * - The function reads the audio stream header to determine whether audio is * available and exits early if disabled. */ - @Synchronized - fun attachAudioConsumer(consumer: (AudioPacket) -> Unit) { + suspend fun attachAudioConsumer(consumer: (AudioPacket) -> Unit): Unit = mutex.withLock { val session = activeSession ?: throw IllegalStateException("scrcpy session not started") val aInput = session.audioInput ?: return // audio disabled or unavailable val aStream = session.audioStream ?: return @@ -307,8 +309,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { } } - @Synchronized - fun clearAudioConsumer() { + suspend fun clearAudioConsumer() = mutex.withLock { audioConsumer = null } @@ -316,15 +317,14 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { * Inject a keycode event to the control channel. * * - Requires an active control channel; throws if absent. - * - Synchronized to serialize control writes. + * - Uses Mutex to serialize control writes. */ - @Synchronized - fun injectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) { - requireControlWriter().injectKeycode(action, keycode, repeat, metaState) - } + suspend fun injectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) = + mutex.withLock { + requireControlWriter().injectKeycode(action, keycode, repeat, metaState) + } - @Synchronized - fun injectText(text: String) { + suspend fun injectText(text: String) = mutex.withLock { requireControlWriter().injectText(text) } @@ -333,10 +333,9 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { * * - Coordinates are expected in device pixels and are written together with * screen dimensions so the server can interpret them correctly. - * - Synchronized to serialize control writes. + * - Uses Mutex to serialize control writes. */ - @Synchronized - fun injectTouch( + suspend fun injectTouch( action: Int, pointerId: Long, x: Int, @@ -346,7 +345,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { pressure: Float, actionButton: Int, buttons: Int, - ) { + ) = mutex.withLock { requireControlWriter().injectTouch( action, pointerId, @@ -360,8 +359,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { ) } - @Synchronized - fun injectScroll( + suspend fun injectScroll( x: Int, y: Int, screenWidth: Int, @@ -369,7 +367,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { hScroll: Float, vScroll: Float, buttons: Int - ) { + ) = mutex.withLock { requireControlWriter().injectScroll( x, y, @@ -381,13 +379,11 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { ) } - @Synchronized - fun pressBackOrScreenOn(action: Int = KeyEvent.ACTION_DOWN) { + suspend fun pressBackOrScreenOn(action: Int = KeyEvent.ACTION_DOWN) = mutex.withLock { requireControlWriter().pressBackOrScreenOn(action) } - @Synchronized - fun setDisplayPower(on: Boolean) { + suspend fun setDisplayPower(on: Boolean) = mutex.withLock { requireControlWriter().setDisplayPower(on) } @@ -397,8 +393,11 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { * - Interrupts and joins reader threads with short timeouts, closes sockets, * and clears state. It is safe to call from any thread. */ - @Synchronized - fun stop() { + suspend fun stop() = mutex.withLock { + stopInternal() + } + + private fun stopInternal() { val session = activeSession ?: return activeSession = null videoConsumer = null @@ -430,20 +429,20 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { fun getLastServerCommand(): String? = lastServerCommand - @Synchronized - fun listEncoders(serverJarPath: Path, options: ScrcpyStartOptions): EncoderLists { - val targetPath = options.serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH } + // TODO: 合并几个 --list-xxxx + suspend fun listEncoders( + serverJarPath: Path, + serverParams: ServerParams, + serverVersion: String = "3.3.4", + serverRemotePath: String = DEFAULT_SERVER_REMOTE_PATH, + ): EncoderLists = mutex.withLock { + val targetPath = serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH } adbService.push(serverJarPath, targetPath) + val cmd = buildServerCommand( targetPath = targetPath, - scid = 0x1234abcd, - options = options.copy( - video = false, - audio = false, - control = false, - listEncoders = true, - cleanup = false, - ), + serverParams = serverParams, + serverVersion = serverVersion, ) Log.i(TAG, "listEncoders(): cmd=$cmd") // scrcpy encoder list is printed in logs, so merge stderr into stdout. @@ -457,20 +456,19 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { return parsed.copy(rawOutput = output) } - @Synchronized - fun listCameraSizes(serverJarPath: Path, options: ScrcpyStartOptions): CameraSizeLists { - val targetPath = options.serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH } + suspend fun listCameraSizes( + serverJarPath: Path, + serverParams: ServerParams, + serverVersion: String = "3.3.4", + serverRemotePath: String = DEFAULT_SERVER_REMOTE_PATH, + ): CameraSizeLists = mutex.withLock { + val targetPath = serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH } adbService.push(serverJarPath, targetPath) + val cmd = buildServerCommand( targetPath = targetPath, - scid = 0x2234abcd, - options = options.copy( - video = false, - audio = false, - control = false, - listCameraSizes = true, - cleanup = false, - ), + serverParams = serverParams, + serverVersion = serverVersion, ) Log.i(TAG, "listCameraSizes(): cmd=$cmd") val output = adbService.shell("$cmd 2>&1") @@ -490,288 +488,11 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { private fun buildServerCommand( targetPath: String, - scid: Int, - options: ScrcpyStartOptions + serverParams: ServerParams, + serverVersion: String, ): String { - val serverArgs = buildServerArgs(scid, options) - return "CLASSPATH=$targetPath app_process / com.genymobile.scrcpy.Server ${options.serverVersion} $serverArgs" - } - - private fun buildServerArgs(scid: Int, options: ScrcpyStartOptions): String { - val videoSource = options.videoSource.trim().ifBlank { "display" } - val isCameraSource = videoSource.equals("camera", ignoreCase = true) - val cameraId = options.cameraId.trim() - val hasCameraId = cameraId.isNotBlank() - val hasExplicitCameraSize = options.cameraSize.trim().isNotBlank() - val hasValidCameraFps = options.cameraFps > 0 - - val args = listOf( - ServerArg( - type = ServerArgType.STRING, - key = "scid", - value = String.format("%08x", scid), - ), - ServerArg( - type = ServerArgType.STRING, - key = "log_level", - value = "debug", - ), - ServerArg( - type = ServerArgType.BOOLEAN, - key = "tunnel_forward", - value = true, - ), - ServerArg( - type = ServerArgType.BOOLEAN, - key = "cleanup", - value = options.cleanup, - ), - ServerArg( - type = ServerArgType.BOOLEAN, - key = "send_device_meta", - value = true, - ), - ServerArg( - type = ServerArgType.BOOLEAN, - key = "send_frame_meta", - value = true, - ), - ServerArg( - type = ServerArgType.BOOLEAN, - key = "send_dummy_byte", - value = true, - ), - ServerArg( - type = ServerArgType.BOOLEAN, - key = "send_codec_meta", - value = true, - ), - ServerArg( - type = ServerArgType.BOOLEAN, - key = "video", - value = options.video, - ), - ServerArg( - type = ServerArgType.BOOLEAN, - key = "audio", - value = options.audio, - ), - ServerArg( - type = ServerArgType.BOOLEAN, - key = "control", - value = options.control, - ), - ServerArg( - type = ServerArgType.STRING, - key = "video_source", - value = videoSource, - ), - ServerArg( - type = ServerArgType.NUMBER, - key = "max_size", - value = options.maxSize, - ), - ServerArg( - type = ServerArgType.STRING, - key = "video_codec", - value = options.videoCodec, - ), - ServerArg( - type = ServerArgType.NUMBER, - key = "video_bit_rate", - value = options.videoBitRate, - ), - ServerArg( - type = ServerArgType.NUMBER, - key = "max_fps", - value = options.maxFps, - ), - ServerArg( - type = ServerArgType.STRING, - key = "camera_id", - value = cameraId, - includeWhen = isCameraSource, - ), - ServerArg( - type = ServerArgType.STRING, - key = "camera_facing", - value = options.cameraFacing.trim(), - includeWhen = isCameraSource && !hasCameraId, - ), - ServerArg( - type = ServerArgType.STRING, - key = "camera_size", - value = options.cameraSize.trim(), - includeWhen = isCameraSource, - ), - ServerArg( - type = ServerArgType.STRING, - key = "camera_ar", - value = options.cameraAr.trim(), - includeWhen = isCameraSource && !hasExplicitCameraSize, - ), - ServerArg( - type = ServerArgType.NUMBER, - key = "camera_fps", - value = options.cameraFps, - includeWhen = isCameraSource, - ), - ServerArg( - type = ServerArgType.BOOLEAN, - key = "camera_high_speed", - value = options.cameraHighSpeed, - includeWhen = isCameraSource && options.cameraHighSpeed && hasValidCameraFps, - ), - ServerArg( - type = ServerArgType.STRING, - key = "audio_codec", - value = options.audioCodec, - includeWhen = options.audio, - ), - ServerArg( - type = ServerArgType.NUMBER, - key = "audio_bit_rate", - value = options.audioBitRate, - includeWhen = options.audio, - ), - ServerArg( - type = ServerArgType.STRING, - key = "audio_source", - value = options.audioSource.trim(), - includeWhen = options.audio, - ), - ServerArg( - type = ServerArgType.BOOLEAN, - key = "audio_dup", - value = options.audioDup, - includeWhen = options.audioDup, - ), - ServerArg( - type = ServerArgType.STRING, - key = "video_encoder", - value = options.videoEncoder.trim(), - ), - ServerArg( - type = ServerArgType.STRING, - key = "video_codec_options", - value = options.videoCodecOptions.trim(), - ), - ServerArg( - type = ServerArgType.STRING, - key = "audio_encoder", - value = options.audioEncoder.trim(), - ), - ServerArg( - type = ServerArgType.STRING, - key = "audio_codec_options", - value = options.audioCodecOptions.trim(), - ), - ServerArg( - type = ServerArgType.STRING, - key = "new_display", - value = options.newDisplay.trim(), - ), - ServerArg( - type = ServerArgType.NUMBER, - key = "display_id", - value = options.displayId, - ), - ServerArg( - type = ServerArgType.STRING, - key = "crop", - value = options.crop.trim(), - ), - ServerArg( - type = ServerArgType.BOOLEAN, - key = "list_encoders", - value = options.listEncoders, - includeWhen = options.listEncoders, - ), - ServerArg( - type = ServerArgType.BOOLEAN, - key = "list_camera_sizes", - value = options.listCameraSizes, - includeWhen = options.listCameraSizes, - ), - // Reserved for future args that require repeated/list values. - // ServerArg( - // type = ServerArgType.LIST, - // key = "_unused_list", - // value = emptyList(), - // includeWhen = false, - // ), - ) - - val rendered = args.mapNotNull { it.render() } - val header = rendered.firstOrNull() - val kv = rendered.drop(1) - return if (header == null) { - kv.joinToString(" ") - } else { - "$header ${kv.joinToString(" ")}".trim() - } - } - - private enum class ServerArgType { - NUMBER, - STRING, - BOOLEAN, - LIST, - } - - private enum class ZeroValueBehavior { - OMIT, - KEEP, - } - - private data class ServerArg( - val type: ServerArgType, - val key: String, - val value: T?, - val includeWhen: Boolean = true, - val zeroValueBehavior: ZeroValueBehavior = ZeroValueBehavior.OMIT, - ) { - fun render(): String? { - if (!includeWhen) return null - return when (type) { - ServerArgType.STRING -> renderString(value as? String) - ServerArgType.BOOLEAN -> renderBoolean(value as? Boolean) - ServerArgType.NUMBER -> renderNumber(value as? Number) - ServerArgType.LIST -> renderList(value as? Iterable<*>) - } - } - - private fun renderString(raw: String?): String? { - val normalized = raw?.trim().orEmpty() - if (normalized.isBlank()) return null - return "$key=$normalized" - } - - private fun renderBoolean(raw: Boolean?): String? { - val normalized = raw ?: return null - return "$key=$normalized" - } - - private fun renderNumber(raw: Number?): String? { - val normalized = raw ?: return null - val asDouble = normalized.toDouble() - if (asDouble == 0.0 && zeroValueBehavior == ZeroValueBehavior.OMIT) return null - val valueString = when (normalized) { - is Float -> normalized.toString() - is Double -> normalized.toString() - else -> normalized.toLong().toString() - } - return "$key=$valueString" - } - - private fun renderList(raw: Iterable<*>?): String? { - val items = raw - ?.mapNotNull { it?.toString()?.trim() } - ?.filter { it.isNotBlank() } - .orEmpty() - if (items.isEmpty()) return null - return "$key=${items.joinToString(",")}" - } + val serverArgs = serverParams.build() + return "CLASSPATH=$targetPath app_process / com.genymobile.scrcpy.Server $serverVersion $serverArgs" } private fun parseEncoderLists(output: String): EncoderLists { @@ -836,7 +557,13 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { ).use { reader -> while (true) { val line = reader.readLine() ?: break - appendServerLog(line) + // Use synchronized block for thread-safe log buffer access + synchronized(serverLogBuffer) { + if (serverLogBuffer.size >= SERVER_LOG_BUFFER_MAX_LINES) { + serverLogBuffer.removeFirst() + } + serverLogBuffer.addLast(line) + } Log.i(TAG, "[server:$socketName] $line") } } @@ -848,21 +575,15 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { } } - @Synchronized - private fun appendServerLog(line: String) { - if (serverLogBuffer.size >= SERVER_LOG_BUFFER_MAX_LINES) { - serverLogBuffer.removeFirst() - } - serverLogBuffer.addLast(line) - } - - @Synchronized private fun snapshotServerLogs(maxLines: Int = 120): String { - if (serverLogBuffer.isEmpty()) { - return "" + val snapshot = synchronized(serverLogBuffer) { + if (serverLogBuffer.isEmpty()) { + return "" + } + val take = maxLines.coerceIn(1, SERVER_LOG_BUFFER_MAX_LINES) + serverLogBuffer.toList().takeLast(take) } - val take = maxLines.coerceIn(1, SERVER_LOG_BUFFER_MAX_LINES) - return serverLogBuffer.toList().takeLast(take).joinToString("\n") + return snapshot.joinToString("\n") } /** @@ -871,7 +592,7 @@ class ScrcpySessionManager(private val adbService: NativeAdbService) { * - Retries a number of times with a short delay (useful during server startup). * - Optionally expects a dummy byte on the stream to validate the server handshake. */ - private fun openAbstractSocketWithRetry( + private suspend fun openAbstractSocketWithRetry( socketName: String, expectDummyByte: Boolean ): AdbSocketStream { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt index b343bb6..6e2ed6d 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt @@ -19,18 +19,29 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade -import io.github.miuzarte.scrcpyforandroid.ScrcpySessionInfo +import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut +import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn +import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions +import io.github.miuzarte.scrcpyforandroid.scrcpy.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.ListOptions +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource import io.github.miuzarte.scrcpyforandroid.services.DevicePageSettings +import io.github.miuzarte.scrcpyforandroid.services.EventLogger +import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo import io.github.miuzarte.scrcpyforandroid.services.loadDevicePageSettings import io.github.miuzarte.scrcpyforandroid.services.loadQuickDevices @@ -65,9 +76,6 @@ import top.yukonga.miuix.kmp.basic.SnackbarHostState import top.yukonga.miuix.kmp.extra.SuperBottomSheet import java.net.InetSocketAddress import java.net.Socket -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale import java.util.concurrent.Executors private const val ADB_CONNECT_TIMEOUT_MS = 3_000L @@ -77,10 +85,9 @@ private const val ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS = 1_200L private const val ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS = 1_500L private const val ADB_TCP_PROBE_TIMEOUT_MS = 600 private const val DEVICE_SHORTCUT_SEPARATOR = "\u001F" -private const val LOG_TAG = "DevicePage" private val DeviceShortcutStateListSaver = - listSaver, String>( + listSaver, String>( save = { list -> list.map { item -> listOf( @@ -109,7 +116,7 @@ private val DeviceShortcutStateListSaver = ) private val StringStateListSaver = - listSaver, String>( + listSaver, String>( save = { it.toList() }, restore = { it.toMutableStateList() }, ) @@ -118,15 +125,14 @@ private val StringStateListSaver = fun DeviceTabScreen( contentPadding: PaddingValues, nativeCore: NativeCoreFacade, + adbService: NativeAdbService, + scrcpy: Scrcpy, snack: SnackbarHostState, scrollBehavior: ScrollBehavior, virtualButtonsLayout: String, showPreviewVirtualButtonText: Boolean, previewCardHeightDp: Int, themeBaseIndex: Int, - customServerUri: String?, - serverRemotePath: String, - onServerRemotePathChange: (String) -> Unit, videoCodec: String, onVideoCodecChange: (String) -> Unit, audioEnabled: Boolean, @@ -287,40 +293,12 @@ fun DeviceTabScreen( currentTargetPort ) else null - val eventLog = rememberSaveable(saver = StringStateListSaver) { mutableStateListOf() } val quickDevices = rememberSaveable(saver = DeviceShortcutStateListSaver) { mutableStateListOf() } val sessionReconnectBlacklistHosts = remember { mutableSetOf() } - LaunchedEffect(eventLog.size) { - onCanClearLogsChange(eventLog.isNotEmpty()) - } - - fun logEvent(message: String, level: Int = Log.INFO, error: Throwable? = null) { - val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date()) - val line = "[$timestamp] $message" - eventLog.add(0, line) - if (eventLog.size > AppDefaults.EVENT_LOG_LINES) { - eventLog.removeRange(AppDefaults.EVENT_LOG_LINES, eventLog.size) - } - when (level) { - Log.ERROR -> if (error != null) Log.e(LOG_TAG, message, error) else Log.e( - LOG_TAG, - message - ) - - Log.WARN -> if (error != null) Log.w(LOG_TAG, message, error) else Log.w( - LOG_TAG, - message - ) - - Log.DEBUG -> if (error != null) Log.d(LOG_TAG, message, error) else Log.d( - LOG_TAG, - message - ) - - else -> if (error != null) Log.i(LOG_TAG, message, error) else Log.i(LOG_TAG, message) - } + LaunchedEffect(EventLogger.eventLog.size) { + onCanClearLogsChange(EventLogger.hasLogs()) } /** @@ -352,8 +330,8 @@ fun DeviceTabScreen( ) { withContext(adbWorkerDispatcher) { // Also stops scrcpy. - runCatching { nativeCore.scrcpyStop() } - runCatching { nativeCore.adbDisconnect() } + runCatching { scrcpy.stop() } + runCatching { adbService.disconnect() } } adbConnected = false currentTargetHost = "" @@ -383,7 +361,6 @@ fun DeviceTabScreen( if (current.host == newHost && current.port == newPort) return sessionReconnectBlacklistHosts += current.host - logEvent("切换连接目标,先断开当前设备: ${current.host}:${current.port}") disconnectAdbConnection(clearQuickOnlineForTarget = current) } @@ -420,10 +397,10 @@ fun DeviceTabScreen( * - Some adb endpoints can take long to accept TCP connects; the UI should not wait * indefinitely. Use a small, caller-chosen timeout to keep UX snappy. */ - suspend fun connectWithTimeout(host: String, port: Int): Boolean { + suspend fun connectWithTimeout(host: String, port: Int) { return withContext(adbWorkerDispatcher) { withTimeout(ADB_CONNECT_TIMEOUT_MS) { - nativeCore.adbConnect(host, port) + adbService.connect(host, port) } } } @@ -444,12 +421,12 @@ fun DeviceTabScreen( suspend fun keepAliveCheck(host: String, port: Int): Boolean { return withContext(adbWorkerDispatcher) { withTimeout(ADB_KEEPALIVE_TIMEOUT_MS) { - val connected = nativeCore.adbIsConnected() + val connected = adbService.isConnected() if (!connected) { return@withTimeout false } runCatching { - nativeCore.adbShell("echo -n 1") + adbService.shell("echo -n 1") true }.getOrElse { false } } @@ -545,25 +522,21 @@ fun DeviceTabScreen( } } - suspend fun runAutoAdbConnect(host: String, port: Int): Boolean { - return runCatching { + suspend fun runAutoAdbConnect(host: String, port: Int) { + runCatching { connectWithTimeout(host, port) }.getOrElse { error -> val detail = error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName logEvent("自动重连失败: $host:$port ($detail)", Log.WARN) - false } } - fun refreshEncoderLists() { + suspend fun refreshEncoderLists() { if (!adbConnected) return - val remotePath = serverRemotePath.trim().ifBlank { AppDefaults.SERVER_REMOTE_PATH } runCatching { - nativeCore.scrcpyListEncoders( - customServerUri = customServerUri, - remotePath = remotePath, - ) - }.onSuccess { lists -> + scrcpy.listOptions(list = ListOptions.ENCODERS) + }.onSuccess { result -> + val lists = result as Scrcpy.ListResult.Encoders onVideoEncoderOptionsChange(lists.videoEncoders) onAudioEncoderOptionsChange(lists.audioEncoders) onVideoEncoderTypeMapChange(lists.videoEncoderTypes) @@ -574,12 +547,15 @@ fun DeviceTabScreen( if (audioEncoder.isNotBlank() && audioEncoder !in audioEncoderOptions) { onAudioEncoderChange("") } - logEvent("编码器列表已刷新: video=${lists.videoEncoders.size} audio=${lists.audioEncoders.size}") + EventLogger.logEvent("编码器列表已刷新: video=${lists.videoEncoders.size} audio=${lists.audioEncoders.size}") if (lists.videoEncoders.isEmpty() && lists.audioEncoders.isEmpty()) { - logEvent("提示: 编码器为空,请检查 server 路径/版本与设备系统日志", Log.WARN) + EventLogger.logEvent( + "提示: 编码器为空,请检查 server 路径/版本与设备系统日志", + Log.WARN + ) val preview = lists.rawOutput.lineSequence().take(20).joinToString(" | ") if (preview.isNotBlank()) { - logEvent("编码器原始输出: $preview", Log.DEBUG) + EventLogger.logEvent("编码器原始输出: $preview", Log.DEBUG) } } }.onFailure { e -> @@ -587,41 +563,46 @@ fun DeviceTabScreen( onAudioEncoderOptionsChange(emptyList()) onVideoEncoderTypeMapChange(emptyMap()) onAudioEncoderTypeMapChange(emptyMap()) - logEvent("读取编码器列表失败: ${e.message ?: e.javaClass.simpleName}", Log.ERROR, e) + EventLogger.logEvent( + "读取编码器列表失败: ${e.message ?: e.javaClass.simpleName}", + Log.ERROR, + e + ) } } - fun refreshCameraSizeLists() { + suspend fun refreshCameraSizeLists() { if (!adbConnected) return - val remotePath = serverRemotePath.trim().ifBlank { AppDefaults.SERVER_REMOTE_PATH } runCatching { - nativeCore.scrcpyListCameraSizes( - customServerUri = customServerUri, - remotePath = remotePath, - ) - }.onSuccess { lists -> + scrcpy.listOptions(ListOptions.CAMERA_SIZES) + }.onSuccess { result -> + val lists = result as Scrcpy.ListResult.CameraSizes onCameraSizeOptionsChange(lists.sizes) if (cameraSizePreset.isNotBlank() && cameraSizePreset != "custom" && cameraSizePreset !in lists.sizes) { onCameraSizePresetChange("") } - logEvent("camera sizes 已刷新: count=${lists.sizes.size}") + EventLogger.logEvent("camera sizes 已刷新: count=${lists.sizes.size}") if (lists.sizes.isEmpty()) { val preview = lists.rawOutput.lineSequence().take(20).joinToString(" | ") if (preview.isNotBlank()) { - logEvent("camera sizes 原始输出: $preview", Log.DEBUG) + EventLogger.logEvent("camera sizes 原始输出: $preview", Log.DEBUG) } } }.onFailure { e -> onCameraSizeOptionsChange(emptyList()) - logEvent("读取 camera sizes 失败: ${e.message ?: e.javaClass.simpleName}", Log.ERROR, e) + EventLogger.logEvent( + "读取 camera sizes 失败: ${e.message ?: e.javaClass.simpleName}", + Log.ERROR, + e + ) } } - fun handleAdbConnected(host: String, port: Int) { + suspend fun handleAdbConnected(host: String, port: Int) { currentTargetHost = host currentTargetPort = port - val info = fetchConnectedDeviceInfo(nativeCore, host, port) + val info = fetchConnectedDeviceInfo(adbService, host, port) val fullLabel = if (info.serial.isNotBlank()) { "${info.model} (${info.serial})" } else { @@ -761,18 +742,18 @@ fun DeviceTabScreen( if (alive) continue logEvent("ADB 长连接中断,尝试自动重连: $host:$port", Log.WARN) - val reconnected = runCatching { connectWithTimeout(host, port) }.getOrElse { false } - adbConnected = reconnected - if (reconnected) { + try { + connectWithTimeout(host, port) + adbConnected = true statusLine = "$host:$port" logEvent("ADB 自动重连成功: $host:$port") scope.launch { snack.showSnackbar("ADB 自动重连成功") } - } else { + } catch (e: Exception) { disconnectAdbConnection() statusLine = "ADB 连接断开" - logEvent("ADB 自动重连失败: $host:$port", Log.ERROR) + logEvent("ADB 自动重连失败: $e", Log.ERROR) scope.launch { snack.showSnackbar("ADB 自动重连失败") } @@ -806,26 +787,27 @@ fun DeviceTabScreen( if (!portReachable) continue quickConnectTriedOnce += targetKey - val ok = runAutoAdbConnect(target.host, target.port) - adbConnected = ok - upsertQuickDevice( - context, - quickDevices, - target.host, - target.port, - ok - ) - if (ok) { + try { + runAutoAdbConnect(target.host, target.port) + adbConnected = true + upsertQuickDevice( + context, + quickDevices, + target.host, + target.port, + true, + ) handleAdbConnected(target.host, target.port) logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}") - break + } catch (_: Exception) { } + break } if (adbConnected) break } val discovered = withContext(Dispatchers.IO) { - nativeCore.adbDiscoverConnectService( + adbService.discoverConnectService( timeoutMs = ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS, includeLanDevices = adbMdnsLanDiscoveryEnabled, ) @@ -870,20 +852,19 @@ fun DeviceTabScreen( continue } - val ok = runAutoAdbConnect(discoveredHost, discoveredPort) - adbConnected = ok - upsertQuickDevice( - context, - quickDevices, - discoveredHost, - discoveredPort, - ok - ) - if (ok) { + try { + runAutoAdbConnect(discoveredHost, discoveredPort) + adbConnected = true + upsertQuickDevice( + context, + quickDevices, + discoveredHost, + discoveredPort, + true + ) handleAdbConnected(discoveredHost, discoveredPort) logEvent("ADB 自动重连成功: $discoveredHost:$discoveredPort") - } else { - logEvent("ADB 自动重连失败: $discoveredHost:$discoveredPort", Log.WARN) + } catch (_: Exception) { } delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS) @@ -925,7 +906,7 @@ fun DeviceTabScreen( runBusy("刷新 Camera Sizes") { refreshCameraSizeLists() } } onClearLogsActionChange { - eventLog.clear() + EventLogger.clearLogs() } onOpenReorderDevicesActionChange { showReorderSheet = true @@ -973,8 +954,8 @@ fun DeviceTabScreen( fun sendVirtualButtonAction(action: VirtualButtonAction) { val keycode = action.keycode ?: return runBusy("发送 ${action.title}") { - nativeCore.scrcpyInjectKeycode(0, keycode) - nativeCore.scrcpyInjectKeycode(1, keycode) + nativeCore.sessionManager.injectKeycode(0, keycode) + nativeCore.sessionManager.injectKeycode(1, keycode) } } @@ -1024,12 +1005,13 @@ fun DeviceTabScreen( itemsIndexed(quickDevices, key = { _, device -> device.id }) { _, device -> val host = device.host val port = device.port - val isConnectedTarget = - adbConnected && currentTarget?.host == host && currentTarget.port == port + val isConnectedTarget = adbConnected + && currentTarget?.host == host + && currentTarget.port == port DeviceTile( device = device, - actionText = if (isConnectedTarget) "断开" else "连接", + actionText = if (!isConnectedTarget) "连接" else "断开", actionEnabled = !busy && !adbConnecting, actionInProgress = adbConnecting && activeDeviceActionId == device.id, onLongPress = { editingDeviceId = device.id }, @@ -1040,7 +1022,24 @@ fun DeviceTabScreen( }, onAction = { haptics.contextClick() - if (isConnectedTarget) { + if (!isConnectedTarget) { + activeDeviceActionId = device.id + runAdbConnect("连接 ADB", onFinished = { activeDeviceActionId = null }) { + disconnectCurrentTargetBeforeConnecting(host, port) + try { + connectWithTimeout(host, port) + adbConnected = true + upsertQuickDevice(context, quickDevices, host, port, true) + handleAdbConnected(host, port) + } catch (e: Exception) { + statusLine = "ADB 连接失败" + logEvent("ADB 连接失败: $e", Log.ERROR) + scope.launch { + snack.showSnackbar("ADB 连接失败") + } + } + } + } else { activeDeviceActionId = device.id runAdbConnect("断开 ADB", onFinished = { activeDeviceActionId = null }) { sessionReconnectBlacklistHosts += host @@ -1050,23 +1049,6 @@ fun DeviceTabScreen( showSnackMessage = "ADB 已断开", ) } - } else { - activeDeviceActionId = device.id - runAdbConnect("连接 ADB", onFinished = { activeDeviceActionId = null }) { - disconnectCurrentTargetBeforeConnecting(host, port) - val ok = connectWithTimeout(host, port) - adbConnected = ok - upsertQuickDevice(context, quickDevices, host, port, ok) - if (ok) { - handleAdbConnected(host, port) - } else { - statusLine = "ADB 连接失败" - logEvent("ADB 连接失败: $host:$port", Log.ERROR) - scope.launch { - snack.showSnackbar("ADB 连接失败") - } - } - } } }, ) @@ -1093,16 +1075,16 @@ fun DeviceTabScreen( }, onConnect = { val target = parseQuickTarget(quickConnectInput) ?: return@QuickConnectCard - runAdbConnect("连接 ADB") { + runAdbConnect("连接 ADB", onFinished = { activeDeviceActionId = null }) { disconnectCurrentTargetBeforeConnecting(target.host, target.port) - val ok = connectWithTimeout(target.host, target.port) - adbConnected = ok - upsertQuickDevice(context, quickDevices, target.host, target.port, ok) - if (ok) { + try { + connectWithTimeout(target.host, target.port) + adbConnected = true + upsertQuickDevice(context, quickDevices, target.host, target.port, true) handleAdbConnected(target.host, target.port) - } else { + } catch (e: Exception) { statusLine = "ADB 连接失败" - logEvent("ADB 连接失败: ${target.host}:${target.port}", Log.ERROR) + logEvent("ADB 连接失败: $e", Log.ERROR) scope.launch { snack.showSnackbar("ADB 连接失败") } @@ -1116,7 +1098,7 @@ fun DeviceTabScreen( busy = busy, autoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen, onDiscoverTarget = { - nativeCore.adbDiscoverPairingService( + adbService.discoverPairingService( includeLanDevices = adbMdnsLanDiscoveryEnabled, ) }, @@ -1125,7 +1107,7 @@ fun DeviceTabScreen( val resolvedHost = host.trim() val resolvedPort = port.toIntOrNull() ?: return@runBusy val resolvedCode = code.trim() - val ok = nativeCore.adbPair( + val ok = adbService.pair( resolvedHost, resolvedPort, resolvedCode, @@ -1222,52 +1204,55 @@ fun DeviceTabScreen( cropY.filter(Char::isDigit), ) val effectiveTurnScreenOff = turnScreenOff && !noControl - val session = nativeCore.scrcpyStart( - NativeCoreFacade.defaultStartRequest( - customServerUri = customServerUri, - maxSize = maxSize, - maxFps = maxFps, - videoBitRate = bitRateBps, - remotePath = serverRemotePath.trim(), - videoCodec = videoCodec, - audio = audioEnabled, - audioCodec = audioCodec, - audioBitRate = audioBitRateBps, - noControl = noControl, - videoEncoder = videoEncoder, - videoCodecOptions = videoCodecOptions, - audioEncoder = audioEncoder, - audioCodecOptions = audioCodecOptions, - audioDup = audioDup, - audioSource = resolvedAudioSource, - videoSource = resolvedVideoSource, - cameraId = resolvedCameraId, - cameraFacing = resolvedCameraFacing, - cameraSize = resolvedCameraSize, - cameraAr = resolvedCameraAr, - cameraFps = resolvedCameraFps, - cameraHighSpeed = cameraHighSpeed, - noAudioPlayback = noAudioPlayback, - noVideo = noVideo, - requireAudio = requireAudio, - turnScreenOff = effectiveTurnScreenOff, - newDisplay = newDisplayArg, - displayId = displayId, - crop = crop, - ), + + val options = ClientOptions( + crop = crop, + videoCodecOptions = videoCodecOptions, + audioCodecOptions = audioCodecOptions, + videoEncoder = videoEncoder, + audioEncoder = audioEncoder, + cameraId = resolvedCameraId, + cameraSize = resolvedCameraSize, + cameraAr = resolvedCameraAr, + cameraFps = resolvedCameraFps.toUShort(), + videoCodec = Codec.fromString(videoCodec), + audioCodec = Codec.fromString(audioCodec), + videoSource = if (resolvedVideoSource == "camera") VideoSource.CAMERA else VideoSource.DISPLAY, + audioSource = if (resolvedAudioSource.isNotBlank()) AudioSource.fromString( + resolvedAudioSource + ) else AudioSource.AUTO, + cameraFacing = if (resolvedCameraFacing.isNotBlank()) CameraFacing.fromString( + resolvedCameraFacing + ) else CameraFacing.ANY, + maxSize = maxSize.toUShort(), + videoBitRate = bitRateBps.toUInt(), + audioBitRate = audioBitRateBps.toUInt(), + maxFps = if (maxFps > 0f) maxFps.toString() else "", + displayId = (displayId ?: 0).toUInt(), + control = !noControl, + video = !noVideo, + audio = audioEnabled, + requireAudio = requireAudio, + audioPlayback = !noAudioPlayback, + turnScreenOff = effectiveTurnScreenOff, + audioDup = audioDup, + newDisplay = newDisplayArg, + cameraHighSpeed = cameraHighSpeed, ) + + // Start scrcpy using Scrcpy class + val session = scrcpy.start( + options = options, + ) + sessionInfo = session statusLine = "scrcpy 运行中" @SuppressLint("DefaultLocale") val videoDetail = if (noVideo) { "off" } else { - "${session.codec} ${session.width}x${session.height} @${ - String.format( - "%.1f", - bitRateMbps - ) - }Mbps" + "${session.codec} ${session.width}x${session.height} " + + "@${String.format("%.1f", bitRateMbps)}Mbps" } val audioDetail = if (!audioEnabled) { "off" @@ -1279,17 +1264,16 @@ fun DeviceTabScreen( scope.launch { snack.showSnackbar("scrcpy 已启动") } - nativeCore.getLastScrcpyServerCommand()?.let { command -> + scrcpy.getLastServerCommand()?.let { command -> logEvent("scrcpy-server args: $command") } } }, onStop = { runBusy("停止 scrcpy") { - nativeCore.scrcpyStop() + scrcpy.stop() sessionInfo = null - statusLine = - currentTarget?.let { "${it.host}:${it.port}" } ?: "ADB 已连接" + statusLine = "${currentTarget!!.host}:${currentTarget.port}" logEvent("scrcpy 已停止") scope.launch { snack.showSnackbar("scrcpy 已停止") @@ -1333,9 +1317,9 @@ fun DeviceTabScreen( } } - if (eventLog.isNotEmpty()) item { + if (EventLogger.hasLogs()) item { Spacer(Modifier.height(UiSpacing.PageItem)) - LogsPanel(lines = eventLog) + LogsPanel(lines = EventLogger.eventLog) } // TODO: 放进 [AppPageLazyColumn] 里 diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt index b0b301b..2c65564 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt @@ -20,7 +20,7 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade -import io.github.miuzarte.scrcpyforandroid.ScrcpySessionInfo +import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions @@ -113,9 +113,9 @@ fun FullscreenControlPage( } } - fun sendKeycode(keycode: Int) { - nativeCore.scrcpyInjectKeycode(0, keycode) - nativeCore.scrcpyInjectKeycode(1, keycode) + suspend fun sendKeycode(keycode: Int) { + nativeCore.sessionManager.injectKeycode(0, keycode) + nativeCore.sessionManager.injectKeycode(1, keycode) } Scaffold(contentWindowInsets = WindowInsets(0, 0, 0, 0)) { contentPadding -> @@ -132,7 +132,7 @@ fun FullscreenControlPage( currentFps = currentFps, enableBackHandler = false, onInjectTouch = { action, pointerId, x, y, pressure, buttons -> - nativeCore.scrcpyInjectTouch( + nativeCore.sessionManager.injectTouch( action = action, pointerId = pointerId, x = x, @@ -150,7 +150,9 @@ fun FullscreenControlPage( bar.Fullscreen( modifier = Modifier.align(Alignment.BottomCenter), onAction = { action -> - action.keycode?.let(::sendKeycode) + action.keycode?.let { + sendKeycode(it) + } }, ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt index 3b3fd80..75b2b10 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt @@ -50,6 +50,8 @@ 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.nativecore.NativeAdbService +import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.services.MainSettings import io.github.miuzarte.scrcpyforandroid.services.loadDevicePageSettings import io.github.miuzarte.scrcpyforandroid.services.loadMainSettings @@ -82,7 +84,7 @@ private enum class MainTabDestination( val icon: ImageVector, ) { Device(title = "设备", label = "设备", icon = Icons.Rounded.Devices), - Settings(title = "设置", label = "设置", icon = Icons.Rounded.Settings), + Settings(title = "设置", label = "设置", icon = Icons.Rounded.Settings); } private sealed interface RootScreen : NavKey { @@ -99,7 +101,11 @@ fun MainPage() { val initialOrientation = remember(activity) { activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } + val nativeCore = remember(context) { NativeCoreFacade.get(context.applicationContext) } + val adbService = remember(context) { NativeAdbService(context) } + val scrcpy = remember(context) { Scrcpy(context) } + val initialSettings = remember(context) { loadMainSettings(context) } val initialDeviceSettings = remember(context) { loadDevicePageSettings(context) } val snackHostState = remember { SnackbarHostState() } @@ -272,7 +278,7 @@ fun MainPage() { } LaunchedEffect(adbKeyName) { - nativeCore.setAdbKeyName(adbKeyName.ifBlank { AppDefaults.ADB_KEY_NAME }) + adbService.keyName = adbKeyName.ifBlank { AppDefaults.ADB_KEY_NAME } } fun popRoot() { @@ -308,8 +314,8 @@ fun MainPage() { lastExitBackPressAtMs = 0L scope.launch { withContext(Dispatchers.IO) { - runCatching { nativeCore.scrcpyStop() } - runCatching { nativeCore.adbDisconnect() } + runCatching { scrcpy.stop() } + runCatching { adbService.disconnect() } } activity?.finish() } @@ -422,15 +428,14 @@ fun MainPage() { DeviceTabScreen( contentPadding = pagePadding, nativeCore = nativeCore, + adbService = adbService, + scrcpy = scrcpy, 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, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt new file mode 100644 index 0000000..9e841ac --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt @@ -0,0 +1,549 @@ +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: 修改配置项时, validate() 判断是否合法, 非法则回退修改 + +// TODO: 管理冲突配置项的关系, 在更多参数页中隐藏冲突项 + +// TODO: 禁用配置项验证, 包括不隐藏冲突配置项 + +// TODO: 配置切换 + +// TODO: 参数预览框可编辑 + +data class ClientOptions( + // var serial: String = "", // to server + + // --crop=width:height:x:y + var crop: String = "", // to server + + // TODO + // --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: UInt = 0u, // to server + // --audio-bit-rate + var audioBitRate: UInt = 0u, // 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 + var displayId: UInt = 0u, // 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=[x][/] + 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 validate() { + 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 > 0u) { + 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 > 0u && newDisplay.isNotBlank()) { + throw IllegalArgumentException( + "Cannot specify both --display-id and --new-display" + ) + } + + if (displayImePolicy != DisplayImePolicy.UNDEFINED + && displayId == 0u && 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" + ) + } + } + } + + 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, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt new file mode 100644 index 0000000..b12d514 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt @@ -0,0 +1,424 @@ +package io.github.miuzarte.scrcpyforandroid.scrcpy + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.core.net.toUri +import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade +import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo +import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService +import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpyAudioPlayer +import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpySessionManager +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions +import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent +import java.io.File +import kotlin.random.Random +import kotlin.random.nextUInt + +/** + * High-level scrcpy client API. + * + * Manages scrcpy sessions including: + * - Server jar extraction and deployment + * - Session lifecycle (start/stop) + * - Audio playback + * - Screen control + * + * @param context Android context + * @param serverAsset Asset path for the default server jar + * @param customServerUri Optional custom server URI (overrides serverAsset) + * @param serverVersion Server version string + * @param serverRemotePath Remote path where server jar will be pushed on device + */ +class Scrcpy( + private val context: Context, + private val serverAsset: String = DEFAULT_SERVER_ASSET, + private val customServerUri: String? = null, + private val serverVersion: String = "3.3.4", + private val serverRemotePath: String = DEFAULT_REMOTE_PATH, +) { + private val adbService = NativeAdbService(context) + private val sessionManager = ScrcpySessionManager(adbService) + private val nativeCore: NativeCoreFacade = NativeCoreFacade.get(context) + + @Volatile + private var currentSession: ScrcpySessionInfo? = null + + @Volatile + private var isRunning: Boolean = false + + @Volatile + private var audioPlayer: ScrcpyAudioPlayer? = null + + companion object { + private const val TAG = "Scrcpy" + const val DEFAULT_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar" + const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v3.3.4" + + // Regex patterns for parsing server output + private val VIDEO_ENCODER_REGEX = Regex("--video-encoder=([\\w.\\-]+)") + private val AUDIO_ENCODER_REGEX = Regex("--audio-encoder=([\\w.\\-]+)") + private val VIDEO_ENCODER_FALLBACK_REGEX = Regex("""--video-encoder=['"]?([^'"\s]+)""") + private val AUDIO_ENCODER_FALLBACK_REGEX = Regex("""--audio-encoder=['"]?([^'"\s]+)""") + private val VIDEO_ENCODER_TYPE_REGEX = + Regex("""--video-codec=\S+\s+--video-encoder=(\S+).*?\((hw|sw)\)""") + private val AUDIO_ENCODER_TYPE_REGEX = + Regex("""--audio-codec=\S+\s+--audio-encoder=(\S+).*?\((hw|sw)\)""") + private val CAMERA_SIZE_REGEX = Regex("--camera-size=([0-9]+x[0-9]+)") + private val CAMERA_SIZE_FALLBACK_REGEX = Regex("\\b([1-9][0-9]{1,4}x[1-9][0-9]{1,4})\\b") + private const val PREVIEW_LINES = 32 + + fun generateScid(): UInt { + // Only use 31 bits to avoid issues with signed values on the Java-side + return (Random.nextUInt() and 0x7FFFFFFFu) + } + } + + suspend fun start( + options: ClientOptions, + ): ScrcpySessionInfo { + if (isRunning) { + throw IllegalStateException("Scrcpy session is already running") + } + + Log.i(TAG, "Initializing scrcpy session") + + try { + // Validate options + options.validate() + + // Generate session ID + val scid = generateScid() + Log.d(TAG, "scid=0x${scid.toString(16)}") + + val serverJar = if (customServerUri.isNullOrBlank()) { + extractAssetToCache(serverAsset) + } else { + extractUriToCache(customServerUri.toUri()) + } + + // Execute server + val info = executeServer( + serverJar = serverJar, + options = options, + scid = scid, + ) + + // Turn screen off if requested + if (options.turnScreenOff) { + if (!options.control) { + Log.w(TAG, "start(): turnScreenOff ignored because control is disabled") + } else { + runCatching { sessionManager.setDisplayPower(on = false) } + .onFailure { e -> Log.w(TAG, "start(): set display power failed", e) } + } + } + + // Create session info + val session = ScrcpySessionInfo( + width = info.width, + height = info.height, + deviceName = info.deviceName, + codec = info.codecName, + controlEnabled = info.controlEnabled, + ) + currentSession = session + isRunning = true + + // Setup video consumer (notify NativeCoreFacade to setup decoders) + if (options.video) { + nativeCore.onScrcpySessionStarted(session, sessionManager) + } + + // Setup audio player + audioPlayer?.release() + audioPlayer = null + if (info.audioCodecId != 0 && options.audioPlayback) { + Log.i( + TAG, + "start(): create audio player codecId=0x${ + info.audioCodecId.toUInt().toString(16) + }" + ) + val player = ScrcpyAudioPlayer(info.audioCodecId) + audioPlayer = player + sessionManager.attachAudioConsumer { packet -> + player.feedPacket(packet.data, packet.ptsUs, packet.isConfig) + } + } else { + Log.i(TAG, "start(): audio playback disabled for this session") + } + + Log.i( + TAG, "start(): Session started successfully - device=${session.deviceName}, " + + "video=${if (options.video) "${session.codec} ${session.width}x${session.height}" else "off"}, " + + "audio=${if (options.audio) options.audioCodec.string else "off"}, " + + "control=${options.control}" + ) + + return session + + } catch (e: Exception) { + Log.e(TAG, "start(): Failed to start scrcpy session", e) + isRunning = false + currentSession = null + throw e + } + } + + suspend fun stop(): Boolean { + if (!isRunning) { + Log.w(TAG, "stop(): No active session to stop") + return false + } + + Log.i(TAG, "stop(): Stopping scrcpy session") + + return try { + nativeCore.onScrcpySessionStopped() + sessionManager.clearVideoConsumer() + sessionManager.clearAudioConsumer() + sessionManager.stop() + audioPlayer?.release() + audioPlayer = null + isRunning = false + currentSession = null + Log.i(TAG, "stop(): Session stopped successfully") + true + } catch (e: Exception) { + Log.e(TAG, "stop(): Failed to stop session", e) + false + } + } + + suspend fun close() { + stop() + adbService.close() + } + + fun isStarted(): Boolean = isRunning && sessionManager.isStarted() + + fun getCurrentSession(): ScrcpySessionInfo? = currentSession + + fun getLastServerCommand(): String? = sessionManager.getLastServerCommand() + + sealed class ListResult { + data class Encoders( + val videoEncoders: List, + val audioEncoders: List, + val videoEncoderTypes: Map = emptyMap(), + val audioEncoderTypes: Map = emptyMap(), + val rawOutput: String = "", + ) : ListResult() + + data class CameraSizes( + val sizes: List, + val rawOutput: String = "", + ) : ListResult() + } + + /** + * List various options from the scrcpy server. + * + * @param list The type of list to retrieve (ENCODERS, CAMERA_SIZES, etc.) + * @return ListResult containing the requested information + */ + suspend fun listOptions(list: ListOptions): ListResult { + val serverJar = if (customServerUri.isNullOrBlank()) { + extractAssetToCache(serverAsset) + } else { + extractUriToCache(customServerUri.toUri()) + } + + // Push server jar to device + adbService.push(serverJar.toPath(), serverRemotePath) + + val scid = generateScid() + + // Create ClientOptions for listing + val options = ClientOptions( + video = false, + audio = false, + control = false, + cleanUp = false, + list = list, + ) + + val serverParams = options.toServerParams(scid) + + // Build server command + val serverCommand = serverParams.build( + "CLASSPATH=$serverRemotePath", + "app_process", + "/", + "com.genymobile.scrcpy.Server", + serverVersion, + ) + + Log.i(TAG, "listOptions(): cmd=$serverCommand") + + // Execute shell command and capture output (merge stderr into stdout) + val output = adbService.shell("$serverCommand 2>&1") + + // Parse output based on list option + return when (list) { + ListOptions.NULL -> { + throw IllegalArgumentException("Nothing to do with ListOptions.NULL") + } + + ListOptions.ENCODERS -> { + val parsed = parseEncoderLists(output) + val preview = output.lineSequence().take(PREVIEW_LINES).joinToString("\n") + Log.i( + TAG, + "listOptions(ENCODERS): parsed video=${parsed.videoEncoders.size} audio=${parsed.audioEncoders.size}, outputPreview=\n$preview", + ) + ListResult.Encoders( + videoEncoders = parsed.videoEncoders, + audioEncoders = parsed.audioEncoders, + videoEncoderTypes = parsed.videoEncoderTypes, + audioEncoderTypes = parsed.audioEncoderTypes, + rawOutput = output, + ) + } + + ListOptions.DISPLAYS -> { + throw Exception("TODO") + } + + ListOptions.CAMERAS -> { + throw Exception("TODO") + } + + ListOptions.CAMERA_SIZES -> { + val parsed = parseCameraSizeLists(output) + val preview = output.lineSequence().take(PREVIEW_LINES).joinToString("\n") + Log.i( + TAG, + "listOptions(CAMERA_SIZES): parsed sizes=${parsed.sizes.size}, outputPreview=\n$preview", + ) + ListResult.CameraSizes( + sizes = parsed.sizes, + rawOutput = output, + ) + } + + else -> { + throw IllegalArgumentException("Unsupported list option: $list") + } + } + } + + private fun parseEncoderLists(output: String): ParsedEncoders { + val video = LinkedHashSet() + val audio = LinkedHashSet() + val videoTypes = linkedMapOf() + val audioTypes = linkedMapOf() + + VIDEO_ENCODER_REGEX.findAll(output).forEach { match -> + video.add(match.groupValues[1]) + } + AUDIO_ENCODER_REGEX.findAll(output).forEach { match -> + audio.add(match.groupValues[1]) + } + // Fallback for log formats that include codec+encoder in one line. + VIDEO_ENCODER_FALLBACK_REGEX.findAll(output).forEach { match -> + video.add(match.groupValues[1]) + } + AUDIO_ENCODER_FALLBACK_REGEX.findAll(output).forEach { match -> + audio.add(match.groupValues[1]) + } + VIDEO_ENCODER_TYPE_REGEX.findAll(output).forEach { match -> + val name = match.groupValues[1] + val type = match.groupValues[2] + if (name.isNotBlank() && type.isNotBlank() && !videoTypes.containsKey(name)) { + videoTypes[name] = type + } + } + AUDIO_ENCODER_TYPE_REGEX.findAll(output).forEach { match -> + val name = match.groupValues[1] + val type = match.groupValues[2] + if (name.isNotBlank() && type.isNotBlank() && !audioTypes.containsKey(name)) { + audioTypes[name] = type + } + } + + return ParsedEncoders( + videoEncoders = video.toList(), + audioEncoders = audio.toList(), + videoEncoderTypes = videoTypes, + audioEncoderTypes = audioTypes, + ) + } + + private fun parseCameraSizeLists(output: String): ParsedCameraSizes { + val sizes = LinkedHashSet() + CAMERA_SIZE_REGEX.findAll(output).forEach { match -> + sizes.add(match.groupValues[1]) + } + CAMERA_SIZE_FALLBACK_REGEX.findAll(output).forEach { match -> + sizes.add(match.groupValues[1]) + } + return ParsedCameraSizes(sizes = sizes.toList()) + } + + private data class ParsedEncoders( + val videoEncoders: List, + val audioEncoders: List, + val videoEncoderTypes: Map, + val audioEncoderTypes: Map, + ) + + private data class ParsedCameraSizes( + val sizes: List, + ) + + private suspend fun executeServer( + serverJar: File, + options: ClientOptions, + scid: UInt, + ): ScrcpySessionManager.SessionInfo { + adbService.push(serverJar.toPath(), serverRemotePath) + + val serverParams = options.toServerParams(scid) + + val serverCommand = serverParams.build( + "CLASSPATH=$serverRemotePath", + "app_process", + "/", + "com.genymobile.scrcpy.Server", + serverVersion, + ) + Log.d(TAG, "Server command: $serverCommand") + + // Execute server (equivalent to sc_adb_execute in C) + Log.i(TAG, "executeServer(): Starting scrcpy server") + logEvent("scrcpy-server args: $serverCommand") + return sessionManager.start( + serverJarPath = serverJar.toPath(), + serverCommand = serverCommand, + scid = scid, + options = options, + ) + } + + private fun extractAssetToCache(assetPath: String): File { + val clean = assetPath.removePrefix("/") + val source = context.assets.open(clean) + val outputFile = File(context.cacheDir, File(clean).name) + source.use { input -> + outputFile.outputStream().use { output -> input.copyTo(output) } + } + return outputFile + } + + private fun extractUriToCache(uri: Uri): File { + val fileName = "custom-scrcpy-server.jar" + val outputFile = File(context.cacheDir, fileName) + context.contentResolver.openInputStream(uri).use { input -> + requireNotNull(input) { "Unable to open selected server URI" } + outputFile.outputStream().use { output -> input.copyTo(output) } + } + return outputFile + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ScrcpyUsageExample.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ScrcpyUsageExample.kt new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt new file mode 100644 index 0000000..0402b79 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt @@ -0,0 +1,283 @@ +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: UInt, + var audioBitRate: UInt, + + 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: UInt, + 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): Unit { + // 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(): MutableList { + val cmd = mutableListOf() + + cmd.add("scid=${scid.toString(16)}") + cmd.add("log_level=${logLevel.string}") + + if (!video) { + cmd.add("video=false") + } + if (videoBitRate > 0u) { + cmd.add("video_bit_rate=$videoBitRate") + } + if (!audio) { + cmd.add("audio=false") + } + if (audioBitRate > 0u) { + 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 > 0u) { + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt new file mode 100644 index 0000000..010a58f --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt @@ -0,0 +1,159 @@ +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 LogLevel(val string: String) { + VERBOSE("verbose"), + DEBUG("debug"), + INFO("info"), + WARN("warn"), + ERROR("error"); + } + + enum class Codec(val string: String) { + H264("h264"), // default, ignore when passing + H265("h265"), + AV1("av1"), + OPUS("opus"), // default, ignore when passing + AAC("aac"), + FLAC("flac"), + RAW("raw"); // wav raw + + companion object { + fun fromString(value: String): Codec { + return entries.find { it.string.equals(value, ignoreCase = true) } ?: H264 + } + } + } + + enum class VideoSource(val string: String) { + DISPLAY("display"), // default, ignore when passing + CAMERA("camera"); + + companion object { + fun fromString(value: String): VideoSource { + return 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): AudioSource { + return 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): CameraFacing { + return 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") + } + } + } + + 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"); + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt index 28efac5..610deff 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt @@ -1,6 +1,8 @@ package io.github.miuzarte.scrcpyforandroid.services import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade +import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService +import kotlinx.coroutines.runBlocking internal data class ConnectedDeviceInfo( val model: String, @@ -22,28 +24,21 @@ internal data class ConnectedDeviceInfo( * - Returns a lightweight [ConnectedDeviceInfo] structure with commonly-used properties. */ internal fun fetchConnectedDeviceInfo( - nativeCore: NativeCoreFacade, + adbService: NativeAdbService, host: String, port: Int -): ConnectedDeviceInfo { - fun prop(name: String): String = - runCatching { nativeCore.adbShell("getprop $name").trim() }.getOrDefault("") +): ConnectedDeviceInfo = runBlocking { + suspend fun prop(name: String): String = runCatching { + adbService.shell("getprop $name").trim() + }.getOrDefault("") - val model = prop("ro.product.model") - val serial = prop("ro.serialno") - val manufacturer = prop("ro.product.manufacturer") - val brand = prop("ro.product.brand") - val device = prop("ro.product.device") - val androidRelease = prop("ro.build.version.release") - val sdkInt = prop("ro.build.version.sdk").toIntOrNull() ?: -1 - - return ConnectedDeviceInfo( - model = model.ifBlank { "$host:$port" }, - serial = serial, - manufacturer = manufacturer, - brand = brand, - device = device, - androidRelease = androidRelease, - sdkInt = sdkInt, + ConnectedDeviceInfo( + model = prop("ro.product.model").ifBlank { "$host:$port" }, + serial = prop("ro.serialno"), + manufacturer = prop("ro.product.manufacturer"), + brand = prop("ro.product.brand"), + device = prop("ro.product.device"), + androidRelease = prop("ro.build.version.release"), + sdkInt = prop("ro.build.version.sdk").toIntOrNull() ?: -1, ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/EventLogger.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/EventLogger.kt new file mode 100644 index 0000000..5f42a8c --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/EventLogger.kt @@ -0,0 +1,70 @@ +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.AppDefaults +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" + + private val _eventLog: SnapshotStateList = mutableStateListOf() + + /** + * Read-only access to the event log list. + */ + val eventLog: List 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 > AppDefaults.EVENT_LOG_LINES) { + _eventLog.removeRange(AppDefaults.EVENT_LOG_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() +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt index 0a28d54..3996703 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt @@ -63,13 +63,14 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade -import io.github.miuzarte.scrcpyforandroid.ScrcpySessionInfo +import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -221,7 +222,7 @@ internal fun StatusCard( internal fun PairingCard( busy: Boolean, autoDiscoverOnDialogOpen: Boolean, - onDiscoverTarget: (() -> Pair?)? = null, + onDiscoverTarget: (suspend () -> Pair?)? = null, onPair: (host: String, port: String, code: String) -> Unit, ) { val showPairDialog = remember { mutableStateOf(false) } @@ -516,7 +517,7 @@ private fun PairingDialog( showDialog: Boolean, enabled: Boolean, autoDiscoverOnDialogOpen: Boolean, - onDiscoverTarget: (() -> Pair?)?, + onDiscoverTarget: (suspend () -> Pair?)?, onDismissRequest: () -> Unit, onDismissFinished: () -> Unit, onConfirm: (host: String, port: String, code: String) -> Unit, @@ -661,6 +662,244 @@ internal fun LogsPanel(lines: List) { } } +/** + * TouchEventHandler + * + * Purpose: + * - Handles touch event processing for fullscreen control screen + * - Manages pointer tracking, coordinate mapping, and touch injection + */ +class TouchEventHandler( + private val coroutineScope: CoroutineScope, + private val session: ScrcpySessionInfo, + private val touchAreaSize: IntSize, + private val activePointerIds: LinkedHashSet, + private val activePointerPositions: LinkedHashMap, + private val activePointerDevicePositions: LinkedHashMap>, + private val pointerLabels: LinkedHashMap, + 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, +) { + private val eventPointerIds = HashSet(10) + private val eventPositions = HashMap(10) + private val eventPressures = HashMap(10) + private val justPressedPointerIds = HashSet(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 { + 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 { + onInjectTouch(UiMotionActions.UP, pointerId.toLong(), x, y, 0f, 0) + } + 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, 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 { + onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, pressure, 0) + } + } + } + } + + 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 { + onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, pressure, 0) + } + } + } + + private fun handlePointerUp( + endedPointerId: Int?, + bounds: ContentBounds, + ) { + if (endedPointerId != null) { + val endPos = eventPositions[endedPointerId] + if (endPos != null) { + activePointerPositions[endedPointerId] = endPos + } + releasePointer(endedPointerId, bounds) + } + } + +} + /** * FullscreenControlScreen * @@ -685,8 +924,10 @@ fun FullscreenControlScreen( showDebugInfo: Boolean, currentFps: Float, enableBackHandler: Boolean = true, - onInjectTouch: (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit, + onInjectTouch: suspend (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit, ) { + BackHandler(enabled = enableBackHandler, onBack = onDismiss) + val coroutineScope = rememberCoroutineScope() var touchAreaSize by remember { mutableStateOf(IntSize.Zero) } val activePointerIds = remember { linkedSetOf() } val activePointerPositions = remember { linkedMapOf() } @@ -695,173 +936,30 @@ fun FullscreenControlScreen( var nextPointerLabel by remember { mutableIntStateOf(1) } var activeTouchCount by remember { mutableIntStateOf(0) } var activeTouchDebug by remember { mutableStateOf("") } - BackHandler(enabled = enableBackHandler, onBack = onDismiss) + + val touchEventHandler = remember(session, touchAreaSize) { + TouchEventHandler( + coroutineScope = coroutineScope, + session = session, + touchAreaSize = touchAreaSize, + activePointerIds = activePointerIds, + activePointerPositions = activePointerPositions, + activePointerDevicePositions = activePointerDevicePositions, + pointerLabels = pointerLabels, + nextPointerLabel = nextPointerLabel, + onInjectTouch = onInjectTouch, + onActiveTouchCountChanged = { activeTouchCount = it }, + onActiveTouchDebugChanged = { activeTouchDebug = it }, + onNextPointerLabelChanged = { nextPointerLabel = it }, + ) + } BoxWithConstraints( modifier = Modifier .fillMaxSize() .background(Color.Black) .pointerInteropFilter { event -> - if (touchAreaSize.width == 0 || touchAreaSize.height == 0) { - return@pointerInteropFilter true - } - - 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 - - fun isInsideContent(rawX: Float, rawY: Float): Boolean { - return rawX in contentLeft..(contentLeft + contentWidth) && - rawY in contentTop..(contentTop + contentHeight) - } - - fun mapToDevice(rawX: Float, rawY: Float): Pair { - val normalizedX = ((rawX - contentLeft) / contentWidth).coerceIn(0f, 1f) - val normalizedY = ((rawY - contentTop) / contentHeight).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 - } - - fun pointerLabel(pointerId: Int): Int { - val existing = pointerLabels[pointerId] - if (existing != null) { - return existing - } - val assigned = nextPointerLabel - nextPointerLabel += 1 - pointerLabels[pointerId] = assigned - return assigned - } - - fun refreshTouchDebug() { - if (activePointerIds.isEmpty()) { - activeTouchDebug = "" - return - } - activeTouchDebug = activePointerIds - .sortedBy { pointerLabel(it) } - .joinToString(separator = "\n") { pointerId -> - val label = pointerLabel(pointerId) - val pos = activePointerDevicePositions[pointerId] - if (pos == null) { - "#$label(id=$pointerId):?" - } else { - "#$label(id=$pointerId):${pos.first},${pos.second}" - } - } - } - - fun releasePointer(pointerId: Int, reason: String) { - if (!activePointerIds.contains(pointerId)) return - val pos = activePointerPositions[pointerId] ?: Offset.Zero - val (x, y) = mapToDevice(pos.x, pos.y) - // val label = pointerLabel(pointerId) - // Log.d( - // FULLSCREEN_TOUCH_LOG_TAG, - // "抬起($reason): pointer#$label(id=$pointerId) x=$x y=$y" - // ) - onInjectTouch(UiMotionActions.UP, pointerId.toLong(), x, y, 0f, 0) - activePointerIds -= pointerId - activePointerPositions.remove(pointerId) - activePointerDevicePositions.remove(pointerId) - pointerLabels.remove(pointerId) - } - - if (event.actionMasked == MotionEvent.ACTION_CANCEL) { - val toCancel = activePointerIds.toList() - for (pointerId in toCancel) { - releasePointer(pointerId, reason = "cancel") - } - activeTouchCount = activePointerIds.size - refreshTouchDebug() - return@pointerInteropFilter true - } - - val eventPointerIds = HashSet(event.pointerCount) - val eventPositions = HashMap(event.pointerCount) - val eventPressures = HashMap(event.pointerCount) - 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) - } - - val disappearedPointers = activePointerIds.filter { it !in eventPointerIds } - for (pointerId in disappearedPointers) { - releasePointer(pointerId, reason = "missing") - } - - val endedPointerId = when (event.actionMasked) { - MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> event.getPointerId(event.actionIndex) - else -> null - } - - val justPressed = HashSet() - 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)) continue - val (x, y) = mapToDevice(raw.x, raw.y) - // val label = pointerLabel(pointerId) - // Log.d( - // FULLSCREEN_TOUCH_LOG_TAG, - // "按下: pointer#$label(id=$pointerId) x=$x y=$y" - // ) - activePointerIds += pointerId - activePointerPositions[pointerId] = raw - activePointerDevicePositions[pointerId] = x to y - justPressed += pointerId - onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, pressure, 0) - } - } - - for (i in 0 until event.pointerCount) { - val pointerId = event.getPointerId(i) - if (!activePointerIds.contains(pointerId)) continue - if (pointerId == endedPointerId) continue - if (pointerId in justPressed) continue - val raw = eventPositions[pointerId] ?: continue - val pressure = eventPressures[pointerId] ?: 0f - activePointerPositions[pointerId] = raw - val (x, y) = mapToDevice(raw.x, raw.y) - activePointerDevicePositions[pointerId] = x to y - onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, pressure, 0) - } - - if (endedPointerId != null) { - val endPos = eventPositions[endedPointerId] - if (endPos != null) { - activePointerPositions[endedPointerId] = endPos - } - releasePointer(endedPointerId, reason = "event") - } - - activeTouchCount = activePointerIds.size - refreshTouchDebug() - true + touchEventHandler.handleMotionEvent(event) } .onSizeChanged { touchAreaSize = it }, ) { @@ -958,6 +1056,7 @@ private fun ScrcpyVideoSurface( ) { val surfaceTag = "video-main" var currentSurface by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() LaunchedEffect(session, currentSurface) { if (session != null && currentSurface != null) { @@ -970,7 +1069,9 @@ private fun ScrcpyVideoSurface( onDispose { val released = currentSurface if (released != null) { - nativeCore.unregisterVideoSurface(surfaceTag, released) + scope.launch { + nativeCore.unregisterVideoSurface(surfaceTag, released) + } released.release() currentSurface = null } @@ -1003,7 +1104,9 @@ private fun ScrcpyVideoSurface( val released = currentSurface currentSurface = null if (released != null) { - nativeCore.unregisterVideoSurface(surfaceTag, released) + scope.launch { + nativeCore.unregisterVideoSurface(surfaceTag, released) + } released.release() } return true diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ReorderableList.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ReorderableList.kt index 6e0c1b8..86c0fb6 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ReorderableList.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ReorderableList.kt @@ -37,7 +37,7 @@ class ReorderableList( private val showCheckbox: Boolean = false, private val onCheckboxChange: ((String, Boolean) -> Unit)? = null, ) { - enum class Orientation { Column, Row } + enum class Orientation { Column, Row; } data class Item( val id: String, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt index 9756d19..dcea852 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -34,6 +35,7 @@ import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults import io.github.miuzarte.scrcpyforandroid.constants.UiAndroidKeycodes import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics +import kotlinx.coroutines.launch import top.yukonga.miuix.kmp.basic.Button import top.yukonga.miuix.kmp.basic.ButtonDefaults import top.yukonga.miuix.kmp.basic.Icon @@ -118,7 +120,7 @@ enum class VirtualButtonAction( "截图", Icons.Rounded.Screenshot, UiAndroidKeycodes.SYSRQ - ), + ); } data class VirtualButtonItem( @@ -230,9 +232,10 @@ class VirtualButtonBar( @Composable fun Fullscreen( - onAction: (VirtualButtonAction) -> Unit, + onAction: suspend (VirtualButtonAction) -> Unit, modifier: Modifier = Modifier, ) { + val scope = rememberCoroutineScope() val haptics = rememberAppHaptics() var showMorePopup by remember { mutableStateOf(false) } @@ -248,7 +251,9 @@ class VirtualButtonBar( if (action == VirtualButtonAction.MORE) { showMorePopup = true } else { - onAction(action) + scope.launch { + onAction(action) + } } }, modifier = Modifier.fillMaxWidth(), @@ -284,9 +289,10 @@ class VirtualButtonBar( show: Boolean, moreActions: List, onDismiss: () -> Unit, - onAction: (VirtualButtonAction) -> Unit, + onAction: suspend (VirtualButtonAction) -> Unit, renderInRootScaffold: Boolean, ) { + val scope = rememberCoroutineScope() val haptics = rememberAppHaptics() val spinnerItems = remember(moreActions) { moreActions.map { action -> @@ -323,7 +329,9 @@ class VirtualButtonBar( dialogMode = false, onSelectedIndexChange = { selectedIdx -> haptics.confirm() - onAction(moreActions[selectedIdx]) + scope.launch { + onAction(moreActions[selectedIdx]) + } }, ) } From 189d4a76b9613526d23097a9b7c1ad833b803f8a Mon Sep 17 00:00:00 2001 From: Miuzarte <982809597@qq.com> Date: Fri, 27 Mar 2026 01:32:25 +0800 Subject: [PATCH 2/8] refactor: new preferences impl with data store --- TODO.md | 4 +- app/build.gradle.kts | 1 + .../constants/ScrcpyPresets.kt | 4 +- .../scrcpyforandroid/models/DeviceModels.kt | 85 +++- .../pages/AdvancedConfigPage.kt | 158 +++---- .../scrcpyforandroid/pages/DevicePage.kt | 349 +++------------ .../scrcpyforandroid/pages/MainPage.kt | 375 +++++++--------- .../scrcpyforandroid/pages/SettingsPage.kt | 108 ++--- .../scrcpyforandroid/scrcpy/Shared.kt | 15 +- .../services/QuickDeviceStore.kt | 7 +- .../scrcpyforandroid/storage/AppSettings.kt | 111 +++++ .../storage/AppSettings_New.kt | 128 ++++++ .../scrcpyforandroid/storage/QuickDevices.kt | 171 ++++++++ .../scrcpyforandroid/storage/ScrcpyOptions.kt | 402 ++++++++++++++++++ .../scrcpyforandroid/storage/Settings.kt | 115 +++++ .../storage/StorageMigration.kt | 0 .../scrcpyforandroid/widgets/DeviceWidgets.kt | 98 +++-- 17 files changed, 1420 insertions(+), 711 deletions(-) create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings_New.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/StorageMigration.kt diff --git a/TODO.md b/TODO.md index 6847934..923de22 100644 --- a/TODO.md +++ b/TODO.md @@ -34,5 +34,7 @@ 顺序无关 +- 多配置切换 +- [音频延迟](https://developer.android.com/ndk/guides/audio/audio-latency) - 横屏布局 -- 原生悬浮窗 +- 原生悬浮窗 [Petterpx/FloatingX](https://github.com/Petterpx/FloatingX) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4b5d668..066ef38 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -97,6 +97,7 @@ dependencies { implementation("org.bouncycastle:bcpkix-jdk18on:1.80") implementation("org.conscrypt:conscrypt-android:2.5.2") implementation("sh.calvin.reorderable:reorderable:3.0.0") + implementation("androidx.datastore:datastore-preferences:1.2.1") testImplementation(libs.junit) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt index daa7025..481ab23 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt @@ -1,7 +1,7 @@ package io.github.miuzarte.scrcpyforandroid.constants object ScrcpyPresets { - val MaxSize = listOf(0, 720, 1080, 1280, 1600, 1920, 2160, 2560, 3200, 3840) + val MaxSize = listOf(0, 720, 1080, 1280, 1600, 1920, 2160, 2560, 3200, 3840) // px val MaxFPS = listOf(0, 24, 30, 45, 60, 90, 120) - val AudioBitRate = listOf(32, 64, 96, 128, 160, 192, 256, 320, 384, 512) + val AudioBitRate = listOf(32, 64, 96, 128, 160, 192, 256, 320, 384, 512) // Kbps } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt index 18b099a..38253ef 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt @@ -1,18 +1,75 @@ package io.github.miuzarte.scrcpyforandroid.models -internal data class ConnectionTarget( - val host: String, - val port: Int, -) +import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults -/** - * A compact shortcut entry for quick-connect lists shown in the UI. - * `online` indicates whether the device was reachable when last probed. - */ -internal data class DeviceShortcut( - val id: String, - val name: String, +data class DeviceShortcut( + val name: String = "", val host: String, - val port: Int, - val online: Boolean, -) + val port: Int = AppDefaults.ADB_PORT, + val online: Boolean = false, +) { + val id: String get() = "$host:$port" + + fun marshalToString( + separator: String = "|", + ): String = listOf( + name.trim(), host.trim(), port.toString() + ).joinToString( + separator = separator + ) + + companion object { + fun unmarshalFrom( + s: String, + delimiter: String = "|", + ): DeviceShortcut? { + val parts = s.split(delimiter, limit = 3) + return 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()) DeviceShortcut( + name = name, + host = host, + port = port, + ) + else null + } + + else -> null + } + } + } +} + +data class ConnectionTarget( + val host: String, + val port: Int = AppDefaults.ADB_PORT, +) { + fun marshalToString(): 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() ?: AppDefaults.ADB_PORT, + ) + + 1 -> ConnectionTarget( + host = parts[0].trim(), + port = AppDefaults.ADB_PORT, + ) + + 0 -> ConnectionTarget( + host = s.trim(), + port = AppDefaults.ADB_PORT, + ) + + else -> null + } + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt index e0db43a..d2a3e1a 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt @@ -11,9 +11,13 @@ 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.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -22,6 +26,8 @@ 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 io.github.miuzarte.scrcpyforandroid.storage.AppSettings +import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions import kotlinx.coroutines.launch import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.ScrollBehavior @@ -69,10 +75,12 @@ internal fun AdvancedConfigPage( contentPadding: PaddingValues, scrollBehavior: ScrollBehavior, snackbarHostState: SnackbarHostState, - sessionStarted: Boolean, - audioEnabled: Boolean, - noControl: Boolean, - onNoControlChange: (Boolean) -> Unit, +// sessionStarted: Boolean, + + // audioEnabled: Boolean, + +// noControl: Boolean, +// onNoControlChange: (Boolean) -> Unit, audioDup: Boolean, onAudioDupChange: (Boolean) -> Unit, audioSourcePreset: String, @@ -109,6 +117,7 @@ internal fun AdvancedConfigPage( onMaxSizeInputChange: (String) -> Unit, maxFpsInput: String, onMaxFpsInputChange: (String) -> Unit, + videoEncoderDropdownItems: List, videoEncoderTypeMap: Map, videoEncoderIndex: Int, @@ -121,27 +130,28 @@ internal fun AdvancedConfigPage( 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 context = LocalContext.current + + val appSettings = remember(context) { AppSettings(context) } + val scrcpyOptions = remember(context) { ScrcpyOptions(context) } + val focusManager = LocalFocusManager.current + val scope = rememberCoroutineScope() + val maxSizePresetIndex = presetIndexFromInputForAdvancedPage(maxSizeInput, ScrcpyPresets.MaxSize) val maxFpsPresetIndex = presetIndexFromInputForAdvancedPage(maxFpsInput, ScrcpyPresets.MaxFPS) @@ -179,6 +189,10 @@ internal fun AdvancedConfigPage( } } + var turnScreenOff by scrcpyOptions.turnScreenOff.asMutableState() + var control by scrcpyOptions.control.asMutableState() + var video by scrcpyOptions.video.asMutableState() + // 高级参数 AppPageLazyColumn( contentPadding = contentPadding, @@ -190,30 +204,27 @@ internal fun AdvancedConfigPage( title = "启动后关闭屏幕", summary = "--turn-screen-off", checked = turnScreenOff, - onCheckedChange = { value -> - onTurnScreenOffChange(value) - if (value) scope.launch { + onCheckedChange = { + turnScreenOff = it + if (it) 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, + checked = !control, + onCheckedChange = { control = !it }, ) SuperSwitch( title = "禁用视频", summary = "--no-video", - checked = noVideo, - onCheckedChange = onNoVideoChange, - enabled = !sessionStarted, + checked = !video, + onCheckedChange = { video = !it }, ) } } @@ -225,15 +236,14 @@ internal fun AdvancedConfigPage( summary = "--video-source", items = videoSourceItems, selectedIndex = videoSourceIndex, - onSelectedIndexChange = { index -> - onVideoSourcePresetChange(VIDEO_SOURCE_OPTIONS[index].first) + onSelectedIndexChange = { + videoSourcePreset = VIDEO_SOURCE_OPTIONS[it].first }, - enabled = !sessionStarted, ) if (videoSourcePreset == "display") { TextField( value = displayIdInput, - onValueChange = onDisplayIdInputChange, + onValueChange = { displayIdInput = it }, label = "--display-id", singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), @@ -246,7 +256,7 @@ internal fun AdvancedConfigPage( if (videoSourcePreset == "camera") { TextField( value = cameraIdInput, - onValueChange = onCameraIdInputChange, + onValueChange = { cameraIdInput = it }, label = "--camera-id", singleLine = true, modifier = Modifier @@ -258,17 +268,15 @@ internal fun AdvancedConfigPage( 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) + onSelectedIndexChange = { + cameraFacingPreset = CAMERA_FACING_OPTIONS[it].first }, - enabled = !sessionStarted, ) SuperDropdown( title = "摄像头分辨率", @@ -278,21 +286,18 @@ internal fun AdvancedConfigPage( 0, (cameraSizeDropdownItems.size - 1).coerceAtLeast(0) ), - onSelectedIndexChange = { index -> - onCameraSizePresetChange( - when (index) { - 0 -> "" - cameraSizeDropdownItems.lastIndex -> "custom" - else -> cameraSizeDropdownItems[index] - }, - ) + onSelectedIndexChange = { + cameraSizePreset = when (it) { + 0 -> "" + cameraSizeDropdownItems.lastIndex -> "custom" + else -> cameraSizeDropdownItems[it] + } }, - enabled = !sessionStarted, ) if (cameraSizePreset == "custom") { TextField( value = cameraSizeCustom, - onValueChange = onCameraSizeCustomChange, + onValueChange = { cameraSizeCustom = it }, label = "--camera-size", singleLine = true, modifier = Modifier @@ -303,7 +308,7 @@ internal fun AdvancedConfigPage( } TextField( value = cameraArInput, - onValueChange = onCameraArInputChange, + onValueChange = { cameraArInput = it }, label = "--camera-ar", singleLine = true, modifier = Modifier @@ -318,11 +323,10 @@ internal fun AdvancedConfigPage( 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()) + cameraFpsInput = 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, @@ -334,16 +338,14 @@ internal fun AdvancedConfigPage( inputFilter = { it.filter(Char::isDigit) }, inputValueRange = 0f..Float.MAX_VALUE, onInputConfirm = { - val normalized = it.ifBlank { "" } - onCameraFpsInputChange(if (normalized == "0") "" else normalized) + cameraFpsInput = if (normalized == "0") "" else normalized }, ) SuperSwitch( title = "高帧率模式", summary = "--camera-high-speed", checked = cameraHighSpeed, - onCheckedChange = onCameraHighSpeedChange, - enabled = !sessionStarted, + onCheckedChange = { cameraHighSpeed = it }, ) } } @@ -356,15 +358,12 @@ internal fun AdvancedConfigPage( summary = "--audio-source", items = audioSourceItems, selectedIndex = audioSourceIndex, - onSelectedIndexChange = { index -> - onAudioSourcePresetChange(AUDIO_SOURCE_OPTIONS[index].first) - }, - enabled = !sessionStarted && audioEnabled, + onSelectedIndexChange = { audioSourcePreset = AUDIO_SOURCE_OPTIONS[it].first }, ) if (audioSourcePreset == "custom") { TextField( value = audioSourceCustom, - onValueChange = onAudioSourceCustomChange, + onValueChange = { audioSourceCustom = it }, label = "--audio-source", singleLine = true, modifier = Modifier @@ -377,21 +376,19 @@ internal fun AdvancedConfigPage( title = "音频双路输出", summary = "--audio-dup", checked = audioDup, - onCheckedChange = onAudioDupChange, - enabled = !sessionStarted && audioEnabled, + onCheckedChange = { audioDup = it }, ) SuperSwitch( title = "仅转发不播放", summary = "--no-audio-playback", checked = noAudioPlayback, - onCheckedChange = onNoAudioPlaybackChange, - enabled = !sessionStarted && audioEnabled, + onCheckedChange = { noAudioPlayback = it }, ) SuperSwitch( title = "音频失败时终止 [TODO]", summary = "--require-audio", checked = requireAudio, - onCheckedChange = onRequireAudioChange, + onCheckedChange = { requireAudio = it }, enabled = false, ) } @@ -406,11 +403,10 @@ internal fun AdvancedConfigPage( onValueChange = { value -> val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxSize.lastIndex) val preset = ScrcpyPresets.MaxSize[idx] - onMaxSizeInputChange(if (preset == 0) "" else preset.toString()) + maxSizeInput = 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, @@ -421,10 +417,7 @@ internal fun AdvancedConfigPage( inputInitialValue = maxSizeInput, inputFilter = { it.filter(Char::isDigit) }, inputValueRange = 0f..Float.MAX_VALUE, - onInputConfirm = { - val normalized = it.ifBlank { "" } - onMaxSizeInputChange(normalized) - }, + onInputConfirm = { maxSizeInput = it.ifBlank { "" } }, ) SuperSlide( title = "最大帧率", @@ -433,11 +426,10 @@ internal fun AdvancedConfigPage( onValueChange = { value -> val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxFPS.lastIndex) val preset = ScrcpyPresets.MaxFPS[idx] - onMaxFpsInputChange(if (preset == 0) "" else preset.toString()) + maxFpsInput = 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, @@ -448,10 +440,7 @@ internal fun AdvancedConfigPage( inputInitialValue = maxFpsInput, inputFilter = { it.filter(Char::isDigit) }, inputValueRange = 0f..Float.MAX_VALUE, - onInputConfirm = { - val normalized = it.ifBlank { "" } - onMaxFpsInputChange(normalized) - }, + onInputConfirm = { maxFpsInput = it.ifBlank { "" } }, ) } } @@ -462,21 +451,17 @@ internal fun AdvancedConfigPage( 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, + onSelectedIndexChange = { videoEncoder = it }, ) TextField( value = videoCodecOptions, - onValueChange = onVideoCodecOptionsChange, + onValueChange = { videoCodecOptions = it }, label = "--video-codec-options", singleLine = true, modifier = Modifier @@ -489,14 +474,11 @@ internal fun AdvancedConfigPage( summary = "--audio-encoder", items = audioEncoderEntries, selectedIndex = audioEncoderIndex, - onSelectedIndexChange = { index -> - onAudioEncoderChange(if (index == 0) "" else audioEncoderDropdownItems[index]) - }, - enabled = !sessionStarted && audioEnabled, + onSelectedIndexChange = { audioEncoder = it }, ) TextField( value = audioCodecOptions, - onValueChange = onAudioCodecOptionsChange, + onValueChange = { audioCodecOptions = it }, label = "--audio-codec-options", singleLine = true, modifier = Modifier @@ -528,7 +510,7 @@ internal fun AdvancedConfigPage( ) { TextField( value = newDisplayWidth, - onValueChange = onNewDisplayWidthChange, + onValueChange = { newDisplayWidth = it }, label = "width", singleLine = true, keyboardOptions = KeyboardOptions( @@ -542,7 +524,7 @@ internal fun AdvancedConfigPage( ) TextField( value = newDisplayHeight, - onValueChange = onNewDisplayHeightChange, + onValueChange = { newDisplayHeight = it }, label = "height", singleLine = true, keyboardOptions = KeyboardOptions( @@ -556,7 +538,7 @@ internal fun AdvancedConfigPage( ) TextField( value = newDisplayDpi, - onValueChange = onNewDisplayDpiChange, + onValueChange = { newDisplayDpi = it }, label = "dpi", singleLine = true, keyboardOptions = KeyboardOptions( @@ -597,7 +579,7 @@ internal fun AdvancedConfigPage( ) { TextField( value = cropWidth, - onValueChange = onCropWidthChange, + onValueChange = { cropWidth = it }, label = "width", singleLine = true, keyboardOptions = KeyboardOptions( @@ -611,7 +593,7 @@ internal fun AdvancedConfigPage( ) TextField( value = cropHeight, - onValueChange = onCropHeightChange, + onValueChange = { cropHeight = it }, label = "height", singleLine = true, keyboardOptions = KeyboardOptions( @@ -630,7 +612,7 @@ internal fun AdvancedConfigPage( ) { TextField( value = cropX, - onValueChange = onCropXChange, + onValueChange = { cropX = it }, label = "x", singleLine = true, keyboardOptions = KeyboardOptions( @@ -644,7 +626,7 @@ internal fun AdvancedConfigPage( ) TextField( value = cropY, - onValueChange = onCropYChange, + onValueChange = { cropY = it }, label = "y", singleLine = true, keyboardOptions = KeyboardOptions( diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt index 6e2ed6d..833341d 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt @@ -32,14 +32,8 @@ import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn -import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions import io.github.miuzarte.scrcpyforandroid.scrcpy.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.ListOptions -import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource -import io.github.miuzarte.scrcpyforandroid.services.DevicePageSettings import io.github.miuzarte.scrcpyforandroid.services.EventLogger import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo @@ -47,10 +41,11 @@ import io.github.miuzarte.scrcpyforandroid.services.loadDevicePageSettings import io.github.miuzarte.scrcpyforandroid.services.loadQuickDevices import io.github.miuzarte.scrcpyforandroid.services.parseQuickTarget import io.github.miuzarte.scrcpyforandroid.services.replaceQuickDevicePort -import io.github.miuzarte.scrcpyforandroid.services.saveDevicePageSettings import io.github.miuzarte.scrcpyforandroid.services.saveQuickDevices import io.github.miuzarte.scrcpyforandroid.services.updateQuickDeviceNameIfEmpty import io.github.miuzarte.scrcpyforandroid.services.upsertQuickDevice +import io.github.miuzarte.scrcpyforandroid.storage.AppSettings +import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel import io.github.miuzarte.scrcpyforandroid.widgets.DeviceEditorScreen import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTile @@ -105,7 +100,6 @@ private val DeviceShortcutStateListSaver = if (parts.size != 5) return@mapNotNull null val port = parts[3].toIntOrNull() ?: return@mapNotNull null DeviceShortcut( - id = parts[0], name = parts[1], host = parts[2], port = port, @@ -129,75 +123,7 @@ fun DeviceTabScreen( scrcpy: Scrcpy, snack: SnackbarHostState, scrollBehavior: ScrollBehavior, - virtualButtonsLayout: String, - showPreviewVirtualButtonText: Boolean, - previewCardHeightDp: Int, - themeBaseIndex: Int, - videoCodec: String, - onVideoCodecChange: (String) -> Unit, - audioEnabled: Boolean, - onAudioEnabledChange: (Boolean) -> Unit, - audioCodec: String, - onAudioCodecChange: (String) -> Unit, - noControl: Boolean, - onNoControlChange: (Boolean) -> Unit, - videoEncoder: String, - onVideoEncoderChange: (String) -> Unit, - videoCodecOptions: String, - onVideoCodecOptionsChange: (String) -> Unit, - audioEncoder: String, - onAudioEncoderChange: (String) -> Unit, - audioCodecOptions: String, - onAudioCodecOptionsChange: (String) -> Unit, - audioDup: Boolean, - onAudioDupChange: (Boolean) -> Unit, - audioSourcePreset: String, - onAudioSourcePresetChange: (String) -> Unit, - audioSourceCustom: String, - onAudioSourceCustomChange: (String) -> Unit, - videoSourcePreset: String, - onVideoSourcePresetChange: (String) -> Unit, - cameraIdInput: String, - onCameraIdInputChange: (String) -> Unit, - cameraFacingPreset: String, - onCameraFacingPresetChange: (String) -> Unit, - cameraSizePreset: String, - onCameraSizePresetChange: (String) -> Unit, - cameraSizeCustom: String, - onCameraSizeCustomChange: (String) -> Unit, - cameraArInput: String, - onCameraArInputChange: (String) -> Unit, - cameraFpsInput: String, - onCameraFpsInputChange: (String) -> Unit, - cameraHighSpeed: Boolean, - onCameraHighSpeedChange: (Boolean) -> Unit, - noAudioPlayback: Boolean, - onNoAudioPlaybackChange: (Boolean) -> Unit, - noVideo: Boolean, - requireAudio: Boolean, - onRequireAudioChange: (Boolean) -> Unit, - turnScreenOff: Boolean, - onTurnScreenOffChange: (Boolean) -> Unit, - maxSizeInput: String, - onMaxSizeInputChange: (String) -> Unit, - maxFpsInput: String, - onMaxFpsInputChange: (String) -> Unit, - newDisplayWidth: String, - onNewDisplayWidthChange: (String) -> Unit, - newDisplayHeight: String, - onNewDisplayHeightChange: (String) -> Unit, - newDisplayDpi: String, - onNewDisplayDpiChange: (String) -> Unit, - displayIdInput: String, - onDisplayIdInputChange: (String) -> Unit, - cropWidth: String, - onCropWidthChange: (String) -> Unit, - cropHeight: String, - onCropHeightChange: (String) -> Unit, - cropX: String, - onCropXChange: (String) -> Unit, - cropY: String, - onCropYChange: (String) -> Unit, + videoEncoderOptions: List, onVideoEncoderOptionsChange: (List) -> Unit, onVideoEncoderTypeMapChange: (Map) -> Unit, @@ -214,12 +140,15 @@ fun DeviceTabScreen( onOpenReorderDevicesActionChange: ((() -> Unit)?) -> Unit, onOpenAdvancedPage: () -> Unit, onOpenFullscreenPage: (ScrcpySessionInfo) -> Unit, - adbPairingAutoDiscoverOnDialogOpen: Boolean, - adbAutoReconnectPairedDevice: Boolean, - adbMdnsLanDiscoveryEnabled: Boolean, ) { val context = LocalContext.current + + val appSettings = remember(context) { AppSettings(context) } + val scrcpyOptions = remember(context) { ScrcpyOptions(context) } + val haptics = rememberAppHaptics() + + val virtualButtonsLayout by appSettings.virtualButtonsLayout.asState() val virtualButtonLayout = remember(virtualButtonsLayout) { VirtualButtonActions.splitLayout(VirtualButtonActions.parseStoredLayout(virtualButtonsLayout)) } @@ -285,8 +214,8 @@ fun DeviceTabScreen( var audioForwardingSupported by rememberSaveable { mutableStateOf(true) } var cameraMirroringSupported by rememberSaveable { mutableStateOf(true) } - var bitRateMbps by rememberSaveable { mutableFloatStateOf(initialSettings.videoBitRateMbps) } - var bitRateInput by rememberSaveable { mutableStateOf(initialSettings.videoBitRateInput) } + var videoBitRateMbps by rememberSaveable { mutableFloatStateOf(initialSettings.videoBitRateMbps) } + var videoBitRateInput by rememberSaveable { mutableStateOf(initialSettings.videoBitRateInput) } var audioBitRateKbps by rememberSaveable { mutableIntStateOf(initialSettings.audioBitRateKbps) } val currentTarget = if (currentTargetHost.isNotBlank()) ConnectionTarget( currentTargetHost, @@ -364,11 +293,14 @@ fun DeviceTabScreen( disconnectAdbConnection(clearQuickOnlineForTarget = current) } + var audio by scrcpyOptions.audio.asMutableState() + var videoSource by scrcpyOptions.videoSource.asMutableState() + fun applyConnectedDeviceCapabilities(sdkInt: Int, release: String) { val audioSupported = sdkInt !in 0..<30 audioForwardingSupported = audioSupported - if (!audioSupported && audioEnabled) { - onAudioEnabledChange(false) + if (!audioSupported && audio) { + audio = false logEvent( "设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持音频转发,已自动关闭", Log.WARN @@ -376,8 +308,8 @@ fun DeviceTabScreen( } val cameraSupported = sdkInt !in 0..<31 cameraMirroringSupported = cameraSupported - if (!cameraSupported && videoSourcePreset == "camera") { - onVideoSourcePresetChange("display") + if (!cameraSupported && videoSource == "camera") { + videoSource = "display" logEvent( "设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持 camera mirroring,已切换为 display", Log.WARN @@ -531,6 +463,9 @@ fun DeviceTabScreen( } } + var videoEncoder by scrcpyOptions.videoEncoder.asMutableState() + var audioEncoder by scrcpyOptions.audioEncoder.asMutableState() + suspend fun refreshEncoderLists() { if (!adbConnected) return runCatching { @@ -542,10 +477,10 @@ fun DeviceTabScreen( onVideoEncoderTypeMapChange(lists.videoEncoderTypes) onAudioEncoderTypeMapChange(lists.audioEncoderTypes) if (videoEncoder.isNotBlank() && videoEncoder !in videoEncoderOptions) { - onVideoEncoderChange("") + videoEncoder = "" } if (audioEncoder.isNotBlank() && audioEncoder !in audioEncoderOptions) { - onAudioEncoderChange("") + audioEncoder = "" } EventLogger.logEvent("编码器列表已刷新: video=${lists.videoEncoders.size} audio=${lists.audioEncoders.size}") if (lists.videoEncoders.isEmpty() && lists.audioEncoders.isEmpty()) { @@ -571,6 +506,8 @@ fun DeviceTabScreen( } } + var cameraSize by scrcpyOptions.cameraSize.asMutableState() + suspend fun refreshCameraSizeLists() { if (!adbConnected) return runCatching { @@ -578,8 +515,8 @@ fun DeviceTabScreen( }.onSuccess { result -> val lists = result as Scrcpy.ListResult.CameraSizes onCameraSizeOptionsChange(lists.sizes) - if (cameraSizePreset.isNotBlank() && cameraSizePreset != "custom" && cameraSizePreset !in lists.sizes) { - onCameraSizePresetChange("") + if (cameraSize.isNotBlank() && cameraSize != "custom" && cameraSize !in lists.sizes) { + cameraSize = "" } EventLogger.logEvent("camera sizes 已刷新: count=${lists.sizes.size}") if (lists.sizes.isEmpty()) { @@ -624,88 +561,11 @@ fun DeviceTabScreen( refreshCameraSizeLists() } - LaunchedEffect(bitRateInput) { - val parsed = bitRateInput.toFloatOrNull() ?: return@LaunchedEffect - bitRateMbps = parsed.coerceAtLeast(0.1f) + LaunchedEffect(videoBitRateInput) { + val parsed = videoBitRateInput.toFloatOrNull() ?: return@LaunchedEffect + videoBitRateMbps = parsed.coerceAtLeast(0.1f) } - LaunchedEffect( - quickConnectInput, - audioBitRateKbps, - bitRateMbps, - bitRateInput, - turnScreenOff, - noControl, - noVideo, - videoSourcePreset, - displayIdInput, - cameraIdInput, - cameraFacingPreset, - cameraSizePreset, - cameraSizeCustom, - cameraArInput, - cameraFpsInput, - cameraHighSpeed, - audioSourcePreset, - audioSourceCustom, - audioDup, - noAudioPlayback, - requireAudio, - maxSizeInput, - maxFpsInput, - videoEncoder, - videoCodecOptions, - audioEncoder, - audioCodecOptions, - newDisplayWidth, - newDisplayHeight, - newDisplayDpi, - cropWidth, - cropHeight, - cropX, - cropY, - ) { - saveDevicePageSettings( - context, - DevicePageSettings( - quickConnectInput = quickConnectInput, - audioBitRateKbps = audioBitRateKbps, - audioBitRateInput = audioBitRateKbps.toString(), - videoBitRateMbps = bitRateMbps, - videoBitRateInput = bitRateInput, - turnScreenOff = turnScreenOff, - noControl = noControl, - noVideo = noVideo, - videoSourcePreset = videoSourcePreset, - displayIdInput = displayIdInput, - cameraIdInput = cameraIdInput, - cameraFacingPreset = cameraFacingPreset, - cameraSizePreset = cameraSizePreset, - cameraSizeCustom = cameraSizeCustom, - cameraAr = cameraArInput, - cameraFps = cameraFpsInput, - cameraHighSpeed = cameraHighSpeed, - audioSourcePreset = audioSourcePreset, - audioSourceCustom = audioSourceCustom, - audioDup = audioDup, - noAudioPlayback = noAudioPlayback, - requireAudio = requireAudio, - maxSizeInput = maxSizeInput, - maxFpsInput = maxFpsInput, - videoEncoder = videoEncoder, - videoCodecOptions = videoCodecOptions, - audioEncoder = audioEncoder, - audioCodecOptions = audioCodecOptions, - newDisplayWidth = newDisplayWidth, - newDisplayHeight = newDisplayHeight, - newDisplayDpi = newDisplayDpi, - cropWidth = cropWidth, - cropHeight = cropHeight, - cropX = cropX, - cropY = cropY, - ), - ) - } LaunchedEffect(Unit) { if (quickDevices.isEmpty()) { @@ -762,7 +622,10 @@ fun DeviceTabScreen( } } - LaunchedEffect(adbConnected, adbAutoReconnectPairedDevice, adbMdnsLanDiscoveryEnabled) { + val adbPairingAutoDiscoverOnDialogOpen by appSettings.adbPairingAutoDiscoverOnDialogOpen.asState() + val adbAutoReconnectPairedDevice by appSettings.adbAutoReconnectPairedDevice.asState() + val adbMdnsLanDiscovery by appSettings.adbMdnsLanDiscovery.asState() + LaunchedEffect(adbConnected, adbAutoReconnectPairedDevice, adbMdnsLanDiscovery) { if (adbConnected || !adbAutoReconnectPairedDevice) return@LaunchedEffect // Background auto reconnect pipeline: @@ -809,7 +672,7 @@ fun DeviceTabScreen( val discovered = withContext(Dispatchers.IO) { adbService.discoverConnectService( timeoutMs = ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS, - includeLanDevices = adbMdnsLanDiscoveryEnabled, + includeLanDevices = adbMdnsLanDiscovery, ) } @@ -985,6 +848,8 @@ fun DeviceTabScreen( editingDeviceId = null } + val devicePreviewCardHeightDp by appSettings.devicePreviewCardHeightDp.asState() + val previewVirtualButtonShowText by appSettings.previewVirtualButtonShowText.asState() // 设备 AppPageLazyColumn( contentPadding = contentPadding, @@ -998,7 +863,6 @@ fun DeviceTabScreen( sessionInfo = sessionInfo, busyLabel = null, connectedDeviceLabel = connectedDeviceLabel, - themeBaseIndex = themeBaseIndex, ) } @@ -1099,7 +963,7 @@ fun DeviceTabScreen( autoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen, onDiscoverTarget = { adbService.discoverPairingService( - includeLanDevices = adbMdnsLanDiscoveryEnabled, + includeLanDevices = adbMdnsLanDiscovery, ) }, onPair = { host, port, code -> @@ -1128,139 +992,34 @@ fun DeviceTabScreen( item { ConfigPanel( busy = busy, - bitRateMbps = bitRateMbps, - onBitRateSliderChange = { - bitRateMbps = it - @SuppressLint("DefaultLocale") - bitRateInput = String.format("%.1f", it) - }, - onBitRateInputChange = { bitRateInput = it }, - audioBitRateKbps = audioBitRateKbps, - onAudioBitRateChange = { audioBitRateKbps = it }, - videoCodec = videoCodec, - onVideoCodecChange = onVideoCodecChange, - audioEnabled = audioEnabled, - onAudioEnabledChange = onAudioEnabledChange, audioForwardingSupported = audioForwardingSupported, - audioCodec = audioCodec, - onAudioCodecChange = onAudioCodecChange, onOpenAdvanced = onOpenAdvancedPage, onStartStopHaptic = { haptics.contextClick() }, onStart = { runBusy("启动 scrcpy") { - if (noVideo && !audioEnabled) { - throw IllegalArgumentException("--no-video 需要同时启用音频") - } - if (audioEnabled && audioSourcePreset == "custom" && audioSourceCustom.isBlank()) { - throw IllegalArgumentException("audio-source 选择自定义时不能为空") - } - val resolvedVideoSource = videoSourcePreset.trim().ifBlank { "display" } - if (resolvedVideoSource == "camera" && !cameraMirroringSupported) { - throw IllegalArgumentException("camera mirroring 需要 Android 12+ (SDK 31+)") - } - val resolvedCameraSize = when (cameraSizePreset) { - "custom" -> cameraSizeCustom.trim() - else -> cameraSizePreset.trim() - } - if (resolvedVideoSource == "camera" && cameraSizePreset == "custom" && resolvedCameraSize.isBlank()) { - throw IllegalArgumentException("camera-size 选择自定义时不能为空") - } - val resolvedCameraId = cameraIdInput.trim() - val resolvedCameraFacing = cameraFacingPreset.trim() - if (resolvedVideoSource == "camera" && resolvedCameraId.isNotBlank() && resolvedCameraFacing.isNotBlank()) { - throw IllegalArgumentException("camera-id 与 camera-facing 不能同时设置") - } - val resolvedCameraAr = cameraArInput.trim() - val resolvedCameraFps = - cameraFpsInput.filter(Char::isDigit).toIntOrNull() ?: 0 - if (resolvedVideoSource == "camera" && cameraHighSpeed && resolvedCameraFps <= 0) { - throw IllegalArgumentException("启用 --camera-high-speed 时,--camera-fps 不能为 0") - } - val maxSize = - maxSizeInput.filter(Char::isDigit).toIntOrNull()?.takeIf { it > 0 } - ?: 0 - val maxFps = - maxFpsInput.filter(Char::isDigit).toIntOrNull()?.toFloat() ?: 0f - if (resolvedVideoSource == "camera" && resolvedCameraSize.isNotBlank() && (maxSize > 0 || resolvedCameraAr.isNotBlank())) { - throw IllegalArgumentException("显式 camera-size 时不能同时设置 --max-size 或 --camera-ar") - } - val bitRateBps = (bitRateMbps * 1_000_000).toInt() - val audioBitRateBps = (audioBitRateKbps.coerceAtLeast(1)) * 1_000 - val resolvedAudioSource = when (audioSourcePreset) { - "custom" -> audioSourceCustom.trim() - else -> audioSourcePreset.trim() - } - val newDisplayArg = buildNewDisplayArg( - newDisplayWidth.filter(Char::isDigit), - newDisplayHeight.filter(Char::isDigit), - newDisplayDpi.filter(Char::isDigit), - ) - val displayId = displayIdInput.filter(Char::isDigit).toIntOrNull() - ?.takeIf { it > 0 } - val crop = buildCropArg( - cropWidth.filter(Char::isDigit), - cropHeight.filter(Char::isDigit), - cropX.filter(Char::isDigit), - cropY.filter(Char::isDigit), - ) - val effectiveTurnScreenOff = turnScreenOff && !noControl - - val options = ClientOptions( - crop = crop, - videoCodecOptions = videoCodecOptions, - audioCodecOptions = audioCodecOptions, - videoEncoder = videoEncoder, - audioEncoder = audioEncoder, - cameraId = resolvedCameraId, - cameraSize = resolvedCameraSize, - cameraAr = resolvedCameraAr, - cameraFps = resolvedCameraFps.toUShort(), - videoCodec = Codec.fromString(videoCodec), - audioCodec = Codec.fromString(audioCodec), - videoSource = if (resolvedVideoSource == "camera") VideoSource.CAMERA else VideoSource.DISPLAY, - audioSource = if (resolvedAudioSource.isNotBlank()) AudioSource.fromString( - resolvedAudioSource - ) else AudioSource.AUTO, - cameraFacing = if (resolvedCameraFacing.isNotBlank()) CameraFacing.fromString( - resolvedCameraFacing - ) else CameraFacing.ANY, - maxSize = maxSize.toUShort(), - videoBitRate = bitRateBps.toUInt(), - audioBitRate = audioBitRateBps.toUInt(), - maxFps = if (maxFps > 0f) maxFps.toString() else "", - displayId = (displayId ?: 0).toUInt(), - control = !noControl, - video = !noVideo, - audio = audioEnabled, - requireAudio = requireAudio, - audioPlayback = !noAudioPlayback, - turnScreenOff = effectiveTurnScreenOff, - audioDup = audioDup, - newDisplay = newDisplayArg, - cameraHighSpeed = cameraHighSpeed, - ) - - // Start scrcpy using Scrcpy class - val session = scrcpy.start( - options = options, - ) - + val options = scrcpyOptions.toClientOptions() + val session = scrcpy.start(options) sessionInfo = session statusLine = "scrcpy 运行中" @SuppressLint("DefaultLocale") - val videoDetail = if (noVideo) { + val videoDetail = if (!options.video) { "off" } else { "${session.codec} ${session.width}x${session.height} " + - "@${String.format("%.1f", bitRateMbps)}Mbps" + "@${String.format("%.1f", videoBitRateMbps)}Mbps" } - val audioDetail = if (!audioEnabled) { + val audioDetail = if (!audio) { "off" } else { - val playback = if (noAudioPlayback) "(no-playback)" else "" - "$audioCodec ${audioBitRateKbps}kbps source=${resolvedAudioSource.ifBlank { "default" }}$playback" + val playback = if (!options.audioPlayback) "(no-playback)" else "" + "${options.audioCodec} ${audioBitRateKbps}kbps source=${options.audioSource}$playback" } - logEvent("scrcpy 已启动: device=${session.deviceName}, video=$videoDetail, audio=$audioDetail, control=${!noControl}, turnScreenOff=$effectiveTurnScreenOff, maxSize=${if (maxSize > 0) maxSize else "auto"}, maxFps=${if (maxFps > 0f) maxFps else "auto"}") + logEvent( + "scrcpy 已启动: device=${session.deviceName}" + + ", video=$videoDetail, audio=$audioDetail" + + ", control=${options.control}, turnScreenOff=${options.turnScreenOff}" + + ", maxSize=${options.maxSize}, maxFps=${options.maxFps}" + ) scope.launch { snack.showSnackbar("scrcpy 已启动") } @@ -1293,7 +1052,7 @@ fun DeviceTabScreen( PreviewCard( sessionInfo = sessionInfo, nativeCore = nativeCore, - previewHeightDp = previewCardHeightDp.coerceAtLeast(120), + previewHeightDp = devicePreviewCardHeightDp.coerceAtLeast(120), controlsVisible = previewControlsVisible, onTapped = { previewControlsVisible = !previewControlsVisible @@ -1302,15 +1061,15 @@ fun DeviceTabScreen( val info = sessionInfo ?: return@PreviewCard onOpenFullscreenPage(info) }, - onOpenFullscreenHaptic = { haptics.contextClick() }, ) } + item { VirtualButtonCard( busy = busy, outsideActions = virtualButtonLayout.first, moreActions = virtualButtonLayout.second, - showText = showPreviewVirtualButtonText, + showText = previewVirtualButtonShowText, onAction = ::sendVirtualButtonAction, ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt index 75b2b10..612ce29 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt @@ -52,10 +52,8 @@ import io.github.miuzarte.scrcpyforandroid.constants.UiMotion import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy -import io.github.miuzarte.scrcpyforandroid.services.MainSettings -import io.github.miuzarte.scrcpyforandroid.services.loadDevicePageSettings -import io.github.miuzarte.scrcpyforandroid.services.loadMainSettings -import io.github.miuzarte.scrcpyforandroid.services.saveMainSettings +import io.github.miuzarte.scrcpyforandroid.storage.AppSettings +import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -97,6 +95,7 @@ private sealed interface RootScreen : NavKey { @Composable fun MainPage() { val context = LocalContext.current + val activity = remember(context) { context as? Activity } val initialOrientation = remember(activity) { activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED @@ -106,8 +105,6 @@ fun MainPage() { val adbService = remember(context) { NativeAdbService(context) } val scrcpy = remember(context) { Scrcpy(context) } - val initialSettings = remember(context) { loadMainSettings(context) } - val initialDeviceSettings = remember(context) { loadDevicePageSettings(context) } val snackHostState = remember { SnackbarHostState() } val tabs = remember { MainTabDestination.entries } val pagerState = rememberPagerState( @@ -132,20 +129,49 @@ fun MainPage() { 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) } + val appSettings = remember(context) { AppSettings(context) } + val scrcpyOptions = remember(context) { ScrcpyOptions(context) } + + /* + val initialSettings = remember(context) { + loadMainSettings(context) + } + var audioEnabled by rememberSaveable { + mutableStateOf(initialSettings.audioEnabled) + } + var audioCodec by rememberSaveable { + mutableStateOf(initialSettings.audioCodec) + } + var videoCodec by rememberSaveable { + mutableStateOf(initialSettings.videoCodec) + } + 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) } @@ -155,36 +181,102 @@ fun MainPage() { 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 initialDeviceSettings = remember(context) { + loadDevicePageSettings(context) + } + 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() } val audioEncoderOptions = remember { mutableStateListOf() } val videoEncoderTypeMap = remember { mutableStateMapOf() } @@ -201,7 +293,10 @@ fun MainPage() { var fullscreenOrientation by rememberSaveable { mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) } - val themeMode = resolveThemeMode(themeBaseIndex, monetEnabled) + + var themeBaseIndex by appSettings.themeBaseIndex.asMutableState() + var monet by appSettings.monet.asMutableState() + val themeMode = resolveThemeMode(themeBaseIndex, monet) val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) } // Restore system orientation when MainPage leaves composition. @@ -211,6 +306,7 @@ fun MainPage() { } } + val keepScreenOnWhenStreamingEnabled by appSettings.keepScreenOnWhenStreaming.asMutableState() // Keep-screen-on is controlled globally, so fullscreen and preview share the same behavior. DisposableEffect(activity, keepScreenOnWhenStreamingEnabled, sessionStarted) { val window = activity?.window @@ -234,49 +330,7 @@ fun MainPage() { 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, - ), - ) - } - + val adbKeyName by appSettings.adbKeyName.asMutableState() LaunchedEffect(adbKeyName) { adbService.keyName = adbKeyName.ifBlank { AppDefaults.ADB_KEY_NAME } } @@ -340,17 +394,19 @@ fun MainPage() { } } - 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() + var customServerUri by appSettings.customServerUri.asMutableState() + val picker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + if (uri == null) return@rememberLauncherForActivityResult + runCatching { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) } + customServerUri = uri.toString() + } val rootEntryProvider = entryProvider { entry(RootScreen.Home) { @@ -432,80 +488,14 @@ fun MainPage() { scrcpy = scrcpy, snack = snackHostState, scrollBehavior = deviceScrollBehavior, - virtualButtonsLayout = virtualButtonsLayout, - showPreviewVirtualButtonText = showPreviewVirtualButtonText, - previewCardHeightDp = devicePreviewCardHeightDp, - themeBaseIndex = themeBaseIndex, - 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() @@ -558,9 +548,6 @@ fun MainPage() { ), ) }, - adbPairingAutoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen, - adbAutoReconnectPairedDevice = adbAutoReconnectPairedDevice, - adbMdnsLanDiscoveryEnabled = adbMdnsLanDiscoveryEnabled, ) } @@ -574,33 +561,12 @@ fun MainPage() { ) { 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( @@ -610,19 +576,6 @@ fun MainPage() { ) ) }, - 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, ) } @@ -632,6 +585,8 @@ fun MainPage() { } } + val videoEncoder by scrcpyOptions.videoEncoder.asState() + val audioEncoder by scrcpyOptions.audioEncoder.asState() entry(RootScreen.Advanced) { val videoEncoderDropdownItems = listOf("默认") + videoEncoderOptions val audioEncoderDropdownItems = listOf("默认") + audioEncoderOptions @@ -808,11 +763,11 @@ fun MainPage() { @Composable private fun DeviceMenuPopup( show: Boolean, - canClearLogs: Boolean, onDismissRequest: () -> Unit, onReorderDevices: () -> Unit, onOpenVirtualButtonOrder: () -> Unit, onClearLogs: () -> Unit, + canClearLogs: Boolean, ) { SuperListPopup( show = show, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt index fdad761..3aeab6b 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt @@ -11,6 +11,9 @@ 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.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight @@ -19,6 +22,8 @@ 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.storage.AppSettings +import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.Icon @@ -43,11 +48,11 @@ private val THEME_BASE_OPTIONS = listOf( ThemeModeOption("深色", ColorSchemeMode.Dark), ) -fun resolveThemeMode(baseIndex: Int, monetEnabled: Boolean): ColorSchemeMode { +fun resolveThemeMode(baseIndex: Int, monet: 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 + 0 -> if (monet) ColorSchemeMode.MonetSystem else ColorSchemeMode.System + 1 -> if (monet) ColorSchemeMode.MonetLight else ColorSchemeMode.Light + else -> if (monet) ColorSchemeMode.MonetDark else ColorSchemeMode.Dark } } @@ -59,36 +64,37 @@ private fun resolveThemeLabel(baseIndex: Int, monetEnabled: Boolean): String { @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 + val appSettings = remember(context) { AppSettings(context) } + val scrcpyOptions = remember(context) { ScrcpyOptions(context) } + + val baseModeItems = THEME_BASE_OPTIONS.map { it.label } + + // 主题 + var themeBaseIndex by appSettings.themeBaseIndex.asMutableState() + var monet by appSettings.monet.asMutableState() + + // 投屏 + var fullscreenDebugInfo by appSettings.fullscreenDebugInfo.asMutableState() + var keepScreenOnWhenStreaming by appSettings.keepScreenOnWhenStreaming.asMutableState() + var devicePreviewCardHeightDp by appSettings.devicePreviewCardHeightDp.asMutableState() + var showFullscreenVirtualButtons by appSettings.showFullscreenVirtualButtons.asMutableState() + + // scrcpy-server + var customServerUri by appSettings.customServerUri.asMutableState() + var serverRemotePath by appSettings.serverRemotePath.asMutableState() + + // ADB + var adbKeyName by appSettings.adbKeyName.asMutableState() + var adbPairingAutoDiscoverOnDialogOpen by appSettings.adbPairingAutoDiscoverOnDialogOpen.asMutableState() + var adbAutoReconnectPairedDevice by appSettings.adbAutoReconnectPairedDevice.asMutableState() + // 设置 AppPageLazyColumn( contentPadding = contentPadding, @@ -99,16 +105,16 @@ fun SettingsScreen( Card { SuperDropdown( title = "外观模式", - summary = resolveThemeLabel(themeBaseIndex, monetEnabled), + summary = resolveThemeLabel(themeBaseIndex, monet), items = baseModeItems, selectedIndex = themeBaseIndex.coerceIn(0, baseModeItems.lastIndex), - onSelectedIndexChange = onThemeBaseIndexChange, + onSelectedIndexChange = { themeBaseIndex = it }, ) SuperSwitch( title = "Monet", summary = "开启后使用 Monet 动态配色", - checked = monetEnabled, - onCheckedChange = onMonetEnabledChange, + checked = monet, + onCheckedChange = { monet = it }, ) } @@ -117,23 +123,21 @@ fun SettingsScreen( SuperSwitch( title = "启用调试信息", summary = "在全屏界面显示触点数量、设备分辨率和实时 FPS", - checked = fullscreenDebugInfoEnabled, - onCheckedChange = onFullscreenDebugInfoEnabledChange, + checked = fullscreenDebugInfo, + onCheckedChange = { fullscreenDebugInfo = it }, ) SuperSwitch( title = "投屏时保持屏幕常亮", summary = "Scrcpy 启动后保持本机屏幕常亮,避免锁屏导致 ADB 断开", - checked = keepScreenOnWhenStreamingEnabled, - onCheckedChange = onKeepScreenOnWhenStreamingEnabledChange, + checked = keepScreenOnWhenStreaming, + onCheckedChange = { keepScreenOnWhenStreaming = it }, ) SuperSlide( title = "预览卡高度", summary = "设备页预览卡高度", value = devicePreviewCardHeightDp.toFloat(), onValueChange = { - onDevicePreviewCardHeightDpChange( - it.roundToInt().coerceAtLeast(120) - ) + devicePreviewCardHeightDp = it.roundToInt().coerceAtLeast(120) }, valueRange = 160f..600f, steps = 439, @@ -142,9 +146,10 @@ fun SettingsScreen( inputInitialValue = devicePreviewCardHeightDp.toString(), inputFilter = { it.filter(Char::isDigit) }, inputValueRange = 120f..Float.MAX_VALUE, - onInputConfirm = { raw -> - raw.toIntOrNull() - ?.let { onDevicePreviewCardHeightDpChange(it.coerceAtLeast(120)) } + onInputConfirm = { input -> + input.toIntOrNull()?.let { + devicePreviewCardHeightDp = it.coerceAtLeast(120) + } }, ) SuperArrow( @@ -161,7 +166,7 @@ fun SettingsScreen( title = "全屏显示虚拟按钮", summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮", checked = showFullscreenVirtualButtons, - onCheckedChange = onShowFullscreenVirtualButtonsChange, + onCheckedChange = { showFullscreenVirtualButtons = it }, ) } @@ -176,20 +181,21 @@ fun SettingsScreen( fontWeight = FontWeight.Medium, ) TextField( - value = customServerUri ?: "", + value = customServerUri, onValueChange = {}, readOnly = true, label = "scrcpy-server-v3.3.4", - useLabelAsPlaceholder = customServerUri == null, + useLabelAsPlaceholder = customServerUri.isBlank(), 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 = "清空") - } + if (customServerUri.isNotBlank()) + IconButton(onClick = { customServerUri = "" }) { + Icon(Icons.Rounded.Clear, contentDescription = "清空") + } IconButton(onClick = onPickServer) { Icon(Icons.Rounded.FileOpen, contentDescription = "选择文件") } @@ -205,7 +211,7 @@ fun SettingsScreen( ) TextField( value = serverRemotePath, - onValueChange = onServerRemotePathChange, + onValueChange = { serverRemotePath = it }, label = AppDefaults.SERVER_REMOTE_PATH, useLabelAsPlaceholder = true, singleLine = true, @@ -227,7 +233,7 @@ fun SettingsScreen( ) TextField( value = adbKeyName, - onValueChange = onAdbKeyNameChange, + onValueChange = { adbKeyName = it }, label = AppDefaults.ADB_KEY_NAME, useLabelAsPlaceholder = true, singleLine = true, @@ -240,13 +246,13 @@ fun SettingsScreen( title = "配对时自动启用发现服务", summary = "打开配对弹窗后自动搜索可用配对端口", checked = adbPairingAutoDiscoverOnDialogOpen, - onCheckedChange = onAdbPairingAutoDiscoverOnDialogOpenChange, + onCheckedChange = { adbPairingAutoDiscoverOnDialogOpen = it }, ) SuperSwitch( title = "自动重连已配对设备", summary = "自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘)", checked = adbAutoReconnectPairedDevice, - onCheckedChange = onAdbAutoReconnectPairedDeviceChange, + onCheckedChange = { adbAutoReconnectPairedDevice = it }, ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt index 010a58f..3a056b7 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt @@ -68,7 +68,7 @@ class Shared { AAC("aac"), FLAC("flac"), RAW("raw"); // wav raw - + companion object { fun fromString(value: String): Codec { return entries.find { it.string.equals(value, ignoreCase = true) } ?: H264 @@ -79,7 +79,7 @@ class Shared { enum class VideoSource(val string: String) { DISPLAY("display"), // default, ignore when passing CAMERA("camera"); - + companion object { fun fromString(value: String): VideoSource { return entries.find { it.string.equals(value, ignoreCase = true) } ?: DISPLAY @@ -100,7 +100,7 @@ class Shared { VOICE_CALL_UPLINK("voice-call-uplink"), VOICE_CALL_DOWNLINK("voice-call-downlink"), VOICE_PERFORMANCE("voice-performance"); - + companion object { fun fromString(value: String): AudioSource { return entries.find { it.string.equals(value, ignoreCase = true) } ?: AUTO @@ -113,7 +113,7 @@ class Shared { FRONT("front"), BACK("back"), EXTERNAL("external"); - + companion object { fun fromString(value: String): CameraFacing { return entries.find { it.string.equals(value, ignoreCase = true) } ?: ANY @@ -142,6 +142,13 @@ class Shared { 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) { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt index 3697433..e8e86d2 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt @@ -25,7 +25,6 @@ internal fun loadQuickDevices(context: Context): List { if (host.isNotBlank()) { result.add( DeviceShortcut( - id = "$host:$port", name = name, host = host, port = port, @@ -44,7 +43,6 @@ internal fun loadQuickDevices(context: Context): List { if (host.isNotBlank()) { result.add( DeviceShortcut( - id = "$host:$port", name = name, host = host, port = port, @@ -83,11 +81,9 @@ internal fun upsertQuickDevice( port: Int, online: Boolean, ) { - val id = "$host:$port" - val idx = quickDevices.indexOfFirst { it.id == id } + val idx = quickDevices.indexOfFirst { it.id == "$host:$port" } val existingName = if (idx >= 0) quickDevices[idx].name else "" val item = DeviceShortcut( - id = id, name = existingName, host = host, port = port, @@ -124,7 +120,6 @@ internal fun replaceQuickDevicePort( val old = quickDevices[idx] val updated = old.copy( - id = "$host:$newPort", port = newPort, online = online, ) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt new file mode 100644 index 0000000..614c4c5 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt @@ -0,0 +1,111 @@ +package io.github.miuzarte.scrcpyforandroid.storage + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey + +class AppSettings(context: Context) : Settings(context, "AppSettings") { + companion object { + private val THEME_BASE_INDEX = Pair( + intPreferencesKey("theme_base_index"), + 0 + ) + private val MONET = Pair( + booleanPreferencesKey("monet"), + false + ) + private val FULLSCREEN_DEBUG_INFO = Pair( + booleanPreferencesKey("fullscreen_debug_info"), + false + ) + private val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = Pair( + booleanPreferencesKey("show_fullscreen_virtual_buttons"), + true + ) + private val KEEP_SCREEN_ON_WHEN_STREAMING = Pair( + booleanPreferencesKey("keep_screen_on_when_streaming"), + false + ) + private val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair( + intPreferencesKey("device_preview_card_height_dp"), + 320 + ) + private val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = Pair( + booleanPreferencesKey("preview_virtual_button_show_text"), + true + ) + private 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" + ) + private val CUSTOM_SERVER_URI = Pair( + stringPreferencesKey("custom_server_uri"), + "" + ) + private val SERVER_REMOTE_PATH = Pair( + stringPreferencesKey("server_remote_path"), + "/data/local/tmp/scrcpy-server.jar" + ) + private val ADB_KEY_NAME = Pair( + stringPreferencesKey("adb_key_name"), + "scrcpy" + ) + private val ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN = Pair( + booleanPreferencesKey("adb_pairing_auto_discover_on_dialog_open"), + true + ) + private val ADB_AUTO_RECONNECT_PAIRED_DEVICE = Pair( + booleanPreferencesKey("adb_auto_reconnect_paired_device"), + true + ) + private 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 keepScreenOnWhenStreaming by setting(KEEP_SCREEN_ON_WHEN_STREAMING) + 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 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) + + override suspend fun toMap(): Map { + return mapOf( + THEME_BASE_INDEX.name to themeBaseIndex.get(), + MONET.name to monet.get(), + FULLSCREEN_DEBUG_INFO.name to fullscreenDebugInfo.get(), + SHOW_FULLSCREEN_VIRTUAL_BUTTONS.name to showFullscreenVirtualButtons.get(), + KEEP_SCREEN_ON_WHEN_STREAMING.name to keepScreenOnWhenStreaming.get(), + DEVICE_PREVIEW_CARD_HEIGHT_DP.name to devicePreviewCardHeightDp.get(), + PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT.name to previewVirtualButtonShowText.get(), + VIRTUAL_BUTTONS_LAYOUT.name to virtualButtonsLayout.get(), + CUSTOM_SERVER_URI.name to customServerUri.get(), + SERVER_REMOTE_PATH.name to serverRemotePath.get(), + ADB_KEY_NAME.name to adbKeyName.get(), + ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN.name to adbPairingAutoDiscoverOnDialogOpen.get(), + ADB_AUTO_RECONNECT_PAIRED_DEVICE.name to adbAutoReconnectPairedDevice.get(), + ADB_MDNS_LAN_DISCOVERY.name to adbMdnsLanDiscovery.get() + ) + } + + // TODO? + override fun validate(): Boolean = true +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings_New.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings_New.kt new file mode 100644 index 0000000..ca8b37c --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings_New.kt @@ -0,0 +1,128 @@ +package io.github.miuzarte.scrcpyforandroid.storage + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey + +/** + * 使用委托方式的 AppSettings 示例 + * + * 使用方式: + * ``` + * // 获取值 + * val theme = appSettings.themeBaseIndex.get() + * + * // 设置值 + * appSettings.themeBaseIndex.set(1) + * + * // 观察变化(Flow) + * appSettings.themeBaseIndex.observe().collect { value -> } + * + * // 在 Composable 中观察(State) + * val theme by appSettings.themeBaseIndex.observeAsState() + * ``` + */ +class AppSettingsNew(context: Context) : Settings(context, "AppSettings") { + companion object { + private val THEME_BASE_INDEX = Pair( + intPreferencesKey("theme_base_index"), + 0 + ) + private val MONET = Pair( + booleanPreferencesKey("monet"), + false + ) + private val FULLSCREEN_DEBUG_INFO = Pair( + booleanPreferencesKey("fullscreen_debug_info"), + false + ) + private val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = Pair( + booleanPreferencesKey("show_fullscreen_virtual_buttons"), + true + ) + private val KEEP_SCREEN_ON_WHEN_STREAMING = Pair( + booleanPreferencesKey("keep_screen_on_when_streaming"), + false + ) + private val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair( + intPreferencesKey("device_preview_card_height_dp"), + 320 + ) + private val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = Pair( + booleanPreferencesKey("preview_virtual_button_show_text"), + true + ) + private 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" + ) + private val CUSTOM_SERVER_URI = Pair( + stringPreferencesKey("custom_server_uri"), + "" + ) + private val SERVER_REMOTE_PATH = Pair( + stringPreferencesKey("server_remote_path"), + "/data/local/tmp/scrcpy-server.jar" + ) + private val ADB_KEY_NAME = Pair( + stringPreferencesKey("adb_key_name"), + "scrcpy" + ) + private val ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN = Pair( + booleanPreferencesKey("adb_pairing_auto_discover_on_dialog_open"), + true + ) + private val ADB_AUTO_RECONNECT_PAIRED_DEVICE = Pair( + booleanPreferencesKey("adb_auto_reconnect_paired_device"), + true + ) + private 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 keepScreenOnWhenStreaming by setting(KEEP_SCREEN_ON_WHEN_STREAMING) + 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 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) + + override suspend fun toMap(): Map { + return mapOf( + THEME_BASE_INDEX.name to themeBaseIndex.get(), + MONET.name to monet.get(), + FULLSCREEN_DEBUG_INFO.name to fullscreenDebugInfo.get(), + SHOW_FULLSCREEN_VIRTUAL_BUTTONS.name to showFullscreenVirtualButtons.get(), + KEEP_SCREEN_ON_WHEN_STREAMING.name to keepScreenOnWhenStreaming.get(), + DEVICE_PREVIEW_CARD_HEIGHT_DP.name to devicePreviewCardHeightDp.get(), + PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT.name to previewVirtualButtonShowText.get(), + VIRTUAL_BUTTONS_LAYOUT.name to virtualButtonsLayout.get(), + CUSTOM_SERVER_URI.name to customServerUri.get(), + SERVER_REMOTE_PATH.name to serverRemotePath.get(), + ADB_KEY_NAME.name to adbKeyName.get(), + ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN.name to adbPairingAutoDiscoverOnDialogOpen.get(), + ADB_AUTO_RECONNECT_PAIRED_DEVICE.name to adbAutoReconnectPairedDevice.get(), + ADB_MDNS_LAN_DISCOVERY.name to adbMdnsLanDiscovery.get() + ) + } + + override fun validate(): Boolean = true +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt new file mode 100644 index 0000000..e701d43 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt @@ -0,0 +1,171 @@ +package io.github.miuzarte.scrcpyforandroid.storage + +import android.content.Context +import androidx.datastore.preferences.core.stringPreferencesKey +import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class QuickDevices(context: Context) : Settings(context, "QuickDevices") { + companion object { + private val QUICK_DEVICES = Pair( + stringPreferencesKey("quick_devices"), + "", + ) + private val QUICK_CONNECT_INPUT = Pair( + stringPreferencesKey("quick_connect_input"), + "", + ) + } + + override suspend fun toMap(): Map { + return mapOf( + QUICK_DEVICES.name to getQuickDevicesRaw(), + QUICK_CONNECT_INPUT.name to getQuickConnectInput(), + ) + } + + override fun validate(): Boolean = true + + private suspend fun getQuickDevicesRaw(): String = getValue(QUICK_DEVICES) + private suspend fun setQuickDevicesRaw(value: String) = setValue(QUICK_DEVICES, value) + private fun observeQuickDevicesRaw(): Flow = observe(QUICK_DEVICES) + + suspend fun getQuickDevices(): List { + val raw = getQuickDevicesRaw() + if (raw.isBlank()) return emptyList() + + val result = mutableListOf() + raw.lineSequence().forEach { line -> + DeviceShortcut.unmarshalFrom(line)?.let { result.add(it) } + } + return result + } + + suspend fun setQuickDevices(quickDevices: List) = + setQuickDevicesRaw(quickDevices.joinToString("\n") { it.marshalToString() }) + + fun observeQuickDevices(): Flow> = observeQuickDevicesRaw() + .map { raw -> + if (raw.isBlank()) return@map emptyList() + + val result = mutableListOf() + raw.lineSequence().forEach { line -> + DeviceShortcut.unmarshalFrom(line)?.let { result.add(it) } + } + result + } + + /** + * 插入或更新快速设备 + */ + suspend fun upsertQuickDevice( + host: String, + port: Int, + online: Boolean, + index: Int? = 0, + ) { + val quickDevices = getQuickDevices().toMutableList() + val id = "$host:$port" + val idx = quickDevices.indexOfFirst { it.id == id } + val existingName = if (idx >= 0) quickDevices[idx].name else "" + val item = DeviceShortcut( + name = existingName, + host = host, + port = port, + online = online, + ) + if (idx >= 0) { + quickDevices[idx] = item + } else { + if (index != null) + quickDevices.add(index, item) + else quickDevices.add(item) + } + setQuickDevices(quickDevices) + } + + /** + * 如果设备名称为空,则更新为备用名称 + */ + suspend fun updateQuickDeviceNameIfEmpty( + host: String, + port: Int, + fallbackName: String, + ) { + val quickDevices = getQuickDevices().toMutableList() + val idx = quickDevices.indexOfFirst { it.host == host && it.port == port } + if (idx >= 0 && quickDevices[idx].name.isBlank()) { + quickDevices[idx] = quickDevices[idx].copy(name = fallbackName) + setQuickDevices(quickDevices) + } + } + + /** + * 替换快速设备的端口 + */ + suspend fun replaceQuickDevicePort( + host: String, + oldPort: Int, + newPort: Int, + online: Boolean, + ) { + val quickDevices = getQuickDevices().toMutableList() + val idx = quickDevices.indexOfFirst { it.host == host && it.port == oldPort } + if (idx < 0) return + + val old = quickDevices[idx] + val updated = old.copy( + port = newPort, + online = online, + ) + + quickDevices[idx] = updated + val dedup = quickDevices.distinctBy { it.id } + setQuickDevices(dedup) + } + + /** + * 删除快速设备 + */ + suspend fun removeQuickDevice(id: String) { + val quickDevices = getQuickDevices().toMutableList() + quickDevices.removeAll { it.id == id } + setQuickDevices(quickDevices) + } + + /** + * 更新设备在线状态 + */ + suspend fun updateDeviceOnlineStatus(host: String, port: Int, online: Boolean) { + val quickDevices = getQuickDevices().toMutableList() + val idx = quickDevices.indexOfFirst { it.host == host && it.port == port } + if (idx >= 0) { + quickDevices[idx] = quickDevices[idx].copy(online = online) + setQuickDevices(quickDevices) + } + } + + /** + * 更新设备名称 + */ + suspend fun updateDeviceName(id: String, name: String) { + val quickDevices = getQuickDevices().toMutableList() + val idx = quickDevices.indexOfFirst { it.id == id } + if (idx >= 0) { + quickDevices[idx] = quickDevices[idx].copy(name = name) + setQuickDevices(quickDevices) + } + } + + /** + * 清空所有快速设备 + */ + suspend fun clearQuickDevices() { + setQuickDevicesRaw("") + } + + private suspend fun getQuickConnectInput(): String = getValue(QUICK_CONNECT_INPUT) + private suspend fun setQuickConnectInput(value: String) = setValue(QUICK_CONNECT_INPUT, value) + private fun observeQuickConnectInput(): Flow = observe(QUICK_CONNECT_INPUT) +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt new file mode 100644 index 0000000..076b5e2 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt @@ -0,0 +1,402 @@ +package io.github.miuzarte.scrcpyforandroid.storage + +import android.content.Context +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.runBlocking + +class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { + companion object { + private val CROP = Pair( + stringPreferencesKey("crop"), + "" + ) + private val RECORD_FILENAME = Pair( + stringPreferencesKey("record_filename"), + "" + ) + private val VIDEO_CODEC_OPTIONS = Pair( + stringPreferencesKey("video_codec_options"), + "" + ) + private val AUDIO_CODEC_OPTIONS = Pair( + stringPreferencesKey("audio_codec_options"), + "" + ) + private val VIDEO_ENCODER = Pair( + stringPreferencesKey("video_encoder"), + "" + ) + private val AUDIO_ENCODER = Pair( + stringPreferencesKey("audio_encoder"), + "" + ) + private val CAMERA_ID = Pair( + stringPreferencesKey("camera_id"), + "" + ) + private val CAMERA_SIZE = Pair( + stringPreferencesKey("camera_size"), + "" + ) + private val CAMERA_AR = Pair( + stringPreferencesKey("camera_ar"), + "" + ) + private val CAMERA_FPS = Pair( + intPreferencesKey("camera_fps"), + 0 + ) + private val LOG_LEVEL = Pair( + stringPreferencesKey("log_level"), + "info" + ) + private val VIDEO_CODEC = Pair( + stringPreferencesKey("video_codec"), + "h264" + ) + private val AUDIO_CODEC = Pair( + stringPreferencesKey("audio_codec"), + "opus" + ) + private val VIDEO_SOURCE = Pair( + stringPreferencesKey("video_source"), + "display" + ) + private val AUDIO_SOURCE = Pair( + stringPreferencesKey("audio_source"), + "output" + ) + private val RECORD_FORMAT = Pair( + stringPreferencesKey("record_format"), + "auto" + ) + private val CAMERA_FACING = Pair( + stringPreferencesKey("camera_facing"), + "any" + ) + private val MAX_SIZE = Pair( + intPreferencesKey("max_size"), + 0 + ) + private val VIDEO_BIT_RATE = Pair( + intPreferencesKey("video_bit_rate"), + 8000000 + ) + private val AUDIO_BIT_RATE = Pair( + intPreferencesKey("audio_bit_rate"), + 128000 + ) + private val MAX_FPS = Pair( + stringPreferencesKey("max_fps"), + "" + ) + private val ANGLE = Pair( + stringPreferencesKey("angle"), + "" + ) + private val CAPTURE_ORIENTATION = Pair( + intPreferencesKey("capture_orientation"), + 0 + ) + private val CAPTURE_ORIENTATION_LOCK = Pair( + stringPreferencesKey("capture_orientation_lock"), + "unlocked" + ) + private val DISPLAY_ORIENTATION = Pair( + intPreferencesKey("display_orientation"), + 0 + ) + private val RECORD_ORIENTATION = Pair( + intPreferencesKey("record_orientation"), + 0 + ) + private val DISPLAY_IME_POLICY = Pair( + stringPreferencesKey("display_ime_policy"), + "undefined" + ) + private val DISPLAY_ID = Pair( + intPreferencesKey("display_id"), + 0 + ) + private val SCREEN_OFF_TIMEOUT = Pair( + longPreferencesKey("screen_off_timeout"), + -1 + ) + private val SHOW_TOUCHES = Pair( + booleanPreferencesKey("show_touches"), + false + ) + private val FULLSCREEN = Pair( + booleanPreferencesKey("fullscreen"), + false + ) + private val CONTROL = Pair( + booleanPreferencesKey("control"), + true + ) + private val VIDEO_PLAYBACK = Pair( + booleanPreferencesKey("video_playback"), + true + ) + private val AUDIO_PLAYBACK = Pair( + booleanPreferencesKey("audio_playback"), + true + ) + private val TURN_SCREEN_OFF = Pair( + booleanPreferencesKey("turn_screen_off"), + false + ) + private val STAY_AWAKE = Pair( + booleanPreferencesKey("stay_awake"), + false + ) + private val DISABLE_SCREENSAVER = Pair( + booleanPreferencesKey("disable_screensaver"), + false + ) + private val POWER_OFF_ON_CLOSE = Pair( + booleanPreferencesKey("power_off_on_close"), + false + ) + private val CLEANUP = Pair( + booleanPreferencesKey("cleanup"), + true + ) + private val POWER_ON = Pair( + booleanPreferencesKey("power_on"), + true + ) + private val VIDEO = Pair( + booleanPreferencesKey("video"), + true + ) + private val AUDIO = Pair( + booleanPreferencesKey("audio"), + true + ) + private val REQUIRE_AUDIO = Pair( + booleanPreferencesKey("require_audio"), + false + ) + private val KILL_ADB_ON_CLOSE = Pair( + booleanPreferencesKey("kill_adb_on_close"), + false + ) + private val CAMERA_HIGH_SPEED = Pair( + booleanPreferencesKey("camera_high_speed"), + false + ) + private val LIST = Pair( + stringPreferencesKey("list"), + "null" + ) + private val AUDIO_DUP = Pair( + booleanPreferencesKey("audio_dup"), + false + ) + private val NEW_DISPLAY = Pair( + stringPreferencesKey("new_display"), + "" + ) + private val START_APP = Pair( + stringPreferencesKey("start_app"), + "" + ) + private val VD_DESTROY_CONTENT = Pair( + booleanPreferencesKey("vd_destroy_content"), + true + ) + private 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 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) + + override suspend fun toMap(): Map { + return mapOf( + CROP.name to crop.get(), + RECORD_FILENAME.name to recordFilename.get(), + VIDEO_CODEC_OPTIONS.name to videoCodecOptions.get(), + AUDIO_CODEC_OPTIONS.name to audioCodecOptions.get(), + VIDEO_ENCODER.name to videoEncoder.get(), + AUDIO_ENCODER.name to audioEncoder.get(), + CAMERA_ID.name to cameraId.get(), + CAMERA_SIZE.name to cameraSize.get(), + CAMERA_AR.name to cameraAr.get(), + CAMERA_FPS.name to cameraFps.get(), + LOG_LEVEL.name to logLevel.get(), + VIDEO_CODEC.name to videoCodec.get(), + AUDIO_CODEC.name to audioCodec.get(), + VIDEO_SOURCE.name to videoSource.get(), + AUDIO_SOURCE.name to audioSource.get(), + RECORD_FORMAT.name to recordFormat.get(), + CAMERA_FACING.name to cameraFacing.get(), + MAX_SIZE.name to maxSize.get(), + VIDEO_BIT_RATE.name to videoBitRate.get(), + AUDIO_BIT_RATE.name to audioBitRate.get(), + MAX_FPS.name to maxFps.get(), + ANGLE.name to angle.get(), + CAPTURE_ORIENTATION.name to captureOrientation.get(), + CAPTURE_ORIENTATION_LOCK.name to captureOrientationLock.get(), + DISPLAY_ORIENTATION.name to displayOrientation.get(), + RECORD_ORIENTATION.name to recordOrientation.get(), + DISPLAY_IME_POLICY.name to displayImePolicy.get(), + DISPLAY_ID.name to displayId.get(), + SCREEN_OFF_TIMEOUT.name to screenOffTimeout.get(), + SHOW_TOUCHES.name to showTouches.get(), + FULLSCREEN.name to fullscreen.get(), + CONTROL.name to control.get(), + VIDEO_PLAYBACK.name to videoPlayback.get(), + AUDIO_PLAYBACK.name to audioPlayback.get(), + TURN_SCREEN_OFF.name to turnScreenOff.get(), + STAY_AWAKE.name to stayAwake.get(), + DISABLE_SCREENSAVER.name to disableScreensaver.get(), + POWER_OFF_ON_CLOSE.name to powerOffOnClose.get(), + CLEANUP.name to cleanup.get(), + POWER_ON.name to powerOn.get(), + VIDEO.name to video.get(), + AUDIO.name to audio.get(), + REQUIRE_AUDIO.name to requireAudio.get(), + KILL_ADB_ON_CLOSE.name to killAdbOnClose.get(), + CAMERA_HIGH_SPEED.name to cameraHighSpeed.get(), + LIST.name to list.get(), + AUDIO_DUP.name to audioDup.get(), + NEW_DISPLAY.name to newDisplay.get(), + START_APP.name to startApp.get(), + VD_DESTROY_CONTENT.name to vdDestroyContent.get(), + VD_SYSTEM_DECORATIONS.name to vdSystemDecorations.get() + ) + } + + override fun validate(): Boolean = runBlocking { + runCatching { + toClientOptions().validate() + true + }.getOrDefault(false) + } + + // TODO: 处理空值 + suspend fun toClientOptions(): ClientOptions { + return ClientOptions( + crop = crop.get(), + recordFilename = recordFilename.get(), + videoCodecOptions = videoCodecOptions.get(), + audioCodecOptions = audioCodecOptions.get(), + videoEncoder = videoEncoder.get(), + audioEncoder = audioEncoder.get(), + cameraId = cameraId.get(), + cameraSize = cameraSize.get(), + cameraAr = cameraAr.get(), + cameraFps = cameraFps.get().toUShort(), + logLevel = LogLevel.valueOf(logLevel.get().uppercase()), + videoCodec = Codec.valueOf(videoCodec.get().uppercase()), + audioCodec = Codec.valueOf(audioCodec.get().uppercase()), + videoSource = VideoSource.valueOf(videoSource.get().uppercase()), + audioSource = AudioSource.valueOf(audioSource.get().uppercase()), + recordFormat = ClientOptions.RecordFormat.valueOf(recordFormat.get().uppercase()), + cameraFacing = CameraFacing.valueOf(cameraFacing.get().uppercase()), + maxSize = maxSize.get().toUShort(), + videoBitRate = videoBitRate.get().toUInt(), + audioBitRate = audioBitRate.get().toUInt(), + maxFps = maxFps.get(), + angle = angle.get(), + captureOrientation = Orientation.fromInt(captureOrientation.get()), + captureOrientationLock = OrientationLock.valueOf( + captureOrientationLock.get().uppercase() + ), + displayOrientation = Orientation.fromInt(displayOrientation.get()), + recordOrientation = Orientation.fromInt(recordOrientation.get()), + displayImePolicy = DisplayImePolicy.valueOf(displayImePolicy.get().uppercase()), + displayId = displayId.get().toUInt(), + screenOffTimeout = Tick(screenOffTimeout.get()), + showTouches = showTouches.get(), + fullscreen = fullscreen.get(), + control = control.get(), + videoPlayback = videoPlayback.get(), + audioPlayback = audioPlayback.get(), + turnScreenOff = turnScreenOff.get(), + stayAwake = stayAwake.get(), + disableScreensaver = disableScreensaver.get(), + powerOffOnClose = powerOffOnClose.get(), + cleanUp = cleanup.get(), + powerOn = powerOn.get(), + video = video.get(), + audio = audio.get(), + requireAudio = requireAudio.get(), + killAdbOnClose = killAdbOnClose.get(), + cameraHighSpeed = cameraHighSpeed.get(), + list = ListOptions.valueOf(list.get().uppercase()), + audioDup = audioDup.get(), + newDisplay = newDisplay.get(), + startApp = startApp.get(), + vdDestroyContent = vdDestroyContent.get(), + vdSystemDecorations = vdSystemDecorations.get() + ) + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt new file mode 100644 index 0000000..ba530cf --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt @@ -0,0 +1,115 @@ +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.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.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlin.reflect.KProperty + +private const val TAG = "Settings" + +abstract class Settings( + context: Context, + private val name: String, + corruptionHandler: ReplaceFileCorruptionHandler? = + ReplaceFileCorruptionHandler { + Log.e(TAG, "Preferences corrupted, resetting.", it) + emptyPreferences() + } +) { + data class Pair( + val key: Preferences.Key, + val defaultValue: T, + ) { + val name: String get() = key.name + } + + /** + * 设置项委托类,自动提供 get/set/observe/observeAsState/asMutableState 方法 + */ + inner class SettingProperty( + private val pair: Pair + ) { + operator fun getValue(thisRef: Any?, property: KProperty<*>): SettingProperty = this + + suspend fun get(): T = getValue(pair) + + suspend fun set(value: T) = setValue(pair, value) + + fun observe(): Flow = this@Settings.observe(pair) + + @Composable + fun asState(): State = this@Settings.asState(pair) + + @Composable + fun asMutableState(): MutableState = this@Settings.asMutableState(pair) + } + + // 为 Context 添加扩展委托属性,确保 DataStore 单例 + private val Context.dataStore: DataStore by preferencesDataStore( + name = this.name, + corruptionHandler = corruptionHandler, + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + ) + + abstract suspend fun toMap(): Map + abstract fun validate(): Boolean + + // 对外暴露的 DataStore 实例 + protected val dataStore: DataStore = context.dataStore + + protected fun setting(pair: Pair): SettingProperty = + SettingProperty(pair) + + protected suspend fun getValue(pair: Pair): T = + dataStore.data.first()[pair.key] ?: pair.defaultValue + + protected suspend fun setValue(pair: Pair, value: T) { + dataStore.edit { preferences -> preferences[pair.key] = value } + } + + protected fun observe(pair: Pair): Flow = + dataStore.data.map { preferences -> preferences[pair.key] ?: pair.defaultValue } + + @Composable + protected fun asState(pair: Pair): State = + observe(pair).collectAsState(initial = pair.defaultValue) + + @Composable + protected fun asMutableState(pair: Pair): MutableState { + val scope = rememberCoroutineScope() + val state = asState(pair) + + return remember(state.value) { + object : MutableState { + 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 } + } + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/StorageMigration.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/StorageMigration.kt new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt index 3996703..72036ed 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt @@ -53,6 +53,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInteropFilter import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -70,6 +71,8 @@ import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide +import io.github.miuzarte.scrcpyforandroid.storage.AppSettings +import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -92,12 +95,16 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme.isDynamicColor import top.yukonga.miuix.kmp.utils.PressFeedbackType import kotlin.math.roundToInt +// TODO: migrate to scrcpy.Shared + +@Deprecated("scrcpy.Shared.Codec") private val VIDEO_CODEC_OPTIONS = listOf( "h264" to "H.264", "h265" to "H.265", "av1" to "AV1", ) +@Deprecated("scrcpy.Shared.Codec") private val AUDIO_CODEC_OPTIONS = listOf( "opus" to "Opus", "aac" to "AAC", @@ -124,8 +131,14 @@ internal fun StatusCard( sessionInfo: ScrcpySessionInfo?, busyLabel: String?, connectedDeviceLabel: String, - themeBaseIndex: Int, ) { + val context = LocalContext.current + + val appSettings = remember(context) { AppSettings(context) } + val scrcpyOptions = remember(context) { ScrcpyOptions(context) } + + val themeBaseIndex by appSettings.themeBaseIndex.asState() + val cleanStatusLine = normalizeStatusLine(statusLine) // 根据应用主题设置决定是否使用深色模式 @@ -261,9 +274,9 @@ internal fun PreviewCard( previewHeightDp: Int, controlsVisible: Boolean, onTapped: () -> Unit, - onOpenFullscreenHaptic: (() -> Unit)? = null, onOpenFullscreen: () -> Unit, ) { + val haptics = rememberAppHaptics() val alpha by animateFloatAsState(if (controlsVisible) 1f else 0f, label = "preview-controls") Card { @@ -312,7 +325,7 @@ internal fun PreviewCard( Button( onClick = { if (alpha > 0.1) { - onOpenFullscreenHaptic?.invoke() + haptics.contextClick() onOpenFullscreen() } }, @@ -361,94 +374,99 @@ internal fun VirtualButtonCard( @Composable internal fun ConfigPanel( busy: Boolean, - bitRateMbps: Float, - onBitRateSliderChange: (Float) -> Unit, - onBitRateInputChange: (String) -> Unit, - audioBitRateKbps: Int, - onAudioBitRateChange: (Int) -> Unit, - videoCodec: String, - onVideoCodecChange: (String) -> Unit, - audioEnabled: Boolean, - onAudioEnabledChange: (Boolean) -> Unit, audioForwardingSupported: Boolean, - audioCodec: String, - onAudioCodecChange: (String) -> Unit, onOpenAdvanced: () -> Unit, onStartStopHaptic: (() -> Unit)? = null, onStart: () -> Unit, onStop: () -> Unit, sessionStarted: Boolean, ) { - val videoCodecItems = remember { VIDEO_CODEC_OPTIONS.map { it.second } } - val videoCodecIndex = - VIDEO_CODEC_OPTIONS.indexOfFirst { it.first == videoCodec }.coerceAtLeast(0) - val audioCodecItems = remember { AUDIO_CODEC_OPTIONS.map { it.second } } - val audioCodecIndex = - AUDIO_CODEC_OPTIONS.indexOfFirst { it.first == audioCodec }.coerceAtLeast(0) - val audioBitRatePresetIndex = - presetIndexFromInput(audioBitRateKbps.toString(), ScrcpyPresets.AudioBitRate) + val context = LocalContext.current + + // val appSettings = remember(context) { AppSettings(context) } + val scrcpyOptions = remember(context) { ScrcpyOptions(context) } SectionSmallTitle("Scrcpy") Card { + var audio by scrcpyOptions.audio.asMutableState() SuperSwitch( title = "音频转发", summary = "转发设备音频到本机 (Android 11+)", - checked = audioEnabled, - onCheckedChange = onAudioEnabledChange, + checked = audio, + onCheckedChange = { audio = it }, enabled = !sessionStarted && audioForwardingSupported, ) + + var audioCodec by scrcpyOptions.audioCodec.asMutableState() + val audioCodecItems = remember { AUDIO_CODEC_OPTIONS.map { it.second } } + val audioCodecIndex = AUDIO_CODEC_OPTIONS.indexOfFirst { + it.first == audioCodec + }.coerceAtLeast(0) + var audioBitRate by scrcpyOptions.audioBitRate.asMutableState() + val audioBitRateKbps = audioBitRate * 1_000f + val audioBitRatePresetIndex = presetIndexFromInput( + audioBitRateKbps.toString(), + ScrcpyPresets.AudioBitRate + ) SuperDropdown( title = "音频编码", summary = "--audio-codec", items = audioCodecItems, selectedIndex = audioCodecIndex, - onSelectedIndexChange = { onAudioCodecChange(AUDIO_CODEC_OPTIONS[it].first) }, - enabled = !sessionStarted && audioEnabled, + onSelectedIndexChange = { audioCodec = AUDIO_CODEC_OPTIONS[it].first }, + enabled = !sessionStarted && audio, ) - if (audioEnabled && (audioCodec == "opus" || audioCodec == "aac")) { + if (audio && (audioCodec == "opus" || audioCodec == "aac")) { SuperSlide( title = "音频码率", summary = "--audio-bit-rate", value = audioBitRatePresetIndex.toFloat(), onValueChange = { value -> val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex) - onAudioBitRateChange(ScrcpyPresets.AudioBitRate[idx]) + audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1024 }, valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(), steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0), enabled = !sessionStarted, unit = "Kbps", - displayText = audioBitRateKbps.toString(), - inputInitialValue = audioBitRateKbps.toString(), + displayText = audioBitRate.toString(), + inputInitialValue = audioBitRate.toString(), inputFilter = { it.filter(Char::isDigit) }, inputValueRange = 1f..Float.MAX_VALUE, onInputConfirm = { raw -> - raw.toIntOrNull()?.takeIf { it > 0 }?.let { onAudioBitRateChange(it) } + raw.toIntOrNull()?.takeIf { it > 0 }?.let { audioBitRate = it } }, ) } + + var videoCodec by scrcpyOptions.videoCodec.asMutableState() + val videoCodecItems = remember { VIDEO_CODEC_OPTIONS.map { it.second } } + val videoCodecIndex = VIDEO_CODEC_OPTIONS.indexOfFirst { + it.first == videoCodec + }.coerceAtLeast(0) + var videoBitRate by scrcpyOptions.videoBitRate.asMutableState() + val videoBitRateMbps = videoBitRate / 1_000_000f SuperDropdown( title = "视频编码", summary = "--video-codec", items = videoCodecItems, selectedIndex = videoCodecIndex, - onSelectedIndexChange = { onVideoCodecChange(VIDEO_CODEC_OPTIONS[it].first) }, + onSelectedIndexChange = { videoCodec = VIDEO_CODEC_OPTIONS[it].first }, enabled = !sessionStarted, ) SuperSlide( title = "视频码率", summary = "--video-bit-rate", - value = bitRateMbps, - onValueChange = { - onBitRateSliderChange(it) - onBitRateInputChange(formatBitRate(it)) + value = videoBitRateMbps, + onValueChange = { mbps -> + videoBitRate = (mbps * 1_000_000).toInt() }, valueRange = 0.1f..40f, steps = 399, enabled = !sessionStarted, unit = "Mbps", displayFormatter = { formatBitRate(it) }, - inputInitialValue = formatBitRate(bitRateMbps), + inputInitialValue = formatBitRate(videoBitRateMbps), inputFilter = { text -> var dotUsed = false text.filter { ch -> @@ -467,18 +485,19 @@ internal fun ConfigPanel( onInputConfirm = { raw -> raw.toFloatOrNull()?.let { parsed -> if (parsed >= 0.1f) { - onBitRateSliderChange(parsed) - onBitRateInputChange(formatBitRate(parsed)) + videoBitRate = (parsed * 1_000_000).toInt() } } }, ) + SuperArrow( title = "高级参数", summary = "更多 scrcpy 启动参数", onClick = onOpenAdvanced, enabled = !sessionStarted, ) + TextButton( text = if (sessionStarted) "停止" else "启动", onClick = { @@ -1353,7 +1372,6 @@ internal fun DeviceEditorScreen( if (h.isNotBlank()) { onSave( DeviceShortcut( - id = "$h:$p", name = name.trim(), host = h, port = p, From 82d06ceeae41af063f5995f7105671002ecd0df6 Mon Sep 17 00:00:00 2001 From: Miuzarte <982809597@qq.com> Date: Sat, 28 Mar 2026 00:22:01 +0800 Subject: [PATCH 3/8] refactor: integrate data store; impl migration --- MAINPAGE_REFACTORING_ANALYSIS.md | 0 .../constants/AppPreferenceKeys.kt | 2 - .../scrcpyforandroid/constants/Defaults.kt | 6 + .../scrcpyforandroid/models/DeviceModels.kt | 133 ++++- .../nativecore/DirectAdbClient.kt | 18 +- .../pages/AdvancedConfigPage.kt | 263 +++++----- .../scrcpyforandroid/pages/DevicePage.kt | 267 ++++------ .../pages/FullscreenControlPage.kt | 27 +- .../scrcpyforandroid/pages/MainPage.kt | 398 +++------------ .../scrcpyforandroid/pages/SettingsPage.kt | 5 +- .../pages/VirtualButtonOrderPage.kt | 28 +- .../scrcpyforandroid/services/EventLogger.kt | 20 +- .../services/QuickDeviceStore.kt | 12 +- .../scrcpyforandroid/storage/AppSettings.kt | 69 +-- .../storage/AppSettings_New.kt | 128 ----- .../storage/PreferenceMigration.kt | 464 ++++++++++++++++++ .../scrcpyforandroid/storage/QuickDevices.kt | 37 +- .../scrcpyforandroid/storage/ScrcpyOptions.kt | 322 ++++++------ .../scrcpyforandroid/widgets/DeviceWidgets.kt | 4 +- .../widgets/VirtualButtons.kt | 4 +- 20 files changed, 1198 insertions(+), 1009 deletions(-) create mode 100644 MAINPAGE_REFACTORING_ANALYSIS.md create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/Defaults.kt delete mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings_New.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/PreferenceMigration.kt diff --git a/MAINPAGE_REFACTORING_ANALYSIS.md b/MAINPAGE_REFACTORING_ANALYSIS.md new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppPreferenceKeys.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppPreferenceKeys.kt index 5272f06..198bd21 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppPreferenceKeys.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppPreferenceKeys.kt @@ -2,8 +2,6 @@ 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" diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/Defaults.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/Defaults.kt new file mode 100644 index 0000000..eecfdb6 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/Defaults.kt @@ -0,0 +1,6 @@ +package io.github.miuzarte.scrcpyforandroid.constants + +object Defaults { + const val EVENT_LOG_LINES = 512 + const val ADB_PORT = 5555; +} \ No newline at end of file diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt index 38253ef..5b390bd 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt @@ -1,17 +1,129 @@ package io.github.miuzarte.scrcpyforandroid.models -import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults +import io.github.miuzarte.scrcpyforandroid.constants.Defaults + +// Composable 用, 不可变 List +class DeviceShortcuts(val devices: List) : List by devices { + fun marshalToString( + separator: String = DEFAULT_SEPARATOR, + ): String = joinToString(separator) { it.marshalToString() } + + 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, + online: Boolean? = 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 finalName = when { + name == null -> old.name + updateNameOnlyWhenEmpty && old.name.isNotBlank() -> old.name + else -> name + } + val finalPort = newPort ?: old.port + val finalOnline = online ?: old.online + + // 若无任何变化,返回原实例 + if (finalName == old.name && finalPort == old.port && finalOnline == old.online) + return this + + val newList = devices.toMutableList().apply { + this[idx] = DeviceShortcut( + name = finalName, + host = old.host, + port = finalPort, + online = finalOnline + ) + } + return DeviceShortcuts( + if (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 = this.devices): DeviceShortcuts = + DeviceShortcuts(devices) +} data class DeviceShortcut( val name: String = "", val host: String, - val port: Int = AppDefaults.ADB_PORT, + val port: Int = Defaults.ADB_PORT, val online: Boolean = false, ) { val id: String get() = "$host:$port" fun marshalToString( - separator: String = "|", + separator: String = DEFAULT_SEPARATOR, ): String = listOf( name.trim(), host.trim(), port.toString() ).joinToString( @@ -19,16 +131,17 @@ data class DeviceShortcut( ) companion object { + const val DEFAULT_SEPARATOR = "|" fun unmarshalFrom( s: String, - delimiter: String = "|", + separator: String = DEFAULT_SEPARATOR, ): DeviceShortcut? { - val parts = s.split(delimiter, limit = 3) + 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() ?: AppDefaults.ADB_PORT + val port = parts[2].trim().toIntOrNull() ?: Defaults.ADB_PORT if (host.isNotBlank()) DeviceShortcut( name = name, host = host, @@ -45,7 +158,7 @@ data class DeviceShortcut( data class ConnectionTarget( val host: String, - val port: Int = AppDefaults.ADB_PORT, + val port: Int = Defaults.ADB_PORT, ) { fun marshalToString(): String = "$host:$port" @@ -55,17 +168,17 @@ data class ConnectionTarget( return when (parts.size) { 2 -> ConnectionTarget( host = parts[0].trim(), - port = parts[1].trim().toIntOrNull() ?: AppDefaults.ADB_PORT, + port = parts[1].trim().toIntOrNull() ?: Defaults.ADB_PORT, ) 1 -> ConnectionTarget( host = parts[0].trim(), - port = AppDefaults.ADB_PORT, + port = Defaults.ADB_PORT, ) 0 -> ConnectionTarget( host = s.trim(), - port = AppDefaults.ADB_PORT, + port = Defaults.ADB_PORT, ) else -> null diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt index e19972c..16f50c3 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt @@ -5,8 +5,7 @@ import android.os.Build import android.util.Base64 import android.util.Log import androidx.core.content.edit -import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults -import io.github.miuzarte.scrcpyforandroid.constants.AppPreferenceKeys +import io.github.miuzarte.scrcpyforandroid.storage.AppSettings import java.io.BufferedInputStream import java.io.Closeable import java.io.EOFException @@ -51,7 +50,7 @@ internal class DirectAdbTransport(private val context: Context) { val publicKeyX509: ByteArray get() = keys.second @Volatile - var keyName: String = AppDefaults.ADB_KEY_NAME + var keyName: String = AppSettings.ADB_KEY_NAME.defaultValue fun connect(host: String, port: Int): DirectAdbConnection { Log.i(TAG, "connect(): opening direct adbd transport to $host:$port") @@ -60,7 +59,7 @@ internal class DirectAdbTransport(private val context: Context) { port, privateKey, publicKeyX509, - keyName.ifBlank { AppDefaults.ADB_KEY_NAME }) + keyName.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue }) conn.handshake() Log.i(TAG, "connect(): handshake success for $host:$port") return conn @@ -78,7 +77,7 @@ internal class DirectAdbTransport(private val context: Context) { val pairingKey = AdbPairingKey( privateKey = privateKey, - alias = keyName.ifBlank { AppDefaults.ADB_KEY_NAME }, + alias = keyName.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue }, ) return DirectAdbPairingClient(targetHost, port, targetCode, pairingKey).use { it.start() @@ -106,11 +105,12 @@ internal class DirectAdbTransport(private val context: Context) { * Returns (privateKey, publicX509Bytes). */ private fun loadOrCreate(): Pair { + // TODO: migrate to data store val prefs = context.getSharedPreferences( - AppPreferenceKeys.NATIVE_ADB_KEY_PREFS_NAME, + "nativecore_adb_rsa", Context.MODE_PRIVATE ) - val privB64 = prefs.getString(AppPreferenceKeys.NATIVE_ADB_PRIVATE_KEY, null) + val privB64 = prefs.getString("priv", null) if (privB64 != null) { try { val kf = KeyFactory.getInstance("RSA") @@ -128,7 +128,7 @@ internal class DirectAdbTransport(private val context: Context) { val kp = kpg.generateKeyPair() prefs.edit { putString( - AppPreferenceKeys.NATIVE_ADB_PRIVATE_KEY, + "priv", Base64.encodeToString(kp.private.encoded, Base64.NO_WRAP) ) } @@ -203,7 +203,7 @@ internal class DirectAdbConnection( val port: Int, private val privateKey: PrivateKey, private val publicKeyX509: ByteArray, - private val keyName: String = AppDefaults.ADB_KEY_NAME, + private val keyName: String = AppSettings.ADB_KEY_NAME.defaultValue, ) : AutoCloseable { private val sha1DigestInfoPrefix = byteArrayOf( diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt index d2a3e1a..9bfcd92 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt @@ -12,9 +12,11 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.platform.LocalContext @@ -56,6 +58,7 @@ private val AUDIO_SOURCE_OPTIONS = listOf( "custom" to "自定义", ) +// TODO: Scrcpy.VideoSource private val VIDEO_SOURCE_OPTIONS = listOf( "display" to "display", "camera" to "camera", @@ -75,73 +78,16 @@ 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, + cameraSizeOptions: SnapshotStateList, cameraSizeDropdownItems: List, - cameraSizeIndex: Int, - cameraArInput: String, - onCameraArInputChange: (String) -> Unit, - cameraFpsInput: String, - onCameraFpsInputChange: (String) -> Unit, - cameraHighSpeed: Boolean, - onCameraHighSpeedChange: (Boolean) -> Unit, - noAudioPlayback: Boolean, - onNoAudioPlaybackChange: (Boolean) -> Unit, - noVideo: Boolean, - onNoVideoChange: (Boolean) -> Unit, - requireAudio: Boolean, - onRequireAudioChange: (Boolean) -> Unit, - turnScreenOff: Boolean, - onTurnScreenOffChange: (Boolean) -> Unit, - maxSizeInput: String, - onMaxSizeInputChange: (String) -> Unit, - maxFpsInput: String, - onMaxFpsInputChange: (String) -> Unit, - videoEncoderDropdownItems: List, videoEncoderTypeMap: Map, videoEncoderIndex: Int, - onVideoEncoderChange: (String) -> Unit, - videoCodecOptions: String, - onVideoCodecOptionsChange: (String) -> Unit, audioEncoderDropdownItems: List, audioEncoderTypeMap: Map, audioEncoderIndex: Int, - onAudioEncoderChange: (String) -> Unit, - audioCodecOptions: String, - onAudioCodecOptionsChange: (String) -> Unit, - onRefreshEncoders: () -> Unit, onRefreshCameraSizes: () -> Unit, - - newDisplayWidth: String, - newDisplayHeight: String, - newDisplayDpi: String, - displayIdInput: String, - cropWidth: String, - cropHeight: String, - cropX: String, - cropY: String, ) { val context = LocalContext.current @@ -152,20 +98,6 @@ internal fun AdvancedConfigPage( 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) @@ -189,10 +121,108 @@ internal fun AdvancedConfigPage( } } + // TODO: handle custom value + // TODO: handle empty input var turnScreenOff by scrcpyOptions.turnScreenOff.asMutableState() var control by scrcpyOptions.control.asMutableState() var video by scrcpyOptions.video.asMutableState() + var videoSource by scrcpyOptions.videoSource.asMutableState() + val videoSourceItems = VIDEO_SOURCE_OPTIONS.map { it.second } + val videoSourceIndex = VIDEO_SOURCE_OPTIONS.indexOfFirst { + it.first == videoSource + }.let { if (it >= 0) it else 0 } + var displayId by scrcpyOptions.displayId.asMutableState() + + var cameraId by scrcpyOptions.cameraId.asMutableState() + var cameraFacing by scrcpyOptions.cameraFacing.asMutableState() + val cameraFacingItems = CAMERA_FACING_OPTIONS.map { it.second } + val cameraFacingIndex = CAMERA_FACING_OPTIONS.indexOfFirst { + it.first == cameraFacing + }.let { if (it >= 0) it else 0 } + var cameraSize by scrcpyOptions.cameraSize.asMutableState() + val cameraSizeIndex = when (cameraSize) { + "custom" -> cameraSizeOptions.size + 1 + in cameraSizeOptions -> cameraSizeOptions.indexOf(cameraSize) + 1 + else -> 0 + } + var cameraAr by scrcpyOptions.cameraAr.asMutableState() + var cameraFps by scrcpyOptions.cameraFps.asMutableState() + val cameraFpsPresetIndex = presetIndexFromInputForAdvancedPage( + cameraFps, CAMERA_FPS_PRESETS + ) + var cameraHighSpeed by scrcpyOptions.cameraHighSpeed.asMutableState() + + var audioSource by scrcpyOptions.audioSource.asMutableState() + val audioSourceItems = AUDIO_SOURCE_OPTIONS.map { it.second } + val audioSourceIndex = AUDIO_SOURCE_OPTIONS.indexOfFirst { + it.first == audioSource + }.let { if (it >= 0) it else 0 } + var audioDup by scrcpyOptions.audioDup.asMutableState() + var audioPlayback by scrcpyOptions.audioPlayback.asMutableState() + var requireAudio by scrcpyOptions.requireAudio.asMutableState() + + var maxSize by scrcpyOptions.maxSize.asMutableState() + val maxSizePresetIndex = presetIndexFromInputForAdvancedPage( + maxSize.toString(), ScrcpyPresets.MaxSize + ) + var maxFps by scrcpyOptions.maxFps.asMutableState() + val maxFpsPresetIndex = presetIndexFromInputForAdvancedPage( + maxFps, ScrcpyPresets.MaxFPS + ) + + var videoEncoder by scrcpyOptions.videoEncoder.asMutableState() + var videoCodecOptions by scrcpyOptions.videoCodecOptions.asMutableState() + var audioEncoder by scrcpyOptions.audioEncoder.asMutableState() + var audioCodecOptions by scrcpyOptions.audioCodecOptions.asMutableState() + + var newDisplayWidth by remember { + mutableStateOf("") + } + var newDisplayHeight by remember { + mutableStateOf("") + } + var newDisplayDpi by remember { + mutableStateOf("") + } + // [x][/] + // TODO: 填充当前值到输入框 + var newDisplay by scrcpyOptions.newDisplay.asMutableState() + fun updateNewDisplay() { + var nd = "" + if (newDisplayWidth.isNotBlank() && newDisplayHeight.isNotBlank()) { + nd += "${newDisplayWidth}x${newDisplayHeight}" + } + if (newDisplayDpi.isNotBlank()) { + nd += "/$newDisplayDpi" + } + newDisplay = nd + } + + var cropWidth by remember { + mutableStateOf("") + } + var cropHeight by remember { + mutableStateOf("") + } + var cropX by remember { + mutableStateOf("") + } + var cropY by remember { + mutableStateOf("") + } + // width:height:x:y + // TODO: 填充当前值到输入框 + var crop by scrcpyOptions.crop.asMutableState() + fun updateCrop(): Unit { + if (cropWidth.isNotBlank() + && cropHeight.isNotBlank() + && cropX.isNotBlank() + && cropY.isNotBlank() + ) crop = "$cropWidth:$cropHeight:$cropX:$cropY" + } + + // 高级参数 AppPageLazyColumn( contentPadding = contentPadding, @@ -237,13 +267,13 @@ internal fun AdvancedConfigPage( items = videoSourceItems, selectedIndex = videoSourceIndex, onSelectedIndexChange = { - videoSourcePreset = VIDEO_SOURCE_OPTIONS[it].first + videoSource = VIDEO_SOURCE_OPTIONS[it].first }, ) - if (videoSourcePreset == "display") { + if (videoSource == "display") { TextField( - value = displayIdInput, - onValueChange = { displayIdInput = it }, + value = displayId.toString(), + onValueChange = { displayId = it.toInt() }, label = "--display-id", singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), @@ -253,10 +283,10 @@ internal fun AdvancedConfigPage( .padding(bottom = UiSpacing.CardContent), ) } - if (videoSourcePreset == "camera") { + if (videoSource == "camera") { TextField( - value = cameraIdInput, - onValueChange = { cameraIdInput = it }, + value = cameraId, + onValueChange = { cameraId = it }, label = "--camera-id", singleLine = true, modifier = Modifier @@ -275,7 +305,7 @@ internal fun AdvancedConfigPage( items = cameraFacingItems, selectedIndex = cameraFacingIndex, onSelectedIndexChange = { - cameraFacingPreset = CAMERA_FACING_OPTIONS[it].first + cameraFacing = CAMERA_FACING_OPTIONS[it].first }, ) SuperDropdown( @@ -283,21 +313,20 @@ internal fun AdvancedConfigPage( summary = "--camera-size", items = cameraSizeDropdownItems, selectedIndex = cameraSizeIndex.coerceIn( - 0, - (cameraSizeDropdownItems.size - 1).coerceAtLeast(0) + 0, (cameraSizeDropdownItems.size - 1).coerceAtLeast(0) ), onSelectedIndexChange = { - cameraSizePreset = when (it) { + cameraSize = when (it) { 0 -> "" cameraSizeDropdownItems.lastIndex -> "custom" else -> cameraSizeDropdownItems[it] } }, ) - if (cameraSizePreset == "custom") { + if (cameraSize == "custom") { TextField( - value = cameraSizeCustom, - onValueChange = { cameraSizeCustom = it }, + value = cameraSize, + onValueChange = { cameraSize = it }, label = "--camera-size", singleLine = true, modifier = Modifier @@ -307,8 +336,8 @@ internal fun AdvancedConfigPage( ) } TextField( - value = cameraArInput, - onValueChange = { cameraArInput = it }, + value = cameraAr, + onValueChange = { cameraAr = it }, label = "--camera-ar", singleLine = true, modifier = Modifier @@ -322,8 +351,7 @@ internal fun AdvancedConfigPage( value = cameraFpsPresetIndex.toFloat(), onValueChange = { value -> val idx = value.roundToInt().coerceIn(0, CAMERA_FPS_PRESETS.lastIndex) - val preset = CAMERA_FPS_PRESETS[idx] - cameraFpsInput = if (preset == 0) "" else preset.toString() + cameraFps = CAMERA_FPS_PRESETS[idx] }, valueRange = 0f..CAMERA_FPS_PRESETS.lastIndex.toFloat(), steps = (CAMERA_FPS_PRESETS.size - 2).coerceAtLeast(0), @@ -332,14 +360,12 @@ internal fun AdvancedConfigPage( showUnitWhenZeroState = false, showKeyPoints = true, keyPoints = CAMERA_FPS_PRESETS.indices.map { it.toFloat() }, - displayText = cameraFpsInput, + displayText = cameraFps.toString(), inputHint = "0 或留空表示默认", - inputInitialValue = cameraFpsInput, + inputInitialValue = cameraFps.toString(), inputFilter = { it.filter(Char::isDigit) }, inputValueRange = 0f..Float.MAX_VALUE, - onInputConfirm = { - cameraFpsInput = if (normalized == "0") "" else normalized - }, + onInputConfirm = { cameraFps = it.toInt() }, ) SuperSwitch( title = "高帧率模式", @@ -358,12 +384,12 @@ internal fun AdvancedConfigPage( summary = "--audio-source", items = audioSourceItems, selectedIndex = audioSourceIndex, - onSelectedIndexChange = { audioSourcePreset = AUDIO_SOURCE_OPTIONS[it].first }, + onSelectedIndexChange = { audioSource = AUDIO_SOURCE_OPTIONS[it].first }, ) - if (audioSourcePreset == "custom") { + if (audioSource == "custom") { TextField( - value = audioSourceCustom, - onValueChange = { audioSourceCustom = it }, + value = audioSource, + onValueChange = { audioSource = it }, label = "--audio-source", singleLine = true, modifier = Modifier @@ -381,8 +407,8 @@ internal fun AdvancedConfigPage( SuperSwitch( title = "仅转发不播放", summary = "--no-audio-playback", - checked = noAudioPlayback, - onCheckedChange = { noAudioPlayback = it }, + checked = !audioPlayback, + onCheckedChange = { audioPlayback = !it }, ) SuperSwitch( title = "音频失败时终止 [TODO]", @@ -402,8 +428,7 @@ internal fun AdvancedConfigPage( value = maxSizePresetIndex.toFloat(), onValueChange = { value -> val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxSize.lastIndex) - val preset = ScrcpyPresets.MaxSize[idx] - maxSizeInput = if (preset == 0) "" else preset.toString() + maxSize = ScrcpyPresets.MaxSize[idx] }, valueRange = 0f..ScrcpyPresets.MaxSize.lastIndex.toFloat(), steps = (ScrcpyPresets.MaxSize.size - 2).coerceAtLeast(0), @@ -412,12 +437,12 @@ internal fun AdvancedConfigPage( showUnitWhenZeroState = false, showKeyPoints = true, keyPoints = ScrcpyPresets.MaxSize.indices.map { it.toFloat() }, - displayText = maxSizeInput, + displayText = maxSize.toString(), inputHint = "0 或留空表示关闭", - inputInitialValue = maxSizeInput, + inputInitialValue = maxSize.toString(), inputFilter = { it.filter(Char::isDigit) }, inputValueRange = 0f..Float.MAX_VALUE, - onInputConfirm = { maxSizeInput = it.ifBlank { "" } }, + onInputConfirm = { maxSize = it.toInt() }, ) SuperSlide( title = "最大帧率", @@ -425,8 +450,7 @@ internal fun AdvancedConfigPage( value = maxFpsPresetIndex.toFloat(), onValueChange = { value -> val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxFPS.lastIndex) - val preset = ScrcpyPresets.MaxFPS[idx] - maxFpsInput = if (preset == 0) "" else preset.toString() + maxFps = ScrcpyPresets.MaxFPS[idx].toString() }, valueRange = 0f..ScrcpyPresets.MaxFPS.lastIndex.toFloat(), steps = (ScrcpyPresets.MaxFPS.size - 2).coerceAtLeast(0), @@ -435,12 +459,12 @@ internal fun AdvancedConfigPage( showUnitWhenZeroState = false, showKeyPoints = true, keyPoints = ScrcpyPresets.MaxFPS.indices.map { it.toFloat() }, - displayText = maxFpsInput, + displayText = maxFps, inputHint = "0 或留空表示关闭", - inputInitialValue = maxFpsInput, + inputInitialValue = maxFps, inputFilter = { it.filter(Char::isDigit) }, inputValueRange = 0f..Float.MAX_VALUE, - onInputConfirm = { maxFpsInput = it.ifBlank { "" } }, + onInputConfirm = { maxFps = it }, ) } } @@ -457,7 +481,7 @@ internal fun AdvancedConfigPage( summary = "--video-encoder", items = videoEncoderEntries, selectedIndex = videoEncoderIndex, - onSelectedIndexChange = { videoEncoder = it }, + onSelectedIndexChange = { videoEncoder = videoEncoderEntries[it].title ?: "" }, ) TextField( value = videoCodecOptions, @@ -474,7 +498,7 @@ internal fun AdvancedConfigPage( summary = "--audio-encoder", items = audioEncoderEntries, selectedIndex = audioEncoderIndex, - onSelectedIndexChange = { audioEncoder = it }, + onSelectedIndexChange = { audioEncoder = audioEncoderEntries[it].title ?: "" }, ) TextField( value = audioCodecOptions, @@ -510,7 +534,7 @@ internal fun AdvancedConfigPage( ) { TextField( value = newDisplayWidth, - onValueChange = { newDisplayWidth = it }, + onValueChange = { newDisplayWidth = it; updateNewDisplay() }, label = "width", singleLine = true, keyboardOptions = KeyboardOptions( @@ -524,7 +548,7 @@ internal fun AdvancedConfigPage( ) TextField( value = newDisplayHeight, - onValueChange = { newDisplayHeight = it }, + onValueChange = { newDisplayHeight = it; updateNewDisplay() }, label = "height", singleLine = true, keyboardOptions = KeyboardOptions( @@ -538,7 +562,7 @@ internal fun AdvancedConfigPage( ) TextField( value = newDisplayDpi, - onValueChange = { newDisplayDpi = it }, + onValueChange = { newDisplayDpi = it; updateNewDisplay() }, label = "dpi", singleLine = true, keyboardOptions = KeyboardOptions( @@ -648,6 +672,13 @@ internal fun AdvancedConfigPage( } } +private fun presetIndexFromInputForAdvancedPage(raw: Int, presets: List): Int { + val exact = presets.indexOf(raw) + if (exact >= 0) return exact + val nearest = presets.withIndex().minByOrNull { (_, preset) -> kotlin.math.abs(preset - raw) } + return nearest?.index ?: 0 +} + private fun presetIndexFromInputForAdvancedPage(raw: String, presets: List): Int { if (raw.isBlank()) return 0 val value = raw.toIntOrNull() ?: return 0 diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt index 833341d..ac1aec7 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt @@ -10,26 +10,23 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateListOf 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.setValue -import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo -import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults +import io.github.miuzarte.scrcpyforandroid.constants.Defaults import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut +import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy @@ -37,14 +34,10 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions import io.github.miuzarte.scrcpyforandroid.services.EventLogger import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo -import io.github.miuzarte.scrcpyforandroid.services.loadDevicePageSettings -import io.github.miuzarte.scrcpyforandroid.services.loadQuickDevices import io.github.miuzarte.scrcpyforandroid.services.parseQuickTarget -import io.github.miuzarte.scrcpyforandroid.services.replaceQuickDevicePort -import io.github.miuzarte.scrcpyforandroid.services.saveQuickDevices -import io.github.miuzarte.scrcpyforandroid.services.updateQuickDeviceNameIfEmpty import io.github.miuzarte.scrcpyforandroid.services.upsertQuickDevice import io.github.miuzarte.scrcpyforandroid.storage.AppSettings +import io.github.miuzarte.scrcpyforandroid.storage.QuickDevices import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel import io.github.miuzarte.scrcpyforandroid.widgets.DeviceEditorScreen @@ -76,26 +69,29 @@ import java.util.concurrent.Executors private const val ADB_CONNECT_TIMEOUT_MS = 3_000L private const val ADB_KEEPALIVE_INTERVAL_MS = 3_000L private const val ADB_KEEPALIVE_TIMEOUT_MS = 1_500L -private const val ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS = 1_200L -private const val ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS = 1_500L -private const val ADB_TCP_PROBE_TIMEOUT_MS = 600 +private const val ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS = 2_000L +private const val ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS = 2_000L +private const val ADB_TCP_PROBE_TIMEOUT_MS = 500 + private const val DEVICE_SHORTCUT_SEPARATOR = "\u001F" -private val DeviceShortcutStateListSaver = - listSaver, String>( - save = { list -> - list.map { item -> +private val DeviceShortcutsSaver = + listSaver( + save = { shortcuts -> + // 将 DeviceShortcuts 中的每个设备转换为一行字符串 + shortcuts.devices.map { device -> listOf( - item.id, - item.name, - item.host, - item.port.toString(), - if (item.online) "1" else "0", + device.id, + device.name, + device.host, + device.port.toString(), + if (device.online) "1" else "0" ).joinToString(DEVICE_SHORTCUT_SEPARATOR) } }, restore = { saved -> - saved.mapNotNull { line -> + // 从保存的字符串列表恢复 DeviceShortcuts + DeviceShortcuts(saved.mapNotNull { line -> val parts = line.split(DEVICE_SHORTCUT_SEPARATOR) if (parts.size != 5) return@mapNotNull null val port = parts[3].toIntOrNull() ?: return@mapNotNull null @@ -103,18 +99,12 @@ private val DeviceShortcutStateListSaver = name = parts[1], host = parts[2], port = port, - online = parts[4] == "1", + online = parts[4] == "1" ) - }.toMutableStateList() + }.toMutableList()) }, ) -private val StringStateListSaver = - listSaver, String>( - save = { it.toList() }, - restore = { it.toMutableStateList() }, - ) - @Composable fun DeviceTabScreen( contentPadding: PaddingValues, @@ -144,6 +134,7 @@ fun DeviceTabScreen( val context = LocalContext.current val appSettings = remember(context) { AppSettings(context) } + val quickDevices = remember(context) { QuickDevices(context) } val scrcpyOptions = remember(context) { ScrcpyOptions(context) } val haptics = rememberAppHaptics() @@ -152,7 +143,7 @@ fun DeviceTabScreen( val virtualButtonLayout = remember(virtualButtonsLayout) { VirtualButtonActions.splitLayout(VirtualButtonActions.parseStoredLayout(virtualButtonsLayout)) } - val initialSettings = remember(context) { loadDevicePageSettings(context) } + // val initialSettings = remember(context) { loadDevicePageSettings(context) } val scope = rememberCoroutineScope() val adbWorkerDispatcher = remember { Executors.newSingleThreadExecutor { runnable -> @@ -173,7 +164,7 @@ fun DeviceTabScreen( var statusLine by rememberSaveable { mutableStateOf("未连接") } var adbConnected by rememberSaveable { mutableStateOf(false) } var currentTargetHost by rememberSaveable { mutableStateOf("") } - var currentTargetPort by rememberSaveable { mutableIntStateOf(AppDefaults.ADB_PORT) } + var currentTargetPort by rememberSaveable { mutableIntStateOf(Defaults.ADB_PORT) } var connectedDeviceLabel by rememberSaveable { mutableStateOf("未连接") } var sessionInfoWidth by rememberSaveable { mutableIntStateOf(0) } var sessionInfoHeight by rememberSaveable { mutableIntStateOf(0) } @@ -203,33 +194,33 @@ fun DeviceTabScreen( } } var previewControlsVisible by rememberSaveable { mutableStateOf(false) } - var editingDeviceId by rememberSaveable { mutableStateOf(null) } + var editingDevice by rememberSaveable { mutableStateOf(null) } var activeDeviceActionId by rememberSaveable { mutableStateOf(null) } var showReorderSheet by rememberSaveable { mutableStateOf(false) } var adbConnecting by rememberSaveable { mutableStateOf(false) } - var connectHost by rememberSaveable { mutableStateOf("") } - var connectPort by rememberSaveable { mutableStateOf(AppDefaults.ADB_PORT.toString()) } - var quickConnectInput by rememberSaveable { mutableStateOf(initialSettings.quickConnectInput) } var audioForwardingSupported by rememberSaveable { mutableStateOf(true) } var cameraMirroringSupported by rememberSaveable { mutableStateOf(true) } - var videoBitRateMbps by rememberSaveable { mutableFloatStateOf(initialSettings.videoBitRateMbps) } - var videoBitRateInput by rememberSaveable { mutableStateOf(initialSettings.videoBitRateInput) } - var audioBitRateKbps by rememberSaveable { mutableIntStateOf(initialSettings.audioBitRateKbps) } - val currentTarget = if (currentTargetHost.isNotBlank()) ConnectionTarget( - currentTargetHost, - currentTargetPort - ) else null + val currentTarget = + if (currentTargetHost.isNotBlank()) + ConnectionTarget( + currentTargetHost, + currentTargetPort + ) else null - val quickDevices = - rememberSaveable(saver = DeviceShortcutStateListSaver) { mutableStateListOf() } val sessionReconnectBlacklistHosts = remember { mutableSetOf() } LaunchedEffect(EventLogger.eventLog.size) { onCanClearLogsChange(EventLogger.hasLogs()) } + var quickDevicesList by quickDevices.quickDevicesList.asMutableState() + var savedShortcuts = rememberSaveable(saver = DeviceShortcutsSaver) { + DeviceShortcuts.unmarshalFrom(quickDevicesList) + } + var quickConnectInput by quickDevices.quickConnectInput.asMutableState() + /** * Disconnect the current ADB connection and stop any running scrcpy session. * @@ -264,16 +255,16 @@ fun DeviceTabScreen( } adbConnected = false currentTargetHost = "" - currentTargetPort = AppDefaults.ADB_PORT + currentTargetPort = Defaults.ADB_PORT audioForwardingSupported = true cameraMirroringSupported = true sessionInfo = null statusLine = "未连接" connectedDeviceLabel = "未连接" clearQuickOnlineForTarget?.let { target -> - if (target.host.isNotBlank()) { - upsertQuickDevice(context, quickDevices, target.host, target.port, false) - } + if (target.host.isNotBlank()) + savedShortcuts = + savedShortcuts.update(host = target.host, port = target.port, online = false) } logMessage?.let { logEvent(it) } if (!showSnackMessage.isNullOrBlank()) { @@ -482,15 +473,15 @@ fun DeviceTabScreen( if (audioEncoder.isNotBlank() && audioEncoder !in audioEncoderOptions) { audioEncoder = "" } - EventLogger.logEvent("编码器列表已刷新: video=${lists.videoEncoders.size} audio=${lists.audioEncoders.size}") + logEvent("编码器列表已刷新: video=${lists.videoEncoders.size} audio=${lists.audioEncoders.size}") if (lists.videoEncoders.isEmpty() && lists.audioEncoders.isEmpty()) { - EventLogger.logEvent( + logEvent( "提示: 编码器为空,请检查 server 路径/版本与设备系统日志", Log.WARN ) val preview = lists.rawOutput.lineSequence().take(20).joinToString(" | ") if (preview.isNotBlank()) { - EventLogger.logEvent("编码器原始输出: $preview", Log.DEBUG) + logEvent("编码器原始输出: $preview", Log.DEBUG) } } }.onFailure { e -> @@ -498,7 +489,7 @@ fun DeviceTabScreen( onAudioEncoderOptionsChange(emptyList()) onVideoEncoderTypeMapChange(emptyMap()) onAudioEncoderTypeMapChange(emptyMap()) - EventLogger.logEvent( + logEvent( "读取编码器列表失败: ${e.message ?: e.javaClass.simpleName}", Log.ERROR, e @@ -518,16 +509,16 @@ fun DeviceTabScreen( if (cameraSize.isNotBlank() && cameraSize != "custom" && cameraSize !in lists.sizes) { cameraSize = "" } - EventLogger.logEvent("camera sizes 已刷新: count=${lists.sizes.size}") + logEvent("camera sizes 已刷新: count=${lists.sizes.size}") if (lists.sizes.isEmpty()) { val preview = lists.rawOutput.lineSequence().take(20).joinToString(" | ") if (preview.isNotBlank()) { - EventLogger.logEvent("camera sizes 原始输出: $preview", Log.DEBUG) + logEvent("camera sizes 原始输出: $preview", Log.DEBUG) } } }.onFailure { e -> onCameraSizeOptionsChange(emptyList()) - EventLogger.logEvent( + logEvent( "读取 camera sizes 失败: ${e.message ?: e.javaClass.simpleName}", Log.ERROR, e @@ -548,9 +539,7 @@ fun DeviceTabScreen( connectedDeviceLabel = info.model applyConnectedDeviceCapabilities(info.sdkInt, info.androidRelease) - updateQuickDeviceNameIfEmpty(context, quickDevices, host, port, fullLabel) - connectHost = host - connectPort = port.toString() + quickDevices.updateQuickDeviceNameIfEmpty(host, port, fullLabel) statusLine = "$host:$port" logEvent("ADB 已连接: model=${info.model}, serial=${info.serial.ifBlank { "unknown" }}, manufacturer=${info.manufacturer.ifBlank { "unknown" }}, brand=${info.brand.ifBlank { "unknown" }}, device=${info.device.ifBlank { "unknown" }}, android=${info.androidRelease.ifBlank { "unknown" }}, sdk=${info.sdkInt}") @@ -561,30 +550,17 @@ fun DeviceTabScreen( refreshCameraSizeLists() } - LaunchedEffect(videoBitRateInput) { - val parsed = videoBitRateInput.toFloatOrNull() ?: return@LaunchedEffect - videoBitRateMbps = parsed.coerceAtLeast(0.1f) - } - - - LaunchedEffect(Unit) { - if (quickDevices.isEmpty()) { - quickDevices.clear() - quickDevices.addAll(loadQuickDevices(context)) - } - } - - LaunchedEffect(adbConnected, currentTargetHost, currentTargetPort, quickDevices.size) { + LaunchedEffect(adbConnected, currentTargetHost, currentTargetPort, savedShortcuts.size) { val activeId = if (adbConnected && currentTargetHost.isNotBlank()) { "$currentTargetHost:$currentTargetPort" } else { null } - for (index in quickDevices.indices) { - val item = quickDevices[index] + for (index in savedShortcuts.indices) { + val item = savedShortcuts[index] val shouldOnline = activeId != null && item.id == activeId if (item.online != shouldOnline) { - quickDevices[index] = item.copy(online = shouldOnline) + savedShortcuts = savedShortcuts.update(id = item.id, online = shouldOnline) } } } @@ -638,7 +614,7 @@ fun DeviceTabScreen( continue } - val quickCandidates = quickDevices.toList() + val quickCandidates = savedShortcuts.toList() if (quickCandidates.isNotEmpty()) { for (target in quickCandidates) { if (adbConnected || adbConnecting) break @@ -653,12 +629,10 @@ fun DeviceTabScreen( try { runAutoAdbConnect(target.host, target.port) adbConnected = true - upsertQuickDevice( - context, - quickDevices, - target.host, - target.port, - true, + savedShortcuts = savedShortcuts.update( + host = target.host, + port = target.port, + online = true ) handleAdbConnected(target.host, target.port) logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}") @@ -686,24 +660,22 @@ fun DeviceTabScreen( delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS) continue } - val knownDevice = quickDevices.firstOrNull { it.host == discoveredHost } + val knownDevice = savedShortcuts.firstOrNull { it.host == discoveredHost } if (knownDevice == null) { delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS) continue } - val portToReplace = quickDevices.firstOrNull { + val portToReplace = savedShortcuts.firstOrNull { it.host == discoveredHost && - it.port != AppDefaults.ADB_PORT && + it.port != Defaults.ADB_PORT && it.port != discoveredPort }?.port if (portToReplace != null) { - replaceQuickDevicePort( - context = context, - quickDevices = quickDevices, + savedShortcuts = savedShortcuts.update( host = discoveredHost, - oldPort = portToReplace, + port = portToReplace, newPort = discoveredPort, - online = false, + online = false ) logEvent( "mDNS 发现新端口,已更新快速设备: $discoveredHost:$portToReplace -> $discoveredHost:$discoveredPort" @@ -718,12 +690,10 @@ fun DeviceTabScreen( try { runAutoAdbConnect(discoveredHost, discoveredPort) adbConnected = true - upsertQuickDevice( - context, - quickDevices, - discoveredHost, - discoveredPort, - true + savedShortcuts = savedShortcuts.update( + host = discoveredHost, + port = discoveredPort, + online = true ) handleAdbConnected(discoveredHost, discoveredPort) logEvent("ADB 自动重连成功: $discoveredHost:$discoveredPort") @@ -790,8 +760,8 @@ fun DeviceTabScreen( ) { val list = remember { ReorderableList( - { - quickDevices.map { device -> + itemsProvider = { + savedShortcuts.map { device -> ReorderableList.Item( id = device.id, title = device.name.ifBlank { device.host }, @@ -800,13 +770,7 @@ fun DeviceTabScreen( } }, onSettle = { fromIndex, toIndex -> - if (fromIndex < 0) return@ReorderableList - val to = toIndex.coerceIn(0, quickDevices.size) - if (fromIndex == to) return@ReorderableList - - val moved = quickDevices.removeAt(fromIndex) - quickDevices.add(to.coerceIn(0, quickDevices.size), moved) - saveQuickDevices(context, quickDevices) + savedShortcuts = savedShortcuts.move(fromIndex, toIndex) }, ) } @@ -822,34 +786,34 @@ fun DeviceTabScreen( } } - if (editingDeviceId != null) { - val device = quickDevices.firstOrNull { it.id == editingDeviceId } - if (device != null) { - DeviceEditorScreen( - contentPadding = contentPadding, - device = device, - onSave = { updated -> - val idx = quickDevices.indexOfFirst { it.id == device.id } - if (idx >= 0) { - quickDevices[idx] = updated.copy(online = quickDevices[idx].online) - saveQuickDevices(context, quickDevices) - } - editingDeviceId = null - }, - onDelete = { - quickDevices.removeAll { it.id == device.id } - saveQuickDevices(context, quickDevices) - editingDeviceId = null - }, - onBack = { editingDeviceId = null }, - ) - return - } - editingDeviceId = null + if (editingDevice != null) { + DeviceEditorScreen( + contentPadding = contentPadding, + device = editingDevice!!, + onSave = { updated -> + savedShortcuts = savedShortcuts.update( + id = editingDevice!!.id, + host = updated.host, + port = updated.port, + online = updated.online, + ) + editingDevice = null + }, + onDelete = { + savedShortcuts = savedShortcuts.remove(id = editingDevice!!.id) + editingDevice = null + }, + onBack = { editingDevice = null }, + ) + return } val devicePreviewCardHeightDp by appSettings.devicePreviewCardHeightDp.asState() val previewVirtualButtonShowText by appSettings.previewVirtualButtonShowText.asState() + + val audioBitRate by scrcpyOptions.audioBitRate.asState() + val videoBitRate by scrcpyOptions.videoBitRate.asState() + // 设备 AppPageLazyColumn( contentPadding = contentPadding, @@ -866,7 +830,7 @@ fun DeviceTabScreen( ) } - itemsIndexed(quickDevices, key = { _, device -> device.id }) { _, device -> + itemsIndexed(savedShortcuts, key = { _, device -> device.id }) { _, device -> val host = device.host val port = device.port val isConnectedTarget = adbConnected @@ -878,7 +842,7 @@ fun DeviceTabScreen( actionText = if (!isConnectedTarget) "连接" else "断开", actionEnabled = !busy && !adbConnecting, actionInProgress = adbConnecting && activeDeviceActionId == device.id, - onLongPress = { editingDeviceId = device.id }, + onLongPress = { editingDevice = device }, onContentClick = { scope.launch { snack.showSnackbar("长按可编辑设备") @@ -893,7 +857,7 @@ fun DeviceTabScreen( try { connectWithTimeout(host, port) adbConnected = true - upsertQuickDevice(context, quickDevices, host, port, true) + savedShortcuts = savedShortcuts.update(host = host, port = port, online = true) handleAdbConnected(host, port) } catch (e: Exception) { statusLine = "ADB 连接失败" @@ -926,13 +890,7 @@ fun DeviceTabScreen( enabled = !adbConnecting, onAddDevice = { val target = parseQuickTarget(quickConnectInput) ?: return@QuickConnectCard - upsertQuickDevice( - context, - quickDevices, - target.host, - target.port, - online = false - ) + savedShortcuts = savedShortcuts.update(host = target.host, port = target.port) scope.launch { snack.showSnackbar("已添加设备: ${target.host}:${target.port}") } @@ -944,7 +902,7 @@ fun DeviceTabScreen( try { connectWithTimeout(target.host, target.port) adbConnected = true - upsertQuickDevice(context, quickDevices, target.host, target.port, true) + savedShortcuts = savedShortcuts.update(host = target.host, port = target.port, online = true) handleAdbConnected(target.host, target.port) } catch (e: Exception) { statusLine = "ADB 连接失败" @@ -1006,13 +964,13 @@ fun DeviceTabScreen( "off" } else { "${session.codec} ${session.width}x${session.height} " + - "@${String.format("%.1f", videoBitRateMbps)}Mbps" + "@${String.format("%.1f", videoBitRate / 1_000_000)}Mbps" } val audioDetail = if (!audio) { "off" } else { val playback = if (!options.audioPlayback) "(no-playback)" else "" - "${options.audioCodec} ${audioBitRateKbps}kbps source=${options.audioSource}$playback" + "${options.audioCodec} ${videoBitRate / 1_000}Kbps source=${options.audioSource}$playback" } logEvent( "scrcpy 已启动: device=${session.deviceName}" + @@ -1085,24 +1043,3 @@ fun DeviceTabScreen( item { Spacer(Modifier.height(UiSpacing.BottomContent)) } } } - -private fun buildNewDisplayArg(width: String, height: String, dpi: String): String { - val w = width.toIntOrNull()?.takeIf { it > 0 } - val h = height.toIntOrNull()?.takeIf { it > 0 } - val d = dpi.toIntOrNull()?.takeIf { it > 0 } - val sizePart = if (w != null && h != null) "${w}x${h}" else "" - return when { - sizePart.isNotEmpty() && d != null -> "$sizePart/$d" - sizePart.isNotEmpty() -> sizePart - d != null -> "/$d" - else -> "" - } -} - -private fun buildCropArg(width: String, height: String, x: String, y: String): String { - val w = width.toIntOrNull()?.takeIf { it > 0 } ?: return "" - val h = height.toIntOrNull()?.takeIf { it > 0 } ?: return "" - val ox = x.toIntOrNull()?.takeIf { it >= 0 } ?: return "" - val oy = y.toIntOrNull()?.takeIf { it >= 0 } ?: return "" - return "$w:$h:$ox:$oy" -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt index 2c65564..d9df992 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt @@ -22,6 +22,8 @@ import androidx.core.view.WindowInsetsControllerCompat import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics +import io.github.miuzarte.scrcpyforandroid.storage.AppSettings +import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar @@ -38,9 +40,6 @@ data class FullscreenControlLaunch( fun FullscreenControlPage( launch: FullscreenControlLaunch, nativeCore: NativeCoreFacade, - virtualButtonsLayout: String, - showDebugInfo: Boolean, - showVirtualButtons: Boolean, onVideoSizeChanged: (width: Int, height: Int) -> Unit, onDismiss: () -> Unit, ) { @@ -48,15 +47,25 @@ fun FullscreenControlPage( BackHandler(enabled = true, onBack = onDismiss) val context = LocalContext.current + + val appSettings = remember(context) { AppSettings(context) } + val scrcpyOptions = remember(context) { ScrcpyOptions(context) } + val haptics = rememberAppHaptics() + val activity = remember(context) { context as? Activity } - val virtualButtonLayout = remember(virtualButtonsLayout) { + + var virtualButtonsLayout by appSettings.virtualButtonsLayout.asMutableState() + val buttonItems = remember(virtualButtonsLayout) { VirtualButtonActions.splitLayout(VirtualButtonActions.parseStoredLayout(virtualButtonsLayout)) } - val bar = remember(virtualButtonLayout) { + val fullscreenDebugInfo by appSettings.fullscreenDebugInfo.asState() + val showFullscreenVirtualButtons by appSettings.showFullscreenVirtualButtons.asState() + + val bar = remember(buttonItems) { VirtualButtonBar( - outsideActions = virtualButtonLayout.first, - moreActions = virtualButtonLayout.second, + outsideActions = buttonItems.first, + moreActions = buttonItems.second, ) } var session by remember(launch) { @@ -128,7 +137,7 @@ fun FullscreenControlPage( session = session, nativeCore = nativeCore, onDismiss = onDismiss, - showDebugInfo = showDebugInfo, + showDebugInfo = fullscreenDebugInfo, currentFps = currentFps, enableBackHandler = false, onInjectTouch = { action, pointerId, x, y, pressure, buttons -> @@ -146,7 +155,7 @@ fun FullscreenControlPage( }, ) - if (showVirtualButtons) { + if (showFullscreenVirtualButtons) { bar.Fullscreen( modifier = Modifier.align(Alignment.BottomCenter), onAction = { action -> diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt index 612ce29..203aa10 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt @@ -33,7 +33,6 @@ 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 @@ -47,7 +46,6 @@ 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.nativecore.NativeAdbService @@ -95,245 +93,43 @@ private sealed interface RootScreen : NavKey { @Composable fun MainPage() { val context = LocalContext.current + val scope = rememberCoroutineScope() 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(context) { NativeCoreFacade.get(context.applicationContext) } val adbService = remember(context) { NativeAdbService(context) } val scrcpy = remember(context) { Scrcpy(context) } val snackHostState = remember { SnackbarHostState() } + val saveableStateHolder = rememberSaveableStateHolder() val tabs = remember { MainTabDestination.entries } val pagerState = rememberPagerState( initialPage = MainTabDestination.Device.ordinal, pageCount = { tabs.size }) val currentTab = tabs[pagerState.currentPage] - val saveableStateHolder = rememberSaveableStateHolder() - val scope = rememberCoroutineScope() val rootBackStack = remember { mutableStateListOf(RootScreen.Home) } val currentRootScreen = rootBackStack.lastOrNull() as? RootScreen ?: RootScreen.Home - val deviceScrollBehavior = - MiuixScrollBehavior(canScroll = { currentTab == MainTabDestination.Device }) - val settingsScrollBehavior = - MiuixScrollBehavior(canScroll = { currentTab == MainTabDestination.Settings }) - val advancedScrollBehavior = MiuixScrollBehavior( + val devicesPageScrollBehavior = MiuixScrollBehavior( + canScroll = { currentTab == MainTabDestination.Device }) + val settingsPageScrollBehavior = MiuixScrollBehavior( + canScroll = { currentTab == MainTabDestination.Settings }) + val advancedPageScrollBehavior = MiuixScrollBehavior( canScroll = { - currentRootScreen is RootScreen.Advanced || currentRootScreen is RootScreen.VirtualButtonOrder - }, - ) - val stringListSaver = listSaver, String>( - save = { value -> ArrayList(value) }, - restore = { restored -> restored.toList() }, - ) - - val appSettings = remember(context) { AppSettings(context) } - val scrcpyOptions = remember(context) { ScrcpyOptions(context) } - - /* - val initialSettings = remember(context) { - loadMainSettings(context) - } - var audioEnabled by rememberSaveable { - mutableStateOf(initialSettings.audioEnabled) - } - var audioCodec by rememberSaveable { - mutableStateOf(initialSettings.audioCodec) - } - var videoCodec by rememberSaveable { - mutableStateOf(initialSettings.videoCodec) - } - 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) - } - - val initialDeviceSettings = remember(context) { - loadDevicePageSettings(context) - } - 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() } - val audioEncoderOptions = remember { mutableStateListOf() } - val videoEncoderTypeMap = remember { mutableStateMapOf() } - val audioEncoderTypeMap = remember { mutableStateMapOf() } - val cameraSizeOptions = remember { mutableStateListOf() } - var sessionStarted by remember { mutableStateOf(false) } - var refreshEncodersAction by remember { mutableStateOf<(() -> Unit)?>(null) } - var refreshCameraSizesAction by remember { mutableStateOf<(() -> Unit)?>(null) } - var clearLogsAction by remember { mutableStateOf<(() -> Unit)?>(null) } - var openReorderDevicesAction by remember { mutableStateOf<(() -> Unit)?>(null) } - var canClearLogs by remember { mutableStateOf(false) } - var showDeviceMenu by rememberSaveable { mutableStateOf(false) } - var lastExitBackPressAtMs by rememberSaveable { mutableLongStateOf(0L) } - var fullscreenOrientation by rememberSaveable { - mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) - } - - var themeBaseIndex by appSettings.themeBaseIndex.asMutableState() - var monet by appSettings.monet.asMutableState() - val themeMode = resolveThemeMode(themeBaseIndex, monet) - val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) } - - // Restore system orientation when MainPage leaves composition. - DisposableEffect(activity) { - onDispose { - activity?.requestedOrientation = initialOrientation - } - } - - val keepScreenOnWhenStreamingEnabled by appSettings.keepScreenOnWhenStreaming.asMutableState() - // 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) + when (currentRootScreen) { + is RootScreen.Advanced -> true + is RootScreen.VirtualButtonOrder -> true + else -> false } - } - } - - // 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 - } - - val adbKeyName by appSettings.adbKeyName.asMutableState() - LaunchedEffect(adbKeyName) { - adbService.keyName = adbKeyName.ifBlank { AppDefaults.ADB_KEY_NAME } - } + }) fun popRoot() { if (rootBackStack.size > 1) { @@ -345,6 +141,7 @@ fun MainPage() { // 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) } fun handleBackNavigation() { if (rootBackStack.size > 1) { popRoot() @@ -394,6 +191,59 @@ fun MainPage() { } } + val appSettings = remember(context) { AppSettings(context) } + val scrcpyOptions = remember(context) { ScrcpyOptions(context) } + + val videoEncoderOptions = remember { mutableStateListOf() } + val audioEncoderOptions = remember { mutableStateListOf() } + val videoEncoderTypeMap = remember { mutableStateMapOf() } + val audioEncoderTypeMap = remember { mutableStateMapOf() } + val cameraSizeOptions = remember { mutableStateListOf() } + var sessionStarted by remember { mutableStateOf(false) } + var refreshEncodersAction by remember { mutableStateOf<(() -> Unit)?>(null) } + var refreshCameraSizesAction by remember { mutableStateOf<(() -> Unit)?>(null) } + var clearLogsAction by remember { mutableStateOf<(() -> Unit)?>(null) } + var openReorderDevicesAction by remember { mutableStateOf<(() -> Unit)?>(null) } + var canClearLogs by remember { mutableStateOf(false) } + var showDeviceMenu by rememberSaveable { mutableStateOf(false) } + var fullscreenOrientation by rememberSaveable { + mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) + } + + var themeBaseIndex by appSettings.themeBaseIndex.asMutableState() + var monet by appSettings.monet.asMutableState() + val themeMode = resolveThemeMode(themeBaseIndex, monet) + val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) } + + val keepScreenOnWhenStreamingEnabled by appSettings.keepScreenOnWhenStreaming.asMutableState() + // 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 + } + + val adbKeyName by appSettings.adbKeyName.asState() + LaunchedEffect(adbKeyName) { + adbService.keyName = adbKeyName.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue } + } + var customServerUri by appSettings.customServerUri.asMutableState() val picker = rememberLauncherForActivityResult( ActivityResultContracts.OpenDocument() @@ -477,7 +327,7 @@ fun MainPage() { }, ) }, - scrollBehavior = deviceScrollBehavior, + scrollBehavior = devicesPageScrollBehavior, ) }, ) { pagePadding -> @@ -487,15 +337,7 @@ fun MainPage() { adbService = adbService, scrcpy = scrcpy, snack = snackHostState, - scrollBehavior = deviceScrollBehavior, - /* - onNoControlChange = { - noControl = it - if (it) { - turnScreenOff = false - } - }, - */ + scrollBehavior = devicesPageScrollBehavior, videoEncoderOptions = videoEncoderOptions, onVideoEncoderOptionsChange = { videoEncoderOptions.clear() @@ -555,7 +397,7 @@ fun MainPage() { topBar = { TopAppBar( title = tab.title, - scrollBehavior = settingsScrollBehavior, + scrollBehavior = settingsPageScrollBehavior, ) }, ) { pagePadding -> @@ -576,7 +418,7 @@ fun MainPage() { ) ) }, - scrollBehavior = settingsScrollBehavior, + scrollBehavior = settingsPageScrollBehavior, ) } } @@ -605,101 +447,32 @@ fun MainPage() { ) } }, - scrollBehavior = advancedScrollBehavior, + scrollBehavior = advancedPageScrollBehavior, ) }, snackbarHost = { SnackbarHost(snackHostState) }, ) { pagePadding -> AdvancedConfigPage( contentPadding = pagePadding, - scrollBehavior = advancedScrollBehavior, - sessionStarted = sessionStarted, + scrollBehavior = advancedPageScrollBehavior, 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 }, + cameraSizeOptions = cameraSizeOptions, 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), + modifier = Modifier.nestedScroll(advancedPageScrollBehavior.nestedScrollConnection), topBar = { TopAppBar( title = "虚拟按钮排序", @@ -711,19 +484,13 @@ fun MainPage() { ) } }, - scrollBehavior = advancedScrollBehavior, + scrollBehavior = advancedPageScrollBehavior, ) }, ) { pagePadding -> VirtualButtonOrderPage( contentPadding = pagePadding, - scrollBehavior = advancedScrollBehavior, - layoutString = virtualButtonsLayout, - onLayoutChange = { layout -> - virtualButtonsLayout = layout - }, - showPreviewText = showPreviewVirtualButtonText, - onShowPreviewTextChange = { showPreviewVirtualButtonText = it }, + scrollBehavior = advancedPageScrollBehavior, ) } } @@ -732,9 +499,6 @@ fun MainPage() { FullscreenControlPage( launch = screen.launch, nativeCore = nativeCore, - virtualButtonsLayout = virtualButtonsLayout, - showDebugInfo = fullscreenDebugInfoEnabled, - showVirtualButtons = showFullscreenVirtualButtons, onVideoSizeChanged = { width, height -> fullscreenOrientation = if (width >= height) { ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt index 3aeab6b..206c42a 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt @@ -18,7 +18,6 @@ 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 @@ -212,7 +211,7 @@ fun SettingsScreen( TextField( value = serverRemotePath, onValueChange = { serverRemotePath = it }, - label = AppDefaults.SERVER_REMOTE_PATH, + label = AppSettings.SERVER_REMOTE_PATH.defaultValue, useLabelAsPlaceholder = true, singleLine = true, modifier = Modifier @@ -234,7 +233,7 @@ fun SettingsScreen( TextField( value = adbKeyName, onValueChange = { adbKeyName = it }, - label = AppDefaults.ADB_KEY_NAME, + label = AppSettings.ADB_KEY_NAME.defaultValue, useLabelAsPlaceholder = true, singleLine = true, modifier = Modifier diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderPage.kt index 9f4f3cf..39b289b 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderPage.kt @@ -6,7 +6,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn +import io.github.miuzarte.scrcpyforandroid.storage.AppSettings +import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions @@ -18,17 +21,16 @@ import top.yukonga.miuix.kmp.extra.SuperSwitch 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)) - } + val context = LocalContext.current - fun emitChanges() { - onLayoutChange(VirtualButtonActions.encodeStoredLayout(buttonItems)) + val appSettings = remember(context) { AppSettings(context) } + val scrcpyOptions = remember(context) { ScrcpyOptions(context) } + + var previewVirtualButtonShowText by appSettings.previewVirtualButtonShowText.asMutableState() + var virtualButtonsLayout by appSettings.virtualButtonsLayout.asMutableState() + var buttonItems by remember(virtualButtonsLayout) { + mutableStateOf(VirtualButtonActions.parseStoredLayout(virtualButtonsLayout)) } AppPageLazyColumn( @@ -41,8 +43,8 @@ internal fun VirtualButtonOrderPage( SuperSwitch( title = "按钮显示文本", summary = "超过3个建议关闭,只对预览卡下方的虚拟按钮生效", - checked = showPreviewText, - onCheckedChange = onShowPreviewTextChange, + checked = previewVirtualButtonShowText, + onCheckedChange = { previewVirtualButtonShowText = it }, ) } } @@ -67,7 +69,7 @@ internal fun VirtualButtonOrderPage( buttonItems = buttonItems.toMutableList().apply { add(toIndex, removeAt(fromIndex)) } - emitChanges() + virtualButtonsLayout = VirtualButtonActions.encodeStoredLayout(buttonItems) }, showCheckbox = true, onCheckboxChange = { id, checked -> @@ -78,7 +80,7 @@ internal fun VirtualButtonOrderPage( item } } - emitChanges() + virtualButtonsLayout = VirtualButtonActions.encodeStoredLayout(buttonItems) }, )() } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/EventLogger.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/EventLogger.kt index 5f42a8c..84204d3 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/EventLogger.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/EventLogger.kt @@ -3,7 +3,7 @@ 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.AppDefaults +import io.github.miuzarte.scrcpyforandroid.constants.Defaults import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -16,14 +16,14 @@ import java.util.Locale */ object EventLogger { private const val LOG_TAG = "EventLogger" - + private val _eventLog: SnapshotStateList = mutableStateListOf() - + /** * Read-only access to the event log list. */ val eventLog: List get() = _eventLog - + /** * Log an event with timestamp and optional error. * @@ -34,12 +34,12 @@ object EventLogger { 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 > AppDefaults.EVENT_LOG_LINES) { - _eventLog.removeRange(AppDefaults.EVENT_LOG_LINES, _eventLog.size) + if (_eventLog.size > Defaults.EVENT_LOG_LINES) { + _eventLog.removeRange(Defaults.EVENT_LOG_LINES, _eventLog.size) } - + // Log to Android logcat when (level) { Log.ERROR -> if (error != null) Log.e(LOG_TAG, message, error) @@ -55,14 +55,14 @@ object EventLogger { else Log.i(LOG_TAG, message) } } - + /** * Clear all event logs. */ fun clearLogs() { _eventLog.clear() } - + /** * Check if there are any logs. */ diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt index e8e86d2..67fa3d3 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt @@ -2,8 +2,8 @@ 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.constants.Defaults import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut @@ -21,7 +21,7 @@ internal fun loadQuickDevices(context: Context): List { 3 -> { val name = parts[0].trim() val host = parts[1].trim() - val port = parts[2].trim().toIntOrNull() ?: AppDefaults.ADB_PORT + val port = parts[2].trim().toIntOrNull() ?: Defaults.ADB_PORT if (host.isNotBlank()) { result.add( DeviceShortcut( @@ -38,8 +38,8 @@ internal fun loadQuickDevices(context: Context): List { // 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 + val port = parts[1].substringAfter(":", Defaults.ADB_PORT.toString()).trim() + .toIntOrNull() ?: Defaults.ADB_PORT if (host.isNotBlank()) { result.add( DeviceShortcut( @@ -69,8 +69,8 @@ internal fun parseQuickTarget(raw: String): ConnectionTarget? { 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 + val port = value.substringAfter(':', Defaults.ADB_PORT.toString()).trim().toIntOrNull() + ?: Defaults.ADB_PORT return ConnectionTarget(host, port) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt index 614c4c5..a1e7d5e 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt @@ -7,59 +7,59 @@ import androidx.datastore.preferences.core.stringPreferencesKey class AppSettings(context: Context) : Settings(context, "AppSettings") { companion object { - private val THEME_BASE_INDEX = Pair( + val THEME_BASE_INDEX = Pair( intPreferencesKey("theme_base_index"), 0 ) - private val MONET = Pair( + val MONET = Pair( booleanPreferencesKey("monet"), false ) - private val FULLSCREEN_DEBUG_INFO = Pair( + val FULLSCREEN_DEBUG_INFO = Pair( booleanPreferencesKey("fullscreen_debug_info"), false ) - private val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = Pair( + val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = Pair( booleanPreferencesKey("show_fullscreen_virtual_buttons"), true ) - private val KEEP_SCREEN_ON_WHEN_STREAMING = Pair( + val KEEP_SCREEN_ON_WHEN_STREAMING = Pair( booleanPreferencesKey("keep_screen_on_when_streaming"), false ) - private val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair( + val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair( intPreferencesKey("device_preview_card_height_dp"), 320 ) - private val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = Pair( + val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = Pair( booleanPreferencesKey("preview_virtual_button_show_text"), true ) - private val VIRTUAL_BUTTONS_LAYOUT = Pair( + 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" ) - private val CUSTOM_SERVER_URI = Pair( + val CUSTOM_SERVER_URI = Pair( stringPreferencesKey("custom_server_uri"), "" ) - private val SERVER_REMOTE_PATH = Pair( + val SERVER_REMOTE_PATH = Pair( stringPreferencesKey("server_remote_path"), "/data/local/tmp/scrcpy-server.jar" ) - private val ADB_KEY_NAME = Pair( + val ADB_KEY_NAME = Pair( stringPreferencesKey("adb_key_name"), "scrcpy" ) - private val ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN = Pair( + val ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN = Pair( booleanPreferencesKey("adb_pairing_auto_discover_on_dialog_open"), true ) - private val ADB_AUTO_RECONNECT_PAIRED_DEVICE = Pair( + val ADB_AUTO_RECONNECT_PAIRED_DEVICE = Pair( booleanPreferencesKey("adb_auto_reconnect_paired_device"), true ) - private val ADB_MDNS_LAN_DISCOVERY = Pair( + val ADB_MDNS_LAN_DISCOVERY = Pair( booleanPreferencesKey("adb_mdns_lan_discovery"), true ) @@ -87,24 +87,29 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { val adbAutoReconnectPairedDevice by setting(ADB_AUTO_RECONNECT_PAIRED_DEVICE) val adbMdnsLanDiscovery by setting(ADB_MDNS_LAN_DISCOVERY) - override suspend fun toMap(): Map { - return mapOf( - THEME_BASE_INDEX.name to themeBaseIndex.get(), - MONET.name to monet.get(), - FULLSCREEN_DEBUG_INFO.name to fullscreenDebugInfo.get(), - SHOW_FULLSCREEN_VIRTUAL_BUTTONS.name to showFullscreenVirtualButtons.get(), - KEEP_SCREEN_ON_WHEN_STREAMING.name to keepScreenOnWhenStreaming.get(), - DEVICE_PREVIEW_CARD_HEIGHT_DP.name to devicePreviewCardHeightDp.get(), - PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT.name to previewVirtualButtonShowText.get(), - VIRTUAL_BUTTONS_LAYOUT.name to virtualButtonsLayout.get(), - CUSTOM_SERVER_URI.name to customServerUri.get(), - SERVER_REMOTE_PATH.name to serverRemotePath.get(), - ADB_KEY_NAME.name to adbKeyName.get(), - ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN.name to adbPairingAutoDiscoverOnDialogOpen.get(), - ADB_AUTO_RECONNECT_PAIRED_DEVICE.name to adbAutoReconnectPairedDevice.get(), - ADB_MDNS_LAN_DISCOVERY.name to adbMdnsLanDiscovery.get() - ) - } + override suspend fun toMap(): Map = mapOf( + // Theme Settings + THEME_BASE_INDEX.name to themeBaseIndex.get(), + MONET.name to monet.get(), + + // Scrcpy Settings + FULLSCREEN_DEBUG_INFO.name to fullscreenDebugInfo.get(), + SHOW_FULLSCREEN_VIRTUAL_BUTTONS.name to showFullscreenVirtualButtons.get(), + KEEP_SCREEN_ON_WHEN_STREAMING.name to keepScreenOnWhenStreaming.get(), + DEVICE_PREVIEW_CARD_HEIGHT_DP.name to devicePreviewCardHeightDp.get(), + PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT.name to previewVirtualButtonShowText.get(), + VIRTUAL_BUTTONS_LAYOUT.name to virtualButtonsLayout.get(), + + // Scrcpy Server Settings + CUSTOM_SERVER_URI.name to customServerUri.get(), + SERVER_REMOTE_PATH.name to serverRemotePath.get(), + + // ADB Settings + ADB_KEY_NAME.name to adbKeyName.get(), + ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN.name to adbPairingAutoDiscoverOnDialogOpen.get(), + ADB_AUTO_RECONNECT_PAIRED_DEVICE.name to adbAutoReconnectPairedDevice.get(), + ADB_MDNS_LAN_DISCOVERY.name to adbMdnsLanDiscovery.get() + ) // TODO? override fun validate(): Boolean = true diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings_New.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings_New.kt deleted file mode 100644 index ca8b37c..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings_New.kt +++ /dev/null @@ -1,128 +0,0 @@ -package io.github.miuzarte.scrcpyforandroid.storage - -import android.content.Context -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.intPreferencesKey -import androidx.datastore.preferences.core.stringPreferencesKey - -/** - * 使用委托方式的 AppSettings 示例 - * - * 使用方式: - * ``` - * // 获取值 - * val theme = appSettings.themeBaseIndex.get() - * - * // 设置值 - * appSettings.themeBaseIndex.set(1) - * - * // 观察变化(Flow) - * appSettings.themeBaseIndex.observe().collect { value -> } - * - * // 在 Composable 中观察(State) - * val theme by appSettings.themeBaseIndex.observeAsState() - * ``` - */ -class AppSettingsNew(context: Context) : Settings(context, "AppSettings") { - companion object { - private val THEME_BASE_INDEX = Pair( - intPreferencesKey("theme_base_index"), - 0 - ) - private val MONET = Pair( - booleanPreferencesKey("monet"), - false - ) - private val FULLSCREEN_DEBUG_INFO = Pair( - booleanPreferencesKey("fullscreen_debug_info"), - false - ) - private val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = Pair( - booleanPreferencesKey("show_fullscreen_virtual_buttons"), - true - ) - private val KEEP_SCREEN_ON_WHEN_STREAMING = Pair( - booleanPreferencesKey("keep_screen_on_when_streaming"), - false - ) - private val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair( - intPreferencesKey("device_preview_card_height_dp"), - 320 - ) - private val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = Pair( - booleanPreferencesKey("preview_virtual_button_show_text"), - true - ) - private 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" - ) - private val CUSTOM_SERVER_URI = Pair( - stringPreferencesKey("custom_server_uri"), - "" - ) - private val SERVER_REMOTE_PATH = Pair( - stringPreferencesKey("server_remote_path"), - "/data/local/tmp/scrcpy-server.jar" - ) - private val ADB_KEY_NAME = Pair( - stringPreferencesKey("adb_key_name"), - "scrcpy" - ) - private val ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN = Pair( - booleanPreferencesKey("adb_pairing_auto_discover_on_dialog_open"), - true - ) - private val ADB_AUTO_RECONNECT_PAIRED_DEVICE = Pair( - booleanPreferencesKey("adb_auto_reconnect_paired_device"), - true - ) - private 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 keepScreenOnWhenStreaming by setting(KEEP_SCREEN_ON_WHEN_STREAMING) - 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 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) - - override suspend fun toMap(): Map { - return mapOf( - THEME_BASE_INDEX.name to themeBaseIndex.get(), - MONET.name to monet.get(), - FULLSCREEN_DEBUG_INFO.name to fullscreenDebugInfo.get(), - SHOW_FULLSCREEN_VIRTUAL_BUTTONS.name to showFullscreenVirtualButtons.get(), - KEEP_SCREEN_ON_WHEN_STREAMING.name to keepScreenOnWhenStreaming.get(), - DEVICE_PREVIEW_CARD_HEIGHT_DP.name to devicePreviewCardHeightDp.get(), - PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT.name to previewVirtualButtonShowText.get(), - VIRTUAL_BUTTONS_LAYOUT.name to virtualButtonsLayout.get(), - CUSTOM_SERVER_URI.name to customServerUri.get(), - SERVER_REMOTE_PATH.name to serverRemotePath.get(), - ADB_KEY_NAME.name to adbKeyName.get(), - ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN.name to adbPairingAutoDiscoverOnDialogOpen.get(), - ADB_AUTO_RECONNECT_PAIRED_DEVICE.name to adbAutoReconnectPairedDevice.get(), - ADB_MDNS_LAN_DISCOVERY.name to adbMdnsLanDiscovery.get() - ) - } - - override fun validate(): Boolean = true -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/PreferenceMigration.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/PreferenceMigration.kt new file mode 100644 index 0000000..952ae5d --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/PreferenceMigration.kt @@ -0,0 +1,464 @@ +package io.github.miuzarte.scrcpyforandroid.storage + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults +import io.github.miuzarte.scrcpyforandroid.constants.AppPreferenceKeys +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val TAG = "PreferenceMigration" +private const val MIGRATION_COMPLETED_KEY = "migration_completed" + +/** + * 从旧的 SharedPreferences 迁移到新的 DataStore + */ +class PreferenceMigration(private val context: Context) { + + private val sharedPrefs: SharedPreferences by lazy { + context.getSharedPreferences(AppPreferenceKeys.PREFS_NAME, Context.MODE_PRIVATE) + } + + /** + * 检查是否已完成迁移 + */ + @Deprecated("做成设置内入口手动调用,这个没必要") + suspend fun isMigrationCompleted(): Boolean = withContext(Dispatchers.IO) { + sharedPrefs.getBoolean(MIGRATION_COMPLETED_KEY, false) + } + + /** + * 执行完整迁移 + * @return 迁移是否成功 + */ + suspend fun migrate(): Boolean = withContext(Dispatchers.IO) { + try { + if (isMigrationCompleted()) { + Log.i(TAG, "Migration already completed, skipping") + return@withContext true + } + + Log.i(TAG, "Starting migration from SharedPreferences to DataStore") + + // 迁移 AppSettings + migrateAppSettings() + + // 迁移 ScrcpyOptions + migrateScrcpyOptions() + + // 迁移 QuickDevices + migrateQuickDevices() + + // 标记迁移完成 + markMigrationCompleted() + + Log.i(TAG, "Migration completed successfully") + true + } catch (e: Exception) { + Log.e(TAG, "Migration failed", e) + false + } + } + + /** + * 迁移应用设置 + */ + private suspend fun migrateAppSettings() { + val appSettings = AppSettings(context) + + // Theme Settings + migrateInt( + AppPreferenceKeys.THEME_BASE_INDEX, + AppDefaults.THEME_BASE_INDEX, + appSettings.themeBaseIndex + ) + migrateBoolean( + AppPreferenceKeys.MONET, + AppDefaults.MONET, + appSettings.monet + ) + + // Scrcpy Settings + migrateBoolean( + AppPreferenceKeys.FULLSCREEN_DEBUG_INFO, + AppDefaults.FULLSCREEN_DEBUG_INFO, + appSettings.fullscreenDebugInfo + ) + migrateBoolean( + AppPreferenceKeys.SHOW_FULLSCREEN_VIRTUAL_BUTTONS, + AppDefaults.SHOW_FULLSCREEN_VIRTUAL_BUTTONS, + appSettings.showFullscreenVirtualButtons + ) + migrateBoolean( + AppPreferenceKeys.KEEP_SCREEN_ON_WHEN_STREAMING, + AppDefaults.KEEP_SCREEN_ON_WHEN_STREAMING, + appSettings.keepScreenOnWhenStreaming + ) + migrateInt( + AppPreferenceKeys.DEVICE_PREVIEW_CARD_HEIGHT_DP, + AppDefaults.DEVICE_PREVIEW_CARD_HEIGHT_DP, + appSettings.devicePreviewCardHeightDp + ) + migrateBoolean( + AppPreferenceKeys.PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT, + AppDefaults.PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT, + appSettings.previewVirtualButtonShowText + ) + migrateString( + AppPreferenceKeys.VIRTUAL_BUTTONS_LAYOUT, + AppDefaults.VIRTUAL_BUTTONS_LAYOUT, + appSettings.virtualButtonsLayout + ) + + // Scrcpy Server Settings + migrateString( + AppPreferenceKeys.CUSTOM_SERVER_URI, + AppDefaults.CUSTOM_SERVER_URI ?: "", + appSettings.customServerUri + ) + migrateString( + AppPreferenceKeys.SERVER_REMOTE_PATH, + AppDefaults.SERVER_REMOTE_PATH_INPUT, + appSettings.serverRemotePath + ) + + // ADB Settings + migrateString( + AppPreferenceKeys.ADB_KEY_NAME, + AppDefaults.ADB_KEY_NAME_INPUT, + appSettings.adbKeyName + ) + migrateBoolean( + AppPreferenceKeys.ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN, + AppDefaults.ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN, + appSettings.adbPairingAutoDiscoverOnDialogOpen + ) + migrateBoolean( + AppPreferenceKeys.ADB_AUTO_RECONNECT_PAIRED_DEVICE, + AppDefaults.ADB_AUTO_RECONNECT_PAIRED_DEVICE, + appSettings.adbAutoReconnectPairedDevice + ) + migrateBoolean( + AppPreferenceKeys.ADB_MDNS_LAN_DISCOVERY, + AppDefaults.ADB_MDNS_LAN_DISCOVERY, + appSettings.adbMdnsLanDiscovery + ) + + Log.d(TAG, "AppSettings migration completed") + } + + /** + * 迁移 Scrcpy 选项 + */ + private suspend fun migrateScrcpyOptions() { + val scrcpyOptions = ScrcpyOptions(context) + + // Audio & Video Codecs + migrateString( + AppPreferenceKeys.AUDIO_CODEC, + AppDefaults.AUDIO_CODEC, + scrcpyOptions.audioCodec + ) + migrateString( + AppPreferenceKeys.VIDEO_CODEC, + AppDefaults.VIDEO_CODEC, + scrcpyOptions.videoCodec + ) + + // Bit Rates + val audioBitRateKbps = sharedPrefs.getInt( + AppPreferenceKeys.AUDIO_BIT_RATE_KBPS, + AppDefaults.AUDIO_BIT_RATE_KBPS + ) + scrcpyOptions.audioBitRate.set(audioBitRateKbps * 1000) // Convert to bps + + val videoBitRateMbps = sharedPrefs.getFloat( + AppPreferenceKeys.VIDEO_BIT_RATE_MBPS, + AppDefaults.VIDEO_BIT_RATE_MBPS + ) + scrcpyOptions.videoBitRate.set((videoBitRateMbps * 1_000_000).toInt()) + + // Control Options + migrateBoolean( + AppPreferenceKeys.TURN_SCREEN_OFF, + AppDefaults.TURN_SCREEN_OFF, + scrcpyOptions.turnScreenOff + ) + migrateBoolean( + AppPreferenceKeys.NO_CONTROL, + AppDefaults.NO_CONTROL + ) { value -> + scrcpyOptions.control.set(!value) // Invert logic + } + migrateBoolean( + AppPreferenceKeys.NO_VIDEO, + AppDefaults.NO_VIDEO + ) { value -> + scrcpyOptions.video.set(!value) // Invert logic + } + + // Video Source + val videoSourcePreset = sharedPrefs.getString( + AppPreferenceKeys.VIDEO_SOURCE_PRESET, + AppDefaults.VIDEO_SOURCE_PRESET + ).orEmpty().ifBlank { AppDefaults.VIDEO_SOURCE_PRESET } + scrcpyOptions.videoSource.set(videoSourcePreset) + + migrateString( + AppPreferenceKeys.DISPLAY_ID, + AppDefaults.DISPLAY_ID + ) { value -> + value.toIntOrNull()?.let { scrcpyOptions.displayId.set(it) } + } + + // Camera Settings + migrateString( + AppPreferenceKeys.CAMERA_ID, + AppDefaults.CAMERA_ID, + scrcpyOptions.cameraId + ) + migrateString( + AppPreferenceKeys.CAMERA_FACING_PRESET, + AppDefaults.CAMERA_FACING_PRESET, + scrcpyOptions.cameraFacing + ) + migrateString( + AppPreferenceKeys.CAMERA_SIZE_PRESET, + AppDefaults.CAMERA_SIZE_PRESET + ) { value -> + if (value == "custom") { + val customSize = sharedPrefs.getString( + AppPreferenceKeys.CAMERA_SIZE_CUSTOM, + AppDefaults.CAMERA_SIZE_CUSTOM + ).orEmpty() + scrcpyOptions.cameraSize.set(customSize) + } else { + scrcpyOptions.cameraSize.set(value) + } + } + migrateString( + AppPreferenceKeys.CAMERA_AR, + AppDefaults.CAMERA_AR, + scrcpyOptions.cameraAr + ) + migrateString( + AppPreferenceKeys.CAMERA_FPS, + AppDefaults.CAMERA_FPS + ) { value -> + value.toIntOrNull()?.let { scrcpyOptions.cameraFps.set(it) } + } + migrateBoolean( + AppPreferenceKeys.CAMERA_HIGH_SPEED, + AppDefaults.CAMERA_HIGH_SPEED, + scrcpyOptions.cameraHighSpeed + ) + + // Audio Source + val audioSourcePreset = sharedPrefs.getString( + AppPreferenceKeys.AUDIO_SOURCE_PRESET, + AppDefaults.AUDIO_SOURCE_PRESET + ).orEmpty().ifBlank { AppDefaults.AUDIO_SOURCE_PRESET } + + if (audioSourcePreset == "custom") { + val customSource = sharedPrefs.getString( + AppPreferenceKeys.AUDIO_SOURCE_CUSTOM, + AppDefaults.AUDIO_SOURCE_CUSTOM + ).orEmpty() + scrcpyOptions.audioSource.set(customSource) + } else { + scrcpyOptions.audioSource.set(audioSourcePreset) + } + + migrateBoolean( + AppPreferenceKeys.AUDIO_DUP, + AppDefaults.AUDIO_DUP, + scrcpyOptions.audioDup + ) + migrateBoolean( + AppPreferenceKeys.NO_AUDIO_PLAYBACK, + AppDefaults.NO_AUDIO_PLAYBACK + ) { value -> + scrcpyOptions.audioPlayback.set(!value) // Invert logic + } + migrateBoolean( + AppPreferenceKeys.REQUIRE_AUDIO, + AppDefaults.REQUIRE_AUDIO, + scrcpyOptions.requireAudio + ) + + // Max Size & FPS + migrateString( + AppPreferenceKeys.MAX_SIZE_INPUT, + AppDefaults.MAX_SIZE_INPUT + ) { value -> + value.toIntOrNull()?.let { scrcpyOptions.maxSize.set(it) } + } + migrateString( + AppPreferenceKeys.MAX_FPS_INPUT, + AppDefaults.MAX_FPS_INPUT, + scrcpyOptions.maxFps + ) + + // Encoders & Codec Options + migrateString( + AppPreferenceKeys.VIDEO_ENCODER, + AppDefaults.VIDEO_ENCODER, + scrcpyOptions.videoEncoder + ) + migrateString( + AppPreferenceKeys.VIDEO_CODEC_OPTION, + AppDefaults.VIDEO_CODEC_OPTION, + scrcpyOptions.videoCodecOptions + ) + migrateString( + AppPreferenceKeys.AUDIO_ENCODER, + AppDefaults.AUDIO_ENCODER, + scrcpyOptions.audioEncoder + ) + migrateString( + AppPreferenceKeys.AUDIO_CODEC_OPTION, + AppDefaults.AUDIO_CODEC_OPTION, + scrcpyOptions.audioCodecOptions + ) + + // New Display + val newDisplayWidth = sharedPrefs.getString( + AppPreferenceKeys.NEW_DISPLAY_WIDTH, + AppDefaults.NEW_DISPLAY_WIDTH + ).orEmpty() + val newDisplayHeight = sharedPrefs.getString( + AppPreferenceKeys.NEW_DISPLAY_HEIGHT, + AppDefaults.NEW_DISPLAY_HEIGHT + ).orEmpty() + val newDisplayDpi = sharedPrefs.getString( + AppPreferenceKeys.NEW_DISPLAY_DPI, + AppDefaults.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( + AppPreferenceKeys.CROP_WIDTH, + AppDefaults.CROP_WIDTH + ).orEmpty() + val cropHeight = sharedPrefs.getString( + AppPreferenceKeys.CROP_HEIGHT, + AppDefaults.CROP_HEIGHT + ).orEmpty() + val cropX = sharedPrefs.getString( + AppPreferenceKeys.CROP_X, + AppDefaults.CROP_X + ).orEmpty() + val cropY = sharedPrefs.getString( + AppPreferenceKeys.CROP_Y, + AppDefaults.CROP_Y + ).orEmpty() + + if (cropWidth.isNotBlank() && cropHeight.isNotBlank() && + cropX.isNotBlank() && cropY.isNotBlank() + ) { + scrcpyOptions.crop.set("${cropWidth}:${cropHeight}:${cropX}:${cropY}") + } + + // Audio enabled flag (convert to audio option) + val audioEnabled = sharedPrefs.getBoolean( + AppPreferenceKeys.AUDIO_ENABLED, + AppDefaults.AUDIO_ENABLED + ) + scrcpyOptions.audio.set(audioEnabled) + + Log.d(TAG, "ScrcpyOptions migration completed") + } + + /** + * 迁移快速设备列表 + */ + private suspend fun migrateQuickDevices() { + val quickDevices = QuickDevices(context) + + // Migrate quick devices list + val quickDevicesRaw = sharedPrefs.getString( + AppPreferenceKeys.QUICK_DEVICES, + "" + ).orEmpty() + if (quickDevicesRaw.isNotBlank()) { + quickDevices.quickDevicesList.set(quickDevicesRaw) + } + + // Migrate quick connect input + migrateString( + AppPreferenceKeys.QUICK_CONNECT_INPUT, + AppDefaults.QUICK_CONNECT_INPUT, + quickDevices.quickConnectInput + ) + + Log.d(TAG, "QuickDevices migration completed") + } + + /** + * 标记迁移完成 + */ + private fun markMigrationCompleted() { + sharedPrefs.edit().putBoolean(MIGRATION_COMPLETED_KEY, true).apply() + } + + // Helper methods for different data types + + private suspend fun migrateString( + key: String, + defaultValue: String, + settingProperty: Settings.SettingProperty + ) { + val value = sharedPrefs.getString(key, defaultValue) + .orEmpty() + .ifBlank { defaultValue } + settingProperty.set(value) + } + + private suspend fun migrateString( + key: String, + defaultValue: String, + action: suspend (String) -> Unit + ) { + val value = sharedPrefs.getString(key, defaultValue) + .orEmpty() + .ifBlank { defaultValue } + action(value) + } + + private suspend fun migrateInt( + key: String, + defaultValue: Int, + settingProperty: Settings.SettingProperty + ) { + val value = sharedPrefs.getInt(key, defaultValue) + settingProperty.set(value) + } + + private suspend fun migrateBoolean( + key: String, + defaultValue: Boolean, + settingProperty: Settings.SettingProperty + ) { + val value = sharedPrefs.getBoolean(key, defaultValue) + settingProperty.set(value) + } + + private suspend fun migrateBoolean( + key: String, + defaultValue: Boolean, + action: suspend (Boolean) -> Unit + ) { + val value = sharedPrefs.getBoolean(key, defaultValue) + action(value) + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt index e701d43..2d96a42 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt @@ -8,31 +8,28 @@ import kotlinx.coroutines.flow.map class QuickDevices(context: Context) : Settings(context, "QuickDevices") { companion object { - private val QUICK_DEVICES = Pair( - stringPreferencesKey("quick_devices"), + val QUICK_DEVICES_LIST = Pair( + stringPreferencesKey("quick_devices_list"), "", ) - private val QUICK_CONNECT_INPUT = Pair( + val QUICK_CONNECT_INPUT = Pair( stringPreferencesKey("quick_connect_input"), "", ) } - override suspend fun toMap(): Map { - return mapOf( - QUICK_DEVICES.name to getQuickDevicesRaw(), - QUICK_CONNECT_INPUT.name to getQuickConnectInput(), - ) - } + val quickDevicesList by setting(QUICK_DEVICES_LIST) + val quickConnectInput by setting(QUICK_CONNECT_INPUT) + + override suspend fun toMap(): Map = mapOf( + QUICK_DEVICES_LIST.name to quickDevicesList.get(), + QUICK_CONNECT_INPUT.name to quickConnectInput.get(), + ) override fun validate(): Boolean = true - private suspend fun getQuickDevicesRaw(): String = getValue(QUICK_DEVICES) - private suspend fun setQuickDevicesRaw(value: String) = setValue(QUICK_DEVICES, value) - private fun observeQuickDevicesRaw(): Flow = observe(QUICK_DEVICES) - suspend fun getQuickDevices(): List { - val raw = getQuickDevicesRaw() + val raw = quickDevicesList.get() if (raw.isBlank()) return emptyList() val result = mutableListOf() @@ -42,10 +39,10 @@ class QuickDevices(context: Context) : Settings(context, "QuickDevices") { return result } - suspend fun setQuickDevices(quickDevices: List) = - setQuickDevicesRaw(quickDevices.joinToString("\n") { it.marshalToString() }) + suspend fun setQuickDevices(list: List) = + quickDevicesList.set(list.joinToString("\n") { it.marshalToString() }) - fun observeQuickDevices(): Flow> = observeQuickDevicesRaw() + fun observeQuickDevices(): Flow> = quickDevicesList.observe() .map { raw -> if (raw.isBlank()) return@map emptyList() @@ -162,10 +159,6 @@ class QuickDevices(context: Context) : Settings(context, "QuickDevices") { * 清空所有快速设备 */ suspend fun clearQuickDevices() { - setQuickDevicesRaw("") + quickDevicesList.set("") } - - private suspend fun getQuickConnectInput(): String = getValue(QUICK_CONNECT_INPUT) - private suspend fun setQuickConnectInput(value: String) = setValue(QUICK_CONNECT_INPUT, value) - private fun observeQuickConnectInput(): Flow = observe(QUICK_CONNECT_INPUT) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt index 076b5e2..c979f36 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt @@ -20,207 +20,207 @@ import kotlinx.coroutines.runBlocking class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { companion object { - private val CROP = Pair( + val CROP = Pair( stringPreferencesKey("crop"), "" ) - private val RECORD_FILENAME = Pair( + val RECORD_FILENAME = Pair( stringPreferencesKey("record_filename"), "" ) - private val VIDEO_CODEC_OPTIONS = Pair( + val VIDEO_CODEC_OPTIONS = Pair( stringPreferencesKey("video_codec_options"), "" ) - private val AUDIO_CODEC_OPTIONS = Pair( + val AUDIO_CODEC_OPTIONS = Pair( stringPreferencesKey("audio_codec_options"), "" ) - private val VIDEO_ENCODER = Pair( + val VIDEO_ENCODER = Pair( stringPreferencesKey("video_encoder"), "" ) - private val AUDIO_ENCODER = Pair( + val AUDIO_ENCODER = Pair( stringPreferencesKey("audio_encoder"), "" ) - private val CAMERA_ID = Pair( + val CAMERA_ID = Pair( stringPreferencesKey("camera_id"), "" ) - private val CAMERA_SIZE = Pair( + val CAMERA_SIZE = Pair( stringPreferencesKey("camera_size"), "" ) - private val CAMERA_AR = Pair( + val CAMERA_AR = Pair( stringPreferencesKey("camera_ar"), "" ) - private val CAMERA_FPS = Pair( + val CAMERA_FPS = Pair( intPreferencesKey("camera_fps"), 0 ) - private val LOG_LEVEL = Pair( + val LOG_LEVEL = Pair( stringPreferencesKey("log_level"), "info" ) - private val VIDEO_CODEC = Pair( + val VIDEO_CODEC = Pair( stringPreferencesKey("video_codec"), "h264" ) - private val AUDIO_CODEC = Pair( + val AUDIO_CODEC = Pair( stringPreferencesKey("audio_codec"), "opus" ) - private val VIDEO_SOURCE = Pair( + val VIDEO_SOURCE = Pair( stringPreferencesKey("video_source"), "display" ) - private val AUDIO_SOURCE = Pair( + val AUDIO_SOURCE = Pair( stringPreferencesKey("audio_source"), "output" ) - private val RECORD_FORMAT = Pair( + val RECORD_FORMAT = Pair( stringPreferencesKey("record_format"), "auto" ) - private val CAMERA_FACING = Pair( + val CAMERA_FACING = Pair( stringPreferencesKey("camera_facing"), "any" ) - private val MAX_SIZE = Pair( + val MAX_SIZE = Pair( intPreferencesKey("max_size"), 0 ) - private val VIDEO_BIT_RATE = Pair( + val VIDEO_BIT_RATE = Pair( intPreferencesKey("video_bit_rate"), 8000000 ) - private val AUDIO_BIT_RATE = Pair( + val AUDIO_BIT_RATE = Pair( intPreferencesKey("audio_bit_rate"), 128000 ) - private val MAX_FPS = Pair( + val MAX_FPS = Pair( stringPreferencesKey("max_fps"), "" ) - private val ANGLE = Pair( + val ANGLE = Pair( stringPreferencesKey("angle"), "" ) - private val CAPTURE_ORIENTATION = Pair( + val CAPTURE_ORIENTATION = Pair( intPreferencesKey("capture_orientation"), 0 ) - private val CAPTURE_ORIENTATION_LOCK = Pair( + val CAPTURE_ORIENTATION_LOCK = Pair( stringPreferencesKey("capture_orientation_lock"), "unlocked" ) - private val DISPLAY_ORIENTATION = Pair( + val DISPLAY_ORIENTATION = Pair( intPreferencesKey("display_orientation"), 0 ) - private val RECORD_ORIENTATION = Pair( + val RECORD_ORIENTATION = Pair( intPreferencesKey("record_orientation"), 0 ) - private val DISPLAY_IME_POLICY = Pair( + val DISPLAY_IME_POLICY = Pair( stringPreferencesKey("display_ime_policy"), "undefined" ) - private val DISPLAY_ID = Pair( + val DISPLAY_ID = Pair( intPreferencesKey("display_id"), 0 ) - private val SCREEN_OFF_TIMEOUT = Pair( + val SCREEN_OFF_TIMEOUT = Pair( longPreferencesKey("screen_off_timeout"), -1 ) - private val SHOW_TOUCHES = Pair( + val SHOW_TOUCHES = Pair( booleanPreferencesKey("show_touches"), false ) - private val FULLSCREEN = Pair( + val FULLSCREEN = Pair( booleanPreferencesKey("fullscreen"), false ) - private val CONTROL = Pair( + val CONTROL = Pair( booleanPreferencesKey("control"), true ) - private val VIDEO_PLAYBACK = Pair( + val VIDEO_PLAYBACK = Pair( booleanPreferencesKey("video_playback"), true ) - private val AUDIO_PLAYBACK = Pair( + val AUDIO_PLAYBACK = Pair( booleanPreferencesKey("audio_playback"), true ) - private val TURN_SCREEN_OFF = Pair( + val TURN_SCREEN_OFF = Pair( booleanPreferencesKey("turn_screen_off"), false ) - private val STAY_AWAKE = Pair( + val STAY_AWAKE = Pair( booleanPreferencesKey("stay_awake"), false ) - private val DISABLE_SCREENSAVER = Pair( + val DISABLE_SCREENSAVER = Pair( booleanPreferencesKey("disable_screensaver"), false ) - private val POWER_OFF_ON_CLOSE = Pair( + val POWER_OFF_ON_CLOSE = Pair( booleanPreferencesKey("power_off_on_close"), false ) - private val CLEANUP = Pair( + val CLEANUP = Pair( booleanPreferencesKey("cleanup"), true ) - private val POWER_ON = Pair( + val POWER_ON = Pair( booleanPreferencesKey("power_on"), true ) - private val VIDEO = Pair( + val VIDEO = Pair( booleanPreferencesKey("video"), true ) - private val AUDIO = Pair( + val AUDIO = Pair( booleanPreferencesKey("audio"), true ) - private val REQUIRE_AUDIO = Pair( + val REQUIRE_AUDIO = Pair( booleanPreferencesKey("require_audio"), false ) - private val KILL_ADB_ON_CLOSE = Pair( + val KILL_ADB_ON_CLOSE = Pair( booleanPreferencesKey("kill_adb_on_close"), false ) - private val CAMERA_HIGH_SPEED = Pair( + val CAMERA_HIGH_SPEED = Pair( booleanPreferencesKey("camera_high_speed"), false ) - private val LIST = Pair( + val LIST = Pair( stringPreferencesKey("list"), "null" ) - private val AUDIO_DUP = Pair( + val AUDIO_DUP = Pair( booleanPreferencesKey("audio_dup"), false ) - private val NEW_DISPLAY = Pair( + val NEW_DISPLAY = Pair( stringPreferencesKey("new_display"), "" ) - private val START_APP = Pair( + val START_APP = Pair( stringPreferencesKey("start_app"), "" ) - private val VD_DESTROY_CONTENT = Pair( + val VD_DESTROY_CONTENT = Pair( booleanPreferencesKey("vd_destroy_content"), true ) - private val VD_SYSTEM_DECORATIONS = Pair( + val VD_SYSTEM_DECORATIONS = Pair( booleanPreferencesKey("vd_system_decorations"), true ) @@ -278,61 +278,59 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { val vdDestroyContent by setting(VD_DESTROY_CONTENT) val vdSystemDecorations by setting(VD_SYSTEM_DECORATIONS) - override suspend fun toMap(): Map { - return mapOf( - CROP.name to crop.get(), - RECORD_FILENAME.name to recordFilename.get(), - VIDEO_CODEC_OPTIONS.name to videoCodecOptions.get(), - AUDIO_CODEC_OPTIONS.name to audioCodecOptions.get(), - VIDEO_ENCODER.name to videoEncoder.get(), - AUDIO_ENCODER.name to audioEncoder.get(), - CAMERA_ID.name to cameraId.get(), - CAMERA_SIZE.name to cameraSize.get(), - CAMERA_AR.name to cameraAr.get(), - CAMERA_FPS.name to cameraFps.get(), - LOG_LEVEL.name to logLevel.get(), - VIDEO_CODEC.name to videoCodec.get(), - AUDIO_CODEC.name to audioCodec.get(), - VIDEO_SOURCE.name to videoSource.get(), - AUDIO_SOURCE.name to audioSource.get(), - RECORD_FORMAT.name to recordFormat.get(), - CAMERA_FACING.name to cameraFacing.get(), - MAX_SIZE.name to maxSize.get(), - VIDEO_BIT_RATE.name to videoBitRate.get(), - AUDIO_BIT_RATE.name to audioBitRate.get(), - MAX_FPS.name to maxFps.get(), - ANGLE.name to angle.get(), - CAPTURE_ORIENTATION.name to captureOrientation.get(), - CAPTURE_ORIENTATION_LOCK.name to captureOrientationLock.get(), - DISPLAY_ORIENTATION.name to displayOrientation.get(), - RECORD_ORIENTATION.name to recordOrientation.get(), - DISPLAY_IME_POLICY.name to displayImePolicy.get(), - DISPLAY_ID.name to displayId.get(), - SCREEN_OFF_TIMEOUT.name to screenOffTimeout.get(), - SHOW_TOUCHES.name to showTouches.get(), - FULLSCREEN.name to fullscreen.get(), - CONTROL.name to control.get(), - VIDEO_PLAYBACK.name to videoPlayback.get(), - AUDIO_PLAYBACK.name to audioPlayback.get(), - TURN_SCREEN_OFF.name to turnScreenOff.get(), - STAY_AWAKE.name to stayAwake.get(), - DISABLE_SCREENSAVER.name to disableScreensaver.get(), - POWER_OFF_ON_CLOSE.name to powerOffOnClose.get(), - CLEANUP.name to cleanup.get(), - POWER_ON.name to powerOn.get(), - VIDEO.name to video.get(), - AUDIO.name to audio.get(), - REQUIRE_AUDIO.name to requireAudio.get(), - KILL_ADB_ON_CLOSE.name to killAdbOnClose.get(), - CAMERA_HIGH_SPEED.name to cameraHighSpeed.get(), - LIST.name to list.get(), - AUDIO_DUP.name to audioDup.get(), - NEW_DISPLAY.name to newDisplay.get(), - START_APP.name to startApp.get(), - VD_DESTROY_CONTENT.name to vdDestroyContent.get(), - VD_SYSTEM_DECORATIONS.name to vdSystemDecorations.get() - ) - } + override suspend fun toMap(): Map = mapOf( + CROP.name to crop.get(), + RECORD_FILENAME.name to recordFilename.get(), + VIDEO_CODEC_OPTIONS.name to videoCodecOptions.get(), + AUDIO_CODEC_OPTIONS.name to audioCodecOptions.get(), + VIDEO_ENCODER.name to videoEncoder.get(), + AUDIO_ENCODER.name to audioEncoder.get(), + CAMERA_ID.name to cameraId.get(), + CAMERA_SIZE.name to cameraSize.get(), + CAMERA_AR.name to cameraAr.get(), + CAMERA_FPS.name to cameraFps.get(), + LOG_LEVEL.name to logLevel.get(), + VIDEO_CODEC.name to videoCodec.get(), + AUDIO_CODEC.name to audioCodec.get(), + VIDEO_SOURCE.name to videoSource.get(), + AUDIO_SOURCE.name to audioSource.get(), + RECORD_FORMAT.name to recordFormat.get(), + CAMERA_FACING.name to cameraFacing.get(), + MAX_SIZE.name to maxSize.get(), + VIDEO_BIT_RATE.name to videoBitRate.get(), + AUDIO_BIT_RATE.name to audioBitRate.get(), + MAX_FPS.name to maxFps.get(), + ANGLE.name to angle.get(), + CAPTURE_ORIENTATION.name to captureOrientation.get(), + CAPTURE_ORIENTATION_LOCK.name to captureOrientationLock.get(), + DISPLAY_ORIENTATION.name to displayOrientation.get(), + RECORD_ORIENTATION.name to recordOrientation.get(), + DISPLAY_IME_POLICY.name to displayImePolicy.get(), + DISPLAY_ID.name to displayId.get(), + SCREEN_OFF_TIMEOUT.name to screenOffTimeout.get(), + SHOW_TOUCHES.name to showTouches.get(), + FULLSCREEN.name to fullscreen.get(), + CONTROL.name to control.get(), + VIDEO_PLAYBACK.name to videoPlayback.get(), + AUDIO_PLAYBACK.name to audioPlayback.get(), + TURN_SCREEN_OFF.name to turnScreenOff.get(), + STAY_AWAKE.name to stayAwake.get(), + DISABLE_SCREENSAVER.name to disableScreensaver.get(), + POWER_OFF_ON_CLOSE.name to powerOffOnClose.get(), + CLEANUP.name to cleanup.get(), + POWER_ON.name to powerOn.get(), + VIDEO.name to video.get(), + AUDIO.name to audio.get(), + REQUIRE_AUDIO.name to requireAudio.get(), + KILL_ADB_ON_CLOSE.name to killAdbOnClose.get(), + CAMERA_HIGH_SPEED.name to cameraHighSpeed.get(), + LIST.name to list.get(), + AUDIO_DUP.name to audioDup.get(), + NEW_DISPLAY.name to newDisplay.get(), + START_APP.name to startApp.get(), + VD_DESTROY_CONTENT.name to vdDestroyContent.get(), + VD_SYSTEM_DECORATIONS.name to vdSystemDecorations.get() + ) override fun validate(): Boolean = runBlocking { runCatching { @@ -342,61 +340,59 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { } // TODO: 处理空值 - suspend fun toClientOptions(): ClientOptions { - return ClientOptions( - crop = crop.get(), - recordFilename = recordFilename.get(), - videoCodecOptions = videoCodecOptions.get(), - audioCodecOptions = audioCodecOptions.get(), - videoEncoder = videoEncoder.get(), - audioEncoder = audioEncoder.get(), - cameraId = cameraId.get(), - cameraSize = cameraSize.get(), - cameraAr = cameraAr.get(), - cameraFps = cameraFps.get().toUShort(), - logLevel = LogLevel.valueOf(logLevel.get().uppercase()), - videoCodec = Codec.valueOf(videoCodec.get().uppercase()), - audioCodec = Codec.valueOf(audioCodec.get().uppercase()), - videoSource = VideoSource.valueOf(videoSource.get().uppercase()), - audioSource = AudioSource.valueOf(audioSource.get().uppercase()), - recordFormat = ClientOptions.RecordFormat.valueOf(recordFormat.get().uppercase()), - cameraFacing = CameraFacing.valueOf(cameraFacing.get().uppercase()), - maxSize = maxSize.get().toUShort(), - videoBitRate = videoBitRate.get().toUInt(), - audioBitRate = audioBitRate.get().toUInt(), - maxFps = maxFps.get(), - angle = angle.get(), - captureOrientation = Orientation.fromInt(captureOrientation.get()), - captureOrientationLock = OrientationLock.valueOf( - captureOrientationLock.get().uppercase() - ), - displayOrientation = Orientation.fromInt(displayOrientation.get()), - recordOrientation = Orientation.fromInt(recordOrientation.get()), - displayImePolicy = DisplayImePolicy.valueOf(displayImePolicy.get().uppercase()), - displayId = displayId.get().toUInt(), - screenOffTimeout = Tick(screenOffTimeout.get()), - showTouches = showTouches.get(), - fullscreen = fullscreen.get(), - control = control.get(), - videoPlayback = videoPlayback.get(), - audioPlayback = audioPlayback.get(), - turnScreenOff = turnScreenOff.get(), - stayAwake = stayAwake.get(), - disableScreensaver = disableScreensaver.get(), - powerOffOnClose = powerOffOnClose.get(), - cleanUp = cleanup.get(), - powerOn = powerOn.get(), - video = video.get(), - audio = audio.get(), - requireAudio = requireAudio.get(), - killAdbOnClose = killAdbOnClose.get(), - cameraHighSpeed = cameraHighSpeed.get(), - list = ListOptions.valueOf(list.get().uppercase()), - audioDup = audioDup.get(), - newDisplay = newDisplay.get(), - startApp = startApp.get(), - vdDestroyContent = vdDestroyContent.get(), - vdSystemDecorations = vdSystemDecorations.get() - ) - } + suspend fun toClientOptions() = ClientOptions( + crop = crop.get(), + recordFilename = recordFilename.get(), + videoCodecOptions = videoCodecOptions.get(), + audioCodecOptions = audioCodecOptions.get(), + videoEncoder = videoEncoder.get(), + audioEncoder = audioEncoder.get(), + cameraId = cameraId.get(), + cameraSize = cameraSize.get(), + cameraAr = cameraAr.get(), + cameraFps = cameraFps.get().toUShort(), + logLevel = LogLevel.valueOf(logLevel.get().uppercase()), + videoCodec = Codec.valueOf(videoCodec.get().uppercase()), + audioCodec = Codec.valueOf(audioCodec.get().uppercase()), + videoSource = VideoSource.valueOf(videoSource.get().uppercase()), + audioSource = AudioSource.valueOf(audioSource.get().uppercase()), + recordFormat = ClientOptions.RecordFormat.valueOf(recordFormat.get().uppercase()), + cameraFacing = CameraFacing.valueOf(cameraFacing.get().uppercase()), + maxSize = maxSize.get().toUShort(), + videoBitRate = videoBitRate.get().toUInt(), + audioBitRate = audioBitRate.get().toUInt(), + maxFps = maxFps.get(), + angle = angle.get(), + captureOrientation = Orientation.fromInt(captureOrientation.get()), + captureOrientationLock = OrientationLock.valueOf( + captureOrientationLock.get().uppercase() + ), + displayOrientation = Orientation.fromInt(displayOrientation.get()), + recordOrientation = Orientation.fromInt(recordOrientation.get()), + displayImePolicy = DisplayImePolicy.valueOf(displayImePolicy.get().uppercase()), + displayId = displayId.get().toUInt(), + screenOffTimeout = Tick(screenOffTimeout.get()), + showTouches = showTouches.get(), + fullscreen = fullscreen.get(), + control = control.get(), + videoPlayback = videoPlayback.get(), + audioPlayback = audioPlayback.get(), + turnScreenOff = turnScreenOff.get(), + stayAwake = stayAwake.get(), + disableScreensaver = disableScreensaver.get(), + powerOffOnClose = powerOffOnClose.get(), + cleanUp = cleanup.get(), + powerOn = powerOn.get(), + video = video.get(), + audio = audio.get(), + requireAudio = requireAudio.get(), + killAdbOnClose = killAdbOnClose.get(), + cameraHighSpeed = cameraHighSpeed.get(), + list = ListOptions.valueOf(list.get().uppercase()), + audioDup = audioDup.get(), + newDisplay = newDisplay.get(), + startApp = startApp.get(), + vdDestroyContent = vdDestroyContent.get(), + vdSystemDecorations = vdSystemDecorations.get() + ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt index 72036ed..63b9cf6 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt @@ -65,7 +65,7 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo -import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults +import io.github.miuzarte.scrcpyforandroid.constants.Defaults import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics @@ -1367,7 +1367,7 @@ internal fun DeviceEditorScreen( TextButton( text = "保存", onClick = { - val p = port.toIntOrNull() ?: AppDefaults.ADB_PORT + val p = port.toIntOrNull() ?: Defaults.ADB_PORT val h = host.trim() if (h.isNotBlank()) { onSave( diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt index dcea852..e130f30 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt @@ -31,10 +31,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp -import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults import io.github.miuzarte.scrcpyforandroid.constants.UiAndroidKeycodes import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing 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.ButtonDefaults @@ -135,7 +135,7 @@ object VirtualButtonActions { fun parseStoredLayout(raw: String): List { if (raw.isBlank()) - return parseStoredLayout(AppDefaults.VIRTUAL_BUTTONS_LAYOUT) + return parseStoredLayout(AppSettings.VIRTUAL_BUTTONS_LAYOUT.defaultValue) return raw.split(',').mapNotNull { item -> val parts = item.trim().split(':') From ebbf9f6d4bd0b93c7200b841f8f4cf53a950f99e Mon Sep 17 00:00:00 2001 From: Miuzarte <982809597@qq.com> Date: Sun, 29 Mar 2026 01:11:05 +0800 Subject: [PATCH 4/8] refactor: improve base class `Settings` --- app/build.gradle.kts | 4 +- .../miuzarte/scrcpyforandroid/MainActivity.kt | 5 + .../nativecore/DirectAdbClient.kt | 58 ++++--- .../pages/AdvancedConfigPage.kt | 8 +- .../scrcpyforandroid/pages/DevicePage.kt | 71 ++++++--- .../pages/FullscreenControlPage.kt | 8 +- .../scrcpyforandroid/pages/MainPage.kt | 6 +- .../scrcpyforandroid/pages/SettingsPage.kt | 56 ++++++- .../pages/VirtualButtonOrderPage.kt | 8 +- .../services/QuickDeviceStore.kt | 68 --------- .../scrcpyforandroid/storage/AdbClientData.kt | 20 +++ .../scrcpyforandroid/storage/AppSettings.kt | 26 +--- .../storage/PreferenceMigration.kt | 116 ++++++++------ .../scrcpyforandroid/storage/QuickDevices.kt | 144 ------------------ .../scrcpyforandroid/storage/ScrcpyOptions.kt | 56 +------ .../scrcpyforandroid/storage/Settings.kt | 27 ++-- .../scrcpyforandroid/storage/Storage.kt | 17 +++ .../storage/StorageMigration.kt | 0 .../scrcpyforandroid/widgets/DeviceWidgets.kt | 38 +++-- 19 files changed, 303 insertions(+), 433 deletions(-) create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AdbClientData.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Storage.kt delete mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/StorageMigration.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 066ef38..57dc91d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,8 +23,8 @@ android { applicationId = "io.github.miuzarte.scrcpyforandroid" minSdk = 26 targetSdk = 36 - versionCode = 5 - versionName = "0.0.5" + versionCode = 6 + versionName = "0.1.0" externalNativeBuild { cmake { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt index c9fda86..39cbeab 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt @@ -5,10 +5,15 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import io.github.miuzarte.scrcpyforandroid.pages.MainPage +import io.github.miuzarte.scrcpyforandroid.storage.Storage class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + // initialize settings singleton + Storage.init(applicationContext) + enableEdgeToEdge() setContent { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt index 16f50c3..9f8e5fd 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt @@ -4,8 +4,10 @@ import android.content.Context import android.os.Build import android.util.Base64 import android.util.Log -import androidx.core.content.edit +import io.github.miuzarte.scrcpyforandroid.storage.AdbClientData import io.github.miuzarte.scrcpyforandroid.storage.AppSettings +import io.github.miuzarte.scrcpyforandroid.storage.Storage +import kotlinx.coroutines.runBlocking import java.io.BufferedInputStream import java.io.Closeable import java.io.EOFException @@ -44,7 +46,7 @@ import kotlin.concurrent.thread */ internal class DirectAdbTransport(private val context: Context) { - private val keys: Pair by lazy { loadOrCreate() } + private val keys: Pair by lazy { runBlocking { loadOrCreate() } } val privateKey: PrivateKey get() = keys.first val publicKeyX509: ByteArray get() = keys.second @@ -101,40 +103,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). */ - private fun loadOrCreate(): Pair { - // TODO: migrate to data store - val prefs = context.getSharedPreferences( - "nativecore_adb_rsa", - Context.MODE_PRIVATE - ) - val privB64 = prefs.getString("priv", null) - if (privB64 != null) { + private suspend fun loadOrCreate( + forceNew: Boolean = false, + ): Pair { + val adbClientData = Storage.adbClientData + + val privB64 = adbClientData.rsaPrivateKey.get() + + if (privB64.isNotBlank() && !forceNew) { try { val kf = KeyFactory.getInstance("RSA") - val priv = - kf.generatePrivate(PKCS8EncodedKeySpec(Base64.decode(privB64, Base64.DEFAULT))) + val priv = kf.generatePrivate( + PKCS8EncodedKeySpec( + Base64.decode(privB64, Base64.DEFAULT) + ) + ) val pub = derivePublicX509(priv) - Log.i(TAG, "loadOrCreate(): loaded persisted RSA key pair, fp=${fingerprint(pub)}") + Log.i( + TAG, + "loadOrCreate(): loaded persisted RSA key pair from DataStore, " + + "fp=${fingerprint(pub)}" + ) return Pair(priv, pub) } 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") kpg.initialize(2048) val kp = kpg.generateKeyPair() - prefs.edit { - putString( - "priv", - Base64.encodeToString(kp.private.encoded, Base64.NO_WRAP) - ) - } + + // 保存到 DataStore + val privateKeyB64 = 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( 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) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt index 9bfcd92..f01cf62 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt @@ -28,8 +28,7 @@ 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 io.github.miuzarte.scrcpyforandroid.storage.AppSettings -import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions +import io.github.miuzarte.scrcpyforandroid.storage.Storage import kotlinx.coroutines.launch import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.ScrollBehavior @@ -89,10 +88,9 @@ internal fun AdvancedConfigPage( onRefreshEncoders: () -> Unit, onRefreshCameraSizes: () -> Unit, ) { - val context = LocalContext.current + val scrcpyOptions = Storage.scrcpyOptions - val appSettings = remember(context) { AppSettings(context) } - val scrcpyOptions = remember(context) { ScrcpyOptions(context) } + val context = LocalContext.current val focusManager = LocalFocusManager.current diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt index ac1aec7..6502619 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt @@ -34,11 +34,7 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions import io.github.miuzarte.scrcpyforandroid.services.EventLogger import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo -import io.github.miuzarte.scrcpyforandroid.services.parseQuickTarget -import io.github.miuzarte.scrcpyforandroid.services.upsertQuickDevice -import io.github.miuzarte.scrcpyforandroid.storage.AppSettings -import io.github.miuzarte.scrcpyforandroid.storage.QuickDevices -import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions +import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel import io.github.miuzarte.scrcpyforandroid.widgets.DeviceEditorScreen import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTile @@ -131,11 +127,11 @@ fun DeviceTabScreen( onOpenAdvancedPage: () -> Unit, onOpenFullscreenPage: (ScrcpySessionInfo) -> Unit, ) { - val context = LocalContext.current + val appSettings = Storage.appSettings + val quickDevices = Storage.quickDevices + val scrcpyOptions = Storage.scrcpyOptions - val appSettings = remember(context) { AppSettings(context) } - val quickDevices = remember(context) { QuickDevices(context) } - val scrcpyOptions = remember(context) { ScrcpyOptions(context) } + val context = LocalContext.current val haptics = rememberAppHaptics() @@ -216,11 +212,19 @@ fun DeviceTabScreen( } var quickDevicesList by quickDevices.quickDevicesList.asMutableState() - var savedShortcuts = rememberSaveable(saver = DeviceShortcutsSaver) { + var savedShortcuts = rememberSaveable(quickDevicesList, saver = DeviceShortcutsSaver) { DeviceShortcuts.unmarshalFrom(quickDevicesList) } var quickConnectInput by quickDevices.quickConnectInput.asMutableState() + // save changes when [savedShortcuts] was modified + LaunchedEffect(savedShortcuts) { + val serialized = savedShortcuts.marshalToString() + if (serialized != quickDevicesList) { + quickDevicesList = serialized + } + } + /** * Disconnect the current ADB connection and stop any running scrcpy session. * @@ -232,7 +236,7 @@ fun DeviceTabScreen( * - Calls `nativeCore.scrcpyStop()` and `nativeCore.adbDisconnect()` (best-effort). * - Resets UI-visible connection state: `adbConnected`, `currentTargetHost/Port`, * `sessionInfo`, device capability flags, `statusLine`, and `connectedDeviceLabel`. - * - Updates the saved quick-device list via [upsertQuickDevice] when a target is provided. + * - Updates the saved quick-device list via [savedShortcuts.update] when a target is provided. * - Logs an optional [logMessage] to the local event log. * - Shows an optional snackbar message asynchronously (launched on the composition scope) * so callers don't get blocked by `snack.showSnackbar` (it is suspending). @@ -263,8 +267,9 @@ fun DeviceTabScreen( connectedDeviceLabel = "未连接" clearQuickOnlineForTarget?.let { target -> if (target.host.isNotBlank()) - savedShortcuts = - savedShortcuts.update(host = target.host, port = target.port, online = false) + savedShortcuts = savedShortcuts.update( + host = target.host, port = target.port, online = false + ) } logMessage?.let { logEvent(it) } if (!showSnackMessage.isNullOrBlank()) { @@ -539,10 +544,24 @@ fun DeviceTabScreen( connectedDeviceLabel = info.model applyConnectedDeviceCapabilities(info.sdkInt, info.androidRelease) - quickDevices.updateQuickDeviceNameIfEmpty(host, port, fullLabel) + savedShortcuts = savedShortcuts.update( + host = host, + port = port, + name = fullLabel, + updateNameOnlyWhenEmpty = true + ) statusLine = "$host:$port" - logEvent("ADB 已连接: model=${info.model}, serial=${info.serial.ifBlank { "unknown" }}, manufacturer=${info.manufacturer.ifBlank { "unknown" }}, brand=${info.brand.ifBlank { "unknown" }}, device=${info.device.ifBlank { "unknown" }}, android=${info.androidRelease.ifBlank { "unknown" }}, sdk=${info.sdkInt}") + logEvent( + "ADB 已连接: " + + "model=${info.model}, " + + "serial=${info.serial.ifBlank { "unknown" }}, " + + "manufacturer=${info.manufacturer.ifBlank { "unknown" }}, " + + "brand=${info.brand.ifBlank { "unknown" }}, " + + "device=${info.device.ifBlank { "unknown" }}, " + + "android=${info.androidRelease.ifBlank { "unknown" }}, " + + "sdk=${info.sdkInt}" + ) scope.launch { snack.showSnackbar("ADB 已连接") } @@ -857,7 +876,8 @@ fun DeviceTabScreen( try { connectWithTimeout(host, port) adbConnected = true - savedShortcuts = savedShortcuts.update(host = host, port = port, online = true) + savedShortcuts = + savedShortcuts.update(host = host, port = port, online = true) handleAdbConnected(host, port) } catch (e: Exception) { statusLine = "ADB 连接失败" @@ -886,23 +906,32 @@ fun DeviceTabScreen( // "快速连接" QuickConnectCard( input = quickConnectInput, - onInputChange = { quickConnectInput = it }, + onValueChange = { quickConnectInput = it }, enabled = !adbConnecting, onAddDevice = { - val target = parseQuickTarget(quickConnectInput) ?: return@QuickConnectCard - savedShortcuts = savedShortcuts.update(host = target.host, port = target.port) + val target = ConnectionTarget.unmarshalFrom(quickConnectInput) + ?: return@QuickConnectCard + savedShortcuts = savedShortcuts.upsert( + DeviceShortcut(host = target.host, port = target.port) + ) + Log.i("SavedShortcuts", "size: ${savedShortcuts.size}, list: $quickDevicesList") scope.launch { snack.showSnackbar("已添加设备: ${target.host}:${target.port}") } }, onConnect = { - val target = parseQuickTarget(quickConnectInput) ?: return@QuickConnectCard + val target = ConnectionTarget.unmarshalFrom(quickConnectInput) + ?: return@QuickConnectCard runAdbConnect("连接 ADB", onFinished = { activeDeviceActionId = null }) { disconnectCurrentTargetBeforeConnecting(target.host, target.port) try { connectWithTimeout(target.host, target.port) adbConnected = true - savedShortcuts = savedShortcuts.update(host = target.host, port = target.port, online = true) + savedShortcuts = savedShortcuts.update( + host = target.host, + port = target.port, + online = true + ) handleAdbConnected(target.host, target.port) } catch (e: Exception) { statusLine = "ADB 连接失败" diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt index d9df992..6ac4fc8 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt @@ -22,8 +22,7 @@ import androidx.core.view.WindowInsetsControllerCompat import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics -import io.github.miuzarte.scrcpyforandroid.storage.AppSettings -import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions +import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar @@ -46,10 +45,9 @@ fun FullscreenControlPage( // Disable predictive back handler temporarily to avoid decoding issues. BackHandler(enabled = true, onBack = onDismiss) - val context = LocalContext.current + val appSettings = Storage.appSettings - val appSettings = remember(context) { AppSettings(context) } - val scrcpyOptions = remember(context) { ScrcpyOptions(context) } + val context = LocalContext.current val haptics = rememberAppHaptics() diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt index 203aa10..eff434a 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt @@ -51,7 +51,7 @@ import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.storage.AppSettings -import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions +import io.github.miuzarte.scrcpyforandroid.storage.Storage import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -191,8 +191,8 @@ fun MainPage() { } } - val appSettings = remember(context) { AppSettings(context) } - val scrcpyOptions = remember(context) { ScrcpyOptions(context) } + val appSettings = Storage.appSettings + val scrcpyOptions = Storage.scrcpyOptions val videoEncoderOptions = remember { mutableStateListOf() } val audioEncoderOptions = remember { mutableStateListOf() } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt index 206c42a..3130e87 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt @@ -1,6 +1,7 @@ package io.github.miuzarte.scrcpyforandroid.pages import android.content.Intent +import android.os.Process import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -13,6 +14,7 @@ import androidx.compose.material.icons.rounded.FileOpen import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -22,12 +24,16 @@ 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.storage.AppSettings -import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions +import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration +import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle +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.ScrollBehavior +import top.yukonga.miuix.kmp.basic.SnackbarHostState import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.TextField import top.yukonga.miuix.kmp.extra.SuperArrow @@ -35,6 +41,7 @@ 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 private data class ThemeModeOption( val label: String, @@ -68,10 +75,13 @@ fun SettingsScreen( onPickServer: () -> Unit, scrollBehavior: ScrollBehavior, ) { + val appContext = LocalContext.current.applicationContext + val appSettings = Storage.appSettings + val context = LocalContext.current - val appSettings = remember(context) { AppSettings(context) } - val scrcpyOptions = remember(context) { ScrcpyOptions(context) } + val scope = rememberCoroutineScope() + val snackHostState = remember { SnackbarHostState() } val baseModeItems = THEME_BASE_OPTIONS.map { it.label } @@ -255,17 +265,49 @@ fun SettingsScreen( ) } + SectionSmallTitle("应用") + Card { + SuperArrow( + title = "恢复旧版本配置", + summary = "从旧版本的 SharedPreferences 恢复至 DataStore", + onClick = { + scope.launch { + val migration = PreferenceMigration(appContext) + migration.migrate(clearSharedPrefs = false) + snackHostState.showSnackbar("迁移完成,应用将重启") + + 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) + } + }, + ) + } + 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( + Intent.ACTION_VIEW, + "https://github.com/Miuzarte/ScrcpyForAndroid".toUri(), + ) ) - context.startActivity(intent) }, ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderPage.kt index 39b289b..f30d70e 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderPage.kt @@ -8,8 +8,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn -import io.github.miuzarte.scrcpyforandroid.storage.AppSettings -import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions +import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions @@ -22,10 +21,9 @@ internal fun VirtualButtonOrderPage( contentPadding: PaddingValues, scrollBehavior: ScrollBehavior, ) { - val context = LocalContext.current + val appSettings = Storage.appSettings - val appSettings = remember(context) { AppSettings(context) } - val scrcpyOptions = remember(context) { ScrcpyOptions(context) } + val context = LocalContext.current var previewVirtualButtonShowText by appSettings.previewVirtualButtonShowText.asMutableState() var virtualButtonsLayout by appSettings.virtualButtonsLayout.asMutableState() diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt index 67fa3d3..c89c6db 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt @@ -4,7 +4,6 @@ import android.content.Context import androidx.core.content.edit import io.github.miuzarte.scrcpyforandroid.constants.AppPreferenceKeys import io.github.miuzarte.scrcpyforandroid.constants.Defaults -import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut internal fun loadQuickDevices(context: Context): List { @@ -63,70 +62,3 @@ internal fun saveQuickDevices(context: Context, quickDevices: List, - host: String, - port: Int, - online: Boolean, -) { - val idx = quickDevices.indexOfFirst { it.id == "$host:$port" } - val existingName = if (idx >= 0) quickDevices[idx].name else "" - val item = DeviceShortcut( - name = existingName, - host = host, - port = port, - online = online, - ) - if (idx >= 0) quickDevices[idx] = item else quickDevices.add(0, item) - saveQuickDevices(context, quickDevices) -} - -internal fun updateQuickDeviceNameIfEmpty( - context: Context, - quickDevices: MutableList, - host: String, - port: Int, - fallbackName: String, -) { - val idx = quickDevices.indexOfFirst { it.host == host && it.port == port } - if (idx >= 0 && quickDevices[idx].name.isBlank()) { - quickDevices[idx] = quickDevices[idx].copy(name = fallbackName) - saveQuickDevices(context, quickDevices) - } -} - -internal fun replaceQuickDevicePort( - context: Context, - quickDevices: MutableList, - 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( - port = newPort, - online = online, - ) - - quickDevices[idx] = updated - val dedup = quickDevices.distinctBy { it.id } - quickDevices.clear() - quickDevices.addAll(dedup) - saveQuickDevices(context, quickDevices) -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AdbClientData.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AdbClientData.kt new file mode 100644 index 0000000..5b92a29 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AdbClientData.kt @@ -0,0 +1,20 @@ +package io.github.miuzarte.scrcpyforandroid.storage + +import android.content.Context +import androidx.datastore.preferences.core.stringPreferencesKey + +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) +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt index a1e7d5e..e0f4f8b 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt @@ -87,30 +87,6 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { val adbAutoReconnectPairedDevice by setting(ADB_AUTO_RECONNECT_PAIRED_DEVICE) val adbMdnsLanDiscovery by setting(ADB_MDNS_LAN_DISCOVERY) - override suspend fun toMap(): Map = mapOf( - // Theme Settings - THEME_BASE_INDEX.name to themeBaseIndex.get(), - MONET.name to monet.get(), - - // Scrcpy Settings - FULLSCREEN_DEBUG_INFO.name to fullscreenDebugInfo.get(), - SHOW_FULLSCREEN_VIRTUAL_BUTTONS.name to showFullscreenVirtualButtons.get(), - KEEP_SCREEN_ON_WHEN_STREAMING.name to keepScreenOnWhenStreaming.get(), - DEVICE_PREVIEW_CARD_HEIGHT_DP.name to devicePreviewCardHeightDp.get(), - PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT.name to previewVirtualButtonShowText.get(), - VIRTUAL_BUTTONS_LAYOUT.name to virtualButtonsLayout.get(), - - // Scrcpy Server Settings - CUSTOM_SERVER_URI.name to customServerUri.get(), - SERVER_REMOTE_PATH.name to serverRemotePath.get(), - - // ADB Settings - ADB_KEY_NAME.name to adbKeyName.get(), - ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN.name to adbPairingAutoDiscoverOnDialogOpen.get(), - ADB_AUTO_RECONNECT_PAIRED_DEVICE.name to adbAutoReconnectPairedDevice.get(), - ADB_MDNS_LAN_DISCOVERY.name to adbMdnsLanDiscovery.get() - ) - // TODO? - override fun validate(): Boolean = true + // fun validate(): Boolean = true } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/PreferenceMigration.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/PreferenceMigration.kt index 952ae5d..0954abb 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/PreferenceMigration.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/PreferenceMigration.kt @@ -3,69 +3,80 @@ package io.github.miuzarte.scrcpyforandroid.storage import android.content.Context import android.content.SharedPreferences import android.util.Log +import androidx.core.content.edit import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults import io.github.miuzarte.scrcpyforandroid.constants.AppPreferenceKeys import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext private const val TAG = "PreferenceMigration" -private const val MIGRATION_COMPLETED_KEY = "migration_completed" /** * 从旧的 SharedPreferences 迁移到新的 DataStore */ -class PreferenceMigration(private val context: Context) { - +class PreferenceMigration(private val appContext: Context) { + private val appSharedPrefs: SharedPreferences by lazy { + appContext.getSharedPreferences(AppPreferenceKeys.PREFS_NAME, Context.MODE_PRIVATE) + } + private val sharedPrefs: SharedPreferences by lazy { - context.getSharedPreferences(AppPreferenceKeys.PREFS_NAME, Context.MODE_PRIVATE) + appContext.getSharedPreferences("nativecore_adb_rsa", Context.MODE_PRIVATE) } /** - * 检查是否已完成迁移 + * 检查是否需要迁移(SharedPreferences 是否包含数据) */ - @Deprecated("做成设置内入口手动调用,这个没必要") - suspend fun isMigrationCompleted(): Boolean = withContext(Dispatchers.IO) { - sharedPrefs.getBoolean(MIGRATION_COMPLETED_KEY, false) + suspend fun needsMigration(): Boolean = withContext(Dispatchers.IO) { + appSharedPrefs.all.isNotEmpty() || sharedPrefs.all.isNotEmpty() } /** * 执行完整迁移 - * @return 迁移是否成功 */ - suspend fun migrate(): Boolean = withContext(Dispatchers.IO) { - try { - if (isMigrationCompleted()) { - Log.i(TAG, "Migration already completed, skipping") - return@withContext true - } - - Log.i(TAG, "Starting migration from SharedPreferences to DataStore") - - // 迁移 AppSettings - migrateAppSettings() - - // 迁移 ScrcpyOptions - migrateScrcpyOptions() - - // 迁移 QuickDevices - migrateQuickDevices() - - // 标记迁移完成 - markMigrationCompleted() - - Log.i(TAG, "Migration completed successfully") - true - } catch (e: Exception) { - Log.e(TAG, "Migration failed", e) - false + 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 = AppSettings(context) + val appSettings = Storage.appSettings // Theme Settings migrateInt( @@ -152,7 +163,7 @@ class PreferenceMigration(private val context: Context) { * 迁移 Scrcpy 选项 */ private suspend fun migrateScrcpyOptions() { - val scrcpyOptions = ScrcpyOptions(context) + val scrcpyOptions = Storage.scrcpyOptions // Audio & Video Codecs migrateString( @@ -383,10 +394,10 @@ class PreferenceMigration(private val context: Context) { * 迁移快速设备列表 */ private suspend fun migrateQuickDevices() { - val quickDevices = QuickDevices(context) + val quickDevices = Storage.quickDevices // Migrate quick devices list - val quickDevicesRaw = sharedPrefs.getString( + val quickDevicesRaw = appSharedPrefs.getString( AppPreferenceKeys.QUICK_DEVICES, "" ).orEmpty() @@ -403,12 +414,21 @@ class PreferenceMigration(private val context: Context) { Log.d(TAG, "QuickDevices migration completed") } - + /** - * 标记迁移完成 + * 迁移 ADB 客户端数据(RSA 密钥) */ - private fun markMigrationCompleted() { - sharedPrefs.edit().putBoolean(MIGRATION_COMPLETED_KEY, true).apply() + 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 @@ -418,7 +438,7 @@ class PreferenceMigration(private val context: Context) { defaultValue: String, settingProperty: Settings.SettingProperty ) { - val value = sharedPrefs.getString(key, defaultValue) + val value = appSharedPrefs.getString(key, defaultValue) .orEmpty() .ifBlank { defaultValue } settingProperty.set(value) @@ -429,7 +449,7 @@ class PreferenceMigration(private val context: Context) { defaultValue: String, action: suspend (String) -> Unit ) { - val value = sharedPrefs.getString(key, defaultValue) + val value = appSharedPrefs.getString(key, defaultValue) .orEmpty() .ifBlank { defaultValue } action(value) @@ -440,7 +460,7 @@ class PreferenceMigration(private val context: Context) { defaultValue: Int, settingProperty: Settings.SettingProperty ) { - val value = sharedPrefs.getInt(key, defaultValue) + val value = appSharedPrefs.getInt(key, defaultValue) settingProperty.set(value) } @@ -449,7 +469,7 @@ class PreferenceMigration(private val context: Context) { defaultValue: Boolean, settingProperty: Settings.SettingProperty ) { - val value = sharedPrefs.getBoolean(key, defaultValue) + val value = appSharedPrefs.getBoolean(key, defaultValue) settingProperty.set(value) } @@ -458,7 +478,7 @@ class PreferenceMigration(private val context: Context) { defaultValue: Boolean, action: suspend (Boolean) -> Unit ) { - val value = sharedPrefs.getBoolean(key, defaultValue) + val value = appSharedPrefs.getBoolean(key, defaultValue) action(value) } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt index 2d96a42..ebba540 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt @@ -2,9 +2,6 @@ package io.github.miuzarte.scrcpyforandroid.storage import android.content.Context import androidx.datastore.preferences.core.stringPreferencesKey -import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map class QuickDevices(context: Context) : Settings(context, "QuickDevices") { companion object { @@ -20,145 +17,4 @@ class QuickDevices(context: Context) : Settings(context, "QuickDevices") { val quickDevicesList by setting(QUICK_DEVICES_LIST) val quickConnectInput by setting(QUICK_CONNECT_INPUT) - - override suspend fun toMap(): Map = mapOf( - QUICK_DEVICES_LIST.name to quickDevicesList.get(), - QUICK_CONNECT_INPUT.name to quickConnectInput.get(), - ) - - override fun validate(): Boolean = true - - suspend fun getQuickDevices(): List { - val raw = quickDevicesList.get() - if (raw.isBlank()) return emptyList() - - val result = mutableListOf() - raw.lineSequence().forEach { line -> - DeviceShortcut.unmarshalFrom(line)?.let { result.add(it) } - } - return result - } - - suspend fun setQuickDevices(list: List) = - quickDevicesList.set(list.joinToString("\n") { it.marshalToString() }) - - fun observeQuickDevices(): Flow> = quickDevicesList.observe() - .map { raw -> - if (raw.isBlank()) return@map emptyList() - - val result = mutableListOf() - raw.lineSequence().forEach { line -> - DeviceShortcut.unmarshalFrom(line)?.let { result.add(it) } - } - result - } - - /** - * 插入或更新快速设备 - */ - suspend fun upsertQuickDevice( - host: String, - port: Int, - online: Boolean, - index: Int? = 0, - ) { - val quickDevices = getQuickDevices().toMutableList() - val id = "$host:$port" - val idx = quickDevices.indexOfFirst { it.id == id } - val existingName = if (idx >= 0) quickDevices[idx].name else "" - val item = DeviceShortcut( - name = existingName, - host = host, - port = port, - online = online, - ) - if (idx >= 0) { - quickDevices[idx] = item - } else { - if (index != null) - quickDevices.add(index, item) - else quickDevices.add(item) - } - setQuickDevices(quickDevices) - } - - /** - * 如果设备名称为空,则更新为备用名称 - */ - suspend fun updateQuickDeviceNameIfEmpty( - host: String, - port: Int, - fallbackName: String, - ) { - val quickDevices = getQuickDevices().toMutableList() - val idx = quickDevices.indexOfFirst { it.host == host && it.port == port } - if (idx >= 0 && quickDevices[idx].name.isBlank()) { - quickDevices[idx] = quickDevices[idx].copy(name = fallbackName) - setQuickDevices(quickDevices) - } - } - - /** - * 替换快速设备的端口 - */ - suspend fun replaceQuickDevicePort( - host: String, - oldPort: Int, - newPort: Int, - online: Boolean, - ) { - val quickDevices = getQuickDevices().toMutableList() - val idx = quickDevices.indexOfFirst { it.host == host && it.port == oldPort } - if (idx < 0) return - - val old = quickDevices[idx] - val updated = old.copy( - port = newPort, - online = online, - ) - - quickDevices[idx] = updated - val dedup = quickDevices.distinctBy { it.id } - setQuickDevices(dedup) - } - - /** - * 删除快速设备 - */ - suspend fun removeQuickDevice(id: String) { - val quickDevices = getQuickDevices().toMutableList() - quickDevices.removeAll { it.id == id } - setQuickDevices(quickDevices) - } - - /** - * 更新设备在线状态 - */ - suspend fun updateDeviceOnlineStatus(host: String, port: Int, online: Boolean) { - val quickDevices = getQuickDevices().toMutableList() - val idx = quickDevices.indexOfFirst { it.host == host && it.port == port } - if (idx >= 0) { - quickDevices[idx] = quickDevices[idx].copy(online = online) - setQuickDevices(quickDevices) - } - } - - /** - * 更新设备名称 - */ - suspend fun updateDeviceName(id: String, name: String) { - val quickDevices = getQuickDevices().toMutableList() - val idx = quickDevices.indexOfFirst { it.id == id } - if (idx >= 0) { - quickDevices[idx] = quickDevices[idx].copy(name = name) - setQuickDevices(quickDevices) - } - } - - /** - * 清空所有快速设备 - */ - suspend fun clearQuickDevices() { - quickDevicesList.set("") - } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt index c979f36..d53509d 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt @@ -278,61 +278,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { val vdDestroyContent by setting(VD_DESTROY_CONTENT) val vdSystemDecorations by setting(VD_SYSTEM_DECORATIONS) - override suspend fun toMap(): Map = mapOf( - CROP.name to crop.get(), - RECORD_FILENAME.name to recordFilename.get(), - VIDEO_CODEC_OPTIONS.name to videoCodecOptions.get(), - AUDIO_CODEC_OPTIONS.name to audioCodecOptions.get(), - VIDEO_ENCODER.name to videoEncoder.get(), - AUDIO_ENCODER.name to audioEncoder.get(), - CAMERA_ID.name to cameraId.get(), - CAMERA_SIZE.name to cameraSize.get(), - CAMERA_AR.name to cameraAr.get(), - CAMERA_FPS.name to cameraFps.get(), - LOG_LEVEL.name to logLevel.get(), - VIDEO_CODEC.name to videoCodec.get(), - AUDIO_CODEC.name to audioCodec.get(), - VIDEO_SOURCE.name to videoSource.get(), - AUDIO_SOURCE.name to audioSource.get(), - RECORD_FORMAT.name to recordFormat.get(), - CAMERA_FACING.name to cameraFacing.get(), - MAX_SIZE.name to maxSize.get(), - VIDEO_BIT_RATE.name to videoBitRate.get(), - AUDIO_BIT_RATE.name to audioBitRate.get(), - MAX_FPS.name to maxFps.get(), - ANGLE.name to angle.get(), - CAPTURE_ORIENTATION.name to captureOrientation.get(), - CAPTURE_ORIENTATION_LOCK.name to captureOrientationLock.get(), - DISPLAY_ORIENTATION.name to displayOrientation.get(), - RECORD_ORIENTATION.name to recordOrientation.get(), - DISPLAY_IME_POLICY.name to displayImePolicy.get(), - DISPLAY_ID.name to displayId.get(), - SCREEN_OFF_TIMEOUT.name to screenOffTimeout.get(), - SHOW_TOUCHES.name to showTouches.get(), - FULLSCREEN.name to fullscreen.get(), - CONTROL.name to control.get(), - VIDEO_PLAYBACK.name to videoPlayback.get(), - AUDIO_PLAYBACK.name to audioPlayback.get(), - TURN_SCREEN_OFF.name to turnScreenOff.get(), - STAY_AWAKE.name to stayAwake.get(), - DISABLE_SCREENSAVER.name to disableScreensaver.get(), - POWER_OFF_ON_CLOSE.name to powerOffOnClose.get(), - CLEANUP.name to cleanup.get(), - POWER_ON.name to powerOn.get(), - VIDEO.name to video.get(), - AUDIO.name to audio.get(), - REQUIRE_AUDIO.name to requireAudio.get(), - KILL_ADB_ON_CLOSE.name to killAdbOnClose.get(), - CAMERA_HIGH_SPEED.name to cameraHighSpeed.get(), - LIST.name to list.get(), - AUDIO_DUP.name to audioDup.get(), - NEW_DISPLAY.name to newDisplay.get(), - START_APP.name to startApp.get(), - VD_DESTROY_CONTENT.name to vdDestroyContent.get(), - VD_SYSTEM_DECORATIONS.name to vdSystemDecorations.get() - ) - - override fun validate(): Boolean = runBlocking { + fun validate(): Boolean = runBlocking { runCatching { toClientOptions().validate() true diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt index ba530cf..020d165 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt @@ -42,11 +42,16 @@ abstract class Settings( } /** - * 设置项委托类,自动提供 get/set/observe/observeAsState/asMutableState 方法 + * 设置项委托类,自动提供 get/set/observe/asState/asMutableState 方法 */ inner class SettingProperty( - private val pair: Pair + val pair: Pair ) { + // 创建时注册自身 + init { + registerProperty(pair.name, this) + } + operator fun getValue(thisRef: Any?, property: KProperty<*>): SettingProperty = this suspend fun get(): T = getValue(pair) @@ -62,6 +67,15 @@ abstract class Settings( fun asMutableState(): MutableState = this@Settings.asMutableState(pair) } + // 注册表, 用于遍历 + private val propertyRegistry = mutableMapOf>() + + private fun registerProperty(name: String, property: SettingProperty) { + propertyRegistry[name] = property + } + + protected fun getAllProperties(): Map> = propertyRegistry.toMap() + // 为 Context 添加扩展委托属性,确保 DataStore 单例 private val Context.dataStore: DataStore by preferencesDataStore( name = this.name, @@ -69,21 +83,16 @@ abstract class Settings( scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) ) - abstract suspend fun toMap(): Map - abstract fun validate(): Boolean - // 对外暴露的 DataStore 实例 protected val dataStore: DataStore = context.dataStore - protected fun setting(pair: Pair): SettingProperty = - SettingProperty(pair) + protected fun setting(pair: Pair) = SettingProperty(pair) protected suspend fun getValue(pair: Pair): T = dataStore.data.first()[pair.key] ?: pair.defaultValue - protected suspend fun setValue(pair: Pair, value: T) { + protected suspend fun setValue(pair: Pair, value: T) = dataStore.edit { preferences -> preferences[pair.key] = value } - } protected fun observe(pair: Pair): Flow = dataStore.data.map { preferences -> preferences[pair.key] ?: pair.defaultValue } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Storage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Storage.kt new file mode 100644 index 0000000..57ffa88 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Storage.kt @@ -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) } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/StorageMigration.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/StorageMigration.kt deleted file mode 100644 index e69de29..0000000 diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt index 63b9cf6..d52f8ca 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt @@ -48,6 +48,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput @@ -71,8 +74,7 @@ import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide -import io.github.miuzarte.scrcpyforandroid.storage.AppSettings -import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions +import io.github.miuzarte.scrcpyforandroid.storage.Storage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -132,10 +134,9 @@ internal fun StatusCard( busyLabel: String?, connectedDeviceLabel: String, ) { - val context = LocalContext.current + val appSettings = Storage.appSettings - val appSettings = remember(context) { AppSettings(context) } - val scrcpyOptions = remember(context) { ScrcpyOptions(context) } + val context = LocalContext.current val themeBaseIndex by appSettings.themeBaseIndex.asState() @@ -381,10 +382,9 @@ internal fun ConfigPanel( onStop: () -> Unit, sessionStarted: Boolean, ) { - val context = LocalContext.current + val scrcpyOptions = Storage.scrcpyOptions - // val appSettings = remember(context) { AppSettings(context) } - val scrcpyOptions = remember(context) { ScrcpyOptions(context) } + val context = LocalContext.current SectionSmallTitle("Scrcpy") Card { @@ -1221,12 +1221,16 @@ internal fun DeviceTile( @Composable internal fun QuickConnectCard( input: String, - onInputChange: (String) -> Unit, + onValueChange: (String) -> Unit, onConnect: () -> Unit, onAddDevice: () -> Unit, enabled: Boolean = true, ) { + val scope = rememberCoroutineScope() val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } + var tempText by remember(input) { mutableStateOf(input) } + var tempFocusState by remember(input) { mutableStateOf(false) } Card( colors = CardDefaults.defaultColors(color = MiuixTheme.colorScheme.primaryContainer), @@ -1256,10 +1260,8 @@ internal fun QuickConnectCard( ) } TextField( - value = input, - onValueChange = { - if (enabled) onInputChange(it) - }, + value = tempText, + onValueChange = { tempText = it }, label = "IP:PORT", enabled = enabled, useLabelAsPlaceholder = true, @@ -1267,7 +1269,15 @@ internal fun QuickConnectCard( modifier = Modifier .fillMaxWidth() .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.SectionTitleLeadingGap), + .padding(bottom = UiSpacing.SectionTitleLeadingGap) + .onFocusChanged { focusState -> + // 失去焦点时回调 + if (!focusState.isFocused && tempFocusState) { + onValueChange(tempText) + } + tempFocusState = focusState.isFocused + } + .focusRequester(focusRequester), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), ) From e970c89e8f3a93d51fc3822392f79d0eb1d5eb00 Mon Sep 17 00:00:00 2001 From: Miuzarte <982809597@qq.com> Date: Mon, 6 Apr 2026 23:38:22 +0800 Subject: [PATCH 5/8] refactor: almost ready for v0.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优化 `Shared` 中的 `Codec` 枚举,增加显示名称并添加 `isLossyAudio` 函数 - 简化 `Codec`、`VideoSource`、`AudioSource` 和 `CameraFacing` 枚举中的 `fromString` 方法 - 将 `fetchConnectedDeviceInfo` 改为挂起函数以支持协程 - 清理 `PreferenceMigration` 和 `ScrcpyOptions`,提高可读性 - 更新 `Settings` 类,添加 `isDefaultValue` 挂起函数 - 改进 `DeviceWidgets`,增强状态管理和 UI 响应性 --- app/build.gradle.kts | 1 + .../miuzarte/scrcpyforandroid/MainActivity.kt | 8 + .../scrcpyforandroid/NativeCoreFacade.kt | 96 +- .../constants/ScrcpyPresets.kt | 64 +- .../scrcpyforandroid/models/DeviceModels.kt | 2 +- .../scrcpyforandroid/models/ScrcpyOptions.kt | 75 ++ .../nativecore/AnnexBDecoder.kt | 3 + .../nativecore/DirectAdbClient.kt | 8 +- .../nativecore/NativeAdbService.kt | 58 +- .../nativecore/ScrcpySessionManager.kt | 918 ------------------ .../pages/AdvancedConfigPage.kt | 500 +++++----- .../scrcpyforandroid/pages/DevicePage.kt | 271 +++--- .../pages/FullscreenControlPage.kt | 18 +- .../scrcpyforandroid/pages/MainPage.kt | 72 +- .../scrcpyforandroid/pages/SettingsPage.kt | 108 ++- .../{SuperSlide.kt => SuperSlider.kt} | 102 +- .../scaffolds/SuperTextField.kt | 121 +++ .../scrcpyforandroid/scrcpy/ClientOptions.kt | 15 +- .../scrcpyforandroid/scrcpy/Scrcpy.kt | 839 +++++++++++++++- .../scrcpyforandroid/scrcpy/ServerParams.kt | 27 +- .../scrcpyforandroid/scrcpy/Shared.kt | 54 +- .../services/DeviceConnectionLogic.kt | 13 +- .../storage/PreferenceMigration.kt | 2 +- .../scrcpyforandroid/storage/ScrcpyOptions.kt | 130 +-- .../scrcpyforandroid/storage/Settings.kt | 2 + .../scrcpyforandroid/widgets/DeviceWidgets.kt | 291 +++--- gradle/libs.versions.toml | 2 + 27 files changed, 2001 insertions(+), 1799 deletions(-) create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/ScrcpyOptions.kt delete mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpySessionManager.kt rename app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/{SuperSlide.kt => SuperSlider.kt} (64%) create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperTextField.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 57dc91d..d448197 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -98,6 +98,7 @@ dependencies { implementation("org.conscrypt:conscrypt-android:2.5.2") implementation("sh.calvin.reorderable:reorderable:3.0.0") implementation("androidx.datastore:datastore-preferences:1.2.1") + implementation(libs.androidx.compose.runtime) testImplementation(libs.junit) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt index 39cbeab..48b2a58 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt @@ -5,7 +5,9 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import io.github.miuzarte.scrcpyforandroid.pages.MainPage +import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration import io.github.miuzarte.scrcpyforandroid.storage.Storage +import kotlinx.coroutines.runBlocking class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -14,6 +16,12 @@ class MainActivity : ComponentActivity() { // initialize settings singleton Storage.init(applicationContext) + val migration = PreferenceMigration(applicationContext) + runBlocking { + if (migration.needsMigration()) + migration.migrate(clearSharedPrefs = true) + } + enableEdgeToEdge() setContent { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt index 8842f36..27d671b 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt @@ -6,9 +6,7 @@ import android.os.Looper import android.util.Log import android.view.Surface import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder -import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService -import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpyAudioPlayer -import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpySessionManager +import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.util.ArrayDeque @@ -16,15 +14,17 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArraySet /** - * Facade that centralizes video rendering and ADB operations. + * Facade that centralizes video rendering. * * Provides helpers for: - * - ADB operations (all suspend functions) * - Surface/Decoder management for video rendering - * - Control input injection (all suspend functions) + * - Video size and FPS monitoring */ -class NativeCoreFacade(private val appContext: Context) { - val sessionManager = ScrcpySessionManager(NativeAdbService(appContext)) +class NativeCoreFacade private constructor() { + @Volatile + var session: Scrcpy.Session? = null + private set + private val sessionLifecycleMutex = Mutex() private val surfaceMap = ConcurrentHashMap() private val surfaceIdentityMap = ConcurrentHashMap() @@ -40,10 +40,7 @@ class NativeCoreFacade(private val appContext: Context) { private var packetCount: Long = 0 @Volatile - private var audioPlayer: ScrcpyAudioPlayer? = null - - @Volatile - private var currentSessionInfo: ScrcpySessionInfo? = null + private var currentSessionInfo: Scrcpy.Session.SessionInfo? = null suspend fun close() { sessionLifecycleMutex.withLock { @@ -63,6 +60,10 @@ class NativeCoreFacade(private val appContext: Context) { */ suspend fun registerVideoSurface(tag: String, surface: Surface) { sessionLifecycleMutex.withLock { + if (!surface.isValid) { + Log.w(TAG, "registerVideoSurface(): skip invalid surface for tag=$tag") + return + } val newId = System.identityHashCode(surface) val oldId = surfaceIdentityMap[tag] if (oldId != null && oldId == newId && decoderMap.containsKey(tag)) { @@ -103,12 +104,12 @@ class NativeCoreFacade(private val appContext: Context) { ) return } - Log.i(TAG, "unregisterVideoSurface(): tag=$tag surfaceId=$requestId") + Log.i(TAG, "unregisterVideoSurface(): tag=$tag surfaceId=$requestId, releasing decoder") surfaceMap.remove(tag) surfaceIdentityMap.remove(tag) - if (currentSessionInfo == null) { - decoderMap.remove(tag)?.release() - } + // Always release decoder when surface is unregistered + // This ensures clean state when surface is recreated + decoderMap.remove(tag)?.release() } } @@ -129,7 +130,7 @@ class NativeCoreFacade(private val appContext: Context) { } suspend fun scrcpyBackOrScreenOn(action: Int = 0) { - sessionManager.pressBackOrScreenOn(action) + session?.pressBackOrScreenOn(action) } /** @@ -137,9 +138,10 @@ class NativeCoreFacade(private val appContext: Context) { * Sets up video decoders for registered surfaces. */ suspend fun onScrcpySessionStarted( - session: ScrcpySessionInfo, - sessionMgr: ScrcpySessionManager + session: Scrcpy.Session.SessionInfo, + sessionMgr: Scrcpy.Session ) = sessionLifecycleMutex.withLock { + this.session = sessionMgr currentSessionInfo = session releaseAllDecoders() synchronized(bootstrapLock) { @@ -148,6 +150,10 @@ class NativeCoreFacade(private val appContext: Context) { } surfaceMap.forEach { (tag, surface) -> + if (!surface.isValid) { + Log.w(TAG, "onScrcpySessionStarted(): skip invalid surface for tag=$tag") + return@forEach + } Log.i(TAG, "onScrcpySessionStarted(): bind decoder to tag=$tag") createOrReplaceDecoder(tag, surface, session) } @@ -164,10 +170,16 @@ class NativeCoreFacade(private val appContext: Context) { "videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoders=${decoderMap.size}" ) } - decoderMap.forEach { (tag, decoder) -> + // Snapshot decoders to avoid feeding released decoders during iteration + val decoders = decoderMap.toMap() + decoders.forEach { (tag, decoder) -> if (!surfaceIdentityMap.containsKey(tag)) { return@forEach } + // Double-check decoder is still in map before feeding + if (decoderMap[tag] != decoder) { + return@forEach + } runCatching { decoder.feedAnnexB( packet.data, @@ -185,6 +197,7 @@ class NativeCoreFacade(private val appContext: Context) { * Cleans up decoders and resets state. */ suspend fun onScrcpySessionStopped() = sessionLifecycleMutex.withLock { + session = null releaseAllDecoders() synchronized(bootstrapLock) { bootstrapPackets.clear() @@ -202,7 +215,7 @@ class NativeCoreFacade(private val appContext: Context) { fun get(context: Context): NativeCoreFacade { return instance ?: synchronized(this) { - instance ?: NativeCoreFacade(context.applicationContext).also { instance = it } + instance ?: NativeCoreFacade().also { instance = it } } } @@ -246,23 +259,26 @@ class NativeCoreFacade(private val appContext: Context) { * - Newly created decoders are fed with any cached bootstrap packets to allow * faster playback startup. */ - private fun createOrReplaceDecoder(tag: String, surface: Surface, session: ScrcpySessionInfo) { - decoderMap.remove(tag)?.release() - val mime = when (session.codec.lowercase()) { - "h264" -> "video/avc" - "h265" -> "video/hevc" - "av1" -> "video/av01" - else -> "video/avc" + private fun createOrReplaceDecoder(tag: String, surface: Surface, session: Scrcpy.Session.SessionInfo) { + if (!surface.isValid) { + Log.w(TAG, "createOrReplaceDecoder(): skip invalid surface for tag=$tag") + return } + decoderMap.remove(tag)?.release() Log.i( TAG, - "createOrReplaceDecoder(): tag=$tag codec=$mime size=${session.width}x${session.height}" + "createOrReplaceDecoder(): tag=$tag codec=${session.codecName} size=${session.width}x${session.height}" ) val decoder = AnnexBDecoder( width = session.width, height = session.height, outputSurface = surface, - mimeType = mime, + mimeType = when (session.codecName.lowercase()) { + "h264" -> "video/avc" + "h265" -> "video/hevc" + "av1" -> "video/av01" + else -> "video/avc" + }, onOutputSizeChanged = { width, height -> val current = currentSessionInfo if (current == null || (current.width == width && current.height == height)) { @@ -304,7 +320,7 @@ class NativeCoreFacade(private val appContext: Context) { } } - private fun cacheBootstrapPacket(packet: ScrcpySessionManager.VideoPacket) { + private fun cacheBootstrapPacket(packet: Scrcpy.Session.VideoPacket) { val cached = CachedPacket( data = packet.data.copyOf(), ptsUs = packet.ptsUs, @@ -343,7 +359,7 @@ class NativeCoreFacade(private val appContext: Context) { * isolated with `runCatching` so one codec failure doesn't stop others. */ @Deprecated("TODO: Determine if this is really unnecessary") - private suspend fun ensureVideoConsumerAttached(sessionMgr: ScrcpySessionManager) { + private suspend fun ensureVideoConsumerAttached(sessionMgr: Scrcpy.Session) { sessionMgr.attachVideoConsumer { packet -> cacheBootstrapPacket(packet) packetCount += 1 @@ -353,10 +369,16 @@ class NativeCoreFacade(private val appContext: Context) { "videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoders=${decoderMap.size}" ) } - decoderMap.forEach { (tag, decoder) -> + // Snapshot decoders to avoid feeding released decoders during iteration + val decoders = decoderMap.toMap() + decoders.forEach { (tag, decoder) -> if (!surfaceIdentityMap.containsKey(tag)) { return@forEach } + // Double-check decoder is still in map before feeding + if (decoderMap[tag] != decoder) { + return@forEach + } runCatching { decoder.feedAnnexB( packet.data, @@ -375,12 +397,4 @@ class NativeCoreFacade(private val appContext: Context) { } decoderMap.clear() } - - data class ScrcpySessionInfo( - val width: Int, - val height: Int, - val deviceName: String, - val codec: String, - val controlEnabled: Boolean, - ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt index 481ab23..5e01a6a 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt @@ -1,7 +1,63 @@ package io.github.miuzarte.scrcpyforandroid.constants -object ScrcpyPresets { - val MaxSize = listOf(0, 720, 1080, 1280, 1600, 1920, 2160, 2560, 3200, 3840) // px - val MaxFPS = listOf(0, 24, 30, 45, 60, 90, 120) - val AudioBitRate = listOf(32, 64, 96, 128, 160, 192, 256, 320, 384, 512) // Kbps +/** + * A generic preset class that holds a list of preset values and provides + * methods to find the index of a value or its nearest match. + * + * @param T The type of preset values (typically Int) + * @param values The list of preset values + */ +class Preset>(val values: List) { + 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.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.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(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 } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt index 5b390bd..d857f54 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt @@ -160,7 +160,7 @@ data class ConnectionTarget( val host: String, val port: Int = Defaults.ADB_PORT, ) { - fun marshalToString(): String = "$host:$port" + override fun toString(): String = "$host:$port" companion object { fun unmarshalFrom(s: String): ConnectionTarget? { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/ScrcpyOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/ScrcpyOptions.kt new file mode 100644 index 0000000..79db4e3 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/ScrcpyOptions.kt @@ -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 { + // [x][/] + + 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() + } + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AnnexBDecoder.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AnnexBDecoder.kt index 7ba5b7e..c8bbe94 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AnnexBDecoder.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AnnexBDecoder.kt @@ -42,6 +42,9 @@ class AnnexBDecoder( private var released = false init { + if (!outputSurface.isValid) { + throw IllegalStateException("Cannot initialize decoder: output surface is not valid") + } val format = MediaFormat.createVideoFormat(mimeType, width, height) if (sps != null) { format.setByteBuffer("csd-0", java.nio.ByteBuffer.wrap(sps)) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt index 9f8e5fd..839cdf6 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt @@ -4,7 +4,6 @@ import android.content.Context import android.os.Build import android.util.Base64 import android.util.Log -import io.github.miuzarte.scrcpyforandroid.storage.AdbClientData import io.github.miuzarte.scrcpyforandroid.storage.AppSettings import io.github.miuzarte.scrcpyforandroid.storage.Storage import kotlinx.coroutines.runBlocking @@ -394,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() { if (!closed) { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt index a3b9510..90ae92a 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt @@ -2,8 +2,10 @@ package io.github.miuzarte.scrcpyforandroid.nativecore import android.content.Context 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 kotlin.time.Duration @@ -14,6 +16,8 @@ import kotlin.time.Duration * * Methods use Mutex for thread-safety because the underlying transport is single-connection * and may be accessed from multiple coroutines. + * + * All network operations are executed on Dispatchers.IO. */ class NativeAdbService(appContext: Context) { private val transport = DirectAdbTransport(appContext) @@ -82,35 +86,39 @@ class NativeAdbService(appContext: Context) { host: String, port: Int, timeout: Duration = Duration.INFINITE, - ) = mutex.withLock { - Log.i(TAG, "connect(): host=$host port=$port") + ) = withContext(Dispatchers.IO) { + mutex.withLock { + Log.i(TAG, "connect(): host=$host port=$port") - if (connection != null - && connection!!.isAlive() - && connectedHost == host - && connectedPort == port - ) { - return@withLock - } - disconnectInternal() + if (connection != null + && connection!!.isAlive() + && connectedHost == host + && connectedPort == port + ) { + return@withLock + } + disconnectInternal() - 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) + 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. */ - suspend fun disconnect() = mutex.withLock { - disconnectInternal() + suspend fun disconnect() = withContext(Dispatchers.IO) { + mutex.withLock { + disconnectInternal() + } } suspend fun isConnected(): Boolean = mutex.withLock { @@ -121,7 +129,9 @@ class NativeAdbService(appContext: Context) { * Execute a shell command on the connected device and return stdout text. */ suspend fun shell(command: String): String = mutex.withLock { - requireConnection().shell(command) + val response = requireConnection().shell(command) + Log.d(TAG, "command: $command, response: $response") + response } suspend fun openShellStream(command: String): AdbSocketStream = mutex.withLock { @@ -136,7 +146,9 @@ class NativeAdbService(appContext: Context) { requireConnection().openStream("localabstract:$name") } - suspend fun close() = disconnect() + suspend fun close() { + disconnect() + } private fun disconnectInternal() { runCatching { connection?.close() } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpySessionManager.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpySessionManager.kt deleted file mode 100644 index b23287d..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpySessionManager.kt +++ /dev/null @@ -1,918 +0,0 @@ -package io.github.miuzarte.scrcpyforandroid.nativecore - -import android.util.Log -import android.view.KeyEvent -import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions -import io.github.miuzarte.scrcpyforandroid.scrcpy.ServerParams -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import java.io.BufferedInputStream -import java.io.BufferedReader -import java.io.DataInputStream -import java.io.DataOutputStream -import java.io.EOFException -import java.io.InputStreamReader -import java.nio.file.Path -import java.security.SecureRandom -import java.util.ArrayDeque -import kotlin.concurrent.thread -import kotlin.math.roundToInt - -class ScrcpySessionManager(private val adbService: NativeAdbService) { - private val mutex = Mutex() - - @Volatile - private var activeSession: ActiveSession? = null - - @Volatile - private var videoConsumer: ((VideoPacket) -> Unit)? = null - - @Volatile - private var videoReaderThread: Thread? = null - - @Volatile - private var audioConsumer: ((AudioPacket) -> Unit)? = null - - @Volatile - private var audioReaderThread: Thread? = null - - @Volatile - private var lastServerCommand: String? = null - private val serverLogBuffer = ArrayDeque() - - /** - * Start a scrcpy session. - * - * Responsibilities: - * - Pushes the server artifact to the device, constructs the server command, - * and opens the server shell stream. - * - Opens the required abstract sockets (video/audio/control) with retries and - * reads initial session metadata (device name, codec, resolution). - * - Initializes an [ActiveSession] which holds socket streams and reader threads. - * - * Threading notes: - * - Uses Mutex for thread-safety to avoid concurrent starts/stops. - * - It may block while interacting with adb; callers should execute it off the UI - * thread when appropriate. - */ - suspend fun start( - serverJarPath: Path, - serverCommand: String, - scid: UInt, - options: ClientOptions, - ): SessionInfo = mutex.withLock { - stopInternal() - serverLogBuffer.clear() - val socketName = socketNameFor(scid.toInt()) - - try { - lastServerCommand = serverCommand - Log.i( - TAG, - "start(): socket=$socketName codec=${options.videoCodec.string} audio=${options.audio} audioCodec=${options.audioCodec.string}" - ) - val serverStream = adbService.openShellStream(serverCommand) - val serverLogThread = startServerLogThread(serverStream, socketName) - Thread.sleep(SERVER_BOOT_DELAY_MS) - - // The first socket always carries device meta (and dummy byte). - val firstStream = openAbstractSocketWithRetry(socketName, expectDummyByte = true) - val firstInput = DataInputStream(BufferedInputStream(firstStream.inputStream)) - - var videoStream: AdbSocketStream? = null - var videoInput: DataInputStream? = null - var audioStream: AdbSocketStream? = null - var audioInput: DataInputStream? = null - var controlStream: AdbSocketStream? = null - - when { - options.video -> { - videoStream = firstStream - videoInput = firstInput - } - - options.audio -> { - audioStream = firstStream - audioInput = firstInput - } - - options.control -> { - controlStream = firstStream - } - - else -> { - throw IllegalArgumentException("At least one of video/audio/control must be enabled") - } - } - - if (options.video && videoStream == null) { - val vStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false) - videoStream = vStream - videoInput = DataInputStream(BufferedInputStream(vStream.inputStream)) - } - - if (options.audio && audioStream == null) { - val aStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false) - audioStream = aStream - audioInput = DataInputStream(BufferedInputStream(aStream.inputStream)) - } - - if (options.control && controlStream == null) { - controlStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false) - } - - val deviceName = readDeviceName(firstInput) - val audioCodecId = - if (options.audio) audioCodecIdFromName(options.audioCodec.string) else 0 - val codecId: Int - val width: Int - val height: Int - if (options.video) { - val vInput = checkNotNull(videoInput) - codecId = vInput.readInt() - width = vInput.readInt() - height = vInput.readInt() - } else { - codecId = 0 - width = 0 - height = 0 - } - - val sessionInfo = SessionInfo( - deviceName = deviceName, - codecId = codecId, - codecName = codecName(codecId), - width = width, - height = height, - audioCodecId = audioCodecId, - controlEnabled = controlStream != null, - ) - - activeSession = ActiveSession( - info = sessionInfo, - socketName = socketName, - serverStream = serverStream, - serverLogThread = serverLogThread, - videoStream = videoStream, - videoInput = videoInput, - audioStream = audioStream, - audioInput = audioInput, - controlStream = controlStream, - controlWriter = controlStream?.outputStream?.let { - ScrcpyControlWriter( - DataOutputStream(it) - ) - }, - ) - return sessionInfo - } catch (t: Throwable) { - val tail = snapshotServerLogs() - val detail = if (tail.isBlank()) "" else " | server_log_tail=\n$tail" - throw IllegalStateException("scrcpy start failed: ${t.message}$detail", t) - } - } - - /** - * Attach a video consumer callback. - * - * - Spawns a dedicated `scrcpy-video-reader` thread that reads framed Annex B - * packets from the video socket and delivers `VideoPacket` instances to [consumer]. - * - The reader thread stops when the session ends or the socket is closed. - * - Consumers should be resilient to occasional dropped packets or reader errors. - */ - suspend fun attachVideoConsumer(consumer: (VideoPacket) -> Unit): Unit = mutex.withLock { - val session = activeSession ?: throw IllegalStateException("scrcpy session not started") - val vInput = session.videoInput ?: return - val vStream = session.videoStream ?: return - videoConsumer = consumer - if (videoReaderThread?.isAlive == true) { - return - } - - videoReaderThread = thread(start = true, name = "scrcpy-video-reader") { - while (activeSession === session && !vStream.closed) { - try { - val ptsAndFlags = vInput.readLong() - val packetSize = vInput.readInt() - if (packetSize <= 0) { - continue - } - - val payload = ByteArray(packetSize) - vInput.readFully(payload) - - val config = (ptsAndFlags and PACKET_FLAG_CONFIG) != 0L - val keyFrame = (ptsAndFlags and PACKET_FLAG_KEY_FRAME) != 0L - val ptsUs = ptsAndFlags and PACKET_PTS_MASK - videoConsumer?.invoke( - VideoPacket( - data = payload, - ptsUs = ptsUs, - isConfig = config, - isKeyFrame = keyFrame, - ), - ) - } catch (_: EOFException) { - break - } catch (_: InterruptedException) { - // Ignore transient interrupts while session remains active. - if (activeSession !== session || vStream.closed) { - break - } - Thread.interrupted() - } catch (e: Exception) { - Log.w(TAG, "video reader failed", e) - break - } - } - } - } - - suspend fun clearVideoConsumer() = mutex.withLock { - videoConsumer = null - } - - /** - * Attach an audio consumer callback. - * - * - Similar to the video consumer, this starts a `scrcpy-audio-reader` thread - * which reads audio packets and dispatches `AudioPacket` to the provided callback. - * - The function reads the audio stream header to determine whether audio is - * available and exits early if disabled. - */ - suspend fun attachAudioConsumer(consumer: (AudioPacket) -> Unit): Unit = mutex.withLock { - val session = activeSession ?: throw IllegalStateException("scrcpy session not started") - val aInput = session.audioInput ?: return // audio disabled or unavailable - val aStream = session.audioStream ?: return - audioConsumer = consumer - if (audioReaderThread?.isAlive == true) return - - audioReaderThread = thread(start = true, name = "scrcpy-audio-reader") { - val streamCodecId = try { - aInput.readInt() - } catch (e: Exception) { - Log.w(TAG, "audio codec header read failed", e) - return@thread - } - when (streamCodecId) { - AUDIO_DISABLED -> { - Log.w(TAG, "audio disabled by server") - return@thread - } - - AUDIO_ERROR -> { - Log.e(TAG, "audio stream configuration error from server") - return@thread - } - - else -> { - Log.i(TAG, "audio stream codec=0x${streamCodecId.toUInt().toString(16)}") - } - } - if (session.info.audioCodecId != 0 && streamCodecId != session.info.audioCodecId) { - Log.w( - TAG, - "audio codec mismatch: requested=0x${ - session.info.audioCodecId.toUInt().toString(16) - } stream=0x${streamCodecId.toUInt().toString(16)}", - ) - } - - while (activeSession === session && !aStream.closed) { - try { - val ptsAndFlags = aInput.readLong() - val packetSize = aInput.readInt() - if (packetSize <= 0) continue - - val payload = ByteArray(packetSize) - aInput.readFully(payload) - - val isConfig = (ptsAndFlags and PACKET_FLAG_CONFIG) != 0L - val ptsUs = ptsAndFlags and PACKET_PTS_MASK - audioConsumer?.invoke( - AudioPacket( - data = payload, - ptsUs = ptsUs, - isConfig = isConfig - ) - ) - } catch (_: EOFException) { - break - } catch (_: InterruptedException) { - if (activeSession !== session || aStream.closed) break - Thread.interrupted() - } catch (e: Exception) { - Log.w(TAG, "audio reader failed", e) - break - } - } - } - } - - suspend fun clearAudioConsumer() = mutex.withLock { - audioConsumer = null - } - - /** - * Inject a keycode event to the control channel. - * - * - Requires an active control channel; throws if absent. - * - Uses Mutex to serialize control writes. - */ - suspend fun injectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) = - mutex.withLock { - requireControlWriter().injectKeycode(action, keycode, repeat, metaState) - } - - suspend fun injectText(text: String) = mutex.withLock { - requireControlWriter().injectText(text) - } - - /** - * Inject a touch event to the control channel. - * - * - Coordinates are expected in device pixels and are written together with - * screen dimensions so the server can interpret them correctly. - * - Uses Mutex to serialize control writes. - */ - suspend fun injectTouch( - action: Int, - pointerId: Long, - x: Int, - y: Int, - screenWidth: Int, - screenHeight: Int, - pressure: Float, - actionButton: Int, - buttons: Int, - ) = mutex.withLock { - requireControlWriter().injectTouch( - action, - pointerId, - x, - y, - screenWidth, - screenHeight, - pressure, - actionButton, - buttons - ) - } - - suspend fun injectScroll( - x: Int, - y: Int, - screenWidth: Int, - screenHeight: Int, - hScroll: Float, - vScroll: Float, - buttons: Int - ) = mutex.withLock { - requireControlWriter().injectScroll( - x, - y, - screenWidth, - screenHeight, - hScroll, - vScroll, - buttons - ) - } - - suspend fun pressBackOrScreenOn(action: Int = KeyEvent.ACTION_DOWN) = mutex.withLock { - requireControlWriter().pressBackOrScreenOn(action) - } - - suspend fun setDisplayPower(on: Boolean) = mutex.withLock { - requireControlWriter().setDisplayPower(on) - } - - /** - * Stop the active session and clean up reader threads and streams. - * - * - Interrupts and joins reader threads with short timeouts, closes sockets, - * and clears state. It is safe to call from any thread. - */ - suspend fun stop() = mutex.withLock { - stopInternal() - } - - private fun stopInternal() { - val session = activeSession ?: return - activeSession = null - videoConsumer = null - audioConsumer = null - - if (Thread.currentThread() !== videoReaderThread) { - runCatching { videoReaderThread?.interrupt() } - runCatching { videoReaderThread?.join(300) } - } - videoReaderThread = null - - if (Thread.currentThread() !== audioReaderThread) { - runCatching { audioReaderThread?.interrupt() } - runCatching { audioReaderThread?.join(300) } - } - audioReaderThread = null - - runCatching { session.controlStream?.close() } - runCatching { session.audioStream?.close() } - runCatching { session.videoStream?.close() } - runCatching { session.serverStream.close() } - if (Thread.currentThread() !== session.serverLogThread) { - runCatching { session.serverLogThread.interrupt() } - runCatching { session.serverLogThread.join(300) } - } - } - - fun isStarted(): Boolean = activeSession != null - - fun getLastServerCommand(): String? = lastServerCommand - - // TODO: 合并几个 --list-xxxx - suspend fun listEncoders( - serverJarPath: Path, - serverParams: ServerParams, - serverVersion: String = "3.3.4", - serverRemotePath: String = DEFAULT_SERVER_REMOTE_PATH, - ): EncoderLists = mutex.withLock { - val targetPath = serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH } - adbService.push(serverJarPath, targetPath) - - val cmd = buildServerCommand( - targetPath = targetPath, - serverParams = serverParams, - serverVersion = serverVersion, - ) - Log.i(TAG, "listEncoders(): cmd=$cmd") - // scrcpy encoder list is printed in logs, so merge stderr into stdout. - val output = adbService.shell("$cmd 2>&1") - val parsed = parseEncoderLists(output) - val preview = output.lineSequence().take(40).joinToString("\n") - Log.i( - TAG, - "listEncoders(): parsed video=${parsed.videoEncoders.size} audio=${parsed.audioEncoders.size}, outputPreview=\n$preview", - ) - return parsed.copy(rawOutput = output) - } - - suspend fun listCameraSizes( - serverJarPath: Path, - serverParams: ServerParams, - serverVersion: String = "3.3.4", - serverRemotePath: String = DEFAULT_SERVER_REMOTE_PATH, - ): CameraSizeLists = mutex.withLock { - val targetPath = serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH } - adbService.push(serverJarPath, targetPath) - - val cmd = buildServerCommand( - targetPath = targetPath, - serverParams = serverParams, - serverVersion = serverVersion, - ) - Log.i(TAG, "listCameraSizes(): cmd=$cmd") - val output = adbService.shell("$cmd 2>&1") - val parsed = parseCameraSizeLists(output) - val preview = output.lineSequence().take(40).joinToString("\n") - Log.i( - TAG, - "listCameraSizes(): parsed sizes=${parsed.sizes.size}, outputPreview=\n$preview", - ) - return parsed.copy(rawOutput = output) - } - - private fun requireControlWriter(): ScrcpyControlWriter { - return activeSession?.controlWriter - ?: throw IllegalStateException("scrcpy control channel not available") - } - - private fun buildServerCommand( - targetPath: String, - serverParams: ServerParams, - serverVersion: String, - ): String { - val serverArgs = serverParams.build() - return "CLASSPATH=$targetPath app_process / com.genymobile.scrcpy.Server $serverVersion $serverArgs" - } - - private fun parseEncoderLists(output: String): EncoderLists { - val video = LinkedHashSet() - val audio = LinkedHashSet() - val videoTypes = linkedMapOf() - val audioTypes = linkedMapOf() - VIDEO_ENCODER_REGEX.findAll(output).forEach { match -> - video.add(match.groupValues[1]) - } - AUDIO_ENCODER_REGEX.findAll(output).forEach { match -> - audio.add(match.groupValues[1]) - } - // Fallback for log formats that include codec+encoder in one line. - VIDEO_ENCODER_FALLBACK_REGEX.findAll(output).forEach { match -> - video.add(match.groupValues[1]) - } - AUDIO_ENCODER_FALLBACK_REGEX.findAll(output).forEach { match -> - audio.add(match.groupValues[1]) - } - VIDEO_ENCODER_TYPE_REGEX.findAll(output).forEach { match -> - val name = match.groupValues[1] - val type = match.groupValues[2] - if (name.isNotBlank() && type.isNotBlank() && !videoTypes.containsKey(name)) { - videoTypes[name] = type - } - } - AUDIO_ENCODER_TYPE_REGEX.findAll(output).forEach { match -> - val name = match.groupValues[1] - val type = match.groupValues[2] - if (name.isNotBlank() && type.isNotBlank() && !audioTypes.containsKey(name)) { - audioTypes[name] = type - } - } - return EncoderLists( - videoEncoders = video.toList(), - audioEncoders = audio.toList(), - videoEncoderTypes = videoTypes, - audioEncoderTypes = audioTypes, - ) - } - - private fun parseCameraSizeLists(output: String): CameraSizeLists { - val sizes = LinkedHashSet() - CAMERA_SIZE_REGEX.findAll(output).forEach { match -> - sizes.add(match.groupValues[1]) - } - CAMERA_SIZE_FALLBACK_REGEX.findAll(output).forEach { match -> - sizes.add(match.groupValues[1]) - } - return CameraSizeLists(sizes = sizes.toList()) - } - - private fun startServerLogThread(serverStream: AdbSocketStream, socketName: String): Thread { - return thread(start = true, name = "scrcpy-server-log") { - try { - BufferedReader( - InputStreamReader( - serverStream.inputStream, - Charsets.UTF_8 - ) - ).use { reader -> - while (true) { - val line = reader.readLine() ?: break - // Use synchronized block for thread-safe log buffer access - synchronized(serverLogBuffer) { - if (serverLogBuffer.size >= SERVER_LOG_BUFFER_MAX_LINES) { - serverLogBuffer.removeFirst() - } - serverLogBuffer.addLast(line) - } - Log.i(TAG, "[server:$socketName] $line") - } - } - } catch (e: Exception) { - if (activeSession != null) { - Log.w(TAG, "server log thread failed", e) - } - } - } - } - - private fun snapshotServerLogs(maxLines: Int = 120): String { - val snapshot = synchronized(serverLogBuffer) { - if (serverLogBuffer.isEmpty()) { - return "" - } - val take = maxLines.coerceIn(1, SERVER_LOG_BUFFER_MAX_LINES) - serverLogBuffer.toList().takeLast(take) - } - return snapshot.joinToString("\n") - } - - /** - * Open an abstract adb socket with retry. - * - * - Retries a number of times with a short delay (useful during server startup). - * - Optionally expects a dummy byte on the stream to validate the server handshake. - */ - private suspend fun openAbstractSocketWithRetry( - socketName: String, - expectDummyByte: Boolean - ): AdbSocketStream { - var lastEx: Exception? = null - repeat(CONNECT_RETRY_COUNT) { attempt -> - try { - val stream = adbService.openAbstractSocket(socketName) - if (expectDummyByte) { - val value = stream.inputStream.read() - if (value < 0) { - stream.close() - throw EOFException("scrcpy dummy byte missing") - } - } - return stream - } catch (e: Exception) { - lastEx = e - if (attempt < CONNECT_RETRY_COUNT - 1) Thread.sleep(CONNECT_RETRY_DELAY_MS) - } - } - throw IllegalStateException("Unable to open scrcpy socket '$socketName'", lastEx) - } - - private fun readDeviceName(input: DataInputStream): String { - val buffer = ByteArray(DEVICE_NAME_FIELD_LENGTH) - input.readFully(buffer) - val firstZero = buffer.indexOf(0) - val length = if (firstZero >= 0) firstZero else buffer.size - return buffer.copyOf(length).toString(Charsets.UTF_8) - } - - private fun codecName(codecId: Int): String { - return when (codecId) { - VIDEO_CODEC_H264 -> "h264" - VIDEO_CODEC_H265 -> "h265" - VIDEO_CODEC_AV1 -> "av1" - else -> "unknown" - } - } - - private fun audioCodecIdFromName(name: String): Int { - return when (name.lowercase()) { - "opus" -> AUDIO_CODEC_OPUS - "aac" -> AUDIO_CODEC_AAC - "raw" -> AUDIO_CODEC_RAW - "flac" -> AUDIO_CODEC_FLAC - else -> 0 - } - } - - data class SessionInfo( - val deviceName: String, - val codecId: Int, - val codecName: String, - val width: Int, - val height: Int, - val audioCodecId: Int = 0, - val controlEnabled: Boolean, - ) - - data class VideoPacket( - val data: ByteArray, - val ptsUs: Long, - val isConfig: Boolean, - val isKeyFrame: Boolean, - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as VideoPacket - - if (ptsUs != other.ptsUs) return false - if (isConfig != other.isConfig) return false - if (isKeyFrame != other.isKeyFrame) return false - if (!data.contentEquals(other.data)) return false - - return true - } - - override fun hashCode(): Int { - var result = ptsUs.hashCode() - result = 31 * result + isConfig.hashCode() - result = 31 * result + isKeyFrame.hashCode() - result = 31 * result + data.contentHashCode() - return result - } - } - - data class AudioPacket( - val data: ByteArray, - val ptsUs: Long, - val isConfig: Boolean, - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as AudioPacket - - if (ptsUs != other.ptsUs) return false - if (isConfig != other.isConfig) return false - if (!data.contentEquals(other.data)) return false - - return true - } - - override fun hashCode(): Int { - var result = ptsUs.hashCode() - result = 31 * result + isConfig.hashCode() - result = 31 * result + data.contentHashCode() - return result - } - } - - data class ScrcpyStartOptions( - val serverVersion: String = "3.3.4", - val serverRemotePath: String = DEFAULT_SERVER_REMOTE_PATH, - val video: Boolean = true, - val audio: Boolean = true, - val control: Boolean = true, - val cleanup: Boolean = true, - val maxSize: Int = 0, - val maxFps: Float = 0f, - val videoBitRate: Int = 8_000_000, - val videoCodec: String = "h264", - val audioBitRate: Int = 128_000, - val audioCodec: String = "opus", - val videoEncoder: String = "", - val videoCodecOptions: String = "", - val audioEncoder: String = "", - val audioCodecOptions: String = "", - val audioDup: Boolean = false, - val audioSource: String = "", - val videoSource: String = "display", - val cameraId: String = "", - val cameraFacing: String = "", - val cameraSize: String = "", - val cameraAr: String = "", - val cameraFps: Int = 0, - val cameraHighSpeed: Boolean = false, - val newDisplay: String = "", - val displayId: Int? = null, - val crop: String = "", - val listEncoders: Boolean = false, - val listCameraSizes: Boolean = false, - ) - - data class EncoderLists( - val videoEncoders: List, - val audioEncoders: List, - val videoEncoderTypes: Map = emptyMap(), - val audioEncoderTypes: Map = emptyMap(), - val rawOutput: String = "", - ) - - data class CameraSizeLists( - val sizes: List, - val rawOutput: String = "", - ) - - private data class ActiveSession( - val info: SessionInfo, - val socketName: String, - val serverStream: AdbSocketStream, - val serverLogThread: Thread, - val videoStream: AdbSocketStream?, - val videoInput: DataInputStream?, - val audioStream: AdbSocketStream?, - val audioInput: DataInputStream?, - val controlStream: AdbSocketStream?, - val controlWriter: ScrcpyControlWriter?, - ) - - private class ScrcpyControlWriter(private val output: DataOutputStream) { - @Synchronized - fun injectKeycode(action: Int, keycode: Int, repeat: Int, metaState: Int) { - output.writeByte(TYPE_INJECT_KEYCODE) - output.writeByte(action) - output.writeInt(keycode) - output.writeInt(repeat) - output.writeInt(metaState) - output.flush() - } - - @Synchronized - fun injectText(text: String) { - val bytes = text.toByteArray(Charsets.UTF_8) - output.writeByte(TYPE_INJECT_TEXT) - output.writeInt(bytes.size) - output.write(bytes) - output.flush() - } - - @Synchronized - fun injectTouch( - action: Int, - pointerId: Long, - x: Int, - y: Int, - screenWidth: Int, - screenHeight: Int, - pressure: Float, - actionButton: Int, - buttons: Int, - ) { - output.writeByte(TYPE_INJECT_TOUCH_EVENT) - output.writeByte(action) - output.writeLong(pointerId) - writePosition(x, y, screenWidth, screenHeight) - output.writeShort(encodeUnsignedFixedPoint16(pressure)) - output.writeInt(actionButton) - output.writeInt(buttons) - output.flush() - } - - @Synchronized - fun injectScroll( - x: Int, - y: Int, - screenWidth: Int, - screenHeight: Int, - hScroll: Float, - vScroll: Float, - buttons: Int - ) { - output.writeByte(TYPE_INJECT_SCROLL_EVENT) - writePosition(x, y, screenWidth, screenHeight) - output.writeShort(encodeSignedFixedPoint16(hScroll / 16f)) - output.writeShort(encodeSignedFixedPoint16(vScroll / 16f)) - output.writeInt(buttons) - output.flush() - } - - @Synchronized - fun pressBackOrScreenOn(action: Int) { - output.writeByte(TYPE_BACK_OR_SCREEN_ON) - output.writeByte(action) - output.flush() - } - - @Synchronized - fun setDisplayPower(on: Boolean) { - output.writeByte(TYPE_SET_DISPLAY_POWER) - output.writeBoolean(on) - output.flush() - } - - private fun writePosition(x: Int, y: Int, screenWidth: Int, screenHeight: Int) { - output.writeInt(x) - output.writeInt(y) - output.writeShort(screenWidth) - output.writeShort(screenHeight) - } - - private fun encodeUnsignedFixedPoint16(value: Float): Int { - val clamped = value.coerceIn(0f, 1f) - return if (clamped >= 1f) { - 0xffff - } else { - (clamped * 65536f).roundToInt().coerceIn(0, 0xfffe) - } - } - - private fun encodeSignedFixedPoint16(value: Float): Int { - val clamped = value.coerceIn(-1f, 1f) - if (clamped >= 1f) { - return 0x7fff - } - if (clamped <= -1f) { - return -0x8000 - } - return (clamped * 32768f).roundToInt().coerceIn(-0x8000, 0x7ffe) - } - } - - companion object { - const val DEFAULT_SERVER_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar" - private const val TAG = "ScrcpySessionManager" - private const val SERVER_BOOT_DELAY_MS = 200L - private const val SERVER_LOG_BUFFER_MAX_LINES = 400 - - private const val CONNECT_RETRY_COUNT = 100 - private const val CONNECT_RETRY_DELAY_MS = 100L - private const val DEVICE_NAME_FIELD_LENGTH = 64 - private const val PACKET_FLAG_CONFIG = 1L shl 63 - private const val PACKET_FLAG_KEY_FRAME = 1L shl 62 - private const val PACKET_PTS_MASK = (1L shl 62) - 1 - private const val VIDEO_CODEC_H264 = 0x68323634 - private const val VIDEO_CODEC_H265 = 0x68323635 - private const val VIDEO_CODEC_AV1 = 0x00617631 - private const val AUDIO_CODEC_OPUS = 0x6f707573 - private const val AUDIO_CODEC_AAC = 0x00616163 - private const val AUDIO_CODEC_FLAC = 0x666c6163 - private const val AUDIO_CODEC_RAW = 0x00726177 - - // Audio stream disable codes from server (writeDisableStream) - private const val AUDIO_DISABLED = 0 - private const val AUDIO_ERROR = 1 - private const val TYPE_INJECT_KEYCODE = 0 - private const val TYPE_INJECT_TEXT = 1 - private const val TYPE_INJECT_TOUCH_EVENT = 2 - private const val TYPE_INJECT_SCROLL_EVENT = 3 - private const val TYPE_BACK_OR_SCREEN_ON = 4 - private const val TYPE_SET_DISPLAY_POWER = 10 - private val VIDEO_ENCODER_REGEX = Regex("--video-encoder=([\\w.\\-]+)") - private val AUDIO_ENCODER_REGEX = Regex("--audio-encoder=([\\w.\\-]+)") - private val VIDEO_ENCODER_FALLBACK_REGEX = Regex("""--video-encoder=['"]?([^'"\s]+)""") - private val AUDIO_ENCODER_FALLBACK_REGEX = Regex("""--audio-encoder=['"]?([^'"\s]+)""") - private val VIDEO_ENCODER_TYPE_REGEX = - Regex("""--video-codec=\S+\s+--video-encoder=(\S+).*?\((hw|sw)\)""") - private val AUDIO_ENCODER_TYPE_REGEX = - Regex("""--audio-codec=\S+\s+--audio-encoder=(\S+).*?\((hw|sw)\)""") - private val CAMERA_SIZE_REGEX = Regex("--camera-size=([0-9]+x[0-9]+)") - private val CAMERA_SIZE_FALLBACK_REGEX = Regex("\\b([1-9][0-9]{1,4}x[1-9][0-9]{1,4})\\b") - - private val random = SecureRandom() - - private fun socketNameFor(scid: Int): String { - return "scrcpy_%08x".format(scid) - } - } -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt index f01cf62..0859957 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt @@ -1,5 +1,6 @@ package io.github.miuzarte.scrcpyforandroid.pages +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -11,25 +12,33 @@ 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.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.platform.LocalContext 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.models.ScrcpyOptions.Crop +import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.NewDisplay import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn -import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide +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.scrcpy.ServerParams +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared import io.github.miuzarte.scrcpyforandroid.storage.Storage import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.ScrollBehavior import top.yukonga.miuix.kmp.basic.SnackbarHostState @@ -42,82 +51,19 @@ 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 "自定义", -) - -// TODO: Scrcpy.VideoSource -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, - cameraSizeOptions: SnapshotStateList, - cameraSizeDropdownItems: List, - videoEncoderDropdownItems: List, - videoEncoderTypeMap: Map, - videoEncoderIndex: Int, - audioEncoderDropdownItems: List, - audioEncoderTypeMap: Map, - audioEncoderIndex: Int, - onRefreshEncoders: () -> Unit, - onRefreshCameraSizes: () -> Unit, + scrcpy: Scrcpy, ) { val scrcpyOptions = Storage.scrcpyOptions - val context = LocalContext.current - val focusManager = LocalFocusManager.current val scope = rememberCoroutineScope() - - 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 }, - ) - } - } + var refreshBusy by remember { mutableStateOf(false) } // TODO: handle custom value // TODO: handle empty input @@ -126,100 +72,164 @@ internal fun AdvancedConfigPage( var video by scrcpyOptions.video.asMutableState() var videoSource by scrcpyOptions.videoSource.asMutableState() - val videoSourceItems = VIDEO_SOURCE_OPTIONS.map { it.second } - val videoSourceIndex = VIDEO_SOURCE_OPTIONS.indexOfFirst { - it.first == videoSource - }.let { if (it >= 0) it else 0 } + val videoSourceItems = remember { Shared.VideoSource.entries.map { it.string } } + val videoSourceIndex = remember(videoSource) { + Shared.VideoSource.entries.indexOfFirst { it.string == videoSource }.coerceAtLeast(0) + } var displayId by scrcpyOptions.displayId.asMutableState() var cameraId by scrcpyOptions.cameraId.asMutableState() var cameraFacing by scrcpyOptions.cameraFacing.asMutableState() - val cameraFacingItems = CAMERA_FACING_OPTIONS.map { it.second } - val cameraFacingIndex = CAMERA_FACING_OPTIONS.indexOfFirst { - it.first == cameraFacing - }.let { if (it >= 0) it else 0 } - var cameraSize by scrcpyOptions.cameraSize.asMutableState() - val cameraSizeIndex = when (cameraSize) { - "custom" -> cameraSizeOptions.size + 1 - in cameraSizeOptions -> cameraSizeOptions.indexOf(cameraSize) + 1 - else -> 0 + val cameraFacingItems = remember { + listOf("默认") + Shared.CameraFacing.entries.drop(1).map { it.string } } + val cameraFacingIndex = remember(cameraFacing) { + if (cameraFacing.isEmpty()) { + 0 + } else { + val idx = Shared.CameraFacing.entries.indexOfFirst { it.string == cameraFacing } + if (idx > 0) idx else 0 + } + } + + var cameraSize by scrcpyOptions.cameraSize.asMutableState() + var cameraSizeCustom by scrcpyOptions.cameraSizeCustom.asMutableState() + var cameraSizeUseCustom by scrcpyOptions.cameraSizeUseCustom.asMutableState() + + var cameraSizeCustomInput by rememberSaveable { mutableStateOf(cameraSizeCustom) } + val cameraSizeDropdownItems = rememberSaveable(scrcpy.cameraSizes) { + listOf("自动", "自定义") + scrcpy.cameraSizes + } + var cameraSizeDropdownIndex by rememberSaveable { + mutableIntStateOf( + when { + cameraSizeUseCustom -> 1 // "自定义" + cameraSize.isEmpty() -> 0 // "自动" + cameraSize in scrcpy.cameraSizes -> scrcpy.cameraSizes.indexOf(cameraSize) + 2 + else -> 0 // 默认自动 + } + ) + } + var cameraAr by scrcpyOptions.cameraAr.asMutableState() var cameraFps by scrcpyOptions.cameraFps.asMutableState() - val cameraFpsPresetIndex = presetIndexFromInputForAdvancedPage( - cameraFps, CAMERA_FPS_PRESETS - ) + val cameraFpsPresetIndex = ScrcpyPresets.CameraFps.indexOfOrNearest(cameraFps) var cameraHighSpeed by scrcpyOptions.cameraHighSpeed.asMutableState() var audioSource by scrcpyOptions.audioSource.asMutableState() - val audioSourceItems = AUDIO_SOURCE_OPTIONS.map { it.second } - val audioSourceIndex = AUDIO_SOURCE_OPTIONS.indexOfFirst { - it.first == audioSource - }.let { if (it >= 0) it else 0 } + val audioSourceItems = remember { + Shared.AudioSource.entries.map { it.string } + } + val audioSourceIndex = remember(audioSource) { + Shared.AudioSource.entries.indexOfFirst { it.string == audioSource }.coerceAtLeast(0) + } var audioDup by scrcpyOptions.audioDup.asMutableState() var audioPlayback by scrcpyOptions.audioPlayback.asMutableState() var requireAudio by scrcpyOptions.requireAudio.asMutableState() var maxSize by scrcpyOptions.maxSize.asMutableState() - val maxSizePresetIndex = presetIndexFromInputForAdvancedPage( - maxSize.toString(), ScrcpyPresets.MaxSize - ) + val maxSizePresetIndex = ScrcpyPresets.MaxSize.indexOfOrNearest(maxSize) var maxFps by scrcpyOptions.maxFps.asMutableState() - val maxFpsPresetIndex = presetIndexFromInputForAdvancedPage( - maxFps, ScrcpyPresets.MaxFPS - ) + val maxFpsPresetIndex = ScrcpyPresets.MaxFPS.indexOfOrNearest(maxFps.toIntOrNull() ?: 0) var videoEncoder by scrcpyOptions.videoEncoder.asMutableState() var videoCodecOptions by scrcpyOptions.videoCodecOptions.asMutableState() var audioEncoder by scrcpyOptions.audioEncoder.asMutableState() var audioCodecOptions by scrcpyOptions.audioCodecOptions.asMutableState() - var newDisplayWidth by remember { - mutableStateOf("") + val videoEncoderDropdownItems = remember(scrcpy.videoEncoders) { + listOf("") + scrcpy.videoEncoders } - var newDisplayHeight by remember { - mutableStateOf("") + val videoEncoderIndex = remember(videoEncoder, scrcpy.videoEncoders) { + (scrcpy.videoEncoders.indexOf(videoEncoder) + 1).coerceAtLeast(0) } - var newDisplayDpi by remember { - mutableStateOf("") + val audioEncoderDropdownItems = remember(scrcpy.audioEncoders) { + listOf("") + scrcpy.audioEncoders } + val audioEncoderIndex = remember(audioEncoder, scrcpy.audioEncoders) { + (scrcpy.audioEncoders.indexOf(audioEncoder) + 1).coerceAtLeast(0) + } + val videoEncoderEntries = videoEncoderDropdownItems.map { encoderName -> + if (encoderName == "") { + SpinnerEntry(title = "自动") + } else { + SpinnerEntry( + title = encoderName, + summary = scrcpy.videoEncoderTypes[encoderName], + ) + } + } + val audioEncoderEntries = audioEncoderDropdownItems.map { encoderName -> + if (encoderName == "") { + SpinnerEntry(title = "自动") + } else { + SpinnerEntry( + title = encoderName, + summary = scrcpy.audioEncoderTypes[encoderName], + ) + } + } + // [x][/] - // TODO: 填充当前值到输入框 var newDisplay by scrcpyOptions.newDisplay.asMutableState() + val (width, height, dpi) = NewDisplay.parseFrom(newDisplay) + var newDisplayWidth by remember(newDisplay) { mutableStateOf(width?.toString() ?: "") } + var newDisplayHeight by remember(newDisplay) { mutableStateOf(height?.toString() ?: "") } + var newDisplayDpi by remember(newDisplay) { mutableStateOf(dpi?.toString() ?: "") } fun updateNewDisplay() { - var nd = "" - if (newDisplayWidth.isNotBlank() && newDisplayHeight.isNotBlank()) { - nd += "${newDisplayWidth}x${newDisplayHeight}" - } - if (newDisplayDpi.isNotBlank()) { - nd += "/$newDisplayDpi" - } - newDisplay = nd + newDisplay = NewDisplay + .parseFrom(newDisplayWidth, newDisplayHeight, newDisplayDpi) + .toString() } - var cropWidth by remember { - mutableStateOf("") - } - var cropHeight by remember { - mutableStateOf("") - } - var cropX by remember { - mutableStateOf("") - } - var cropY by remember { - mutableStateOf("") - } // width:height:x:y - // TODO: 填充当前值到输入框 var crop by scrcpyOptions.crop.asMutableState() - fun updateCrop(): Unit { - if (cropWidth.isNotBlank() - && cropHeight.isNotBlank() - && cropX.isNotBlank() - && cropY.isNotBlank() - ) crop = "$cropWidth:$cropHeight:$cropX:$cropY" + val (cWidth, cHeight, cX, cY) = Crop.parseFrom(crop) + var cropWidth by remember(crop) { mutableStateOf(cWidth?.toString() ?: "") } + var cropHeight by remember(crop) { mutableStateOf(cHeight?.toString() ?: "") } + var cropX by remember(crop) { mutableStateOf(cX?.toString() ?: "") } + var cropY by remember(crop) { mutableStateOf(cY?.toString() ?: "") } + fun updateCrop() { + crop = Crop + .parseFrom(cropWidth, cropHeight, cropX, cropY) + .toString() } + var serverParamsPreview by rememberSaveable { + mutableStateOf(runBlocking { + scrcpyOptions + .toClientOptions() + .toServerParams(0u) + .toList(simplify = true) + .joinToString(ServerParams.SEPARATOR) + }) + } + + // 监听所有选项变化,自动更新 serverParams 预览 + LaunchedEffect( + turnScreenOff, control, video, + videoSource, displayId, + cameraId, cameraFacing, cameraSize, cameraAr, cameraFps, cameraHighSpeed, + audioSource, audioDup, audioPlayback, requireAudio, + maxSize, maxFps, + videoEncoder, videoCodecOptions, + audioEncoder, audioCodecOptions, + newDisplay, crop, + ) { + val clientOptions = scrcpyOptions.toClientOptions() + + try { + clientOptions.validate() + } catch (e: IllegalArgumentException) { + snackbarHostState.showSnackbar("Invalid options: ${e.message}") + return@LaunchedEffect + } + + serverParamsPreview = clientOptions + .toServerParams(0u) + .toList(simplify = true) + .joinToString(ServerParams.SEPARATOR) + } // 高级参数 AppPageLazyColumn( @@ -227,6 +237,15 @@ internal fun AdvancedConfigPage( scrollBehavior = scrollBehavior, ) { item { + Card { + TextField( + value = serverParamsPreview, + onValueChange = {}, + readOnly = true, + modifier = Modifier.fillMaxWidth(), + ) + } + Card { SuperSwitch( title = "启动后关闭屏幕", @@ -265,13 +284,13 @@ internal fun AdvancedConfigPage( items = videoSourceItems, selectedIndex = videoSourceIndex, onSelectedIndexChange = { - videoSource = VIDEO_SOURCE_OPTIONS[it].first + videoSource = Shared.VideoSource.entries[it].string }, ) - if (videoSource == "display") { + AnimatedVisibility(videoSource == "display") { TextField( - value = displayId.toString(), - onValueChange = { displayId = it.toInt() }, + value = if (displayId == -1) "" else displayId.toString(), + onValueChange = { displayId = it.toIntOrNull() ?: -1 }, label = "--display-id", singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), @@ -281,7 +300,7 @@ internal fun AdvancedConfigPage( .padding(bottom = UiSpacing.CardContent), ) } - if (videoSource == "camera") { + AnimatedVisibility(videoSource == "camera") { TextField( value = cameraId, onValueChange = { cameraId = it }, @@ -292,47 +311,95 @@ internal fun AdvancedConfigPage( .padding(horizontal = UiSpacing.CardContent) .padding(bottom = UiSpacing.CardContent), ) + } + AnimatedVisibility(videoSource == "camera") { SuperArrow( title = "重新获取 Camera Sizes", summary = "--list-camera-sizes", - onClick = onRefreshCameraSizes, + onClick = { + if (refreshBusy) return@SuperArrow + scope.launch { + refreshBusy = true + try { + scrcpy.refreshCameraSizes() + snackbarHostState.showSnackbar("Camera Sizes 已刷新") + } catch (e: Exception) { + snackbarHostState.showSnackbar("刷新失败: ${e.message}") + } finally { + refreshBusy = false + } + } + }, ) + } + AnimatedVisibility(videoSource == "camera") { SuperDropdown( title = "摄像头朝向", summary = "--camera-facing", items = cameraFacingItems, selectedIndex = cameraFacingIndex, onSelectedIndexChange = { - cameraFacing = CAMERA_FACING_OPTIONS[it].first + cameraFacing = + if (it == 0) "" else Shared.CameraFacing.entries[it].string }, ) + } + AnimatedVisibility(videoSource == "camera") { SuperDropdown( title = "摄像头分辨率", summary = "--camera-size", items = cameraSizeDropdownItems, - selectedIndex = cameraSizeIndex.coerceIn( - 0, (cameraSizeDropdownItems.size - 1).coerceAtLeast(0) - ), + selectedIndex = cameraSizeDropdownIndex, onSelectedIndexChange = { - cameraSize = when (it) { - 0 -> "" - cameraSizeDropdownItems.lastIndex -> "custom" - else -> cameraSizeDropdownItems[it] + cameraSizeDropdownIndex = it + cameraSizeUseCustom = it == 1 + when (it) { + 0 -> { + // "自动" + cameraSize = "" + cameraSizeCustomInput = "" + } + + 1 -> { + // "自定义" - 进入自定义输入模式 + cameraSizeCustomInput = cameraSize.takeIf { size -> + size.isNotEmpty() && size !in scrcpy.cameraSizes + } ?: "" + } + + else -> { + // 选择列表中的实际分辨率 + cameraSize = cameraSizeDropdownItems[it] + cameraSizeCustomInput = "" + } } }, ) - if (cameraSize == "custom") { - TextField( - value = cameraSize, - onValueChange = { cameraSize = it }, - label = "--camera-size", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - } + } + // 只在选择"自定义"时显示输入框 + AnimatedVisibility(videoSource == "camera" && cameraSizeUseCustom) { + SuperTextField( + value = cameraSizeCustomInput, + onValueChange = { cameraSizeCustomInput = it }, + onFocusLost = { + if (cameraSizeCustomInput in scrcpy.cameraSizes) { + cameraSizeDropdownIndex = + scrcpy.cameraSizes.indexOf(cameraSizeCustomInput) + 2 + cameraSizeUseCustom = false + } else { + cameraSizeCustom = cameraSizeCustomInput + } + }, + label = "--camera-size", + useLabelAsPlaceholder = true, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = UiSpacing.CardContent) + .padding(bottom = UiSpacing.CardContent), + ) + } + AnimatedVisibility(videoSource == "camera") { TextField( value = cameraAr, onValueChange = { cameraAr = it }, @@ -343,28 +410,37 @@ internal fun AdvancedConfigPage( .padding(horizontal = UiSpacing.CardContent) .padding(bottom = UiSpacing.CardContent), ) - SuperSlide( + } + AnimatedVisibility(videoSource == "camera") { + SuperSlider( title = "摄像头帧率", summary = "--camera-fps", value = cameraFpsPresetIndex.toFloat(), onValueChange = { value -> - val idx = value.roundToInt().coerceIn(0, CAMERA_FPS_PRESETS.lastIndex) - cameraFps = CAMERA_FPS_PRESETS[idx] + val idx = + value.roundToInt().coerceIn(0, ScrcpyPresets.CameraFps.lastIndex) + cameraFps = ScrcpyPresets.CameraFps[idx] }, - valueRange = 0f..CAMERA_FPS_PRESETS.lastIndex.toFloat(), - steps = (CAMERA_FPS_PRESETS.size - 2).coerceAtLeast(0), + valueRange = 0f..ScrcpyPresets.CameraFps.lastIndex.toFloat(), + steps = (ScrcpyPresets.CameraFps.size - 2).coerceAtLeast(0), unit = "fps", zeroStateText = "默认", showUnitWhenZeroState = false, showKeyPoints = true, - keyPoints = CAMERA_FPS_PRESETS.indices.map { it.toFloat() }, + keyPoints = ScrcpyPresets.CameraFps.indices.map { it.toFloat() }, displayText = cameraFps.toString(), inputHint = "0 或留空表示默认", inputInitialValue = cameraFps.toString(), inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 0f..Float.MAX_VALUE, - onInputConfirm = { cameraFps = it.toInt() }, + inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), + onInputConfirm = { input -> + input.toIntOrNull() + ?.let { cameraFps = it } + ?: run { cameraFps = 0 } + }, ) + } + AnimatedVisibility(videoSource == "camera") { SuperSwitch( title = "高帧率模式", summary = "--camera-high-speed", @@ -382,20 +458,10 @@ internal fun AdvancedConfigPage( summary = "--audio-source", items = audioSourceItems, selectedIndex = audioSourceIndex, - onSelectedIndexChange = { audioSource = AUDIO_SOURCE_OPTIONS[it].first }, + onSelectedIndexChange = { + audioSource = Shared.AudioSource.entries[it].string + }, ) - if (audioSource == "custom") { - TextField( - value = audioSource, - onValueChange = { audioSource = it }, - label = "--audio-source", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - } SuperSwitch( title = "音频双路输出", summary = "--audio-dup", @@ -420,12 +486,13 @@ internal fun AdvancedConfigPage( item { Card { - SuperSlide( + SuperSlider( title = "最大分辨率", summary = "--max-size", value = maxSizePresetIndex.toFloat(), onValueChange = { value -> - val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxSize.lastIndex) + val idx = + value.roundToInt().coerceIn(0, ScrcpyPresets.MaxSize.lastIndex) maxSize = ScrcpyPresets.MaxSize[idx] }, valueRange = 0f..ScrcpyPresets.MaxSize.lastIndex.toFloat(), @@ -439,16 +506,16 @@ internal fun AdvancedConfigPage( inputHint = "0 或留空表示关闭", inputInitialValue = maxSize.toString(), inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 0f..Float.MAX_VALUE, - onInputConfirm = { maxSize = it.toInt() }, + inputValueRange = 0f..UInt.MAX_VALUE.toFloat(), + onInputConfirm = { input -> input.toIntOrNull()?.let { maxSize = it } }, ) - SuperSlide( + SuperSlider( title = "最大帧率", summary = "--max-fps", value = maxFpsPresetIndex.toFloat(), onValueChange = { value -> val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxFPS.lastIndex) - maxFps = ScrcpyPresets.MaxFPS[idx].toString() + maxFps = if (idx == 0) "" else ScrcpyPresets.MaxFPS[idx].toString() }, valueRange = 0f..ScrcpyPresets.MaxFPS.lastIndex.toFloat(), steps = (ScrcpyPresets.MaxFPS.size - 2).coerceAtLeast(0), @@ -461,7 +528,7 @@ internal fun AdvancedConfigPage( inputHint = "0 或留空表示关闭", inputInitialValue = maxFps, inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 0f..Float.MAX_VALUE, + inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), onInputConfirm = { maxFps = it }, ) } @@ -472,14 +539,29 @@ internal fun AdvancedConfigPage( SuperArrow( title = "重新获取编码器列表", summary = "--list-encoders", - onClick = onRefreshEncoders, + onClick = { + if (refreshBusy) return@SuperArrow + scope.launch { + refreshBusy = true + try { + scrcpy.refreshEncoders() + snackbarHostState.showSnackbar("编码器列表已刷新") + } catch (e: Exception) { + snackbarHostState.showSnackbar("刷新失败: ${e.message}") + } finally { + refreshBusy = false + } + } + }, ) SuperSpinner( title = "视频编码器", summary = "--video-encoder", items = videoEncoderEntries, selectedIndex = videoEncoderIndex, - onSelectedIndexChange = { videoEncoder = videoEncoderEntries[it].title ?: "" }, + onSelectedIndexChange = { + videoEncoder = videoEncoderEntries[it].title ?: "" + }, ) TextField( value = videoCodecOptions, @@ -496,7 +578,9 @@ internal fun AdvancedConfigPage( summary = "--audio-encoder", items = audioEncoderEntries, selectedIndex = audioEncoderIndex, - onSelectedIndexChange = { audioEncoder = audioEncoderEntries[it].title ?: "" }, + onSelectedIndexChange = { + audioEncoder = audioEncoderEntries[it].title ?: "" + }, ) TextField( value = audioCodecOptions, @@ -531,9 +615,9 @@ internal fun AdvancedConfigPage( horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), ) { TextField( + label = "width", value = newDisplayWidth, onValueChange = { newDisplayWidth = it; updateNewDisplay() }, - label = "width", singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -545,9 +629,9 @@ internal fun AdvancedConfigPage( modifier = Modifier.weight(1f), ) TextField( + label = "height", value = newDisplayHeight, onValueChange = { newDisplayHeight = it; updateNewDisplay() }, - label = "height", singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -559,9 +643,9 @@ internal fun AdvancedConfigPage( modifier = Modifier.weight(1f), ) TextField( + label = "dpi", value = newDisplayDpi, onValueChange = { newDisplayDpi = it; updateNewDisplay() }, - label = "dpi", singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -600,9 +684,9 @@ internal fun AdvancedConfigPage( horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), ) { TextField( - value = cropWidth, - onValueChange = { cropWidth = it }, label = "width", + value = cropWidth, + onValueChange = { cropWidth = it; updateCrop() }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -614,9 +698,9 @@ internal fun AdvancedConfigPage( modifier = Modifier.weight(1f), ) TextField( - value = cropHeight, - onValueChange = { cropHeight = it }, label = "height", + value = cropHeight, + onValueChange = { cropHeight = it; updateCrop() }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -633,9 +717,9 @@ internal fun AdvancedConfigPage( horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), ) { TextField( - value = cropX, - onValueChange = { cropX = it }, label = "x", + value = cropX, + onValueChange = { cropX = it; updateCrop() }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -647,9 +731,9 @@ internal fun AdvancedConfigPage( modifier = Modifier.weight(1f), ) TextField( - value = cropY, - onValueChange = { cropY = it }, label = "y", + value = cropY, + onValueChange = { cropY = it; updateCrop() }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -669,27 +753,3 @@ internal fun AdvancedConfigPage( item { Spacer(Modifier.height(UiSpacing.BottomContent)) } } } - -private fun presetIndexFromInputForAdvancedPage(raw: Int, presets: List): Int { - val exact = presets.indexOf(raw) - if (exact >= 0) return exact - val nearest = presets.withIndex().minByOrNull { (_, preset) -> kotlin.math.abs(preset - raw) } - return nearest?.index ?: 0 -} - -private fun presetIndexFromInputForAdvancedPage(raw: String, presets: List): Int { - if (raw.isBlank()) return 0 - val value = raw.toIntOrNull() ?: return 0 - val exact = presets.indexOf(value) - if (exact >= 0) return exact - val nearest = presets.withIndex().minByOrNull { (_, preset) -> kotlin.math.abs(preset - value) } - return nearest?.index ?: 0 -} - -private fun resolveEncoderTypeLabel(raw: String?): String { - return when (raw?.trim()?.lowercase()) { - "hw" -> "hw" - "sw" -> "sw" - else -> "" - } -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt index 6502619..c5240e3 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt @@ -14,13 +14,11 @@ import androidx.compose.runtime.mutableIntStateOf 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.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade -import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo import io.github.miuzarte.scrcpyforandroid.constants.Defaults import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics @@ -30,7 +28,6 @@ import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy -import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions import io.github.miuzarte.scrcpyforandroid.services.EventLogger import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo @@ -50,7 +47,6 @@ import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonCard import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -60,47 +56,14 @@ import top.yukonga.miuix.kmp.basic.SnackbarHostState import top.yukonga.miuix.kmp.extra.SuperBottomSheet import java.net.InetSocketAddress import java.net.Socket -import java.util.concurrent.Executors -private const val ADB_CONNECT_TIMEOUT_MS = 3_000L +private const val ADB_CONNECT_TIMEOUT_MS = 12_000L private const val ADB_KEEPALIVE_INTERVAL_MS = 3_000L private const val ADB_KEEPALIVE_TIMEOUT_MS = 1_500L private const val ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS = 2_000L private const val ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS = 2_000L private const val ADB_TCP_PROBE_TIMEOUT_MS = 500 -private const val DEVICE_SHORTCUT_SEPARATOR = "\u001F" - -private val DeviceShortcutsSaver = - listSaver( - save = { shortcuts -> - // 将 DeviceShortcuts 中的每个设备转换为一行字符串 - shortcuts.devices.map { device -> - listOf( - device.id, - device.name, - device.host, - device.port.toString(), - if (device.online) "1" else "0" - ).joinToString(DEVICE_SHORTCUT_SEPARATOR) - } - }, - restore = { saved -> - // 从保存的字符串列表恢复 DeviceShortcuts - DeviceShortcuts(saved.mapNotNull { line -> - val parts = line.split(DEVICE_SHORTCUT_SEPARATOR) - if (parts.size != 5) return@mapNotNull null - val port = parts[3].toIntOrNull() ?: return@mapNotNull null - DeviceShortcut( - name = parts[1], - host = parts[2], - port = port, - online = parts[4] == "1" - ) - }.toMutableList()) - }, - ) - @Composable fun DeviceTabScreen( contentPadding: PaddingValues, @@ -109,23 +72,12 @@ fun DeviceTabScreen( scrcpy: Scrcpy, snack: SnackbarHostState, scrollBehavior: ScrollBehavior, - - videoEncoderOptions: List, - onVideoEncoderOptionsChange: (List) -> Unit, - onVideoEncoderTypeMapChange: (Map) -> Unit, - audioEncoderOptions: List, - onAudioEncoderOptionsChange: (List) -> Unit, - onAudioEncoderTypeMapChange: (Map) -> Unit, - cameraSizeOptions: List, - onCameraSizeOptionsChange: (List) -> Unit, onSessionStartedChange: (Boolean) -> Unit, - onRefreshEncodersActionChange: ((() -> Unit)?) -> Unit, - onRefreshCameraSizesActionChange: ((() -> Unit)?) -> Unit, - onClearLogsActionChange: ((() -> Unit)?) -> Unit, + onClearLogsActionChange: ((() -> Unit)) -> Unit, onCanClearLogsChange: (Boolean) -> Unit, - onOpenReorderDevicesActionChange: ((() -> Unit)?) -> Unit, + onOpenReorderDevicesActionChange: ((() -> Unit)) -> Unit, onOpenAdvancedPage: () -> Unit, - onOpenFullscreenPage: (ScrcpySessionInfo) -> Unit, + onOpenFullscreenPage: (Scrcpy.Session.SessionInfo) -> Unit, ) { val appSettings = Storage.appSettings val quickDevices = Storage.quickDevices @@ -141,24 +93,14 @@ fun DeviceTabScreen( } // val initialSettings = remember(context) { loadDevicePageSettings(context) } val scope = rememberCoroutineScope() - val adbWorkerDispatcher = remember { - Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "adb-connect-worker").apply { isDaemon = true } - }.asCoroutineDispatcher() - } // Run adb operations on a dedicated single thread. // Try to avoid blocking UI/recomposition and keeps adb call ordering deterministic. - DisposableEffect(adbWorkerDispatcher) { - onDispose { - adbWorkerDispatcher.close() - } - } - var busy by rememberSaveable { mutableStateOf(false) } var statusLine by rememberSaveable { mutableStateOf("未连接") } var adbConnected by rememberSaveable { mutableStateOf(false) } + var isQuickConnected by rememberSaveable { mutableStateOf(false) } var currentTargetHost by rememberSaveable { mutableStateOf("") } var currentTargetPort by rememberSaveable { mutableIntStateOf(Defaults.ADB_PORT) } var connectedDeviceLabel by rememberSaveable { mutableStateOf("未连接") } @@ -168,7 +110,7 @@ fun DeviceTabScreen( var sessionInfoCodec by rememberSaveable { mutableStateOf("") } var sessionInfoControlEnabled by rememberSaveable { mutableStateOf(false) } var sessionInfo by remember { - mutableStateOf(null) + mutableStateOf(null) } LaunchedEffect( sessionInfoWidth, @@ -178,12 +120,16 @@ fun DeviceTabScreen( sessionInfoControlEnabled ) { sessionInfo = if (sessionInfoDeviceName.isNotBlank()) { - ScrcpySessionInfo( + Scrcpy.Session.SessionInfo( width = sessionInfoWidth, height = sessionInfoHeight, deviceName = sessionInfoDeviceName, - codec = sessionInfoCodec, + codecId = 0, + codecName = sessionInfoCodec, + audioCodecId = 0, controlEnabled = sessionInfoControlEnabled, + host = currentTargetHost, + port = currentTargetPort, ) } else { null @@ -202,7 +148,7 @@ fun DeviceTabScreen( if (currentTargetHost.isNotBlank()) ConnectionTarget( currentTargetHost, - currentTargetPort + currentTargetPort, ) else null val sessionReconnectBlacklistHosts = remember { mutableSetOf() } @@ -212,11 +158,11 @@ fun DeviceTabScreen( } var quickDevicesList by quickDevices.quickDevicesList.asMutableState() - var savedShortcuts = rememberSaveable(quickDevicesList, saver = DeviceShortcutsSaver) { - DeviceShortcuts.unmarshalFrom(quickDevicesList) - } - var quickConnectInput by quickDevices.quickConnectInput.asMutableState() + var savedShortcuts by remember { mutableStateOf(DeviceShortcuts.unmarshalFrom(quickDevicesList)) } + LaunchedEffect(quickDevicesList) { + savedShortcuts = DeviceShortcuts.unmarshalFrom(quickDevicesList) + } // save changes when [savedShortcuts] was modified LaunchedEffect(savedShortcuts) { val serialized = savedShortcuts.marshalToString() @@ -225,15 +171,18 @@ fun DeviceTabScreen( } } + var quickConnectInput by quickDevices.quickConnectInput.asMutableState() + var quickConnectInputTemp by remember(quickConnectInput) { mutableStateOf(quickConnectInput) } + /** * Disconnect the current ADB connection and stop any running scrcpy session. * * Concurrency / thread boundary: - * - Native calls that may block are executed on [adbWorkerDispatcher] using [withContext]. + * - Native calls that may block are executed on ADB dispatcher using adbService.withAdbDispatcher. * - This ensures UI coroutines are never blocked by synchronous native I/O. * * Side effects: - * - Calls `nativeCore.scrcpyStop()` and `nativeCore.adbDisconnect()` (best-effort). + * - Calls `scrcpy.stop()` and `adbService.disconnect()` (best-effort). * - Resets UI-visible connection state: `adbConnected`, `currentTargetHost/Port`, * `sessionInfo`, device capability flags, `statusLine`, and `connectedDeviceLabel`. * - Updates the saved quick-device list via [savedShortcuts.update] when a target is provided. @@ -252,11 +201,9 @@ fun DeviceTabScreen( logMessage: String? = null, showSnackMessage: String? = null, ) { - withContext(adbWorkerDispatcher) { - // Also stops scrcpy. - runCatching { scrcpy.stop() } - runCatching { adbService.disconnect() } - } + // Also stops scrcpy. + runCatching { scrcpy.stop() } + runCatching { adbService.disconnect() } adbConnected = false currentTargetHost = "" currentTargetPort = Defaults.ADB_PORT @@ -326,10 +273,8 @@ fun DeviceTabScreen( * indefinitely. Use a small, caller-chosen timeout to keep UX snappy. */ suspend fun connectWithTimeout(host: String, port: Int) { - return withContext(adbWorkerDispatcher) { - withTimeout(ADB_CONNECT_TIMEOUT_MS) { - adbService.connect(host, port) - } + return withTimeout(ADB_CONNECT_TIMEOUT_MS) { + adbService.connect(host, port) } } @@ -347,17 +292,12 @@ fun DeviceTabScreen( * echo check helps detect that state. */ suspend fun keepAliveCheck(host: String, port: Int): Boolean { - return withContext(adbWorkerDispatcher) { - withTimeout(ADB_KEEPALIVE_TIMEOUT_MS) { - val connected = adbService.isConnected() - if (!connected) { - return@withTimeout false - } - runCatching { - adbService.shell("echo -n 1") - true - }.getOrElse { false } + return withTimeout(ADB_KEEPALIVE_TIMEOUT_MS) { + val connected = adbService.isConnected() + if (!connected) { + return@withTimeout false } + return@withTimeout true } } @@ -391,7 +331,7 @@ fun DeviceTabScreen( fun runBusy(label: String, onFinished: (() -> Unit)? = null, block: suspend () -> Unit) { // For non-adb actions (start/stop/pair/list refresh...). if (busy) return - scope.launch { + scope.launch(kotlinx.coroutines.Dispatchers.IO) { busy = true try { block() @@ -425,10 +365,16 @@ fun DeviceTabScreen( * UI controls remain actionable while background retries occur. * - Errors and timeouts are logged and surfaced similarly to `runBusy`. */ - fun runAdbConnect(label: String, onFinished: (() -> Unit)? = null, block: suspend () -> Unit) { + fun runAdbConnect( + label: String, + onStarted: (() -> Unit)? = null, + onFinished: (() -> Unit)? = null, + block: suspend () -> Unit, + ) { // For manual adb operations from user actions. if (adbConnecting) return scope.launch { + onStarted?.invoke() adbConnecting = true try { block() @@ -465,35 +411,23 @@ fun DeviceTabScreen( suspend fun refreshEncoderLists() { if (!adbConnected) return runCatching { - scrcpy.listOptions(list = ListOptions.ENCODERS) - }.onSuccess { result -> - val lists = result as Scrcpy.ListResult.Encoders - onVideoEncoderOptionsChange(lists.videoEncoders) - onAudioEncoderOptionsChange(lists.audioEncoders) - onVideoEncoderTypeMapChange(lists.videoEncoderTypes) - onAudioEncoderTypeMapChange(lists.audioEncoderTypes) - if (videoEncoder.isNotBlank() && videoEncoder !in videoEncoderOptions) { + scrcpy.refreshEncoders() + }.onSuccess { + // Validate current selections + if (videoEncoder.isNotBlank() && videoEncoder !in scrcpy.videoEncoders) { videoEncoder = "" } - if (audioEncoder.isNotBlank() && audioEncoder !in audioEncoderOptions) { + if (audioEncoder.isNotBlank() && audioEncoder !in scrcpy.audioEncoders) { audioEncoder = "" } - logEvent("编码器列表已刷新: video=${lists.videoEncoders.size} audio=${lists.audioEncoders.size}") - if (lists.videoEncoders.isEmpty() && lists.audioEncoders.isEmpty()) { + logEvent("编码器列表已刷新: video=${scrcpy.videoEncoders.size} audio=${scrcpy.audioEncoders.size}") + if (scrcpy.videoEncoders.isEmpty() && scrcpy.audioEncoders.isEmpty()) { logEvent( "提示: 编码器为空,请检查 server 路径/版本与设备系统日志", Log.WARN ) - val preview = lists.rawOutput.lineSequence().take(20).joinToString(" | ") - if (preview.isNotBlank()) { - logEvent("编码器原始输出: $preview", Log.DEBUG) - } } }.onFailure { e -> - onVideoEncoderOptionsChange(emptyList()) - onAudioEncoderOptionsChange(emptyList()) - onVideoEncoderTypeMapChange(emptyMap()) - onAudioEncoderTypeMapChange(emptyMap()) logEvent( "读取编码器列表失败: ${e.message ?: e.javaClass.simpleName}", Log.ERROR, @@ -507,22 +441,14 @@ fun DeviceTabScreen( suspend fun refreshCameraSizeLists() { if (!adbConnected) return runCatching { - scrcpy.listOptions(ListOptions.CAMERA_SIZES) - }.onSuccess { result -> - val lists = result as Scrcpy.ListResult.CameraSizes - onCameraSizeOptionsChange(lists.sizes) - if (cameraSize.isNotBlank() && cameraSize != "custom" && cameraSize !in lists.sizes) { + scrcpy.refreshCameraSizes() + }.onSuccess { + // Validate current selection + if (cameraSize.isNotBlank() && cameraSize != "custom" && cameraSize !in scrcpy.cameraSizes) { cameraSize = "" } - logEvent("camera sizes 已刷新: count=${lists.sizes.size}") - if (lists.sizes.isEmpty()) { - val preview = lists.rawOutput.lineSequence().take(20).joinToString(" | ") - if (preview.isNotBlank()) { - logEvent("camera sizes 原始输出: $preview", Log.DEBUG) - } - } + logEvent("camera sizes 已刷新: count=${scrcpy.cameraSizes.size}") }.onFailure { e -> - onCameraSizeOptionsChange(emptyList()) logEvent( "读取 camera sizes 失败: ${e.message ?: e.javaClass.simpleName}", Log.ERROR, @@ -738,7 +664,7 @@ fun DeviceTabScreen( sessionInfoWidth = sessionInfo?.width ?: 0 sessionInfoHeight = sessionInfo?.height ?: 0 sessionInfoDeviceName = sessionInfo?.deviceName.orEmpty() - sessionInfoCodec = sessionInfo?.codec.orEmpty() + sessionInfoCodec = sessionInfo?.codecName.orEmpty() sessionInfoControlEnabled = sessionInfo?.controlEnabled == true } else { sessionInfoWidth = 0 @@ -751,12 +677,6 @@ fun DeviceTabScreen( } DisposableEffect(Unit) { - onRefreshEncodersActionChange { - runBusy("刷新编码器") { refreshEncoderLists() } - } - onRefreshCameraSizesActionChange { - runBusy("刷新 Camera Sizes") { refreshCameraSizeLists() } - } onClearLogsActionChange { EventLogger.clearLogs() } @@ -764,11 +684,8 @@ fun DeviceTabScreen( showReorderSheet = true } onDispose { - onRefreshEncodersActionChange(null) - onRefreshCameraSizesActionChange(null) - onClearLogsActionChange(null) + // canClearLogs 是 DevicePage 特有的状态,需要重置 onCanClearLogsChange(false) - onOpenReorderDevicesActionChange(null) } } @@ -800,8 +717,8 @@ fun DeviceTabScreen( fun sendVirtualButtonAction(action: VirtualButtonAction) { val keycode = action.keycode ?: return runBusy("发送 ${action.title}") { - nativeCore.sessionManager.injectKeycode(0, keycode) - nativeCore.sessionManager.injectKeycode(1, keycode) + nativeCore.session?.injectKeycode(0, keycode) + nativeCore.session?.injectKeycode(1, keycode) } } @@ -870,14 +787,19 @@ fun DeviceTabScreen( onAction = { haptics.contextClick() if (!isConnectedTarget) { - activeDeviceActionId = device.id - runAdbConnect("连接 ADB", onFinished = { activeDeviceActionId = null }) { + runAdbConnect( + "连接 ADB", + onStarted = { activeDeviceActionId = device.id }, + onFinished = { activeDeviceActionId = null }, + ) { disconnectCurrentTargetBeforeConnecting(host, port) try { connectWithTimeout(host, port) adbConnected = true - savedShortcuts = - savedShortcuts.update(host = host, port = port, online = true) + isQuickConnected = false // 标记为快速设备连接 + savedShortcuts = savedShortcuts.update( + host = host, port = port, online = true + ) handleAdbConnected(host, port) } catch (e: Exception) { statusLine = "ADB 连接失败" @@ -889,7 +811,11 @@ fun DeviceTabScreen( } } else { activeDeviceActionId = device.id - runAdbConnect("断开 ADB", onFinished = { activeDeviceActionId = null }) { + runAdbConnect( + "断开 ADB", + onStarted = { activeDeviceActionId = device.id }, + onFinished = { activeDeviceActionId = null }, + ) { sessionReconnectBlacklistHosts += host disconnectAdbConnection( clearQuickOnlineForTarget = ConnectionTarget(host, port), @@ -905,28 +831,37 @@ fun DeviceTabScreen( if (!adbConnected) item { // "快速连接" QuickConnectCard( - input = quickConnectInput, - onValueChange = { quickConnectInput = it }, + input = quickConnectInputTemp, + onValueChange = { + quickConnectInputTemp = it + quickConnectInput = quickConnectInputTemp + }, + // onFocusChange = { quickConnectInput = quickConnectInputTemp }, enabled = !adbConnecting, onAddDevice = { - val target = ConnectionTarget.unmarshalFrom(quickConnectInput) + val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp) ?: return@QuickConnectCard savedShortcuts = savedShortcuts.upsert( DeviceShortcut(host = target.host, port = target.port) ) - Log.i("SavedShortcuts", "size: ${savedShortcuts.size}, list: $quickDevicesList") + Log.d("SavedShortcuts", "size: ${savedShortcuts.size}, list: $quickDevicesList") scope.launch { snack.showSnackbar("已添加设备: ${target.host}:${target.port}") } }, onConnect = { - val target = ConnectionTarget.unmarshalFrom(quickConnectInput) + val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp) ?: return@QuickConnectCard - runAdbConnect("连接 ADB", onFinished = { activeDeviceActionId = null }) { + runAdbConnect( + "连接 ADB", + onStarted = { activeDeviceActionId = target.toString() }, + onFinished = { activeDeviceActionId = null }, + ) { disconnectCurrentTargetBeforeConnecting(target.host, target.port) try { connectWithTimeout(target.host, target.port) adbConnected = true + isQuickConnected = true // 标记为快速连接 savedShortcuts = savedShortcuts.update( host = target.host, port = target.port, @@ -956,7 +891,7 @@ fun DeviceTabScreen( onPair = { host, port, code -> runBusy("执行配对") { val resolvedHost = host.trim() - val resolvedPort = port.toIntOrNull() ?: return@runBusy + val resolvedPort = port.trim().toIntOrNull() ?: return@runBusy val resolvedCode = code.trim() val ok = adbService.pair( resolvedHost, @@ -980,26 +915,30 @@ fun DeviceTabScreen( ConfigPanel( busy = busy, audioForwardingSupported = audioForwardingSupported, + cameraMirroringSupported = cameraMirroringSupported, onOpenAdvanced = onOpenAdvancedPage, onStartStopHaptic = { haptics.contextClick() }, onStart = { runBusy("启动 scrcpy") { val options = scrcpyOptions.toClientOptions() val session = scrcpy.start(options) - sessionInfo = session + sessionInfo = session.copy( + host = currentTargetHost, + port = currentTargetPort + ) statusLine = "scrcpy 运行中" @SuppressLint("DefaultLocale") val videoDetail = if (!options.video) { "off" } else { - "${session.codec} ${session.width}x${session.height} " + - "@${String.format("%.1f", videoBitRate / 1_000_000)}Mbps" + "${session.codecName} ${session.width}x${session.height} " + + "@${String.format("%.1f", videoBitRate / 1_000_000f)}Mbps" } val audioDetail = if (!audio) { "off" } else { val playback = if (!options.audioPlayback) "(no-playback)" else "" - "${options.audioCodec} ${videoBitRate / 1_000}Kbps source=${options.audioSource}$playback" + "${options.audioCodec} ${videoBitRate / 1_000f}Kbps source=${options.audioSource}$playback" } logEvent( "scrcpy 已启动: device=${session.deviceName}" + @@ -1010,9 +949,6 @@ fun DeviceTabScreen( scope.launch { snack.showSnackbar("scrcpy 已启动") } - scrcpy.getLastServerCommand()?.let { command -> - logEvent("scrcpy-server args: $command") - } } }, onStop = { @@ -1026,7 +962,23 @@ fun DeviceTabScreen( } } }, - sessionStarted = sessionInfo != null, + sessionInfo = sessionInfo, + onDisconnect = { + runAdbConnect( + "断开 ADB", + onStarted = {}, + onFinished = {}, + ) { + currentTarget?.let { target -> + sessionReconnectBlacklistHosts += target.host + disconnectAdbConnection( + clearQuickOnlineForTarget = target, + logMessage = "ADB 已断开", + showSnackMessage = "ADB 已断开", + ) + } + } + }, ) } @@ -1068,7 +1020,6 @@ fun DeviceTabScreen( LogsPanel(lines = EventLogger.eventLog) } - // TODO: 放进 [AppPageLazyColumn] 里 item { Spacer(Modifier.height(UiSpacing.BottomContent)) } } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt index 6ac4fc8..4b38956 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt @@ -20,8 +20,8 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade -import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics +import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions @@ -68,11 +68,13 @@ fun FullscreenControlPage( } var session by remember(launch) { mutableStateOf( - ScrcpySessionInfo( + Scrcpy.Session.SessionInfo( width = launch.width, height = launch.height, deviceName = launch.deviceName.ifBlank { "设备" }, - codec = launch.codec.ifBlank { "unknown" }, + codecId = 0, + codecName = launch.codec.ifBlank { "unknown" }, + audioCodecId = 0, controlEnabled = true, ), ) @@ -121,8 +123,12 @@ fun FullscreenControlPage( } suspend fun sendKeycode(keycode: Int) { - nativeCore.sessionManager.injectKeycode(0, keycode) - nativeCore.sessionManager.injectKeycode(1, keycode) + runCatching { + 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 -> @@ -139,7 +145,7 @@ fun FullscreenControlPage( currentFps = currentFps, enableBackHandler = false, onInjectTouch = { action, pointerId, x, y, pressure, buttons -> - nativeCore.sessionManager.injectTouch( + nativeCore.session?.injectTouch( action = action, pointerId = pointerId, x = x, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt index eff434a..3f38849 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt @@ -29,7 +29,6 @@ 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 @@ -93,6 +92,7 @@ private sealed interface RootScreen : NavKey { @Composable fun MainPage() { val context = LocalContext.current + val appContext = context.applicationContext val scope = rememberCoroutineScope() val activity = remember(context) { context as? Activity } @@ -105,9 +105,9 @@ fun MainPage() { } } - val nativeCore = remember(context) { NativeCoreFacade.get(context.applicationContext) } - val adbService = remember(context) { NativeAdbService(context) } - val scrcpy = remember(context) { Scrcpy(context) } + val nativeCore = remember(appContext) { NativeCoreFacade.get(appContext) } + val adbService = remember(appContext) { NativeAdbService(appContext) } + val scrcpy = remember(appContext, adbService) { Scrcpy(appContext, adbService) } val snackHostState = remember { SnackbarHostState() } val saveableStateHolder = rememberSaveableStateHolder() @@ -194,16 +194,9 @@ fun MainPage() { val appSettings = Storage.appSettings val scrcpyOptions = Storage.scrcpyOptions - val videoEncoderOptions = remember { mutableStateListOf() } - val audioEncoderOptions = remember { mutableStateListOf() } - val videoEncoderTypeMap = remember { mutableStateMapOf() } - val audioEncoderTypeMap = remember { mutableStateMapOf() } - val cameraSizeOptions = remember { mutableStateListOf() } var sessionStarted by remember { mutableStateOf(false) } - var refreshEncodersAction by remember { mutableStateOf<(() -> Unit)?>(null) } - var refreshCameraSizesAction by remember { mutableStateOf<(() -> Unit)?>(null) } - var clearLogsAction by remember { mutableStateOf<(() -> Unit)?>(null) } - var openReorderDevicesAction by remember { mutableStateOf<(() -> Unit)?>(null) } + var clearLogsAction by remember { mutableStateOf({}) } + var openReorderDevicesAction by remember { mutableStateOf({}) } var canClearLogs by remember { mutableStateOf(false) } var showDeviceMenu by rememberSaveable { mutableStateOf(false) } var fullscreenOrientation by rememberSaveable { @@ -314,7 +307,7 @@ fun MainPage() { canClearLogs = canClearLogs, onDismissRequest = { showDeviceMenu = false }, onReorderDevices = { - openReorderDevicesAction?.invoke() + openReorderDevicesAction() showDeviceMenu = false }, onOpenVirtualButtonOrder = { @@ -322,7 +315,7 @@ fun MainPage() { showDeviceMenu = false }, onClearLogs = { - clearLogsAction?.invoke() + clearLogsAction() showDeviceMenu = false }, ) @@ -338,34 +331,7 @@ fun MainPage() { scrcpy = scrcpy, snack = snackHostState, scrollBehavior = devicesPageScrollBehavior, - 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 = { @@ -385,7 +351,7 @@ fun MainPage() { deviceName = session.deviceName, width = session.width, height = session.height, - codec = session.codec, + codec = session.codecName, ), ), ) @@ -404,7 +370,7 @@ fun MainPage() { SettingsScreen( contentPadding = pagePadding, onOpenReorderDevices = { - openReorderDevicesAction?.invoke() + openReorderDevicesAction() }, onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) @@ -427,14 +393,7 @@ fun MainPage() { } } - val videoEncoder by scrcpyOptions.videoEncoder.asState() - val audioEncoder by scrcpyOptions.audioEncoder.asState() 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( @@ -456,16 +415,7 @@ fun MainPage() { contentPadding = pagePadding, scrollBehavior = advancedPageScrollBehavior, snackbarHostState = snackHostState, - cameraSizeDropdownItems = listOf("默认") + cameraSizeOptions + listOf("自定义"), - cameraSizeOptions = cameraSizeOptions, - videoEncoderDropdownItems = videoEncoderDropdownItems, - videoEncoderTypeMap = videoEncoderTypeMap, - videoEncoderIndex = videoEncoderIndex, - audioEncoderDropdownItems = audioEncoderDropdownItems, - audioEncoderTypeMap = audioEncoderTypeMap, - audioEncoderIndex = audioEncoderIndex, - onRefreshEncoders = { refreshEncodersAction?.invoke() }, - onRefreshCameraSizes = { refreshCameraSizesAction?.invoke() }, + scrcpy = scrcpy, ) } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt index 3130e87..f500843 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt @@ -2,6 +2,7 @@ package io.github.miuzarte.scrcpyforandroid.pages import android.content.Intent import android.os.Process +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -12,9 +13,12 @@ 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.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -22,13 +26,15 @@ import androidx.compose.ui.text.font.FontWeight import androidx.core.net.toUri 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.scaffolds.SuperSlider +import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField import io.github.miuzarte.scrcpyforandroid.storage.AppSettings import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.IconButton @@ -77,6 +83,10 @@ fun SettingsScreen( ) { val appContext = LocalContext.current.applicationContext val appSettings = Storage.appSettings + var needMigration by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + needMigration = PreferenceMigration(appContext).needsMigration() + } val context = LocalContext.current @@ -98,9 +108,22 @@ fun SettingsScreen( // scrcpy-server var customServerUri by appSettings.customServerUri.asMutableState() var serverRemotePath by appSettings.serverRemotePath.asMutableState() + var serverRemotePathInput by rememberSaveable { + mutableStateOf( + // 默认值留空显示为 hint + if (runBlocking { appSettings.serverRemotePath.isDefaultValue() }) "" + else serverRemotePath + ) + } // ADB var adbKeyName by appSettings.adbKeyName.asMutableState() + var adbKeyNameInput by rememberSaveable { + mutableStateOf( + if (runBlocking { appSettings.adbKeyName.isDefaultValue() }) "" + else adbKeyName + ) + } var adbPairingAutoDiscoverOnDialogOpen by appSettings.adbPairingAutoDiscoverOnDialogOpen.asMutableState() var adbAutoReconnectPairedDevice by appSettings.adbAutoReconnectPairedDevice.asMutableState() @@ -141,7 +164,7 @@ fun SettingsScreen( checked = keepScreenOnWhenStreaming, onCheckedChange = { keepScreenOnWhenStreaming = it }, ) - SuperSlide( + SuperSlider( title = "预览卡高度", summary = "设备页预览卡高度", value = devicePreviewCardHeightDp.toFloat(), @@ -149,12 +172,12 @@ fun SettingsScreen( devicePreviewCardHeightDp = it.roundToInt().coerceAtLeast(120) }, valueRange = 160f..600f, - steps = 439, + steps = 600-160-1, unit = "dp", displayFormatter = { it.roundToInt().toString() }, inputInitialValue = devicePreviewCardHeightDp.toString(), inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 120f..Float.MAX_VALUE, + inputValueRange = 120f..UShort.MAX_VALUE.toFloat(), onInputConfirm = { input -> input.toIntOrNull()?.let { devicePreviewCardHeightDp = it.coerceAtLeast(120) @@ -218,9 +241,13 @@ fun SettingsScreen( .padding(bottom = UiSpacing.FieldLabelBottom), fontWeight = FontWeight.Medium, ) - TextField( - value = serverRemotePath, - onValueChange = { serverRemotePath = it }, + SuperTextField( + value = serverRemotePathInput, + onValueChange = { serverRemotePathInput = it }, + onFocusLost = { + if (serverRemotePathInput == AppSettings.SERVER_REMOTE_PATH.defaultValue) + serverRemotePathInput = "" + }, label = AppSettings.SERVER_REMOTE_PATH.defaultValue, useLabelAsPlaceholder = true, singleLine = true, @@ -240,9 +267,13 @@ fun SettingsScreen( .padding(top = UiSpacing.CardContent, bottom = UiSpacing.FieldLabelBottom), fontWeight = FontWeight.Medium, ) - TextField( - value = adbKeyName, - onValueChange = { adbKeyName = it }, + SuperTextField( + value = adbKeyNameInput, + onValueChange = { adbKeyNameInput = it }, + onFocusLost = { + if (adbKeyNameInput == AppSettings.ADB_KEY_NAME.defaultValue) + adbKeyNameInput = "" + }, label = AppSettings.ADB_KEY_NAME.defaultValue, useLabelAsPlaceholder = true, singleLine = true, @@ -265,35 +296,41 @@ fun SettingsScreen( ) } - SectionSmallTitle("应用") - Card { - SuperArrow( - title = "恢复旧版本配置", - summary = "从旧版本的 SharedPreferences 恢复至 DataStore", - onClick = { - scope.launch { - val migration = PreferenceMigration(appContext) - migration.migrate(clearSharedPrefs = false) - snackHostState.showSnackbar("迁移完成,应用将重启") + // 这部分应该不会显示出来, + // 应用启动时就会执行迁移与旧数据的删除 + AnimatedVisibility(needMigration) { + SectionSmallTitle("应用") + } + AnimatedVisibility(needMigration) { + Card { + SuperArrow( + title = "恢复旧版本配置", + summary = "从旧版本的 SharedPreferences 恢复至 DataStore", + onClick = { + scope.launch { + val migration = PreferenceMigration(appContext) + migration.migrate(clearSharedPrefs = false) + snackHostState.showSnackbar("迁移完成,应用将重启") - delay(1000) + delay(1000) - val intent = context.packageManager.getLaunchIntentForPackage( - context.packageName - ) - intent?.apply { - addFlags( - Intent.FLAG_ACTIVITY_NEW_TASK - or Intent.FLAG_ACTIVITY_CLEAR_TASK + val intent = context.packageManager.getLaunchIntentForPackage( + context.packageName ) - } - context.startActivity(intent) + intent?.apply { + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK + or Intent.FLAG_ACTIVITY_CLEAR_TASK + ) + } + context.startActivity(intent) - Process.killProcess(Process.myPid()) - exitProcess(0) - } - }, - ) + Process.killProcess(Process.myPid()) + exitProcess(0) + } + }, + ) + } } SectionSmallTitle("关于") @@ -313,7 +350,6 @@ fun SettingsScreen( } } - // TODO: 放进 [AppPageLazyColumn] 里 item { Spacer(Modifier.height(UiSpacing.BottomContent)) } } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlide.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt similarity index 64% rename from app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlide.kt rename to app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt index 13240dd..f202b08 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlide.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt @@ -2,16 +2,17 @@ package io.github.miuzarte.scrcpyforandroid.scaffolds import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import top.yukonga.miuix.kmp.basic.ButtonDefaults import top.yukonga.miuix.kmp.basic.Slider @@ -23,7 +24,7 @@ import top.yukonga.miuix.kmp.extra.SuperDialog import top.yukonga.miuix.kmp.theme.MiuixTheme @Composable -fun SuperSlide( +fun SuperSlider( title: String, summary: String, value: Float, @@ -82,60 +83,71 @@ fun SuperSlide( }, ) - if (showInputDialog) { - var valueText by remember(inputInitialValue) { mutableStateOf(inputInitialValue) } - val activeInputRange = inputValueRange ?: valueRange - SuperDialog( - show = true, - onDismissRequest = { - showInputDialog = false - holdArrow = false - }, - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - Text(text = inputTitle) - } + SliderInputDialog( + showDialog = showInputDialog, + title = inputTitle, + summary = inputHint, + initialValue = inputInitialValue, + inputFilter = inputFilter, + inputValueRange = inputValueRange ?: valueRange, + onDismissRequest = { showInputDialog = false }, + onDismissFinished = { holdArrow = false }, + onConfirm = { input -> + onInputConfirm(input) + showInputDialog = false + }, + ) +} + +@Composable +private fun SliderInputDialog( + showDialog: Boolean, + title: String, + summary: String, + initialValue: String, + inputFilter: (String) -> String, + inputValueRange: ClosedFloatingPointRange, + onDismissRequest: () -> Unit, + onDismissFinished: () -> Unit, + onConfirm: (String) -> Unit, +) { + SuperDialog( + show = showDialog, + title = title, + summary = summary, + onDismissRequest = onDismissRequest, + onDismissFinished = onDismissFinished, + content = { + var text by remember(initialValue) { mutableStateOf(initialValue) } + TextField( - value = valueText, - onValueChange = { valueText = inputFilter(it) }, - label = inputHint, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier - .fillMaxWidth() - .padding(top = UiSpacing.Large), + modifier = Modifier.padding(bottom = 16.dp), + value = text, + maxLines = 1, + onValueChange = { newValue -> + text = inputFilter(newValue) + }, ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = UiSpacing.Large), - horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), - ) { + + Row(horizontalArrangement = Arrangement.SpaceBetween) { TextButton( text = "取消", + onClick = onDismissRequest, modifier = Modifier.weight(1f), - onClick = { - showInputDialog = false - holdArrow = false - }, ) + Spacer(Modifier.width(20.dp)) TextButton( text = "确定", - modifier = Modifier.weight(1f), onClick = { - val inputValue = valueText.trim().toFloatOrNull() - if (inputValue != null && inputValue >= activeInputRange.start && inputValue <= activeInputRange.endInclusive) { - onInputConfirm(valueText.trim()) - showInputDialog = false - holdArrow = false + val inputValue = text.toFloatOrNull() ?: 0f + if (inputValue >= inputValueRange.start && inputValue <= inputValueRange.endInclusive) { + onConfirm(text.trim()) } }, + modifier = Modifier.weight(1f), colors = ButtonDefaults.textButtonColorsPrimary(), ) } - } - } + }, + ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperTextField.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperTextField.kt new file mode 100644 index 0000000..c00f273 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperTextField.kt @@ -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, + ) +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt index 9e841ac..a0e56d6 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt @@ -88,9 +88,9 @@ data class ClientOptions( var maxSize: UShort = 0u, // to server // --video-bit-rate - var videoBitRate: UInt = 0u, // to server + var videoBitRate: Int = 0, // to server // --audio-bit-rate - var audioBitRate: UInt = 0u, // to server + var audioBitRate: Int = 0, // to server // --max-fps var maxFps: String = "", // float to be parsed by the server @@ -115,7 +115,8 @@ data class ClientOptions( // var windowHeight: UShort, // --display-id - var displayId: UInt = 0u, // to server + // -1 for empty text field + var displayId: Int = -1, // to server // var videoBuffer: Tick, // var audioBuffer: Tick, @@ -278,7 +279,7 @@ data class ClientOptions( } if (videoSource == VideoSource.CAMERA) { - if (displayId > 0u) { + if (displayId > 0) { throw IllegalArgumentException( "--display-id is only available with --video-source=display" ) @@ -331,14 +332,14 @@ data class ClientOptions( ) } - if (displayId > 0u && newDisplay.isNotBlank()) { + if (displayId > 0 && newDisplay.isNotBlank()) { throw IllegalArgumentException( "Cannot specify both --display-id and --new-display" ) } if (displayImePolicy != DisplayImePolicy.UNDEFINED - && displayId == 0u && newDisplay.isBlank() + && displayId == 0 && newDisplay.isBlank() ) { throw IllegalArgumentException( "--display-ime-policy is only supported on a secondary display" @@ -466,7 +467,7 @@ data class ClientOptions( } */ - if (control) { + if (!control) { if (turnScreenOff) { throw IllegalArgumentException( "Cannot request to turn screen off if control is disabled" diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt index b12d514..68138c6 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt @@ -3,15 +3,29 @@ package io.github.miuzarte.scrcpyforandroid.scrcpy import android.content.Context import android.net.Uri import android.util.Log +import android.view.KeyEvent import androidx.core.net.toUri import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade -import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo +import io.github.miuzarte.scrcpyforandroid.constants.Defaults +import io.github.miuzarte.scrcpyforandroid.nativecore.AdbSocketStream import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpyAudioPlayer -import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpySessionManager import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.BufferedInputStream +import java.io.BufferedReader +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.EOFException import java.io.File +import java.io.InputStreamReader +import java.util.ArrayDeque +import kotlin.concurrent.thread +import kotlin.math.roundToInt import kotlin.random.Random import kotlin.random.nextUInt @@ -24,25 +38,25 @@ import kotlin.random.nextUInt * - Audio playback * - Screen control * - * @param context Android context + * @param appContext Android context * @param serverAsset Asset path for the default server jar * @param customServerUri Optional custom server URI (overrides serverAsset) * @param serverVersion Server version string * @param serverRemotePath Remote path where server jar will be pushed on device */ class Scrcpy( - private val context: Context, + private val appContext: Context, + private val adbService: NativeAdbService, private val serverAsset: String = DEFAULT_SERVER_ASSET, private val customServerUri: String? = null, private val serverVersion: String = "3.3.4", private val serverRemotePath: String = DEFAULT_REMOTE_PATH, ) { - private val adbService = NativeAdbService(context) - private val sessionManager = ScrcpySessionManager(adbService) - private val nativeCore: NativeCoreFacade = NativeCoreFacade.get(context) + private val session = Session(adbService) + private val nativeCore: NativeCoreFacade = NativeCoreFacade.get(appContext) @Volatile - private var currentSession: ScrcpySessionInfo? = null + private var currentSession: Session.SessionInfo? = null @Volatile private var isRunning: Boolean = false @@ -50,6 +64,19 @@ class Scrcpy( @Volatile private var audioPlayer: ScrcpyAudioPlayer? = null + // Cached encoder and camera size data + private val _videoEncoders = mutableListOf() + private val _audioEncoders = mutableListOf() + private val _videoEncoderTypes = mutableMapOf() + private val _audioEncoderTypes = mutableMapOf() + private val _cameraSizes = mutableListOf() + + val videoEncoders: List get() = _videoEncoders.toList() + val audioEncoders: List get() = _audioEncoders.toList() + val videoEncoderTypes: Map get() = _videoEncoderTypes.toMap() + val audioEncoderTypes: Map get() = _audioEncoderTypes.toMap() + val cameraSizes: List get() = _cameraSizes.toList() + companion object { private const val TAG = "Scrcpy" const val DEFAULT_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar" @@ -74,9 +101,7 @@ class Scrcpy( } } - suspend fun start( - options: ClientOptions, - ): ScrcpySessionInfo { + suspend fun start(options: ClientOptions): Session.SessionInfo = withContext(Dispatchers.IO) { if (isRunning) { throw IllegalStateException("Scrcpy session is already running") } @@ -109,25 +134,18 @@ class Scrcpy( if (!options.control) { Log.w(TAG, "start(): turnScreenOff ignored because control is disabled") } else { - runCatching { sessionManager.setDisplayPower(on = false) } + runCatching { session.setDisplayPower(on = false) } .onFailure { e -> Log.w(TAG, "start(): set display power failed", e) } } } // Create session info - val session = ScrcpySessionInfo( - width = info.width, - height = info.height, - deviceName = info.deviceName, - codec = info.codecName, - controlEnabled = info.controlEnabled, - ) - currentSession = session + currentSession = info isRunning = true // Setup video consumer (notify NativeCoreFacade to setup decoders) if (options.video) { - nativeCore.onScrcpySessionStarted(session, sessionManager) + nativeCore.onScrcpySessionStarted(info, session) } // Setup audio player @@ -142,7 +160,7 @@ class Scrcpy( ) val player = ScrcpyAudioPlayer(info.audioCodecId) audioPlayer = player - sessionManager.attachAudioConsumer { packet -> + session.attachAudioConsumer { packet -> player.feedPacket(packet.data, packet.ptsUs, packet.isConfig) } } else { @@ -150,13 +168,13 @@ class Scrcpy( } Log.i( - TAG, "start(): Session started successfully - device=${session.deviceName}, " + - "video=${if (options.video) "${session.codec} ${session.width}x${session.height}" else "off"}, " + + TAG, "start(): Session started successfully - device=${info.deviceName}, " + + "video=${if (options.video) "${info.codecName} ${info.width}x${info.height}" else "off"}, " + "audio=${if (options.audio) options.audioCodec.string else "off"}, " + "control=${options.control}" ) - return session + return@withContext info } catch (e: Exception) { Log.e(TAG, "start(): Failed to start scrcpy session", e) @@ -166,19 +184,19 @@ class Scrcpy( } } - suspend fun stop(): Boolean { + suspend fun stop(): Boolean = withContext(Dispatchers.IO) { if (!isRunning) { Log.w(TAG, "stop(): No active session to stop") - return false + return@withContext false } Log.i(TAG, "stop(): Stopping scrcpy session") - return try { + return@withContext try { nativeCore.onScrcpySessionStopped() - sessionManager.clearVideoConsumer() - sessionManager.clearAudioConsumer() - sessionManager.stop() + session.clearVideoConsumer() + session.clearAudioConsumer() + session.stop() audioPlayer?.release() audioPlayer = null isRunning = false @@ -196,11 +214,11 @@ class Scrcpy( adbService.close() } - fun isStarted(): Boolean = isRunning && sessionManager.isStarted() + fun isStarted(): Boolean = isRunning && session.isStarted() - fun getCurrentSession(): ScrcpySessionInfo? = currentSession + fun getCurrentSession(): Session.SessionInfo? = currentSession - fun getLastServerCommand(): String? = sessionManager.getLastServerCommand() + fun getLastServerCommand(): String? = session.getLastServerCommand() sealed class ListResult { data class Encoders( @@ -217,13 +235,49 @@ class Scrcpy( ) : ListResult() } + /** + * Refresh encoder lists from the device. + * Results are cached and can be accessed via videoEncoders, audioEncoders, etc. + * + * @throws Exception if the operation fails + */ + suspend fun refreshEncoders() { + val result = listOptions(ListOptions.ENCODERS) as ListResult.Encoders + _videoEncoders.clear() + _videoEncoders.addAll(result.videoEncoders) + _audioEncoders.clear() + _audioEncoders.addAll(result.audioEncoders) + _videoEncoderTypes.clear() + _videoEncoderTypes.putAll(result.videoEncoderTypes) + _audioEncoderTypes.clear() + _audioEncoderTypes.putAll(result.audioEncoderTypes) + + Log.i(TAG, "refreshEncoders(): video=${_videoEncoders.size}, audio=${_audioEncoders.size}") + } + + /** + * Refresh camera sizes from the device. + * Results are cached and can be accessed via cameraSizes. + * + * @throws Exception if the operation fails + */ + suspend fun refreshCameraSizes() { + val result = listOptions(ListOptions.CAMERA_SIZES) as ListResult.CameraSizes + _cameraSizes.clear() + _cameraSizes.addAll(result.sizes.sortedWith(compareByDescending { size -> + size.substringBefore('x').toIntOrNull() ?: 0 + })) + + Log.i(TAG, "refreshCameraSizes(): sizes=${_cameraSizes.size}") + } + /** * List various options from the scrcpy server. * * @param list The type of list to retrieve (ENCODERS, CAMERA_SIZES, etc.) * @return ListResult containing the requested information */ - suspend fun listOptions(list: ListOptions): ListResult { + suspend fun listOptions(list: ListOptions): ListResult = withContext(Dispatchers.IO) { val serverJar = if (customServerUri.isNullOrBlank()) { extractAssetToCache(serverAsset) } else { @@ -261,7 +315,7 @@ class Scrcpy( val output = adbService.shell("$serverCommand 2>&1") // Parse output based on list option - return when (list) { + return@withContext when (list) { ListOptions.NULL -> { throw IllegalArgumentException("Nothing to do with ListOptions.NULL") } @@ -377,7 +431,7 @@ class Scrcpy( serverJar: File, options: ClientOptions, scid: UInt, - ): ScrcpySessionManager.SessionInfo { + ): Session.SessionInfo { adbService.push(serverJar.toPath(), serverRemotePath) val serverParams = options.toServerParams(scid) @@ -394,18 +448,22 @@ class Scrcpy( // Execute server (equivalent to sc_adb_execute in C) Log.i(TAG, "executeServer(): Starting scrcpy server") logEvent("scrcpy-server args: $serverCommand") - return sessionManager.start( - serverJarPath = serverJar.toPath(), + val sessionInfo = session.start( serverCommand = serverCommand, scid = scid, options = options, ) + Log.i(TAG, "executeServer(): session.start() returned, checking if session is still active") + if (!session.isStarted()) { + Log.e(TAG, "executeServer(): WARNING - session was cleared immediately after start()!") + } + return sessionInfo } private fun extractAssetToCache(assetPath: String): File { val clean = assetPath.removePrefix("/") - val source = context.assets.open(clean) - val outputFile = File(context.cacheDir, File(clean).name) + val source = appContext.assets.open(clean) + val outputFile = File(appContext.cacheDir, File(clean).name) source.use { input -> outputFile.outputStream().use { output -> input.copyTo(output) } } @@ -414,11 +472,706 @@ class Scrcpy( private fun extractUriToCache(uri: Uri): File { val fileName = "custom-scrcpy-server.jar" - val outputFile = File(context.cacheDir, fileName) - context.contentResolver.openInputStream(uri).use { input -> + 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 } + + /** + * Session manager for scrcpy protocol. + * Handles socket communication, video/audio streaming, and control input. + */ + class Session(private val adbService: NativeAdbService) { + private val mutex = Mutex() + + @Volatile + private var activeSession: ActiveSession? = null + + @Volatile + private var videoConsumer: ((VideoPacket) -> Unit)? = null + + @Volatile + private var videoReaderThread: Thread? = null + + @Volatile + private var audioConsumer: ((AudioPacket) -> Unit)? = null + + @Volatile + private var audioReaderThread: Thread? = null + + @Volatile + private var lastServerCommand: String? = null + private val serverLogBuffer = ArrayDeque() + + suspend fun start( + serverCommand: String, + scid: UInt, + options: ClientOptions, + ): SessionInfo = mutex.withLock { + stopInternal() + serverLogBuffer.clear() + val socketName = socketNameFor(scid.toInt()) + + try { + lastServerCommand = serverCommand + val serverStream = adbService.openShellStream(serverCommand) + val serverLogThread = startServerLogThread(serverStream, socketName) + Thread.sleep(SERVER_BOOT_DELAY_MS) + + val firstStream = openAbstractSocketWithRetry(socketName, expectDummyByte = true) + val firstInput = DataInputStream(BufferedInputStream(firstStream.inputStream)) + + var videoStream: AdbSocketStream? = null + var videoInput: DataInputStream? = null + var audioStream: AdbSocketStream? = null + var audioInput: DataInputStream? = null + var controlStream: AdbSocketStream? = null + + when { + options.video -> { + videoStream = firstStream + videoInput = firstInput + } + + options.audio -> { + audioStream = firstStream + audioInput = firstInput + } + + options.control -> { + controlStream = firstStream + } + + else -> { + throw IllegalArgumentException("At least one of video/audio/control must be enabled") + } + } + + if (options.video && videoStream == null) { + val vStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false) + videoStream = vStream + videoInput = DataInputStream(BufferedInputStream(vStream.inputStream)) + } + + if (options.audio && audioStream == null) { + val aStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false) + audioStream = aStream + audioInput = DataInputStream(BufferedInputStream(aStream.inputStream)) + } + + if (options.control && controlStream == null) { + controlStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false) + } + + val deviceName = readDeviceName(firstInput) + val audioCodecId = + if (options.audio) audioCodecIdFromName(options.audioCodec.string) + else 0 + val codecId: Int + val width: Int + val height: Int + if (options.video) { + val vInput = checkNotNull(videoInput) + codecId = vInput.readInt() + width = vInput.readInt() + height = vInput.readInt() + } else { + codecId = 0 + width = 0 + height = 0 + } + + val sessionInfo = SessionInfo( + deviceName = deviceName, + codecId = codecId, + codecName = codecName(codecId), + width = width, + height = height, + audioCodecId = audioCodecId, + controlEnabled = controlStream != null, + ) + + val controlWriter = controlStream?.let { stream -> + ControlWriter(DataOutputStream(stream.outputStream)) + } + + val newSession = ActiveSession( + info = sessionInfo, + socketName = socketName, + serverStream = serverStream, + serverLogThread = serverLogThread, + videoStream = videoStream, + videoInput = videoInput, + audioStream = audioStream, + audioInput = audioInput, + controlStream = controlStream, + controlWriter = controlWriter, + ) + activeSession = newSession + return sessionInfo + } catch (t: Throwable) { + val tail = snapshotServerLogs() + val detail = if (tail.isBlank()) "" else " | server_log_tail=\n$tail" + throw IllegalStateException("scrcpy start failed: ${t.message}$detail", t) + } + } + + suspend fun attachVideoConsumer(consumer: (VideoPacket) -> Unit): Unit = mutex.withLock { + val session = activeSession ?: throw IllegalStateException("scrcpy session not started") + val vInput = session.videoInput ?: return + val vStream = session.videoStream ?: return + videoConsumer = consumer + if (videoReaderThread?.isAlive == true) { + return + } + + videoReaderThread = thread(start = true, name = "scrcpy-video-reader") { + try { + while (activeSession === session && !vStream.closed) { + try { + val ptsAndFlags = vInput.readLong() + val packetSize = vInput.readInt() + if (packetSize <= 0) { + continue + } + + val payload = ByteArray(packetSize) + vInput.readFully(payload) + + val config = (ptsAndFlags and PACKET_FLAG_CONFIG) != 0L + val keyFrame = (ptsAndFlags and PACKET_FLAG_KEY_FRAME) != 0L + val ptsUs = ptsAndFlags and PACKET_PTS_MASK + videoConsumer?.invoke( + VideoPacket( + data = payload, + ptsUs = ptsUs, + isConfig = config, + isKeyFrame = keyFrame, + ), + ) + } catch (_: EOFException) { + break + } catch (_: InterruptedException) { + if (activeSession !== session || vStream.closed) { + break + } + Thread.interrupted() + } catch (e: Exception) { + Log.w(TAG, "video reader failed", e) + break + } + } + } finally { + } + } + } + + suspend fun clearVideoConsumer() = mutex.withLock { + videoConsumer = null + } + + suspend fun attachAudioConsumer(consumer: (AudioPacket) -> Unit): Unit = mutex.withLock { + val session = activeSession ?: throw IllegalStateException("scrcpy session not started") + val aInput = session.audioInput ?: return + val aStream = session.audioStream ?: return + audioConsumer = consumer + if (audioReaderThread?.isAlive == true) return + + audioReaderThread = thread(start = true, name = "scrcpy-audio-reader") { + try { + val streamCodecId = try { + aInput.readInt() + } catch (e: Exception) { + Log.w(TAG, "audio codec header read failed", e) + return@thread + } + when (streamCodecId) { + AUDIO_DISABLED -> { + Log.w(TAG, "audio disabled by server") + return@thread + } + + AUDIO_ERROR -> { + Log.e(TAG, "audio stream configuration error from server") + return@thread + } + + else -> { + Log.i( + TAG, + "audio stream codec=0x${streamCodecId.toUInt().toString(16)}" + ) + } + } + + while (activeSession === session && !aStream.closed) { + try { + val ptsAndFlags = aInput.readLong() + val packetSize = aInput.readInt() + if (packetSize <= 0) continue + + val payload = ByteArray(packetSize) + aInput.readFully(payload) + + val isConfig = (ptsAndFlags and PACKET_FLAG_CONFIG) != 0L + val ptsUs = ptsAndFlags and PACKET_PTS_MASK + audioConsumer?.invoke( + AudioPacket( + data = payload, + ptsUs = ptsUs, + isConfig = isConfig + ) + ) + } catch (_: EOFException) { + break + } catch (_: InterruptedException) { + if (activeSession !== session || aStream.closed) { + break + } + Thread.interrupted() + } catch (e: Exception) { + Log.w(TAG, "audio reader failed", e) + break + } + } + } finally { + } + } + } + + suspend fun clearAudioConsumer() = mutex.withLock { + audioConsumer = null + } + + suspend fun injectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) = + mutex.withLock { + try { + requireControlWriter().injectKeycode(action, keycode, repeat, metaState) + } catch (e: IllegalStateException) { + Log.w(TAG, "injectKeycode(): control channel not available", e) + } + } + + suspend fun injectText(text: String) = mutex.withLock { + try { + requireControlWriter().injectText(text) + } catch (e: IllegalStateException) { + Log.w(TAG, "injectText(): control channel not available", e) + } + } + + suspend fun injectTouch( + action: Int, + pointerId: Long, + x: Int, + y: Int, + screenWidth: Int, + screenHeight: Int, + pressure: Float, + actionButton: Int, + buttons: Int, + ) = mutex.withLock { + try { + requireControlWriter().injectTouch( + action, + pointerId, + x, + y, + screenWidth, + screenHeight, + pressure, + actionButton, + buttons + ) + } catch (e: IllegalStateException) { + Log.w(TAG, "injectTouch(): control channel not available", e) + } + } + + suspend fun injectScroll( + x: Int, + y: Int, + screenWidth: Int, + screenHeight: Int, + hScroll: Float, + vScroll: Float, + buttons: Int + ) = mutex.withLock { + try { + requireControlWriter().injectScroll( + x, + y, + screenWidth, + screenHeight, + hScroll, + vScroll, + buttons + ) + } catch (e: IllegalStateException) { + Log.w(TAG, "injectScroll(): control channel not available", e) + } + } + + suspend fun pressBackOrScreenOn(action: Int = KeyEvent.ACTION_DOWN) = mutex.withLock { + try { + requireControlWriter().pressBackOrScreenOn(action) + } catch (e: IllegalStateException) { + Log.w(TAG, "pressBackOrScreenOn(): control channel not available", e) + } + } + + suspend fun setDisplayPower(on: Boolean) = mutex.withLock { + try { + requireControlWriter().setDisplayPower(on) + } catch (e: IllegalStateException) { + Log.w(TAG, "setDisplayPower(): control channel not available", e) + } + } + + suspend fun stop() = mutex.withLock { + stopInternal() + } + + private fun stopInternal() { + val session = activeSession ?: return + activeSession = null + videoConsumer = null + audioConsumer = null + + if (Thread.currentThread() !== videoReaderThread) { + runCatching { videoReaderThread?.interrupt() } + runCatching { videoReaderThread?.join(300) } + } + videoReaderThread = null + + if (Thread.currentThread() !== audioReaderThread) { + runCatching { audioReaderThread?.interrupt() } + runCatching { audioReaderThread?.join(300) } + } + audioReaderThread = null + + runCatching { session.controlStream?.close() } + runCatching { session.audioStream?.close() } + runCatching { session.videoStream?.close() } + runCatching { session.serverStream.close() } + if (Thread.currentThread() !== session.serverLogThread) { + runCatching { session.serverLogThread.interrupt() } + runCatching { session.serverLogThread.join(300) } + } + } + + fun isStarted(): Boolean = activeSession != null + + fun getLastServerCommand(): String? = lastServerCommand + + private fun requireControlWriter(): ControlWriter { + val session = activeSession + ?: throw IllegalStateException("scrcpy control channel not available") + return session.controlWriter + ?: throw IllegalStateException("scrcpy control channel not available") + } + + private fun startServerLogThread( + serverStream: AdbSocketStream, + socketName: String + ): Thread { + return thread(start = true, name = "scrcpy-server-log") { + try { + BufferedReader( + InputStreamReader( + serverStream.inputStream, + Charsets.UTF_8 + ) + ).use { reader -> + while (true) { + val line = reader.readLine() ?: break + synchronized(serverLogBuffer) { + if (serverLogBuffer.size >= SERVER_LOG_BUFFER_MAX_LINES) { + serverLogBuffer.removeFirst() + } + serverLogBuffer.addLast(line) + } + Log.i(TAG, "[server:$socketName] $line") + } + } + } catch (e: Exception) { + if (activeSession != null) { + Log.w(TAG, "server log thread failed", e) + } + } + } + } + + private fun snapshotServerLogs(maxLines: Int = 120): String { + val snapshot = synchronized(serverLogBuffer) { + if (serverLogBuffer.isEmpty()) { + return "" + } + val take = maxLines.coerceIn(1, SERVER_LOG_BUFFER_MAX_LINES) + serverLogBuffer.toList().takeLast(take) + } + return snapshot.joinToString("\n") + } + + private suspend fun openAbstractSocketWithRetry( + socketName: String, + expectDummyByte: Boolean + ): AdbSocketStream { + var lastEx: Exception? = null + repeat(CONNECT_RETRY_COUNT) { attempt -> + try { + val stream = adbService.openAbstractSocket(socketName) + if (expectDummyByte) { + val value = stream.inputStream.read() + if (value < 0) { + stream.close() + throw EOFException("scrcpy dummy byte missing") + } + } + return stream + } catch (e: Exception) { + lastEx = e + if (attempt < CONNECT_RETRY_COUNT - 1) Thread.sleep(CONNECT_RETRY_DELAY_MS) + } + } + throw IllegalStateException("Unable to open scrcpy socket '$socketName'", lastEx) + } + + private fun readDeviceName(input: DataInputStream): String { + val buffer = ByteArray(DEVICE_NAME_FIELD_LENGTH) + input.readFully(buffer) + val firstZero = buffer.indexOf(0) + val length = if (firstZero >= 0) firstZero else buffer.size + return buffer.copyOf(length).toString(Charsets.UTF_8) + } + + private fun codecName(codecId: Int) = + when (codecId) { + VIDEO_CODEC_H264 -> "h264" + VIDEO_CODEC_H265 -> "h265" + VIDEO_CODEC_AV1 -> "av1" + else -> "unknown" + } + + private fun audioCodecIdFromName(name: String) = + when (name.lowercase()) { + "opus" -> AUDIO_CODEC_OPUS + "aac" -> AUDIO_CODEC_AAC + "raw" -> AUDIO_CODEC_RAW + "flac" -> AUDIO_CODEC_FLAC + else -> 0 + } + + data class SessionInfo( + val deviceName: String, + val codecId: Int, + val codecName: String, + val width: Int, + val height: Int, + val audioCodecId: Int = 0, + val controlEnabled: Boolean, + val host: String = "", + val port: Int = Defaults.ADB_PORT, + ) + + data class VideoPacket( + val data: ByteArray, + val ptsUs: Long, + val isConfig: Boolean, + val isKeyFrame: Boolean, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as VideoPacket + if (ptsUs != other.ptsUs) return false + if (isConfig != other.isConfig) return false + if (isKeyFrame != other.isKeyFrame) return false + if (!data.contentEquals(other.data)) return false + return true + } + + override fun hashCode(): Int { + var result = ptsUs.hashCode() + result = 31 * result + isConfig.hashCode() + result = 31 * result + isKeyFrame.hashCode() + result = 31 * result + data.contentHashCode() + return result + } + } + + data class AudioPacket( + val data: ByteArray, + val ptsUs: Long, + val isConfig: Boolean, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as AudioPacket + if (ptsUs != other.ptsUs) return false + if (isConfig != other.isConfig) return false + if (!data.contentEquals(other.data)) return false + return true + } + + override fun hashCode(): Int { + var result = ptsUs.hashCode() + result = 31 * result + isConfig.hashCode() + result = 31 * result + data.contentHashCode() + return result + } + } + + private data class ActiveSession( + val info: SessionInfo, + val socketName: String, + val serverStream: AdbSocketStream, + val serverLogThread: Thread, + val videoStream: AdbSocketStream?, + val videoInput: DataInputStream?, + val audioStream: AdbSocketStream?, + val audioInput: DataInputStream?, + val controlStream: AdbSocketStream?, + val controlWriter: ControlWriter?, + ) + + private class ControlWriter(private val output: DataOutputStream) { + @Synchronized + fun injectKeycode(action: Int, keycode: Int, repeat: Int, metaState: Int) { + output.writeByte(TYPE_INJECT_KEYCODE) + output.writeByte(action) + output.writeInt(keycode) + output.writeInt(repeat) + output.writeInt(metaState) + output.flush() + } + + @Synchronized + fun injectText(text: String) { + val bytes = text.toByteArray(Charsets.UTF_8) + output.writeByte(TYPE_INJECT_TEXT) + output.writeInt(bytes.size) + output.write(bytes) + output.flush() + } + + @Synchronized + fun injectTouch( + action: Int, + pointerId: Long, + x: Int, + y: Int, + screenWidth: Int, + screenHeight: Int, + pressure: Float, + actionButton: Int, + buttons: Int, + ) { + output.writeByte(TYPE_INJECT_TOUCH_EVENT) + output.writeByte(action) + output.writeLong(pointerId) + writePosition(x, y, screenWidth, screenHeight) + output.writeShort(encodeUnsignedFixedPoint16(pressure)) + output.writeInt(actionButton) + output.writeInt(buttons) + output.flush() + } + + @Synchronized + fun injectScroll( + x: Int, + y: Int, + screenWidth: Int, + screenHeight: Int, + hScroll: Float, + vScroll: Float, + buttons: Int + ) { + output.writeByte(TYPE_INJECT_SCROLL_EVENT) + writePosition(x, y, screenWidth, screenHeight) + output.writeShort(encodeSignedFixedPoint16(hScroll / 16f)) + output.writeShort(encodeSignedFixedPoint16(vScroll / 16f)) + output.writeInt(buttons) + output.flush() + } + + @Synchronized + fun pressBackOrScreenOn(action: Int) { + output.writeByte(TYPE_BACK_OR_SCREEN_ON) + output.writeByte(action) + output.flush() + } + + @Synchronized + fun setDisplayPower(on: Boolean) { + output.writeByte(TYPE_SET_DISPLAY_POWER) + output.writeBoolean(on) + output.flush() + } + + private fun writePosition(x: Int, y: Int, screenWidth: Int, screenHeight: Int) { + output.writeInt(x) + output.writeInt(y) + output.writeShort(screenWidth) + output.writeShort(screenHeight) + } + + private fun encodeUnsignedFixedPoint16(value: Float): Int { + val clamped = value.coerceIn(0f, 1f) + return if (clamped >= 1f) { + 0xffff + } else { + (clamped * 65536f).roundToInt().coerceIn(0, 0xfffe) + } + } + + private fun encodeSignedFixedPoint16(value: Float): Int { + val clamped = value.coerceIn(-1f, 1f) + if (clamped >= 1f) { + return 0x7fff + } + if (clamped <= -1f) { + return -0x8000 + } + return (clamped * 32768f).roundToInt().coerceIn(-0x8000, 0x7ffe) + } + } + + companion object { + private const val SERVER_BOOT_DELAY_MS = 200L + private const val SERVER_LOG_BUFFER_MAX_LINES = 400 + private const val CONNECT_RETRY_COUNT = 100 + private const val CONNECT_RETRY_DELAY_MS = 100L + private const val DEVICE_NAME_FIELD_LENGTH = 64 + private const val PACKET_FLAG_CONFIG = 1L shl 63 + private const val PACKET_FLAG_KEY_FRAME = 1L shl 62 + private const val PACKET_PTS_MASK = (1L shl 62) - 1 + + private const val VIDEO_CODEC_H264 = 0x68323634 + private const val VIDEO_CODEC_H265 = 0x68323635 + private const val VIDEO_CODEC_AV1 = 0x00617631 + private const val AUDIO_CODEC_OPUS = 0x6f707573 + private const val AUDIO_CODEC_AAC = 0x00616163 + private const val AUDIO_CODEC_FLAC = 0x666c6163 + private const val AUDIO_CODEC_RAW = 0x00726177 + + private const val AUDIO_DISABLED = 0 + private const val AUDIO_ERROR = 1 + + private const val TYPE_INJECT_KEYCODE = 0 + private const val TYPE_INJECT_TEXT = 1 + private const val TYPE_INJECT_TOUCH_EVENT = 2 + private const val TYPE_INJECT_SCROLL_EVENT = 3 + private const val TYPE_BACK_OR_SCREEN_ON = 4 + private const val TYPE_SET_DISPLAY_POWER = 10 + + private fun socketNameFor(scid: Int): String { + return "scrcpy_%08x".format(scid) + } + } + } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt index 0402b79..f8b54d6 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt @@ -45,8 +45,8 @@ data class ServerParams( var maxSize: UShort, - var videoBitRate: UInt, - var audioBitRate: UInt, + 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 @@ -58,7 +58,7 @@ data class ServerParams( var control: Boolean, - var displayId: UInt, + var displayId: Int, var newDisplay: String, var displayImePolicy: DisplayImePolicy, @@ -96,7 +96,7 @@ data class ServerParams( const val ILLEGAL_CHARACTER_SET: String = " ;'\"*$?&`#\\|<>[]{}()!~\r\n" } - private fun validate(str: String): Unit { + private fun validate(str: String) { // forbid special shell characters if (str.any { it in ILLEGAL_CHARACTER_SET }) { throw IllegalArgumentException("Invalid server param: [$str]") @@ -107,23 +107,28 @@ data class ServerParams( return (extraArgs.toList() + this.toList()).joinToString(SEPARATOR) } - fun toList(): MutableList { + fun toList(simplify: Boolean = false): MutableList { val cmd = mutableListOf() - cmd.add("scid=${scid.toString(16)}") - cmd.add("log_level=${logLevel.string}") + if (!simplify) { + cmd.add("scid=${scid.toString(16)}") + cmd.add("log_level=${logLevel.string}") + } if (!video) { cmd.add("video=false") } - if (videoBitRate > 0u) { + if (videoBitRate > 0) { cmd.add("video_bit_rate=$videoBitRate") } if (!audio) { cmd.add("audio=false") } - if (audioBitRate > 0u) { - cmd.add("audio_bit_rate=$audioBitRate") + if (audioBitRate > 0) { + if (audioCodec.isLossyAudio()!!) { + // 比官方实现多个判断编解码器类型 + cmd.add("audio_bit_rate=$audioBitRate") + } } if (videoCodec != Codec.H264) { cmd.add("video_codec=${videoCodec.string}") @@ -177,7 +182,7 @@ data class ServerParams( // By default, control is true cmd.add("control=false") } - if (displayId > 0u) { + if (displayId >= 0) { cmd.add("display_id=$displayId") } if (cameraId.isNotBlank()) { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt index 3a056b7..71cede1 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt @@ -60,19 +60,33 @@ class Shared { ERROR("error"); } - enum class Codec(val string: String) { - H264("h264"), // default, ignore when passing - H265("h265"), - AV1("av1"), - OPUS("opus"), // default, ignore when passing - AAC("aac"), - FLAC("flac"), - RAW("raw"); // wav raw + enum class Codec(val string: String, val displayName: String) { + H264("h264", "H.264"), // default, ignore when passing + H265("h265", "H.265"), + AV1("av1", "AV1"), + OPUS("opus", "Opus"), // default, ignore when passing + AAC("aac", "AAC"), + FLAC("flac", "FLAC"), + RAW("raw", "RAW"); // wav raw + + fun isLossyAudio(): Boolean? = when (this) { + OPUS, AAC -> true + FLAC, RAW -> false + else -> null + } + + enum class Type { VIDEO, AUDIO } companion object { - fun fromString(value: String): Codec { - return entries.find { it.string.equals(value, ignoreCase = true) } ?: H264 - } + val VIDEO = listOf(H264, H265, AV1) + val AUDIO = listOf(OPUS, AAC, FLAC, RAW) + + fun fromString(value: String, type: Type = Type.VIDEO) = + entries.find { it.string.equals(value, ignoreCase = true) } + ?: when (type) { + Type.VIDEO -> H264 + Type.AUDIO -> OPUS + } } } @@ -81,9 +95,9 @@ class Shared { CAMERA("camera"); companion object { - fun fromString(value: String): VideoSource { - return entries.find { it.string.equals(value, ignoreCase = true) } ?: DISPLAY - } + fun fromString(value: String) = + entries.find { it.string.equals(value, ignoreCase = true) } + ?: DISPLAY } } @@ -102,9 +116,9 @@ class Shared { VOICE_PERFORMANCE("voice-performance"); companion object { - fun fromString(value: String): AudioSource { - return entries.find { it.string.equals(value, ignoreCase = true) } ?: AUTO - } + fun fromString(value: String) = + entries.find { it.string.equals(value, ignoreCase = true) } + ?: AUTO } } @@ -115,9 +129,9 @@ class Shared { EXTERNAL("external"); companion object { - fun fromString(value: String): CameraFacing { - return entries.find { it.string.equals(value, ignoreCase = true) } ?: ANY - } + fun fromString(value: String) = + entries.find { it.string.equals(value, ignoreCase = true) } + ?: ANY } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt index 610deff..daebe77 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt @@ -1,8 +1,8 @@ package io.github.miuzarte.scrcpyforandroid.services -import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext internal data class ConnectedDeviceInfo( val model: String, @@ -18,16 +18,15 @@ internal data class ConnectedDeviceInfo( * Fetch basic device properties from an already-connected device. * * Notes: - * - This function issues multiple `adb shell getprop` commands via [nativeCore.adbShell]. - * Each call may block on native I/O, so callers should execute this on the dedicated - * ADB worker dispatcher rather than the UI thread. + * - This function issues multiple `shell getprop` commands via [adbService.shell]. + * Each call may block on native I/O, so it should be called from a coroutine context. * - Returns a lightweight [ConnectedDeviceInfo] structure with commonly-used properties. */ -internal fun fetchConnectedDeviceInfo( +internal suspend fun fetchConnectedDeviceInfo( adbService: NativeAdbService, host: String, port: Int -): ConnectedDeviceInfo = runBlocking { +): ConnectedDeviceInfo = withContext(Dispatchers.IO) { suspend fun prop(name: String): String = runCatching { adbService.shell("getprop $name").trim() }.getOrDefault("") diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/PreferenceMigration.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/PreferenceMigration.kt index 0954abb..266ddc4 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/PreferenceMigration.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/PreferenceMigration.kt @@ -18,7 +18,7 @@ class PreferenceMigration(private val appContext: Context) { private val appSharedPrefs: SharedPreferences by lazy { appContext.getSharedPreferences(AppPreferenceKeys.PREFS_NAME, Context.MODE_PRIVATE) } - + private val sharedPrefs: SharedPreferences by lazy { appContext.getSharedPreferences("nativecore_adb_rsa", Context.MODE_PRIVATE) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt index d53509d..cdc896a 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt @@ -22,207 +22,215 @@ 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 + 0, ) val LOG_LEVEL = Pair( stringPreferencesKey("log_level"), - "info" + "info", ) val VIDEO_CODEC = Pair( stringPreferencesKey("video_codec"), - "h264" + "h264", ) val AUDIO_CODEC = Pair( stringPreferencesKey("audio_codec"), - "opus" + "opus", ) val VIDEO_SOURCE = Pair( stringPreferencesKey("video_source"), - "display" + "display", ) val AUDIO_SOURCE = Pair( stringPreferencesKey("audio_source"), - "output" + "output", ) val RECORD_FORMAT = Pair( stringPreferencesKey("record_format"), - "auto" + "auto", ) val CAMERA_FACING = Pair( stringPreferencesKey("camera_facing"), - "any" + "any", ) val MAX_SIZE = Pair( intPreferencesKey("max_size"), - 0 + 0, ) val VIDEO_BIT_RATE = Pair( intPreferencesKey("video_bit_rate"), - 8000000 + 8000000, ) val AUDIO_BIT_RATE = Pair( intPreferencesKey("audio_bit_rate"), - 128000 + 128000, ) val MAX_FPS = Pair( stringPreferencesKey("max_fps"), - "" + "", ) val ANGLE = Pair( stringPreferencesKey("angle"), - "" + "", ) val CAPTURE_ORIENTATION = Pair( intPreferencesKey("capture_orientation"), - 0 + 0, ) val CAPTURE_ORIENTATION_LOCK = Pair( stringPreferencesKey("capture_orientation_lock"), - "unlocked" + "unlocked", ) val DISPLAY_ORIENTATION = Pair( intPreferencesKey("display_orientation"), - 0 + 0, ) val RECORD_ORIENTATION = Pair( intPreferencesKey("record_orientation"), - 0 + 0, ) val DISPLAY_IME_POLICY = Pair( stringPreferencesKey("display_ime_policy"), - "undefined" + "undefined", ) val DISPLAY_ID = Pair( intPreferencesKey("display_id"), - 0 + -1, // undefined ) val SCREEN_OFF_TIMEOUT = Pair( longPreferencesKey("screen_off_timeout"), - -1 + -1, ) val SHOW_TOUCHES = Pair( booleanPreferencesKey("show_touches"), - false + false, ) val FULLSCREEN = Pair( booleanPreferencesKey("fullscreen"), - false + false, ) val CONTROL = Pair( booleanPreferencesKey("control"), - true + true, ) val VIDEO_PLAYBACK = Pair( booleanPreferencesKey("video_playback"), - true + true, ) val AUDIO_PLAYBACK = Pair( booleanPreferencesKey("audio_playback"), - true + true, ) val TURN_SCREEN_OFF = Pair( booleanPreferencesKey("turn_screen_off"), - false + false, ) val STAY_AWAKE = Pair( booleanPreferencesKey("stay_awake"), - false + false, ) val DISABLE_SCREENSAVER = Pair( booleanPreferencesKey("disable_screensaver"), - false + false, ) val POWER_OFF_ON_CLOSE = Pair( booleanPreferencesKey("power_off_on_close"), - false + false, ) val CLEANUP = Pair( booleanPreferencesKey("cleanup"), - true + true, ) val POWER_ON = Pair( booleanPreferencesKey("power_on"), - true + true, ) val VIDEO = Pair( booleanPreferencesKey("video"), - true + true, ) val AUDIO = Pair( booleanPreferencesKey("audio"), - true + true, ) val REQUIRE_AUDIO = Pair( booleanPreferencesKey("require_audio"), - false + false, ) val KILL_ADB_ON_CLOSE = Pair( booleanPreferencesKey("kill_adb_on_close"), - false + false, ) val CAMERA_HIGH_SPEED = Pair( booleanPreferencesKey("camera_high_speed"), - false + false, ) val LIST = Pair( stringPreferencesKey("list"), - "null" + "null", ) val AUDIO_DUP = Pair( booleanPreferencesKey("audio_dup"), - false + 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 + true, ) val VD_SYSTEM_DECORATIONS = Pair( booleanPreferencesKey("vd_system_decorations"), - true + true, ) } @@ -234,6 +242,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { 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) @@ -294,19 +304,19 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { videoEncoder = videoEncoder.get(), audioEncoder = audioEncoder.get(), cameraId = cameraId.get(), - cameraSize = cameraSize.get(), + cameraSize = if (!cameraSizeUseCustom.get()) cameraSize.get() else cameraSizeCustom.get(), cameraAr = cameraAr.get(), cameraFps = cameraFps.get().toUShort(), logLevel = LogLevel.valueOf(logLevel.get().uppercase()), - videoCodec = Codec.valueOf(videoCodec.get().uppercase()), - audioCodec = Codec.valueOf(audioCodec.get().uppercase()), - videoSource = VideoSource.valueOf(videoSource.get().uppercase()), - audioSource = AudioSource.valueOf(audioSource.get().uppercase()), + videoCodec = Codec.fromString(videoCodec.get()), + audioCodec = Codec.fromString(audioCodec.get()), + videoSource = VideoSource.fromString(videoSource.get()), + audioSource = AudioSource.fromString(audioSource.get()), recordFormat = ClientOptions.RecordFormat.valueOf(recordFormat.get().uppercase()), - cameraFacing = CameraFacing.valueOf(cameraFacing.get().uppercase()), + cameraFacing = CameraFacing.fromString(cameraFacing.get()), maxSize = maxSize.get().toUShort(), - videoBitRate = videoBitRate.get().toUInt(), - audioBitRate = audioBitRate.get().toUInt(), + videoBitRate = videoBitRate.get(), + audioBitRate = audioBitRate.get(), maxFps = maxFps.get(), angle = angle.get(), captureOrientation = Orientation.fromInt(captureOrientation.get()), @@ -316,7 +326,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { displayOrientation = Orientation.fromInt(displayOrientation.get()), recordOrientation = Orientation.fromInt(recordOrientation.get()), displayImePolicy = DisplayImePolicy.valueOf(displayImePolicy.get().uppercase()), - displayId = displayId.get().toUInt(), + displayId = displayId.get(), screenOffTimeout = Tick(screenOffTimeout.get()), showTouches = showTouches.get(), fullscreen = fullscreen.get(), diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt index 020d165..faf3e5d 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt @@ -54,6 +54,8 @@ abstract class Settings( operator fun getValue(thisRef: Any?, property: KProperty<*>): SettingProperty = this + suspend fun isDefaultValue() = getValue(pair) == pair.defaultValue + suspend fun get(): T = getValue(pair) suspend fun set(value: T) = setValue(pair, value) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt index d52f8ca..95a9881 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt @@ -2,10 +2,12 @@ package io.github.miuzarte.scrcpyforandroid.widgets import android.annotation.SuppressLint import android.graphics.SurfaceTexture +import android.util.Log import android.view.MotionEvent import android.view.Surface import android.view.TextureView import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -36,7 +38,6 @@ import androidx.compose.material.icons.rounded.Fullscreen import androidx.compose.material.icons.rounded.LinkOff import androidx.compose.material.icons.rounded.Wifi import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -48,9 +49,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput @@ -67,13 +65,16 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade -import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade.ScrcpySessionInfo import io.github.miuzarte.scrcpyforandroid.constants.Defaults import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut -import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide +import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts +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.scrcpy.Shared.Codec import io.github.miuzarte.scrcpyforandroid.storage.Storage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -97,23 +98,6 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme.isDynamicColor import top.yukonga.miuix.kmp.utils.PressFeedbackType import kotlin.math.roundToInt -// TODO: migrate to scrcpy.Shared - -@Deprecated("scrcpy.Shared.Codec") -private val VIDEO_CODEC_OPTIONS = listOf( - "h264" to "H.264", - "h265" to "H.265", - "av1" to "AV1", -) - -@Deprecated("scrcpy.Shared.Codec") -private val AUDIO_CODEC_OPTIONS = listOf( - "opus" to "Opus", - "aac" to "AAC", - "flac" to "FLAC", - "raw" to "RAW", -) - private object UiMotionActions { const val DOWN = 0 const val UP = 1 @@ -130,7 +114,7 @@ internal fun StatusCard( statusLine: String, adbConnected: Boolean, streaming: Boolean, - sessionInfo: ScrcpySessionInfo?, + sessionInfo: Scrcpy.Session.SessionInfo?, busyLabel: String?, connectedDeviceLabel: String, ) { @@ -183,7 +167,7 @@ internal fun StatusCard( ), secondSmall = StatusSmallCardSpec( "编解码器", - sessionInfo.codec, + sessionInfo.codecName, ), ) } @@ -270,7 +254,7 @@ internal fun PairingCard( @Composable internal fun PreviewCard( - sessionInfo: ScrcpySessionInfo?, + sessionInfo: Scrcpy.Session.SessionInfo?, nativeCore: NativeCoreFacade, previewHeightDp: Int, controlsVisible: Boolean, @@ -376,19 +360,51 @@ internal fun VirtualButtonCard( internal fun ConfigPanel( busy: Boolean, audioForwardingSupported: Boolean, + cameraMirroringSupported: Boolean, onOpenAdvanced: () -> Unit, onStartStopHaptic: (() -> Unit)? = null, onStart: () -> Unit, onStop: () -> Unit, - sessionStarted: Boolean, + sessionInfo: Scrcpy.Session.SessionInfo?, + onDisconnect: () -> Unit = {}, ) { val scrcpyOptions = Storage.scrcpyOptions + val quickDevices = Storage.quickDevices val context = LocalContext.current + val sessionStarted = sessionInfo != null + + // Check if device exists in shortcuts + var quickDevicesList by quickDevices.quickDevicesList.asMutableState() + val savedShortcuts = remember(quickDevicesList) { + DeviceShortcuts.unmarshalFrom(quickDevicesList) + } + val isQuickConnected = remember(sessionInfo, savedShortcuts) { + sessionInfo?.let { info -> + savedShortcuts.get(info.host, info.port) == null + } ?: false + } + + var audio by scrcpyOptions.audio.asMutableState() + + var audioCodec by scrcpyOptions.audioCodec.asMutableState() + val audioCodecItems = remember { Codec.AUDIO.map { it.displayName } } + val audioCodecIndex = Codec.AUDIO.indexOfFirst { + it.string == audioCodec + }.coerceAtLeast(0) + var audioBitRate by scrcpyOptions.audioBitRate.asMutableState() + + var videoCodec by scrcpyOptions.videoCodec.asMutableState() + val videoCodecItems = remember { Codec.VIDEO.map { it.displayName } } + val videoCodecIndex = Codec.VIDEO.indexOfFirst { + it.string == videoCodec + }.coerceAtLeast(0) + var videoBitRate by scrcpyOptions.videoBitRate.asMutableState() + val videoBitRateMbps = videoBitRate / 1_000_000f + SectionSmallTitle("Scrcpy") Card { - var audio by scrcpyOptions.audio.asMutableState() SuperSwitch( title = "音频转发", summary = "转发设备音频到本机 (Android 11+)", @@ -397,64 +413,46 @@ internal fun ConfigPanel( enabled = !sessionStarted && audioForwardingSupported, ) - var audioCodec by scrcpyOptions.audioCodec.asMutableState() - val audioCodecItems = remember { AUDIO_CODEC_OPTIONS.map { it.second } } - val audioCodecIndex = AUDIO_CODEC_OPTIONS.indexOfFirst { - it.first == audioCodec - }.coerceAtLeast(0) - var audioBitRate by scrcpyOptions.audioBitRate.asMutableState() - val audioBitRateKbps = audioBitRate * 1_000f - val audioBitRatePresetIndex = presetIndexFromInput( - audioBitRateKbps.toString(), - ScrcpyPresets.AudioBitRate - ) SuperDropdown( title = "音频编码", summary = "--audio-codec", items = audioCodecItems, selectedIndex = audioCodecIndex, - onSelectedIndexChange = { audioCodec = AUDIO_CODEC_OPTIONS[it].first }, + onSelectedIndexChange = { audioCodec = Codec.AUDIO[it].string }, enabled = !sessionStarted && audio, ) - if (audio && (audioCodec == "opus" || audioCodec == "aac")) { - SuperSlide( + AnimatedVisibility(audio && (audioCodec == "opus" || audioCodec == "aac")) { + SuperSlider( title = "音频码率", summary = "--audio-bit-rate", - value = audioBitRatePresetIndex.toFloat(), + value = ScrcpyPresets.AudioBitRate.indexOfOrNearest(audioBitRate / 1000).toFloat(), onValueChange = { value -> val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex) - audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1024 + audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1000 }, valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(), steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0), enabled = !sessionStarted, unit = "Kbps", - displayText = audioBitRate.toString(), - inputInitialValue = audioBitRate.toString(), + displayText = (audioBitRate / 1000).toString(), + inputInitialValue = (audioBitRate / 1000).toString(), inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 1f..Float.MAX_VALUE, + inputValueRange = 1f..UShort.MAX_VALUE.toFloat(), onInputConfirm = { raw -> - raw.toIntOrNull()?.takeIf { it > 0 }?.let { audioBitRate = it } + raw.toIntOrNull()?.takeIf { it > 0 }?.let { audioBitRate = it * 1000 } }, ) } - var videoCodec by scrcpyOptions.videoCodec.asMutableState() - val videoCodecItems = remember { VIDEO_CODEC_OPTIONS.map { it.second } } - val videoCodecIndex = VIDEO_CODEC_OPTIONS.indexOfFirst { - it.first == videoCodec - }.coerceAtLeast(0) - var videoBitRate by scrcpyOptions.videoBitRate.asMutableState() - val videoBitRateMbps = videoBitRate / 1_000_000f SuperDropdown( title = "视频编码", summary = "--video-codec", items = videoCodecItems, selectedIndex = videoCodecIndex, - onSelectedIndexChange = { videoCodec = VIDEO_CODEC_OPTIONS[it].first }, + onSelectedIndexChange = { videoCodec = Codec.VIDEO[it].string }, enabled = !sessionStarted, ) - SuperSlide( + SuperSlider( title = "视频码率", summary = "--video-bit-rate", value = videoBitRateMbps, @@ -481,7 +479,7 @@ internal fun ConfigPanel( } } }, - inputValueRange = 0.1f..Float.MAX_VALUE, + inputValueRange = 0.1f..UInt.MAX_VALUE.toFloat(), onInputConfirm = { raw -> raw.toFloatOrNull()?.let { parsed -> if (parsed >= 0.1f) { @@ -498,23 +496,61 @@ internal fun ConfigPanel( enabled = !sessionStarted, ) - TextButton( - text = if (sessionStarted) "停止" else "启动", - onClick = { - onStartStopHaptic?.invoke() - if (sessionStarted) onStop() else onStart() - }, + Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = UiSpacing.CardContent) .padding(bottom = UiSpacing.CardContent), - enabled = !busy, - colors = if (sessionStarted) { - ButtonDefaults.textButtonColors() - } else { - ButtonDefaults.textButtonColorsPrimary() - }, - ) + horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), + ) { + AnimatedVisibility(!isQuickConnected) { + AnimatedVisibility(!sessionStarted) { + TextButton( + text = "启动", + onClick = { + onStartStopHaptic?.invoke() + onStart() + }, + modifier = Modifier.fillMaxWidth(), + enabled = !busy, + colors = ButtonDefaults.textButtonColorsPrimary(), + ) + } + AnimatedVisibility(sessionStarted) { + TextButton( + text = "停止", + onClick = { + onStartStopHaptic?.invoke() + onStop() + }, + modifier = Modifier.fillMaxWidth(), + enabled = !busy, + ) + } + } + // display them at the same time in quick connection + AnimatedVisibility(isQuickConnected) { + TextButton( + text = "断开", + onClick = { + onStartStopHaptic?.invoke() + onDisconnect() + }, + modifier = Modifier.weight(1f/4f), + enabled = !busy, + ) + TextButton( + text = "启动", + onClick = { + onStartStopHaptic?.invoke() + onStart() + }, + modifier = Modifier.weight(3f/4f), + enabled = !busy, + colors = ButtonDefaults.textButtonColorsPrimary(), + ) + } + } } } @@ -576,10 +612,12 @@ private fun PairingDialog( title = "使用配对码配对设备", summary = "使用六位数的配对码配对新设备", onDismissRequest = { - clearInputs() onDismissRequest() }, - onDismissFinished = onDismissFinished, + onDismissFinished = { + clearInputs() + onDismissFinished() + }, content = { TextField( value = host, @@ -634,7 +672,6 @@ private fun PairingDialog( TextButton( text = "取消", onClick = { - clearInputs() onDismissRequest() }, modifier = Modifier.weight(1f), @@ -643,7 +680,7 @@ private fun PairingDialog( text = "配对", onClick = { onConfirm(host.trim(), port.trim(), code.trim()) - clearInputs() + onDismissRequest() }, enabled = enabled && host.trim().isNotBlank() && @@ -657,15 +694,6 @@ private fun PairingDialog( ) } -private fun presetIndexFromInput(raw: String, presets: List): Int { - if (raw.isBlank()) return 0 - val value = raw.toIntOrNull() ?: return 0 - val exact = presets.indexOf(value) - if (exact >= 0) return exact - val nearest = presets.withIndex().minByOrNull { (_, preset) -> kotlin.math.abs(preset - value) } - return nearest?.index ?: 0 -} - @SuppressLint("DefaultLocale") private fun formatBitRate(value: Float): String = String.format("%.1f", value) @@ -690,7 +718,7 @@ internal fun LogsPanel(lines: List) { */ class TouchEventHandler( private val coroutineScope: CoroutineScope, - private val session: ScrcpySessionInfo, + private val session: Scrcpy.Session.SessionInfo, private val touchAreaSize: IntSize, private val activePointerIds: LinkedHashSet, private val activePointerPositions: LinkedHashMap, @@ -814,7 +842,11 @@ class TouchEventHandler( val pos = activePointerPositions[pointerId] ?: Offset.Zero val (x, y) = mapToDevice(pos.x, pos.y, bounds) coroutineScope.launch { - onInjectTouch(UiMotionActions.UP, pointerId.toLong(), x, y, 0f, 0) + 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) @@ -877,7 +909,15 @@ class TouchEventHandler( activePointerDevicePositions[pointerId] = x to y justPressedPointerIds += pointerId coroutineScope.launch { - onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, pressure, 0) + runCatching { + onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, pressure, 0) + }.onFailure { e -> + Log.w( + FULLSCREEN_TOUCH_LOG_TAG, + "handlePointerDown failed for pointerId=$pointerId", + e + ) + } } } } @@ -899,7 +939,15 @@ class TouchEventHandler( val (x, y) = mapToDevice(raw.x, raw.y, bounds) activePointerDevicePositions[pointerId] = x to y coroutineScope.launch { - onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, pressure, 0) + runCatching { + onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, pressure, 0) + }.onFailure { e -> + Log.w( + FULLSCREEN_TOUCH_LOG_TAG, + "handlePointerMove failed for pointerId=$pointerId", + e + ) + } } } } @@ -937,7 +985,7 @@ class TouchEventHandler( */ @Composable fun FullscreenControlScreen( - session: ScrcpySessionInfo, + session: Scrcpy.Session.SessionInfo, nativeCore: NativeCoreFacade, onDismiss: () -> Unit, showDebugInfo: Boolean, @@ -1071,30 +1119,16 @@ fun FullscreenControlScreen( private fun ScrcpyVideoSurface( modifier: Modifier, nativeCore: NativeCoreFacade, - session: ScrcpySessionInfo?, + session: Scrcpy.Session.SessionInfo?, ) { val surfaceTag = "video-main" var currentSurface by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() LaunchedEffect(session, currentSurface) { - if (session != null && currentSurface != null) { - nativeCore.registerVideoSurface(surfaceTag, currentSurface!!) - } - // Unregistration is handled directly in onSurfaceTextureDestroyed and DisposableEffect - } - - DisposableEffect(Unit) { - onDispose { - val released = currentSurface - if (released != null) { - scope.launch { - nativeCore.unregisterVideoSurface(surfaceTag, released) - } - released.release() - currentSurface = null - } - // If currentSurface is null, onSurfaceTextureDestroyed already handled cleanup + val surface = currentSurface + if (session != null && surface != null && surface.isValid) { + nativeCore.registerVideoSurface(surfaceTag, surface) } } @@ -1108,9 +1142,15 @@ private fun ScrcpyVideoSurface( width: Int, height: Int ) { - currentSurface?.release() // Release stale surface if any @SuppressLint("Recycle") - currentSurface = Surface(surfaceTexture) + val newSurface = Surface(surfaceTexture) + currentSurface = newSurface + // Register immediately when surface becomes available + if (session != null) { + scope.launch { + nativeCore.registerVideoSurface(surfaceTag, newSurface) + } + } } override fun onSurfaceTextureSizeChanged( @@ -1120,15 +1160,9 @@ private fun ScrcpyVideoSurface( ) = Unit override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { - val released = currentSurface - currentSurface = null - if (released != null) { - scope.launch { - nativeCore.unregisterVideoSurface(surfaceTag, released) - } - released.release() - } - return true + // Return false to keep the SurfaceTexture alive + // This prevents the surface from being destroyed when the view is detached + return false } override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) = Unit @@ -1222,15 +1256,13 @@ internal fun DeviceTile( internal fun QuickConnectCard( input: String, onValueChange: (String) -> Unit, + onFocusChange: (() -> Unit)? = null, onConnect: () -> Unit, onAddDevice: () -> Unit, enabled: Boolean = true, ) { val scope = rememberCoroutineScope() val focusManager = LocalFocusManager.current - val focusRequester = remember { FocusRequester() } - var tempText by remember(input) { mutableStateOf(input) } - var tempFocusState by remember(input) { mutableStateOf(false) } Card( colors = CardDefaults.defaultColors(color = MiuixTheme.colorScheme.primaryContainer), @@ -1259,9 +1291,9 @@ internal fun QuickConnectCard( color = MiuixTheme.colorScheme.onPrimaryContainer, ) } - TextField( - value = tempText, - onValueChange = { tempText = it }, + SuperTextField( + value = input, + onValueChange = onValueChange, label = "IP:PORT", enabled = enabled, useLabelAsPlaceholder = true, @@ -1269,17 +1301,10 @@ internal fun QuickConnectCard( modifier = Modifier .fillMaxWidth() .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.SectionTitleLeadingGap) - .onFocusChanged { focusState -> - // 失去焦点时回调 - if (!focusState.isFocused && tempFocusState) { - onValueChange(tempText) - } - tempFocusState = focusState.isFocused - } - .focusRequester(focusRequester), + .padding(bottom = UiSpacing.SectionTitleLeadingGap), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + onFocusLost = onFocusChange, ) Row( modifier = Modifier diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 75221e1..d8b39a6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ androidxJunit = "1.3.0" espressoCore = "3.7.0" miuix = "0.8.7" material = "1.13.0" +runtime = "1.10.5" [libraries] 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-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" } +androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "runtime" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 95dc24e6766f03f631c7d8c4020dc450ffa020de Mon Sep 17 00:00:00 2001 From: Miuzarte <982809597@qq.com> Date: Wed, 8 Apr 2026 23:47:53 +0800 Subject: [PATCH 6/8] refactor: use Parcelable Bundle for state management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 ScrcpyOptions 中引入 Parcelable 数据类 `Bundle`,用于封装各项配置 - 更新了使用新 Bundle 结构读写配置的方法 - 使用 StateFlow 增强状态管理,实现响应式 UI 更新 - 重构 toClientOptions 方法,接受 Bundle 参数以提高清晰度和可维护性 - 调整音频和视频编解码器设置,使其使用新的 Bundle 结构 - 更新 ConfigPanel 以反映 ScrcpyOptions 的变化,并更有效地管理状态 - 移除 DeviceWidgets 中的未使用代码并清理导入 - 重构解码流程,驻留一个中转 Surface,解决先前 Surface 销毁后解码无法恢复的问题 --- TODO.md | 4 - app/build.gradle.kts | 1 + .../miuzarte/scrcpyforandroid/MainActivity.kt | 4 +- .../scrcpyforandroid/NativeCoreFacade.kt | 175 ++--- .../nativecore/PersistentVideoRenderer.kt | 359 +++++++++ .../pages/AdvancedConfigPage.kt | 731 +++++++++++++----- .../scrcpyforandroid/pages/DevicePage.kt | 401 +++++++--- ...trolPage.kt => FullscreenControlScreen.kt} | 110 +-- .../scrcpyforandroid/pages/MainPage.kt | 550 ------------- .../scrcpyforandroid/pages/MainScreen.kt | 425 ++++++++++ .../pages/ReorderDevicesScreen.kt | 90 +++ .../scrcpyforandroid/pages/SettingsPage.kt | 184 +++-- .../pages/VirtualButtonOrderPage.kt | 86 --- .../pages/VirtualButtonOrderScreen.kt | 156 ++++ .../{PageLayouts.kt => LazyColumn.kt} | 2 +- .../scrcpyforandroid/scaffolds/SuperSlider.kt | 24 +- .../scrcpy/TouchEventHandler.kt | 273 +++++++ .../scrcpyforandroid/storage/AdbClientData.kt | 31 + .../scrcpyforandroid/storage/AppSettings.kt | 69 ++ .../scrcpyforandroid/storage/QuickDevices.kt | 31 + .../scrcpyforandroid/storage/ScrcpyOptions.kt | 294 +++++-- .../scrcpyforandroid/storage/Settings.kt | 51 ++ .../scrcpyforandroid/widgets/DeviceWidgets.kt | 552 ++++--------- 23 files changed, 2976 insertions(+), 1627 deletions(-) create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/PersistentVideoRenderer.kt rename app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/{FullscreenControlPage.kt => FullscreenControlScreen.kt} (63%) delete mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ReorderDevicesScreen.kt delete mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderPage.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt rename app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/{PageLayouts.kt => LazyColumn.kt} (98%) create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/TouchEventHandler.kt diff --git a/TODO.md b/TODO.md index 923de22..97448bf 100644 --- a/TODO.md +++ b/TODO.md @@ -4,10 +4,6 @@ - Refactoring -## BUGS - -- Figure out is virtual display destroyed at the end of scrcpy session - ## WIDGETS - `SuperTextField` Click to pop a dialog with custom notes/summary diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d448197..76676bc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose.compiler) + id("kotlin-parcelize") } val defaultAbiList = listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt index 48b2a58..5686bd8 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt @@ -4,7 +4,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent 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 @@ -25,7 +25,7 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { - MainPage() + MainScreen() } } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt index 27d671b..14e5f21 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt @@ -6,11 +6,11 @@ import android.os.Looper import android.util.Log import android.view.Surface import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder +import io.github.miuzarte.scrcpyforandroid.nativecore.PersistentVideoRenderer import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.util.ArrayDeque -import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArraySet /** @@ -24,11 +24,11 @@ class NativeCoreFacade private constructor() { @Volatile var session: Scrcpy.Session? = null private set - + private val sessionLifecycleMutex = Mutex() - private val surfaceMap = ConcurrentHashMap() - private val surfaceIdentityMap = ConcurrentHashMap() - private val decoderMap = ConcurrentHashMap() + private val renderer = PersistentVideoRenderer() + private var activeSurfaceId: Int? = null + private var decoder: AnnexBDecoder? = null private val videoSizeListeners = CopyOnWriteArraySet<(Int, Int) -> Unit>() private val videoFpsListeners = CopyOnWriteArraySet<(Float) -> Unit>() private val mainHandler = Handler(Looper.getMainLooper()) @@ -45,71 +45,75 @@ class NativeCoreFacade private constructor() { suspend fun close() { sessionLifecycleMutex.withLock { releaseAllDecoders() + renderer.release() } } /** - * 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 * 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. */ - suspend fun registerVideoSurface(tag: String, surface: Surface) { + suspend fun attachVideoSurface(surface: Surface) { sessionLifecycleMutex.withLock { if (!surface.isValid) { - Log.w(TAG, "registerVideoSurface(): skip invalid surface for tag=$tag") + Log.w(TAG, "attachVideoSurface(): skip invalid surface") return } val newId = System.identityHashCode(surface) - val oldId = surfaceIdentityMap[tag] - if (oldId != null && oldId == newId && decoderMap.containsKey(tag)) { + if (activeSurfaceId == newId && decoder != null) { return } - Log.i(TAG, "registerVideoSurface(): tag=$tag surfaceId=$newId oldSurfaceId=$oldId") - surfaceMap[tag] = surface - surfaceIdentityMap[tag] = newId + Log.i(TAG, "attachVideoSurface(): surfaceId=$newId oldSurfaceId=$activeSurfaceId") + activeSurfaceId = newId + renderer.attachDisplaySurface(surface) 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") + 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(tag, surface, session) + createOrReplaceDecoder(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. - * - When there is no active session, the decoder for the tag is released immediately. - * - This protects the native decoder from feeding into a released Surface. + * - When [releaseDecoder] is false, only the active display target is cleared so a future + * 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. */ - suspend fun unregisterVideoSurface(tag: String, surface: Surface? = null) { + suspend fun detachVideoSurface(surface: Surface? = null, releaseDecoder: Boolean = false) { sessionLifecycleMutex.withLock { - val currentId = surfaceIdentityMap[tag] + val currentId = activeSurfaceId val requestId = surface?.let { System.identityHashCode(it) } if (requestId != null && currentId != null && requestId != currentId) { Log.i( TAG, - "unregisterVideoSurface(): skip stale request tag=$tag requestSurfaceId=$requestId currentSurfaceId=$currentId" + "detachVideoSurface(): skip stale request requestSurfaceId=$requestId currentSurfaceId=$currentId" ) return } - Log.i(TAG, "unregisterVideoSurface(): tag=$tag surfaceId=$requestId, releasing decoder") - surfaceMap.remove(tag) - surfaceIdentityMap.remove(tag) - // Always release decoder when surface is unregistered - // This ensures clean state when surface is recreated - decoderMap.remove(tag)?.release() + Log.i( + TAG, + "detachVideoSurface(): surfaceId=$requestId releaseDecoder=$releaseDecoder" + ) + activeSurfaceId = null + renderer.detachDisplaySurface(surface, releaseSurface = false) + if (releaseDecoder) { + Log.i(TAG, "detachVideoSurface(): releasing decoder with destroyed surface") + decoder?.release() + decoder = null + } } } @@ -148,46 +152,29 @@ class NativeCoreFacade private constructor() { bootstrapPackets.clear() latestConfigPacket = null } - - surfaceMap.forEach { (tag, surface) -> - if (!surface.isValid) { - Log.w(TAG, "onScrcpySessionStarted(): skip invalid surface for tag=$tag") - return@forEach - } - Log.i(TAG, "onScrcpySessionStarted(): bind decoder to tag=$tag") - createOrReplaceDecoder(tag, surface, session) + if (activeSurfaceId != null) { + Log.i(TAG, "onScrcpySessionStarted(): bind decoder to persistent surface") + createOrReplaceDecoder(session) } packetCount = 0 - // if (!request.noVideo) { - // ensureVideoConsumerAttached() - // } 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} decoders=${decoderMap.size}" + "videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoder=${decoder != null}" ) } - // Snapshot decoders to avoid feeding released decoders during iteration - val decoders = decoderMap.toMap() - decoders.forEach { (tag, decoder) -> - if (!surfaceIdentityMap.containsKey(tag)) { - return@forEach - } - // Double-check decoder is still in map before feeding - if (decoderMap[tag] != decoder) { - return@forEach - } - runCatching { - decoder.feedAnnexB( - packet.data, - packet.ptsUs, - packet.isKeyFrame, - packet.isConfig - ) - } + val currentDecoder = decoder ?: return@attachVideoConsumer + if (activeSurfaceId == null) return@attachVideoConsumer + runCatching { + currentDecoder.feedAnnexB( + packet.data, + packet.ptsUs, + packet.isKeyFrame, + packet.isConfig + ) } } } @@ -251,7 +238,7 @@ class NativeCoreFacade private constructor() { } /** - * 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]. * - The decoder's `onOutputSizeChanged` callback publishes size changes to @@ -259,17 +246,15 @@ class NativeCoreFacade private constructor() { * - Newly created decoders are fed with any cached bootstrap packets to allow * faster playback startup. */ - private fun createOrReplaceDecoder(tag: String, surface: Surface, session: Scrcpy.Session.SessionInfo) { - if (!surface.isValid) { - Log.w(TAG, "createOrReplaceDecoder(): skip invalid surface for tag=$tag") - return - } - decoderMap.remove(tag)?.release() + private fun createOrReplaceDecoder(session: Scrcpy.Session.SessionInfo) { + val surface = renderer.getDecoderSurface() + decoder?.release() + decoder = null Log.i( TAG, - "createOrReplaceDecoder(): tag=$tag codec=${session.codecName} size=${session.width}x${session.height}" + "createOrReplaceDecoder(): codec=${session.codecName} size=${session.width}x${session.height} persistent=true" ) - val decoder = AnnexBDecoder( + val newDecoder = AnnexBDecoder( width = session.width, height = session.height, outputSurface = surface, @@ -303,8 +288,8 @@ class NativeCoreFacade private constructor() { } }, ) - decoderMap[tag] = decoder - replayBootstrapPackets(decoder) + decoder = newDecoder + replayBootstrapPackets(newDecoder) } private fun replayBootstrapPackets(decoder: AnnexBDecoder) { @@ -355,8 +340,7 @@ class NativeCoreFacade private constructor() { * * - 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. + * - Kept only for documentation parity with the old multi-decoder design. */ @Deprecated("TODO: Determine if this is really unnecessary") private suspend fun ensureVideoConsumerAttached(sessionMgr: Scrcpy.Session) { @@ -366,35 +350,24 @@ class NativeCoreFacade private constructor() { if (packetCount == 1L || packetCount % 120L == 0L) { Log.i( TAG, - "videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoders=${decoderMap.size}" + "videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoder=${decoder != null}" ) } - // Snapshot decoders to avoid feeding released decoders during iteration - val decoders = decoderMap.toMap() - decoders.forEach { (tag, decoder) -> - if (!surfaceIdentityMap.containsKey(tag)) { - return@forEach - } - // Double-check decoder is still in map before feeding - if (decoderMap[tag] != decoder) { - return@forEach - } - runCatching { - decoder.feedAnnexB( - packet.data, - packet.ptsUs, - packet.isKeyFrame, - packet.isConfig - ) - } + val currentDecoder = decoder ?: return@attachVideoConsumer + if (activeSurfaceId == null) return@attachVideoConsumer + runCatching { + currentDecoder.feedAnnexB( + packet.data, + packet.ptsUs, + packet.isKeyFrame, + packet.isConfig + ) } } } private fun releaseAllDecoders() { - decoderMap.values.forEach { decoder -> - runCatching { decoder.release() } - } - decoderMap.clear() + runCatching { decoder?.release() } + decoder = null } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/PersistentVideoRenderer.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/PersistentVideoRenderer.kt new file mode 100644 index 0000000..f6be3e2 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/PersistentVideoRenderer.kt @@ -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(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); + } + """ + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt index 0859957..4fd720f 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt @@ -1,5 +1,6 @@ package io.github.miuzarte.scrcpyforandroid.pages +import android.annotation.SuppressLint import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -11,13 +12,18 @@ 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.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.mutableIntStateOf 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 @@ -30,125 +36,215 @@ import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.Crop import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.NewDisplay -import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn +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.scrcpy.ServerParams import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared -import io.github.miuzarte.scrcpyforandroid.storage.Storage +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec +import io.github.miuzarte.scrcpyforandroid.storage.Settings +import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions +import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking 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.SnackbarHost 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.basic.TopAppBar 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 +@Composable +internal fun AdvancedConfigScreen( + onBack: () -> Unit, + scrollBehavior: ScrollBehavior, + snack: SnackbarHostState, + scrcpy: Scrcpy, +) { + Scaffold( + topBar = { + TopAppBar( + title = "高级参数", + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "返回" + ) + } + }, + scrollBehavior = scrollBehavior, + ) + }, + snackbarHost = { SnackbarHost(snack) }, + ) { contentPadding -> + AdvancedConfigPage( + contentPadding = contentPadding, + scrollBehavior = scrollBehavior, + snack = snack, + scrcpy = scrcpy, + ) + } +} + @Composable internal fun AdvancedConfigPage( contentPadding: PaddingValues, scrollBehavior: ScrollBehavior, - snackbarHostState: SnackbarHostState, + snack: SnackbarHostState, scrcpy: Scrcpy, ) { - val scrcpyOptions = Storage.scrcpyOptions - val focusManager = LocalFocusManager.current - val scope = rememberCoroutineScope() - var refreshBusy by remember { mutableStateOf(false) } - // TODO: handle custom value - // TODO: handle empty input - var turnScreenOff by scrcpyOptions.turnScreenOff.asMutableState() - var control by scrcpyOptions.control.asMutableState() - var video by scrcpyOptions.video.asMutableState() + var refreshBusy by rememberSaveable { mutableStateOf(false) } - var videoSource by scrcpyOptions.videoSource.asMutableState() - val videoSourceItems = remember { Shared.VideoSource.entries.map { it.string } } - val videoSourceIndex = remember(videoSource) { - Shared.VideoSource.entries.indexOfFirst { it.string == videoSource }.coerceAtLeast(0) + val soBundleShared by scrcpyOptions.bundleState.collectAsState() + val soBundleSharedLatest by rememberUpdatedState(soBundleShared) + var soBundle by rememberSaveable(soBundleShared) { mutableStateOf(soBundleShared) } + val soBundleLatest by rememberUpdatedState(soBundle) + LaunchedEffect(soBundleShared) { + if (soBundle != soBundleShared) { + soBundle = soBundleShared + } } - var displayId by scrcpyOptions.displayId.asMutableState() - - var cameraId by scrcpyOptions.cameraId.asMutableState() - var cameraFacing by scrcpyOptions.cameraFacing.asMutableState() - val cameraFacingItems = remember { - listOf("默认") + Shared.CameraFacing.entries.drop(1).map { it.string } + LaunchedEffect(soBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (soBundle != soBundleSharedLatest) { + scrcpyOptions.saveBundle(soBundle) + } } - val cameraFacingIndex = remember(cameraFacing) { - if (cameraFacing.isEmpty()) { + DisposableEffect(Unit) { + onDispose { + scope.launch { + scrcpyOptions.saveBundle(soBundleLatest) + } + } + } + + val audioCodecItems = rememberSaveable { Codec.AUDIO.map { it.displayName } } + val audioCodecIndex = rememberSaveable(soBundle) { + Codec.AUDIO + .indexOfFirst { it.string == soBundle.audioCodec } + .coerceAtLeast(0) + } + + val videoCodecItems = rememberSaveable { Codec.VIDEO.map { it.displayName } } + val videoCodecIndex = rememberSaveable(soBundle) { + Codec.VIDEO + .indexOfFirst { it.string == soBundle.videoCodec } + .coerceAtLeast(0) + } + + val videoSourceItems = rememberSaveable { Shared.VideoSource.entries.map { it.string } } + val videoSourceIndex = rememberSaveable(soBundle) { + Shared.VideoSource.entries + .indexOfFirst { it.string == soBundle.videoSource } + .coerceAtLeast(0) + } + + var displayIdInput by rememberSaveable(soBundle) { + mutableStateOf( + if (soBundle.displayId == -1) "" + else soBundle.displayId.toString() + ) + } + + var cameraIdInput by rememberSaveable(soBundle) { + mutableStateOf(soBundle.cameraId) + } + + val cameraFacingItems = rememberSaveable { + listOf("默认") + Shared.CameraFacing.entries + .drop(1) + .map { it.string } + } + val cameraFacingIndex = rememberSaveable(soBundle) { + if (soBundle.cameraFacing.isEmpty()) { 0 } else { - val idx = Shared.CameraFacing.entries.indexOfFirst { it.string == cameraFacing } + val idx = Shared.CameraFacing.entries + .indexOfFirst { it.string == soBundle.cameraFacing } if (idx > 0) idx else 0 } } - var cameraSize by scrcpyOptions.cameraSize.asMutableState() - var cameraSizeCustom by scrcpyOptions.cameraSizeCustom.asMutableState() - var cameraSizeUseCustom by scrcpyOptions.cameraSizeUseCustom.asMutableState() + var cameraSizeCustomInput by rememberSaveable(soBundle) { + mutableStateOf(soBundle.cameraSizeCustom) + } - var cameraSizeCustomInput by rememberSaveable { mutableStateOf(cameraSizeCustom) } val cameraSizeDropdownItems = rememberSaveable(scrcpy.cameraSizes) { listOf("自动", "自定义") + scrcpy.cameraSizes } - var cameraSizeDropdownIndex by rememberSaveable { + var cameraSizeDropdownIndex by rememberSaveable(soBundle, scrcpy.cameraSizes) { mutableIntStateOf( when { - cameraSizeUseCustom -> 1 // "自定义" - cameraSize.isEmpty() -> 0 // "自动" - cameraSize in scrcpy.cameraSizes -> scrcpy.cameraSizes.indexOf(cameraSize) + 2 + soBundle.cameraSizeUseCustom -> 1 // "自定义" + soBundle.cameraSize.isEmpty() -> 0 // "自动" + soBundle.cameraSize in scrcpy.cameraSizes -> + scrcpy.cameraSizes.indexOf(soBundle.cameraSize) + 2 + else -> 0 // 默认自动 } ) } - var cameraAr by scrcpyOptions.cameraAr.asMutableState() - var cameraFps by scrcpyOptions.cameraFps.asMutableState() - val cameraFpsPresetIndex = ScrcpyPresets.CameraFps.indexOfOrNearest(cameraFps) - var cameraHighSpeed by scrcpyOptions.cameraHighSpeed.asMutableState() + var cameraArInput by rememberSaveable(soBundle) { + mutableStateOf(soBundle.cameraAr) + } - var audioSource by scrcpyOptions.audioSource.asMutableState() - val audioSourceItems = remember { + val cameraFpsPresetIndex = rememberSaveable(soBundle) { + ScrcpyPresets.CameraFps.indexOfOrNearest(soBundle.cameraFps) + } + + val audioSourceItems = rememberSaveable { Shared.AudioSource.entries.map { it.string } } - val audioSourceIndex = remember(audioSource) { - Shared.AudioSource.entries.indexOfFirst { it.string == audioSource }.coerceAtLeast(0) + val audioSourceIndex = rememberSaveable(soBundle) { + Shared.AudioSource.entries + .indexOfFirst { it.string == soBundle.audioSource } + .coerceAtLeast(0) } - var audioDup by scrcpyOptions.audioDup.asMutableState() - var audioPlayback by scrcpyOptions.audioPlayback.asMutableState() - var requireAudio by scrcpyOptions.requireAudio.asMutableState() - var maxSize by scrcpyOptions.maxSize.asMutableState() - val maxSizePresetIndex = ScrcpyPresets.MaxSize.indexOfOrNearest(maxSize) - var maxFps by scrcpyOptions.maxFps.asMutableState() - val maxFpsPresetIndex = ScrcpyPresets.MaxFPS.indexOfOrNearest(maxFps.toIntOrNull() ?: 0) + val maxSizePresetIndex = rememberSaveable(soBundle) { + ScrcpyPresets.MaxSize.indexOfOrNearest(soBundle.maxSize) + } - var videoEncoder by scrcpyOptions.videoEncoder.asMutableState() - var videoCodecOptions by scrcpyOptions.videoCodecOptions.asMutableState() - var audioEncoder by scrcpyOptions.audioEncoder.asMutableState() - var audioCodecOptions by scrcpyOptions.audioCodecOptions.asMutableState() + val maxFpsPresetIndex = rememberSaveable(soBundle) { + ScrcpyPresets.MaxFPS.indexOfOrNearest(soBundle.maxFps.toIntOrNull() ?: 0) + } - val videoEncoderDropdownItems = remember(scrcpy.videoEncoders) { + var videoCodecOptionsInput by rememberSaveable(soBundle) { + mutableStateOf(soBundle.videoCodecOptions) + } + + var audioCodecOptionsInput by rememberSaveable(soBundle) { + mutableStateOf(soBundle.audioCodecOptions) + } + + val videoEncoderDropdownItems = rememberSaveable(scrcpy.videoEncoders) { listOf("") + scrcpy.videoEncoders } - val videoEncoderIndex = remember(videoEncoder, scrcpy.videoEncoders) { - (scrcpy.videoEncoders.indexOf(videoEncoder) + 1).coerceAtLeast(0) + val videoEncoderIndex = rememberSaveable(soBundle, scrcpy.videoEncoders) { + (scrcpy.videoEncoders.indexOf(soBundle.videoEncoder) + 1).coerceAtLeast(0) } - val audioEncoderDropdownItems = remember(scrcpy.audioEncoders) { + + val audioEncoderDropdownItems = rememberSaveable(scrcpy.audioEncoders) { listOf("") + scrcpy.audioEncoders } - val audioEncoderIndex = remember(audioEncoder, scrcpy.audioEncoders) { - (scrcpy.audioEncoders.indexOf(audioEncoder) + 1).coerceAtLeast(0) + val audioEncoderIndex = rememberSaveable(soBundle, scrcpy.audioEncoders) { + (scrcpy.audioEncoders.indexOf(soBundle.audioEncoder) + 1).coerceAtLeast(0) } + val videoEncoderEntries = videoEncoderDropdownItems.map { encoderName -> if (encoderName == "") { SpinnerEntry(title = "自动") @@ -159,6 +255,7 @@ internal fun AdvancedConfigPage( ) } } + val audioEncoderEntries = audioEncoderDropdownItems.map { encoderName -> if (encoderName == "") { SpinnerEntry(title = "自动") @@ -171,68 +268,56 @@ internal fun AdvancedConfigPage( } // [x][/] - var newDisplay by scrcpyOptions.newDisplay.asMutableState() - val (width, height, dpi) = NewDisplay.parseFrom(newDisplay) - var newDisplayWidth by remember(newDisplay) { mutableStateOf(width?.toString() ?: "") } - var newDisplayHeight by remember(newDisplay) { mutableStateOf(height?.toString() ?: "") } - var newDisplayDpi by remember(newDisplay) { mutableStateOf(dpi?.toString() ?: "") } - fun updateNewDisplay() { - newDisplay = NewDisplay - .parseFrom(newDisplayWidth, newDisplayHeight, newDisplayDpi) - .toString() + val (ndWidth, ndHeight, ndDpi) = remember(soBundle) { + NewDisplay.parseFrom(soBundle.newDisplay) + } + var newDisplayWidthInput by rememberSaveable(soBundle) { + mutableStateOf(ndWidth?.toString() ?: "") + } + var newDisplayHeightInput by rememberSaveable(soBundle) { + mutableStateOf(ndHeight?.toString() ?: "") + } + var newDisplayDpiInput by rememberSaveable(soBundle) { + mutableStateOf(ndDpi?.toString() ?: "") } // width:height:x:y - var crop by scrcpyOptions.crop.asMutableState() - val (cWidth, cHeight, cX, cY) = Crop.parseFrom(crop) - var cropWidth by remember(crop) { mutableStateOf(cWidth?.toString() ?: "") } - var cropHeight by remember(crop) { mutableStateOf(cHeight?.toString() ?: "") } - var cropX by remember(crop) { mutableStateOf(cX?.toString() ?: "") } - var cropY by remember(crop) { mutableStateOf(cY?.toString() ?: "") } - fun updateCrop() { - crop = Crop - .parseFrom(cropWidth, cropHeight, cropX, cropY) - .toString() + val (cWidth, cHeight, cX, cY) = remember(soBundle) { + Crop.parseFrom(soBundle.crop) + } + var cropWidthInput by rememberSaveable(soBundle) { + mutableStateOf(cWidth?.toString() ?: "") + } + var cropHeightInput by rememberSaveable(soBundle) { + mutableStateOf(cHeight?.toString() ?: "") + } + var cropXInput by rememberSaveable(soBundle) { + mutableStateOf(cX?.toString() ?: "") + } + var cropYInput by rememberSaveable(soBundle) { + mutableStateOf(cY?.toString() ?: "") } - var serverParamsPreview by rememberSaveable { - mutableStateOf(runBlocking { - scrcpyOptions - .toClientOptions() - .toServerParams(0u) - .toList(simplify = true) - .joinToString(ServerParams.SEPARATOR) - }) - } - - // 监听所有选项变化,自动更新 serverParams 预览 - LaunchedEffect( - turnScreenOff, control, video, - videoSource, displayId, - cameraId, cameraFacing, cameraSize, cameraAr, cameraFps, cameraHighSpeed, - audioSource, audioDup, audioPlayback, requireAudio, - maxSize, maxFps, - videoEncoder, videoCodecOptions, - audioEncoder, audioCodecOptions, - newDisplay, crop, - ) { - val clientOptions = scrcpyOptions.toClientOptions() + var serverParamsPreview by rememberSaveable { mutableStateOf("") } + // 监听选项变化, 自动更新预览 + LaunchedEffect(soBundle) { + val clientOptions = scrcpyOptions.toClientOptions(soBundle) try { clientOptions.validate() } catch (e: IllegalArgumentException) { - snackbarHostState.showSnackbar("Invalid options: ${e.message}") + snack.showSnackbar("Invalid options: ${e.message}") return@LaunchedEffect } serverParamsPreview = clientOptions .toServerParams(0u) .toList(simplify = true) - .joinToString(ServerParams.SEPARATOR) + // improve readability using hard line breaks + .joinToString("\n") } - // 高级参数 - AppPageLazyColumn( + LazyColumn( contentPadding = contentPadding, scrollBehavior = scrollBehavior, ) { @@ -245,33 +330,143 @@ internal fun AdvancedConfigPage( modifier = Modifier.fillMaxWidth(), ) } + } + item { Card { SuperSwitch( title = "启动后关闭屏幕", summary = "--turn-screen-off", - checked = turnScreenOff, + checked = soBundle.turnScreenOff, onCheckedChange = { - turnScreenOff = it + soBundle = soBundle.copy(turnScreenOff = it) if (it) scope.launch { // github.com/Genymobile/scrcpy/issues/3376 // github.com/Genymobile/scrcpy/issues/4587 // github.com/Genymobile/scrcpy/issues/5676 - snackbarHostState.showSnackbar("注意:大部分设备在关闭屏幕后刷新率会降低/减半") + snack.showSnackbar("注意:大部分设备在关闭屏幕后刷新率会降低/减半") } }, ) SuperSwitch( title = "禁用控制", summary = "--no-control", - checked = !control, - onCheckedChange = { control = !it }, + checked = !soBundle.control, + onCheckedChange = { soBundle = soBundle.copy(control = !it) }, + // 拦不住同时点, 弃用 + // enabled = audio || video, ) SuperSwitch( title = "禁用视频", summary = "--no-video", - checked = !video, - onCheckedChange = { video = !it }, + checked = !soBundle.video, + onCheckedChange = { soBundle = soBundle.copy(video = !it) }, + // enabled = audio || control, + ) + SuperSwitch( + title = "禁用音频", + summary = "--no-audio", + checked = !soBundle.audio, + onCheckedChange = { soBundle = soBundle.copy(audio = !it) }, + // enabled = control || video, + ) + } + } + + item { + Card { + SuperDropdown( + title = "音频编码", + summary = "--audio-codec", + items = audioCodecItems, + selectedIndex = audioCodecIndex, + onSelectedIndexChange = { + soBundle = soBundle.copy(audioCodec = Codec.AUDIO[it].string) + }, + ) + SuperSlider( + title = "音频码率", + summary = "--audio-bit-rate", + value = if (soBundle.audioBitRate <= 0) 0f + else (ScrcpyPresets.AudioBitRate + .indexOfOrNearest(soBundle.audioBitRate / 1000) + 1 + ).toFloat(), + onValueChange = { value -> + val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.size) + soBundle = soBundle.copy( + audioBitRate = + if (idx == 0) 0 + else ScrcpyPresets.AudioBitRate[idx - 1] * 1000 + ) + }, + valueRange = 0f..ScrcpyPresets.AudioBitRate.size.toFloat(), + steps = (ScrcpyPresets.AudioBitRate.size - 1).coerceAtLeast(0), + unit = "Kbps", + zeroStateText = "默认", + displayText = (soBundle.audioBitRate / 1_000).toString(), + inputInitialValue = + if (soBundle.audioBitRate <= 0) "" + else (soBundle.audioBitRate / 1_000).toString(), + inputFilter = { it.filter(Char::isDigit) }, + inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), + onInputConfirm = { raw -> + raw.toIntOrNull() + ?.takeIf { it >= 0 } + ?.let { soBundle = soBundle.copy(audioBitRate = it * 1000) } + }, + ) + + SuperDropdown( + title = "视频编码", + summary = "--video-codec", + items = videoCodecItems, + selectedIndex = videoCodecIndex, + onSelectedIndexChange = { + soBundle = soBundle.copy(videoCodec = Codec.VIDEO[it].string) + }, + ) + @SuppressLint("DefaultLocale") + SuperSlider( + title = "视频码率", + summary = "--video-bit-rate", + value = soBundle.videoBitRate / 1_000_000f, + onValueChange = { mbps -> + soBundle = soBundle.copy( + videoBitRate = (mbps * 10).roundToInt() * (1_000_000 / 10) + ) + }, + valueRange = 0f..40f, + steps = 400 - 1, + unit = "Mbps", + zeroStateText = "默认", + displayFormatter = { String.format("%.1f", it) }, + inputInitialValue = + if (soBundle.videoBitRate <= 0) "" + else String.format("%.1f", soBundle.videoBitRate / 1_000_000f), + inputFilter = { text -> + var dotUsed = false + text.filter { ch -> + when { + ch.isDigit() -> true + ch == '.' && !dotUsed -> { + dotUsed = true + true + } + + else -> false + } + } + }, + inputValueRange = 0f..UInt.MAX_VALUE.toFloat(), + onInputConfirm = { raw -> + raw.toFloatOrNull()?.let { parsed -> + if (parsed >= 0f) { + soBundle = soBundle.copy( + videoBitRate = (parsed * 1_000_000f).roundToInt() + ) + } + } + }, ) } } @@ -284,13 +479,18 @@ internal fun AdvancedConfigPage( items = videoSourceItems, selectedIndex = videoSourceIndex, onSelectedIndexChange = { - videoSource = Shared.VideoSource.entries[it].string + soBundle = soBundle.copy( + videoSource = Shared.VideoSource.entries[it].string + ) }, ) - AnimatedVisibility(videoSource == "display") { - TextField( - value = if (displayId == -1) "" else displayId.toString(), - onValueChange = { displayId = it.toIntOrNull() ?: -1 }, + AnimatedVisibility(soBundle.videoSource == "display") { + SuperTextField( + value = displayIdInput, + onValueChange = { displayIdInput = it }, + onFocusLost = { + soBundle = soBundle.copy(displayId = displayIdInput.toIntOrNull() ?: -1) + }, label = "--display-id", singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), @@ -300,10 +500,13 @@ internal fun AdvancedConfigPage( .padding(bottom = UiSpacing.CardContent), ) } - AnimatedVisibility(videoSource == "camera") { - TextField( - value = cameraId, - onValueChange = { cameraId = it }, + AnimatedVisibility(soBundle.videoSource == "camera") { + SuperTextField( + value = cameraIdInput, + onValueChange = { cameraIdInput = it }, + onFocusLost = { + soBundle = soBundle.copy(cameraId = cameraIdInput) + }, label = "--camera-id", singleLine = true, modifier = Modifier @@ -312,7 +515,7 @@ internal fun AdvancedConfigPage( .padding(bottom = UiSpacing.CardContent), ) } - AnimatedVisibility(videoSource == "camera") { + AnimatedVisibility(soBundle.videoSource == "camera") { SuperArrow( title = "重新获取 Camera Sizes", summary = "--list-camera-sizes", @@ -322,9 +525,9 @@ internal fun AdvancedConfigPage( refreshBusy = true try { scrcpy.refreshCameraSizes() - snackbarHostState.showSnackbar("Camera Sizes 已刷新") + snack.showSnackbar("Camera Sizes 已刷新") } catch (e: Exception) { - snackbarHostState.showSnackbar("刷新失败: ${e.message}") + snack.showSnackbar("刷新失败: ${e.message}") } finally { refreshBusy = false } @@ -332,19 +535,22 @@ internal fun AdvancedConfigPage( }, ) } - AnimatedVisibility(videoSource == "camera") { + AnimatedVisibility(soBundle.videoSource == "camera") { SuperDropdown( title = "摄像头朝向", summary = "--camera-facing", items = cameraFacingItems, selectedIndex = cameraFacingIndex, onSelectedIndexChange = { - cameraFacing = - if (it == 0) "" else Shared.CameraFacing.entries[it].string + soBundle = soBundle.copy( + cameraFacing = + if (it == 0) "" + else Shared.CameraFacing.entries[it].string + ) }, ) } - AnimatedVisibility(videoSource == "camera") { + AnimatedVisibility(soBundle.videoSource == "camera") { SuperDropdown( title = "摄像头分辨率", summary = "--camera-size", @@ -352,24 +558,30 @@ internal fun AdvancedConfigPage( selectedIndex = cameraSizeDropdownIndex, onSelectedIndexChange = { cameraSizeDropdownIndex = it - cameraSizeUseCustom = it == 1 when (it) { 0 -> { // "自动" - cameraSize = "" + soBundle = soBundle.copy( + cameraSize = "", + cameraSizeUseCustom = false, + ) cameraSizeCustomInput = "" } 1 -> { // "自定义" - 进入自定义输入模式 - cameraSizeCustomInput = cameraSize.takeIf { size -> - size.isNotEmpty() && size !in scrcpy.cameraSizes - } ?: "" + soBundle = soBundle.copy( + cameraSizeUseCustom = true, + ) + cameraSizeCustomInput = soBundle.cameraSize } else -> { // 选择列表中的实际分辨率 - cameraSize = cameraSizeDropdownItems[it] + soBundle = soBundle.copy( + cameraSize = cameraSizeDropdownItems[it], + cameraSizeUseCustom = false, + ) cameraSizeCustomInput = "" } } @@ -377,17 +589,18 @@ internal fun AdvancedConfigPage( ) } // 只在选择"自定义"时显示输入框 - AnimatedVisibility(videoSource == "camera" && cameraSizeUseCustom) { + AnimatedVisibility(soBundle.videoSource == "camera" && soBundle.cameraSizeUseCustom) { SuperTextField( value = cameraSizeCustomInput, onValueChange = { cameraSizeCustomInput = it }, onFocusLost = { if (cameraSizeCustomInput in scrcpy.cameraSizes) { + // 输入的值存在于列表中, 取消自定义输入 cameraSizeDropdownIndex = scrcpy.cameraSizes.indexOf(cameraSizeCustomInput) + 2 - cameraSizeUseCustom = false + soBundle = soBundle.copy(cameraSizeUseCustom = false) } else { - cameraSizeCustom = cameraSizeCustomInput + soBundle = soBundle.copy(cameraSizeCustom = cameraSizeCustomInput) } }, label = "--camera-size", @@ -399,10 +612,11 @@ internal fun AdvancedConfigPage( .padding(bottom = UiSpacing.CardContent), ) } - AnimatedVisibility(videoSource == "camera") { - TextField( - value = cameraAr, - onValueChange = { cameraAr = it }, + AnimatedVisibility(soBundle.videoSource == "camera") { + SuperTextField( + value = cameraArInput, + onValueChange = { cameraArInput = it }, + onFocusLost = { soBundle = soBundle.copy(cameraAr = cameraArInput) }, label = "--camera-ar", singleLine = true, modifier = Modifier @@ -411,15 +625,15 @@ internal fun AdvancedConfigPage( .padding(bottom = UiSpacing.CardContent), ) } - AnimatedVisibility(videoSource == "camera") { + AnimatedVisibility(soBundle.videoSource == "camera") { SuperSlider( title = "摄像头帧率", summary = "--camera-fps", value = cameraFpsPresetIndex.toFloat(), onValueChange = { value -> - val idx = - value.roundToInt().coerceIn(0, ScrcpyPresets.CameraFps.lastIndex) - cameraFps = ScrcpyPresets.CameraFps[idx] + val idx = value.roundToInt() + .coerceIn(0, ScrcpyPresets.CameraFps.lastIndex) + soBundle = soBundle.copy(cameraFps = ScrcpyPresets.CameraFps[idx]) }, valueRange = 0f..ScrcpyPresets.CameraFps.lastIndex.toFloat(), steps = (ScrcpyPresets.CameraFps.size - 2).coerceAtLeast(0), @@ -428,24 +642,24 @@ internal fun AdvancedConfigPage( showUnitWhenZeroState = false, showKeyPoints = true, keyPoints = ScrcpyPresets.CameraFps.indices.map { it.toFloat() }, - displayText = cameraFps.toString(), - inputHint = "0 或留空表示默认", - inputInitialValue = cameraFps.toString(), + displayText = soBundle.cameraFps.toString(), + inputSummary = "0 或留空表示默认", + inputInitialValue = soBundle.cameraFps.toString(), inputFilter = { it.filter(Char::isDigit) }, inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), - onInputConfirm = { input -> - input.toIntOrNull() - ?.let { cameraFps = it } - ?: run { cameraFps = 0 } + onInputConfirm = { + soBundle = soBundle.copy( + cameraFps = it.toIntOrNull() ?: run { 0 } + ) }, ) } - AnimatedVisibility(videoSource == "camera") { + AnimatedVisibility(soBundle.videoSource == "camera") { SuperSwitch( title = "高帧率模式", summary = "--camera-high-speed", - checked = cameraHighSpeed, - onCheckedChange = { cameraHighSpeed = it }, + checked = soBundle.cameraHighSpeed, + onCheckedChange = { soBundle = soBundle.copy(cameraHighSpeed = it) }, ) } } @@ -459,27 +673,28 @@ internal fun AdvancedConfigPage( items = audioSourceItems, selectedIndex = audioSourceIndex, onSelectedIndexChange = { - audioSource = Shared.AudioSource.entries[it].string + soBundle = soBundle.copy( + audioSource = Shared.AudioSource.entries[it].string + ) }, ) SuperSwitch( title = "音频双路输出", summary = "--audio-dup", - checked = audioDup, - onCheckedChange = { audioDup = it }, + checked = soBundle.audioDup, + onCheckedChange = { soBundle = soBundle.copy(audioDup = it) }, ) SuperSwitch( title = "仅转发不播放", summary = "--no-audio-playback", - checked = !audioPlayback, - onCheckedChange = { audioPlayback = !it }, + checked = !soBundle.audioPlayback, + onCheckedChange = { soBundle = soBundle.copy(audioPlayback = !it) }, ) SuperSwitch( - title = "音频失败时终止 [TODO]", + title = "音频失败时终止", summary = "--require-audio", - checked = requireAudio, - onCheckedChange = { requireAudio = it }, - enabled = false, + checked = soBundle.requireAudio, + onCheckedChange = { soBundle = soBundle.copy(requireAudio = it) }, ) } } @@ -490,10 +705,10 @@ internal fun AdvancedConfigPage( title = "最大分辨率", summary = "--max-size", value = maxSizePresetIndex.toFloat(), - onValueChange = { value -> - val idx = - value.roundToInt().coerceIn(0, ScrcpyPresets.MaxSize.lastIndex) - maxSize = ScrcpyPresets.MaxSize[idx] + onValueChange = { + val idx = it.roundToInt() + .coerceIn(0, ScrcpyPresets.MaxSize.lastIndex) + soBundle = soBundle.copy(maxSize = ScrcpyPresets.MaxSize[idx]) }, valueRange = 0f..ScrcpyPresets.MaxSize.lastIndex.toFloat(), steps = (ScrcpyPresets.MaxSize.size - 2).coerceAtLeast(0), @@ -502,20 +717,30 @@ internal fun AdvancedConfigPage( showUnitWhenZeroState = false, showKeyPoints = true, keyPoints = ScrcpyPresets.MaxSize.indices.map { it.toFloat() }, - displayText = maxSize.toString(), - inputHint = "0 或留空表示关闭", - inputInitialValue = maxSize.toString(), + displayText = soBundle.maxSize.toString(), + inputTitle = "最大分辨率 (px)", + inputSummary = "0 或留空表示关闭", + inputInitialValue = soBundle.maxSize.takeIf { it != 0 }?.toString() ?: "", inputFilter = { it.filter(Char::isDigit) }, inputValueRange = 0f..UInt.MAX_VALUE.toFloat(), - onInputConfirm = { input -> input.toIntOrNull()?.let { maxSize = it } }, + onInputConfirm = { + soBundle = soBundle.copy( + maxSize = it.toIntOrNull() ?: run { 0 } + ) + }, ) SuperSlider( title = "最大帧率", summary = "--max-fps", value = maxFpsPresetIndex.toFloat(), onValueChange = { value -> - val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxFPS.lastIndex) - maxFps = if (idx == 0) "" else ScrcpyPresets.MaxFPS[idx].toString() + val idx = value.roundToInt() + .coerceIn(0, ScrcpyPresets.MaxFPS.lastIndex) + soBundle = soBundle.copy( + maxFps = + if (idx == 0) "" + else ScrcpyPresets.MaxFPS[idx].toString() + ) }, valueRange = 0f..ScrcpyPresets.MaxFPS.lastIndex.toFloat(), steps = (ScrcpyPresets.MaxFPS.size - 2).coerceAtLeast(0), @@ -524,12 +749,13 @@ internal fun AdvancedConfigPage( showUnitWhenZeroState = false, showKeyPoints = true, keyPoints = ScrcpyPresets.MaxFPS.indices.map { it.toFloat() }, - displayText = maxFps, - inputHint = "0 或留空表示关闭", - inputInitialValue = maxFps, + displayText = soBundle.maxFps, + inputTitle = "最大帧率 (FPS)", + inputSummary = "0 或留空表示关闭", + inputInitialValue = soBundle.maxFps, inputFilter = { it.filter(Char::isDigit) }, inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), - onInputConfirm = { maxFps = it }, + onInputConfirm = { soBundle = soBundle.copy(maxFps = it) }, ) } } @@ -545,9 +771,9 @@ internal fun AdvancedConfigPage( refreshBusy = true try { scrcpy.refreshEncoders() - snackbarHostState.showSnackbar("编码器列表已刷新") + snack.showSnackbar("编码器列表已刷新") } catch (e: Exception) { - snackbarHostState.showSnackbar("刷新失败: ${e.message}") + snack.showSnackbar("刷新失败: ${e.message}") } finally { refreshBusy = false } @@ -560,12 +786,15 @@ internal fun AdvancedConfigPage( items = videoEncoderEntries, selectedIndex = videoEncoderIndex, onSelectedIndexChange = { - videoEncoder = videoEncoderEntries[it].title ?: "" + soBundle = soBundle.copy(videoEncoder = videoEncoderEntries[it].title ?: "") }, ) - TextField( - value = videoCodecOptions, - onValueChange = { videoCodecOptions = it }, + SuperTextField( + value = videoCodecOptionsInput, + onValueChange = { videoCodecOptionsInput = it }, + onFocusLost = { + soBundle = soBundle.copy(videoCodecOptions = videoCodecOptionsInput) + }, label = "--video-codec-options", singleLine = true, modifier = Modifier @@ -579,12 +808,15 @@ internal fun AdvancedConfigPage( items = audioEncoderEntries, selectedIndex = audioEncoderIndex, onSelectedIndexChange = { - audioEncoder = audioEncoderEntries[it].title ?: "" + soBundle = soBundle.copy(audioEncoder = audioEncoderEntries[it].title ?: "") }, ) - TextField( - value = audioCodecOptions, - onValueChange = { audioCodecOptions = it }, + SuperTextField( + value = audioCodecOptionsInput, + onValueChange = { audioCodecOptionsInput = it }, + onFocusLost = { + soBundle = soBundle.copy(audioCodecOptions = audioCodecOptionsInput) + }, label = "--audio-codec-options", singleLine = true, modifier = Modifier @@ -614,10 +846,21 @@ internal fun AdvancedConfigPage( .padding(bottom = UiSpacing.CardContent), horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), ) { - TextField( + SuperTextField( label = "width", - value = newDisplayWidth, - onValueChange = { newDisplayWidth = it; updateNewDisplay() }, + value = newDisplayWidthInput, + onValueChange = { newDisplayWidthInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + newDisplay = NewDisplay + .parseFrom( + newDisplayWidthInput, + newDisplayHeightInput, + newDisplayDpiInput + ) + .toString() + ) + }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -628,10 +871,21 @@ internal fun AdvancedConfigPage( ), modifier = Modifier.weight(1f), ) - TextField( + SuperTextField( label = "height", - value = newDisplayHeight, - onValueChange = { newDisplayHeight = it; updateNewDisplay() }, + value = newDisplayHeightInput, + onValueChange = { newDisplayHeightInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + newDisplay = NewDisplay + .parseFrom( + newDisplayWidthInput, + newDisplayHeightInput, + newDisplayDpiInput + ) + .toString() + ) + }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -642,10 +896,21 @@ internal fun AdvancedConfigPage( ), modifier = Modifier.weight(1f), ) - TextField( + SuperTextField( label = "dpi", - value = newDisplayDpi, - onValueChange = { newDisplayDpi = it; updateNewDisplay() }, + value = newDisplayDpiInput, + onValueChange = { newDisplayDpiInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + newDisplay = NewDisplay + .parseFrom( + newDisplayWidthInput, + newDisplayHeightInput, + newDisplayDpiInput + ) + .toString() + ) + }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -683,10 +948,22 @@ internal fun AdvancedConfigPage( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), ) { - TextField( + SuperTextField( label = "width", - value = cropWidth, - onValueChange = { cropWidth = it; updateCrop() }, + value = cropWidthInput, + onValueChange = { cropWidthInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + crop = Crop + .parseFrom( + cropWidthInput, + cropHeightInput, + cropXInput, + cropYInput + ) + .toString() + ) + }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -697,10 +974,22 @@ internal fun AdvancedConfigPage( ), modifier = Modifier.weight(1f), ) - TextField( + SuperTextField( label = "height", - value = cropHeight, - onValueChange = { cropHeight = it; updateCrop() }, + value = cropHeightInput, + onValueChange = { cropHeightInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + crop = Crop + .parseFrom( + cropWidthInput, + cropHeightInput, + cropXInput, + cropYInput + ) + .toString() + ) + }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -716,10 +1005,22 @@ internal fun AdvancedConfigPage( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), ) { - TextField( + SuperTextField( label = "x", - value = cropX, - onValueChange = { cropX = it; updateCrop() }, + value = cropXInput, + onValueChange = { cropXInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + crop = Crop + .parseFrom( + cropWidthInput, + cropHeightInput, + cropXInput, + cropYInput + ) + .toString() + ) + }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -730,10 +1031,22 @@ internal fun AdvancedConfigPage( ), modifier = Modifier.weight(1f), ) - TextField( + SuperTextField( label = "y", - value = cropY, - onValueChange = { cropY = it; updateCrop() }, + value = cropYInput, + onValueChange = { cropYInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + crop = Crop + .parseFrom( + cropWidthInput, + cropHeightInput, + cropXInput, + cropYInput + ) + .toString() + ) + }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt index c5240e3..cf5c7ed 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt @@ -4,20 +4,26 @@ import android.annotation.SuppressLint import android.util.Log import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.MoreVert 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.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 io.github.miuzarte.scrcpyforandroid.NativeCoreFacade import io.github.miuzarte.scrcpyforandroid.constants.Defaults import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing @@ -26,20 +32,21 @@ import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService -import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn +import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.services.EventLogger import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo -import io.github.miuzarte.scrcpyforandroid.storage.Storage +import io.github.miuzarte.scrcpyforandroid.storage.Settings +import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings +import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices +import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel import io.github.miuzarte.scrcpyforandroid.widgets.DeviceEditorScreen import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTile -import io.github.miuzarte.scrcpyforandroid.widgets.LogsPanel import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard import io.github.miuzarte.scrcpyforandroid.widgets.QuickConnectCard -import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle import io.github.miuzarte.scrcpyforandroid.widgets.StatusCard import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction @@ -51,9 +58,21 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import top.yukonga.miuix.kmp.basic.Card +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.PopupPositionProvider +import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.ScrollBehavior import top.yukonga.miuix.kmp.basic.SnackbarHostState -import top.yukonga.miuix.kmp.extra.SuperBottomSheet +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.SuperListPopup +import top.yukonga.miuix.kmp.theme.MiuixTheme import java.net.InetSocketAddress import java.net.Socket @@ -66,6 +85,71 @@ private const val ADB_TCP_PROBE_TIMEOUT_MS = 500 @Composable fun DeviceTabScreen( + nativeCore: NativeCoreFacade, + adbService: NativeAdbService, + scrcpy: Scrcpy, + snack: SnackbarHostState, + scrollBehavior: ScrollBehavior, + onOpenVirtualButtonOrder: () -> Unit, + onSessionStartedChange: (Boolean) -> Unit, + onOpenReorderDevices: () -> Unit, + onOpenAdvancedPage: () -> Unit, + onOpenFullscreenPage: (Scrcpy.Session.SessionInfo) -> Unit, +) { + var showThreePointMenu by rememberSaveable { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = "设备", + actions = { + IconButton( + onClick = { showThreePointMenu = true }, + holdDownState = showThreePointMenu, + ) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = "更多" + ) + } + DeviceMenuPopup( + show = showThreePointMenu, + onDismissRequest = { showThreePointMenu = false }, + onReorderDevices = { + onOpenReorderDevices() + showThreePointMenu = false + }, + onOpenVirtualButtonOrder = { + onOpenVirtualButtonOrder() + showThreePointMenu = false + }, + canClearLogs = EventLogger.hasLogs(), + onClearLogs = { + EventLogger.clearLogs() + showThreePointMenu = false + }, + ) + }, + scrollBehavior = scrollBehavior, + ) + }, + ) { pagePadding -> + DeviceTabPage( + contentPadding = pagePadding, + nativeCore = nativeCore, + adbService = adbService, + scrcpy = scrcpy, + snack = snack, + scrollBehavior = scrollBehavior, + onSessionStartedChange = onSessionStartedChange, + onOpenAdvancedPage = onOpenAdvancedPage, + onOpenFullscreenPage = onOpenFullscreenPage, + ) + } +} + +@Composable +fun DeviceTabPage( contentPadding: PaddingValues, nativeCore: NativeCoreFacade, adbService: NativeAdbService, @@ -73,26 +157,60 @@ fun DeviceTabScreen( snack: SnackbarHostState, scrollBehavior: ScrollBehavior, onSessionStartedChange: (Boolean) -> Unit, - onClearLogsActionChange: ((() -> Unit)) -> Unit, - onCanClearLogsChange: (Boolean) -> Unit, - onOpenReorderDevicesActionChange: ((() -> Unit)) -> Unit, onOpenAdvancedPage: () -> Unit, onOpenFullscreenPage: (Scrcpy.Session.SessionInfo) -> Unit, ) { - val appSettings = Storage.appSettings - val quickDevices = Storage.quickDevices - val scrcpyOptions = Storage.scrcpyOptions - - val context = LocalContext.current - + val scope = rememberCoroutineScope() val haptics = rememberAppHaptics() - val virtualButtonsLayout by appSettings.virtualButtonsLayout.asState() - 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 initialSettings = remember(context) { loadDevicePageSettings(context) } - val scope = rememberCoroutineScope() + LaunchedEffect(asBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (asBundle != asBundleSharedLatest) { + appSettings.saveBundle(asBundle) + } + } + DisposableEffect(Unit) { + onDispose { + scope.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 { + scope.launch { + quickDevices.saveBundle(qdBundleLatest) + } + } + } + + // read only + val soBundleShared by scrcpyOptions.bundleState.collectAsState() // Run adb operations on a dedicated single thread. // Try to avoid blocking UI/recomposition and keeps adb call ordering deterministic. @@ -138,7 +256,6 @@ fun DeviceTabScreen( var previewControlsVisible by rememberSaveable { mutableStateOf(false) } var editingDevice by rememberSaveable { mutableStateOf(null) } var activeDeviceActionId by rememberSaveable { mutableStateOf(null) } - var showReorderSheet by rememberSaveable { mutableStateOf(false) } var adbConnecting by rememberSaveable { mutableStateOf(false) } var audioForwardingSupported by rememberSaveable { mutableStateOf(true) } @@ -146,34 +263,32 @@ fun DeviceTabScreen( val currentTarget = if (currentTargetHost.isNotBlank()) - ConnectionTarget( - currentTargetHost, - currentTargetPort, - ) else null + ConnectionTarget(currentTargetHost, currentTargetPort) + else null val sessionReconnectBlacklistHosts = remember { mutableSetOf() } - LaunchedEffect(EventLogger.eventLog.size) { - onCanClearLogsChange(EventLogger.hasLogs()) + val virtualButtonLayout = remember(asBundle.virtualButtonsLayout) { + VirtualButtonActions.splitLayout( + VirtualButtonActions.parseStoredLayout(asBundle.virtualButtonsLayout) + ) } - var quickDevicesList by quickDevices.quickDevicesList.asMutableState() - var savedShortcuts by remember { mutableStateOf(DeviceShortcuts.unmarshalFrom(quickDevicesList)) } + var savedShortcuts by remember { + mutableStateOf(DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList)) + } - LaunchedEffect(quickDevicesList) { - savedShortcuts = DeviceShortcuts.unmarshalFrom(quickDevicesList) + LaunchedEffect(qdBundle.quickDevicesList) { + savedShortcuts = DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList) } // save changes when [savedShortcuts] was modified LaunchedEffect(savedShortcuts) { val serialized = savedShortcuts.marshalToString() - if (serialized != quickDevicesList) { - quickDevicesList = serialized + if (serialized != qdBundle.quickDevicesList) { + qdBundle = qdBundle.copy(quickDevicesList = serialized) } } - var quickConnectInput by quickDevices.quickConnectInput.asMutableState() - var quickConnectInputTemp by remember(quickConnectInput) { mutableStateOf(quickConnectInput) } - /** * Disconnect the current ADB connection and stop any running scrcpy session. * @@ -236,14 +351,13 @@ fun DeviceTabScreen( disconnectAdbConnection(clearQuickOnlineForTarget = current) } - var audio by scrcpyOptions.audio.asMutableState() - var videoSource by scrcpyOptions.videoSource.asMutableState() - fun applyConnectedDeviceCapabilities(sdkInt: Int, release: String) { val audioSupported = sdkInt !in 0..<30 audioForwardingSupported = audioSupported - if (!audioSupported && audio) { - audio = false + if (!audioSupported && soBundleShared.audio) { + scope.launch { + scrcpyOptions.updateBundle { it.copy(audio = false) } + } logEvent( "设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持音频转发,已自动关闭", Log.WARN @@ -251,8 +365,10 @@ fun DeviceTabScreen( } val cameraSupported = sdkInt !in 0..<31 cameraMirroringSupported = cameraSupported - if (!cameraSupported && videoSource == "camera") { - videoSource = "display" + if (!cameraSupported && soBundleShared.videoSource == "camera") { + scope.launch { + scrcpyOptions.updateBundle { it.copy(videoSource = "display") } + } logEvent( "设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持 camera mirroring,已切换为 display", Log.WARN @@ -405,20 +521,17 @@ fun DeviceTabScreen( } } - var videoEncoder by scrcpyOptions.videoEncoder.asMutableState() - var audioEncoder by scrcpyOptions.audioEncoder.asMutableState() - suspend fun refreshEncoderLists() { if (!adbConnected) return runCatching { scrcpy.refreshEncoders() }.onSuccess { // Validate current selections - if (videoEncoder.isNotBlank() && videoEncoder !in scrcpy.videoEncoders) { - videoEncoder = "" + if (soBundleShared.videoEncoder.isNotBlank() && soBundleShared.videoEncoder !in scrcpy.videoEncoders) { + scrcpyOptions.updateBundle { it.copy(videoEncoder = "") } } - if (audioEncoder.isNotBlank() && audioEncoder !in scrcpy.audioEncoders) { - audioEncoder = "" + if (soBundleShared.audioEncoder.isNotBlank() && soBundleShared.audioEncoder !in scrcpy.audioEncoders) { + scrcpyOptions.updateBundle { it.copy(audioEncoder = "") } } logEvent("编码器列表已刷新: video=${scrcpy.videoEncoders.size} audio=${scrcpy.audioEncoders.size}") if (scrcpy.videoEncoders.isEmpty() && scrcpy.audioEncoders.isEmpty()) { @@ -436,16 +549,18 @@ fun DeviceTabScreen( } } - var cameraSize by scrcpyOptions.cameraSize.asMutableState() - suspend fun refreshCameraSizeLists() { if (!adbConnected) return runCatching { scrcpy.refreshCameraSizes() }.onSuccess { // Validate current selection - if (cameraSize.isNotBlank() && cameraSize != "custom" && cameraSize !in scrcpy.cameraSizes) { - cameraSize = "" + if ( + soBundleShared.cameraSize.isNotBlank() + && soBundleShared.cameraSize != "custom" + && soBundleShared.cameraSize !in scrcpy.cameraSizes + ) { + scrcpyOptions.updateBundle { it.copy(cameraSize = "") } } logEvent("camera sizes 已刷新: count=${scrcpy.cameraSizes.size}") }.onFailure { e -> @@ -543,9 +658,9 @@ fun DeviceTabScreen( } } - val adbPairingAutoDiscoverOnDialogOpen by appSettings.adbPairingAutoDiscoverOnDialogOpen.asState() - val adbAutoReconnectPairedDevice by appSettings.adbAutoReconnectPairedDevice.asState() - val adbMdnsLanDiscovery by appSettings.adbMdnsLanDiscovery.asState() + val adbPairingAutoDiscoverOnDialogOpen = asBundle.adbPairingAutoDiscoverOnDialogOpen + val adbAutoReconnectPairedDevice = asBundle.adbAutoReconnectPairedDevice + val adbMdnsLanDiscovery = asBundle.adbMdnsLanDiscovery LaunchedEffect(adbConnected, adbAutoReconnectPairedDevice, adbMdnsLanDiscovery) { if (adbConnected || !adbAutoReconnectPairedDevice) return@LaunchedEffect @@ -676,44 +791,6 @@ fun DeviceTabScreen( onSessionStartedChange(sessionInfo != null) } - DisposableEffect(Unit) { - onClearLogsActionChange { - EventLogger.clearLogs() - } - onOpenReorderDevicesActionChange { - showReorderSheet = true - } - onDispose { - // canClearLogs 是 DevicePage 特有的状态,需要重置 - onCanClearLogsChange(false) - } - } - - SuperBottomSheet( - show = showReorderSheet, - title = "快速设备排序", - onDismissRequest = { showReorderSheet = false }, - ) { - val list = remember { - 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) - }, - ) - } - list() - Spacer(Modifier.height(UiSpacing.BottomSheetBottom)) - } - fun sendVirtualButtonAction(action: VirtualButtonAction) { val keycode = action.keycode ?: return runBusy("发送 ${action.title}") { @@ -744,14 +821,14 @@ fun DeviceTabScreen( return } - val devicePreviewCardHeightDp by appSettings.devicePreviewCardHeightDp.asState() - val previewVirtualButtonShowText by appSettings.previewVirtualButtonShowText.asState() + val devicePreviewCardHeightDp = asBundle.devicePreviewCardHeightDp + val previewVirtualButtonShowText = asBundle.previewVirtualButtonShowText - val audioBitRate by scrcpyOptions.audioBitRate.asState() - val videoBitRate by scrcpyOptions.videoBitRate.asState() + val audioBitRate = soBundleShared.audioBitRate + val videoBitRate = soBundleShared.videoBitRate // 设备 - AppPageLazyColumn( + LazyColumn( contentPadding = contentPadding, scrollBehavior = scrollBehavior, ) { @@ -831,26 +908,27 @@ fun DeviceTabScreen( if (!adbConnected) item { // "快速连接" QuickConnectCard( - input = quickConnectInputTemp, + input = qdBundle.quickConnectInput, onValueChange = { - quickConnectInputTemp = it - quickConnectInput = quickConnectInputTemp + qdBundle = qdBundle.copy(quickConnectInput = it) }, - // onFocusChange = { quickConnectInput = quickConnectInputTemp }, enabled = !adbConnecting, onAddDevice = { - val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp) + val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput) ?: return@QuickConnectCard savedShortcuts = savedShortcuts.upsert( DeviceShortcut(host = target.host, port = target.port) ) - Log.d("SavedShortcuts", "size: ${savedShortcuts.size}, list: $quickDevicesList") + Log.d( + "SavedShortcuts", + "size: ${savedShortcuts.size}, list: ${qdBundle.quickDevicesList}" + ) scope.launch { snack.showSnackbar("已添加设备: ${target.host}:${target.port}") } }, onConnect = { - val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp) + val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput) ?: return@QuickConnectCard runAdbConnect( "连接 ADB", @@ -914,13 +992,15 @@ fun DeviceTabScreen( item { ConfigPanel( busy = busy, + snack = snack, audioForwardingSupported = audioForwardingSupported, cameraMirroringSupported = cameraMirroringSupported, + isQuickConnected = isQuickConnected, onOpenAdvanced = onOpenAdvancedPage, onStartStopHaptic = { haptics.contextClick() }, onStart = { runBusy("启动 scrcpy") { - val options = scrcpyOptions.toClientOptions() + val options = scrcpyOptions.toClientOptions(soBundleShared) val session = scrcpy.start(options) sessionInfo = session.copy( host = currentTargetHost, @@ -928,18 +1008,17 @@ fun DeviceTabScreen( ) statusLine = "scrcpy 运行中" @SuppressLint("DefaultLocale") - val videoDetail = if (!options.video) { - "off" - } else { - "${session.codecName} ${session.width}x${session.height} " + + val videoDetail = + if (!options.video) "off" + else if (videoBitRate <= 0) "${session.codecName} ${session.width}x${session.height} @default" + else "${session.codecName} ${session.width}x${session.height} " + "@${String.format("%.1f", videoBitRate / 1_000_000f)}Mbps" - } - val audioDetail = if (!audio) { - "off" - } else { - val playback = if (!options.audioPlayback) "(no-playback)" else "" - "${options.audioCodec} ${videoBitRate / 1_000f}Kbps source=${options.audioSource}$playback" - } + + val audioDetail = + if (!soBundleShared.audio) "off" + else if (audioBitRate <= 0) "${options.audioCodec} default source=${options.audioSource}" + else "${options.audioCodec} ${audioBitRate / 1_000f}Kbps source=${options.audioSource}${if (!options.audioPlayback) "(no-playback)" else ""}" + logEvent( "scrcpy 已启动: device=${session.deviceName}" + ", video=$videoDetail, audio=$audioDetail" + @@ -1017,9 +1096,91 @@ fun DeviceTabScreen( if (EventLogger.hasLogs()) item { Spacer(Modifier.height(UiSpacing.PageItem)) - LogsPanel(lines = EventLogger.eventLog) + Card { + TextField( + value = EventLogger.eventLog.joinToString(separator = "\n"), + onValueChange = {}, + readOnly = true, + modifier = Modifier.fillMaxWidth(), + ) + } } item { Spacer(Modifier.height(UiSpacing.BottomContent)) } } } + +@Composable +private fun DeviceMenuPopup( + show: Boolean, + onDismissRequest: () -> Unit, + onReorderDevices: () -> Unit, + onOpenVirtualButtonOrder: () -> Unit, + canClearLogs: Boolean, + onClearLogs: () -> Unit, +) { + SuperListPopup( + show = show, + popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider, + alignment = PopupPositionProvider.Align.TopEnd, + onDismissRequest = onDismissRequest, + enableWindowDim = false, + ) { + ListPopupColumn { + DeviceMenuPopupItem( + text = "快速设备排序", + optionSize = 3, + index = 0, + onClick = onReorderDevices, + ) + DeviceMenuPopupItem( + text = "虚拟按钮排序", + optionSize = 3, + index = 1, + onClick = onOpenVirtualButtonOrder, + ) + DeviceMenuPopupItem( + text = "清空日志", + optionSize = 3, + index = 2, + enabled = canClearLogs, + onClick = onClearLogs, + ) + } + } +} + +@Composable +private fun DeviceMenuPopupItem( + text: String, + optionSize: Int, + index: Int, + enabled: Boolean = true, + // TODO: (Int) -> Unit + onClick: () -> Unit, +) { + if (enabled) { + DropdownImpl( + text = text, + optionSize = optionSize, + isSelected = false, + index = index, + onSelectedIndexChange = { onClick() }, + ) + return + } + + val additionalTopPadding = if (index == 0) UiSpacing.PopupHorizontal else UiSpacing.PageItem + val additionalBottomPadding = + if (index == optionSize - 1) UiSpacing.PopupHorizontal else UiSpacing.PageItem + Text( + text = text, + fontSize = MiuixTheme.textStyles.body1.fontSize, + fontWeight = FontWeight.Medium, + color = MiuixTheme.colorScheme.disabledOnSecondaryVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = UiSpacing.PopupHorizontal) + .padding(top = additionalTopPadding, bottom = additionalBottomPadding), + ) +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt similarity index 63% rename from app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt rename to app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt index 4b38956..2c62473 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt @@ -8,10 +8,15 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf 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.Alignment import androidx.compose.ui.Modifier @@ -22,43 +27,65 @@ import androidx.core.view.WindowInsetsControllerCompat import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy -import io.github.miuzarte.scrcpyforandroid.storage.Storage +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.VirtualButtonActions import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import top.yukonga.miuix.kmp.basic.Scaffold -data class FullscreenControlLaunch( - val deviceName: String, - val width: Int, - val height: Int, - val codec: String, -) - @Composable -fun FullscreenControlPage( - launch: FullscreenControlLaunch, +fun FullscreenControlScreen( + onBack: () -> Unit, + session: Scrcpy.Session.SessionInfo, nativeCore: NativeCoreFacade, onVideoSizeChanged: (width: Int, height: Int) -> Unit, - onDismiss: () -> Unit, ) { // Disable predictive back handler temporarily to avoid decoding issues. - BackHandler(enabled = true, onBack = onDismiss) + BackHandler(enabled = true, onBack = onBack) - val appSettings = Storage.appSettings val context = LocalContext.current val haptics = rememberAppHaptics() + val scope = rememberCoroutineScope() val activity = remember(context) { context as? Activity } - var virtualButtonsLayout by appSettings.virtualButtonsLayout.asMutableState() - val buttonItems = 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 fullscreenDebugInfo by appSettings.fullscreenDebugInfo.asState() - val showFullscreenVirtualButtons by appSettings.showFullscreenVirtualButtons.asState() + LaunchedEffect(asBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (asBundle != asBundleSharedLatest) { + appSettings.saveBundle(asBundle) + } + } + DisposableEffect(Unit) { + onDispose { + scope.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( @@ -66,19 +93,7 @@ fun FullscreenControlPage( moreActions = buttonItems.second, ) } - var session by remember(launch) { - mutableStateOf( - Scrcpy.Session.SessionInfo( - width = launch.width, - height = launch.height, - deviceName = launch.deviceName.ifBlank { "设备" }, - codecId = 0, - codecName = launch.codec.ifBlank { "unknown" }, - audioCodecId = 0, - controlEnabled = true, - ), - ) - } + var currentFps by remember { mutableFloatStateOf(0f) } DisposableEffect(activity) { @@ -103,7 +118,6 @@ fun FullscreenControlPage( DisposableEffect(nativeCore) { val listener: (Int, Int) -> Unit = { w, h -> - session = session.copy(width = w, height = h) onVideoSizeChanged(w, h) } nativeCore.addVideoSizeListener(listener) @@ -124,8 +138,10 @@ fun FullscreenControlPage( suspend fun sendKeycode(keycode: Int) { runCatching { - nativeCore.session?.injectKeycode(0, keycode) - nativeCore.session?.injectKeycode(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) } @@ -140,22 +156,24 @@ fun FullscreenControlPage( FullscreenControlScreen( session = session, nativeCore = nativeCore, - onDismiss = onDismiss, + onDismiss = onBack, showDebugInfo = fullscreenDebugInfo, currentFps = currentFps, enableBackHandler = false, onInjectTouch = { action, pointerId, x, y, pressure, buttons -> - nativeCore.session?.injectTouch( - action = action, - pointerId = pointerId, - x = x, - y = y, - screenWidth = session.width, - screenHeight = session.height, - pressure = pressure, - actionButton = 0, - buttons = buttons, - ) + withContext(Dispatchers.IO) { + nativeCore.session?.injectTouch( + action = action, + pointerId = pointerId, + x = x, + y = y, + screenWidth = session.width, + screenHeight = session.height, + pressure = pressure, + actionButton = 0, + buttons = buttons, + ) + } }, ) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt deleted file mode 100644 index 3f38849..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt +++ /dev/null @@ -1,550 +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.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -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.UiMotion -import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing -import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService -import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy -import io.github.miuzarte.scrcpyforandroid.storage.AppSettings -import io.github.miuzarte.scrcpyforandroid.storage.Storage -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 appContext = context.applicationContext - val scope = rememberCoroutineScope() - - 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 scrcpy = remember(appContext, adbService) { Scrcpy(appContext, adbService) } - - val snackHostState = remember { SnackbarHostState() } - val saveableStateHolder = rememberSaveableStateHolder() - val tabs = remember { MainTabDestination.entries } - val pagerState = rememberPagerState( - initialPage = MainTabDestination.Device.ordinal, - pageCount = { tabs.size }) - val currentTab = tabs[pagerState.currentPage] - val rootBackStack = remember { mutableStateListOf(RootScreen.Home) } - val currentRootScreen = rootBackStack.lastOrNull() as? RootScreen ?: RootScreen.Home - val devicesPageScrollBehavior = MiuixScrollBehavior( - canScroll = { currentTab == MainTabDestination.Device }) - val settingsPageScrollBehavior = MiuixScrollBehavior( - canScroll = { currentTab == MainTabDestination.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) } - 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 { scrcpy.stop() } - runCatching { adbService.disconnect() } - } - 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 appSettings = Storage.appSettings - val scrcpyOptions = Storage.scrcpyOptions - - var sessionStarted by remember { mutableStateOf(false) } - var clearLogsAction by remember { mutableStateOf({}) } - var openReorderDevicesAction by remember { mutableStateOf({}) } - var canClearLogs by remember { mutableStateOf(false) } - var showDeviceMenu by rememberSaveable { mutableStateOf(false) } - var fullscreenOrientation by rememberSaveable { - mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) - } - - var themeBaseIndex by appSettings.themeBaseIndex.asMutableState() - var monet by appSettings.monet.asMutableState() - val themeMode = resolveThemeMode(themeBaseIndex, monet) - val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) } - - val keepScreenOnWhenStreamingEnabled by appSettings.keepScreenOnWhenStreaming.asMutableState() - // 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 - } - - val adbKeyName by appSettings.adbKeyName.asState() - LaunchedEffect(adbKeyName) { - adbService.keyName = adbKeyName.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue } - } - - var customServerUri by appSettings.customServerUri.asMutableState() - val picker = rememberLauncherForActivityResult( - ActivityResultContracts.OpenDocument() - ) { uri: Uri? -> - if (uri == null) return@rememberLauncherForActivityResult - runCatching { - context.contentResolver.takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION - ) - } - customServerUri = uri.toString() - } - - val rootEntryProvider = entryProvider { - entry(RootScreen.Home) { - Scaffold( - bottomBar = { - NavigationBar { - tabs.forEach { tab -> - NavigationBarItem( - selected = currentTab == tab, - onClick = { - scope.launch { - pagerState.animateScrollToPage( - page = tab.ordinal, - animationSpec = spring( - dampingRatio = UiMotion.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() - showDeviceMenu = false - }, - onOpenVirtualButtonOrder = { - rootBackStack.add(RootScreen.VirtualButtonOrder) - showDeviceMenu = false - }, - onClearLogs = { - clearLogsAction() - showDeviceMenu = false - }, - ) - }, - scrollBehavior = devicesPageScrollBehavior, - ) - }, - ) { pagePadding -> - DeviceTabScreen( - contentPadding = pagePadding, - nativeCore = nativeCore, - adbService = adbService, - scrcpy = scrcpy, - snack = snackHostState, - scrollBehavior = devicesPageScrollBehavior, - onSessionStartedChange = { sessionStarted = 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.codecName, - ), - ), - ) - }, - ) - } - - MainTabDestination.Settings -> Scaffold( - topBar = { - TopAppBar( - title = tab.title, - scrollBehavior = settingsPageScrollBehavior, - ) - }, - ) { pagePadding -> - SettingsScreen( - contentPadding = pagePadding, - onOpenReorderDevices = { - openReorderDevicesAction() - }, - onOpenVirtualButtonOrder = { - rootBackStack.add(RootScreen.VirtualButtonOrder) - }, - onPickServer = { - picker.launch( - arrayOf( - "application/java-archive", - "application/octet-stream", - "*/*" - ) - ) - }, - scrollBehavior = settingsPageScrollBehavior, - ) - } - } - } - } - } - } - - entry(RootScreen.Advanced) { - Scaffold( - topBar = { - TopAppBar( - title = "高级参数", - navigationIcon = { - IconButton(onClick = { popRoot() }) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "返回" - ) - } - }, - scrollBehavior = advancedPageScrollBehavior, - ) - }, - snackbarHost = { SnackbarHost(snackHostState) }, - ) { pagePadding -> - AdvancedConfigPage( - contentPadding = pagePadding, - scrollBehavior = advancedPageScrollBehavior, - snackbarHostState = snackHostState, - scrcpy = scrcpy, - ) - } - } - - entry(RootScreen.VirtualButtonOrder) { - Scaffold( - modifier = Modifier.nestedScroll(advancedPageScrollBehavior.nestedScrollConnection), - topBar = { - TopAppBar( - title = "虚拟按钮排序", - navigationIcon = { - IconButton(onClick = { popRoot() }) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "返回" - ) - } - }, - scrollBehavior = advancedPageScrollBehavior, - ) - }, - ) { pagePadding -> - VirtualButtonOrderPage( - contentPadding = pagePadding, - scrollBehavior = advancedPageScrollBehavior, - ) - } - } - - entry { screen -> - FullscreenControlPage( - launch = screen.launch, - nativeCore = nativeCore, - 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, - onDismissRequest: () -> Unit, - onReorderDevices: () -> Unit, - onOpenVirtualButtonOrder: () -> Unit, - onClearLogs: () -> Unit, - canClearLogs: Boolean, -) { - SuperListPopup( - show = show, - popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider, - alignment = PopupPositionProvider.Align.TopEnd, - onDismissRequest = onDismissRequest, - enableWindowDim = false, - ) { - ListPopupColumn { - DeviceMenuPopupItem( - text = "快速设备排序", - optionSize = 3, - index = 0, - onClick = onReorderDevices, - ) - DeviceMenuPopupItem( - text = "虚拟按钮排序", - optionSize = 3, - index = 1, - onClick = onOpenVirtualButtonOrder, - ) - DeviceMenuPopupItem( - text = "清空日志", - optionSize = 3, - index = 2, - enabled = canClearLogs, - onClick = onClearLogs, - ) - } - } -} - -@Composable -private fun DeviceMenuPopupItem( - text: String, - optionSize: Int, - index: Int, - enabled: Boolean = true, - // TODO: (Int) -> Unit - onClick: () -> Unit, -) { - if (enabled) { - DropdownImpl( - text = text, - optionSize = optionSize, - isSelected = false, - index = index, - onSelectedIndexChange = { onClick() }, - ) - return - } - - val additionalTopPadding = if (index == 0) UiSpacing.PopupHorizontal else UiSpacing.PageItem - val additionalBottomPadding = - if (index == optionSize - 1) UiSpacing.PopupHorizontal else UiSpacing.PageItem - Text( - text = text, - fontSize = MiuixTheme.textStyles.body1.fontSize, - fontWeight = FontWeight.Medium, - color = MiuixTheme.colorScheme.disabledOnSecondaryVariant, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.PopupHorizontal) - .padding(top = additionalTopPadding, bottom = additionalBottomPadding), - ) -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt new file mode 100644 index 0000000..6125900 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt @@ -0,0 +1,425 @@ +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.padding +import androidx.compose.foundation.pager.HorizontalPager +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.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.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.Dispatchers +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.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 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 scrcpy = remember(appContext, adbService) { + Scrcpy(appContext, adbService) + } + + val snackHostState = remember { SnackbarHostState() } + 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(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) } + 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. + } + } + + 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 { + scope.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 { + scope.launch { + quickDevices.saveBundle(qdBundleLatest) + } + } + } + + var sessionStarted by remember { mutableStateOf(false) } + var showReorderDevices by rememberSaveable { mutableStateOf(false) } + var fullscreenOrientation by rememberSaveable { + mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) + } + + 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) + } + } + + val themeMode = resolveThemeMode(asBundle.themeBaseIndex, asBundle.monet) + val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) } + + val keepScreenOnWhenStreamingEnabled = asBundle.keepScreenOnWhenStreaming + // 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(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 { + 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, + snack = snackHostState, + scrollBehavior = devicesPageScrollBehavior, + onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) }, + onSessionStartedChange = { sessionStarted = it }, + 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, + snack = snackHostState, + 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) { + AdvancedConfigScreen( + onBack = ::popRoot, + scrollBehavior = advancedPageScrollBehavior, + snack = snackHostState, + scrcpy = scrcpy, + ) + } + + entry(RootScreen.VirtualButtonOrder) { + VirtualButtonOrderScreen( + onBack = ::popRoot, + scrollBehavior = advancedPageScrollBehavior, + ) + } + + entry { 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, + ) + + MiuixTheme(controller = themeController) { + NavDisplay( + entries = rootEntries, + onBack = ::popRoot, + ) + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ReorderDevicesScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ReorderDevicesScreen.kt new file mode 100644 index 0000000..2006027 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ReorderDevicesScreen.kt @@ -0,0 +1,90 @@ +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.delay +import kotlinx.coroutines.launch +import top.yukonga.miuix.kmp.extra.SuperBottomSheet + +@Composable +fun ReorderDevicesScreen( + show: Boolean, + onDismissRequest: () -> Unit, +) { + val scope = rememberCoroutineScope() + + 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 { + scope.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.BottomSheetBottom)) + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt index f500843..417d151 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt @@ -13,11 +13,14 @@ 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 @@ -25,23 +28,25 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.core.net.toUri import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing -import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn +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.storage.AppSettings import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration +import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking 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.SnackbarHostState 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 @@ -75,11 +80,39 @@ private fun resolveThemeLabel(baseIndex: Int, monetEnabled: Boolean): String { @Composable fun SettingsScreen( - contentPadding: PaddingValues, + scrollBehavior: ScrollBehavior, + snack: SnackbarHostState, onOpenReorderDevices: () -> Unit, onOpenVirtualButtonOrder: () -> Unit, onPickServer: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = "设置", + scrollBehavior = scrollBehavior, + ) + }, + ) { pagePadding -> + SettingsPage( + contentPadding = pagePadding, + scrollBehavior = scrollBehavior, + snack = snack, + onOpenReorderDevices = onOpenReorderDevices, + onOpenVirtualButtonOrder = onOpenVirtualButtonOrder, + onPickServer = onPickServer, + ) + } +} + +@Composable +fun SettingsPage( + contentPadding: PaddingValues, scrollBehavior: ScrollBehavior, + snack: SnackbarHostState, + onOpenReorderDevices: () -> Unit, + onOpenVirtualButtonOrder: () -> Unit, + onPickServer: () -> Unit, ) { val appContext = LocalContext.current.applicationContext val appSettings = Storage.appSettings @@ -91,44 +124,57 @@ fun SettingsScreen( val context = LocalContext.current val scope = rememberCoroutineScope() - val snackHostState = remember { SnackbarHostState() } + + 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 { + scope.launch { + appSettings.saveBundle(asBundleLatest) + } + } + } val baseModeItems = THEME_BASE_OPTIONS.map { it.label } - - // 主题 - var themeBaseIndex by appSettings.themeBaseIndex.asMutableState() - var monet by appSettings.monet.asMutableState() - - // 投屏 - var fullscreenDebugInfo by appSettings.fullscreenDebugInfo.asMutableState() - var keepScreenOnWhenStreaming by appSettings.keepScreenOnWhenStreaming.asMutableState() - var devicePreviewCardHeightDp by appSettings.devicePreviewCardHeightDp.asMutableState() - var showFullscreenVirtualButtons by appSettings.showFullscreenVirtualButtons.asMutableState() - - // scrcpy-server - var customServerUri by appSettings.customServerUri.asMutableState() - var serverRemotePath by appSettings.serverRemotePath.asMutableState() var serverRemotePathInput by rememberSaveable { mutableStateOf( - // 默认值留空显示为 hint - if (runBlocking { appSettings.serverRemotePath.isDefaultValue() }) "" - else serverRemotePath + if (asBundleShared.serverRemotePath == AppSettings.SERVER_REMOTE_PATH.defaultValue) "" + else asBundleShared.serverRemotePath ) } + LaunchedEffect(asBundleShared.serverRemotePath) { + serverRemotePathInput = + if (asBundleShared.serverRemotePath == AppSettings.SERVER_REMOTE_PATH.defaultValue) "" + else asBundleShared.serverRemotePath + } - // ADB - var adbKeyName by appSettings.adbKeyName.asMutableState() var adbKeyNameInput by rememberSaveable { mutableStateOf( - if (runBlocking { appSettings.adbKeyName.isDefaultValue() }) "" - else adbKeyName + if (asBundleShared.adbKeyName == AppSettings.ADB_KEY_NAME.defaultValue) "" + else asBundleShared.adbKeyName ) } - var adbPairingAutoDiscoverOnDialogOpen by appSettings.adbPairingAutoDiscoverOnDialogOpen.asMutableState() - var adbAutoReconnectPairedDevice by appSettings.adbAutoReconnectPairedDevice.asMutableState() + LaunchedEffect(asBundleShared.adbKeyName) { + adbKeyNameInput = + if (asBundleShared.adbKeyName == AppSettings.ADB_KEY_NAME.defaultValue) "" + else asBundleShared.adbKeyName + } // 设置 - AppPageLazyColumn( + LazyColumn( contentPadding = contentPadding, scrollBehavior = scrollBehavior, ) { @@ -137,16 +183,20 @@ fun SettingsScreen( Card { SuperDropdown( title = "外观模式", - summary = resolveThemeLabel(themeBaseIndex, monet), + summary = resolveThemeLabel(asBundle.themeBaseIndex, asBundle.monet), items = baseModeItems, - selectedIndex = themeBaseIndex.coerceIn(0, baseModeItems.lastIndex), - onSelectedIndexChange = { themeBaseIndex = it }, + selectedIndex = asBundle.themeBaseIndex.coerceIn(0, baseModeItems.lastIndex), + onSelectedIndexChange = { + asBundle = asBundle.copy(themeBaseIndex = it) + }, ) SuperSwitch( title = "Monet", summary = "开启后使用 Monet 动态配色", - checked = monet, - onCheckedChange = { monet = it }, + checked = asBundle.monet, + onCheckedChange = { + asBundle = asBundle.copy(monet = it) + }, ) } @@ -155,32 +205,40 @@ fun SettingsScreen( SuperSwitch( title = "启用调试信息", summary = "在全屏界面显示触点数量、设备分辨率和实时 FPS", - checked = fullscreenDebugInfo, - onCheckedChange = { fullscreenDebugInfo = it }, + checked = asBundle.fullscreenDebugInfo, + onCheckedChange = { + asBundle = asBundle.copy(fullscreenDebugInfo = it) + }, ) SuperSwitch( title = "投屏时保持屏幕常亮", summary = "Scrcpy 启动后保持本机屏幕常亮,避免锁屏导致 ADB 断开", - checked = keepScreenOnWhenStreaming, - onCheckedChange = { keepScreenOnWhenStreaming = it }, + checked = asBundle.keepScreenOnWhenStreaming, + onCheckedChange = { + asBundle = asBundle.copy(keepScreenOnWhenStreaming = it) + }, ) SuperSlider( title = "预览卡高度", summary = "设备页预览卡高度", - value = devicePreviewCardHeightDp.toFloat(), + value = asBundle.devicePreviewCardHeightDp.toFloat(), onValueChange = { - devicePreviewCardHeightDp = it.roundToInt().coerceAtLeast(120) + asBundle = asBundle.copy( + devicePreviewCardHeightDp = it.roundToInt().coerceAtLeast(120) + ) }, valueRange = 160f..600f, - steps = 600-160-1, + steps = 600-160-2, unit = "dp", displayFormatter = { it.roundToInt().toString() }, - inputInitialValue = devicePreviewCardHeightDp.toString(), + inputInitialValue = asBundle.devicePreviewCardHeightDp.toString(), inputFilter = { it.filter(Char::isDigit) }, inputValueRange = 120f..UShort.MAX_VALUE.toFloat(), onInputConfirm = { input -> input.toIntOrNull()?.let { - devicePreviewCardHeightDp = it.coerceAtLeast(120) + asBundle = asBundle.copy( + devicePreviewCardHeightDp = it.coerceAtLeast(120) + ) } }, ) @@ -197,8 +255,10 @@ fun SettingsScreen( SuperSwitch( title = "全屏显示虚拟按钮", summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮", - checked = showFullscreenVirtualButtons, - onCheckedChange = { showFullscreenVirtualButtons = it }, + checked = asBundle.showFullscreenVirtualButtons, + onCheckedChange = { + asBundle = asBundle.copy(showFullscreenVirtualButtons = it) + }, ) } @@ -213,19 +273,21 @@ fun SettingsScreen( fontWeight = FontWeight.Medium, ) TextField( - value = customServerUri, + value = asBundle.customServerUri, onValueChange = {}, readOnly = true, label = "scrcpy-server-v3.3.4", - useLabelAsPlaceholder = customServerUri.isBlank(), + useLabelAsPlaceholder = asBundle.customServerUri.isBlank(), modifier = Modifier .fillMaxWidth() .padding(horizontal = UiSpacing.CardContent) .padding(bottom = UiSpacing.CardContent), trailingIcon = { Row(modifier = Modifier.padding(end = UiSpacing.SectionTitleLeadingGap)) { - if (customServerUri.isNotBlank()) - IconButton(onClick = { customServerUri = "" }) { + if (asBundle.customServerUri.isNotBlank()) + IconButton(onClick = { + asBundle = asBundle.copy(customServerUri = "") + }) { Icon(Icons.Rounded.Clear, contentDescription = "清空") } IconButton(onClick = onPickServer) { @@ -247,6 +309,11 @@ fun SettingsScreen( onFocusLost = { if (serverRemotePathInput == AppSettings.SERVER_REMOTE_PATH.defaultValue) serverRemotePathInput = "" + asBundle = asBundle.copy( + serverRemotePath = serverRemotePathInput.ifBlank { + AppSettings.SERVER_REMOTE_PATH.defaultValue + } + ) }, label = AppSettings.SERVER_REMOTE_PATH.defaultValue, useLabelAsPlaceholder = true, @@ -273,6 +340,9 @@ fun SettingsScreen( 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, @@ -285,14 +355,22 @@ fun SettingsScreen( SuperSwitch( title = "配对时自动启用发现服务", summary = "打开配对弹窗后自动搜索可用配对端口", - checked = adbPairingAutoDiscoverOnDialogOpen, - onCheckedChange = { adbPairingAutoDiscoverOnDialogOpen = it }, + checked = asBundle.adbPairingAutoDiscoverOnDialogOpen, + onCheckedChange = { + asBundle = asBundle.copy( + adbPairingAutoDiscoverOnDialogOpen = it + ) + }, ) SuperSwitch( title = "自动重连已配对设备", summary = "自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘)", - checked = adbAutoReconnectPairedDevice, - onCheckedChange = { adbAutoReconnectPairedDevice = it }, + checked = asBundle.adbAutoReconnectPairedDevice, + onCheckedChange = { + asBundle = asBundle.copy( + adbAutoReconnectPairedDevice = it + ) + }, ) } @@ -310,7 +388,7 @@ fun SettingsScreen( scope.launch { val migration = PreferenceMigration(appContext) migration.migrate(clearSharedPrefs = false) - snackHostState.showSnackbar("迁移完成,应用将重启") + snack.showSnackbar("迁移完成,应用将重启") delay(1000) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderPage.kt deleted file mode 100644 index f30d70e..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderPage.kt +++ /dev/null @@ -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 androidx.compose.ui.platform.LocalContext -import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn -import io.github.miuzarte.scrcpyforandroid.storage.Storage -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, -) { - val appSettings = Storage.appSettings - - val context = LocalContext.current - - var previewVirtualButtonShowText by appSettings.previewVirtualButtonShowText.asMutableState() - var virtualButtonsLayout by appSettings.virtualButtonsLayout.asMutableState() - var buttonItems by remember(virtualButtonsLayout) { - mutableStateOf(VirtualButtonActions.parseStoredLayout(virtualButtonsLayout)) - } - - AppPageLazyColumn( - contentPadding = contentPadding, - scrollBehavior = scrollBehavior, - ) { - // 按钮显示文本开关 - item { - Card { - SuperSwitch( - title = "按钮显示文本", - summary = "超过3个建议关闭,只对预览卡下方的虚拟按钮生效", - checked = previewVirtualButtonShowText, - onCheckedChange = { 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)) - } - virtualButtonsLayout = VirtualButtonActions.encodeStoredLayout(buttonItems) - }, - showCheckbox = true, - onCheckboxChange = { id, checked -> - buttonItems = buttonItems.map { item -> - if (item.action.id == id) { - item.copy(showOutside = checked) - } else { - item - } - } - virtualButtonsLayout = VirtualButtonActions.encodeStoredLayout(buttonItems) - }, - )() - } - } -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt new file mode 100644 index 0000000..ba3a8ba --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt @@ -0,0 +1,156 @@ +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.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 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 { + scope.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) + ) + }, + )() + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/PageLayouts.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/LazyColumn.kt similarity index 98% rename from app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/PageLayouts.kt rename to app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/LazyColumn.kt index f884751..4e781a0 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/PageLayouts.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/LazyColumn.kt @@ -20,7 +20,7 @@ import top.yukonga.miuix.kmp.basic.ScrollBehavior import top.yukonga.miuix.kmp.utils.overScrollVertical @Composable -fun AppPageLazyColumn( +fun LazyColumn( contentPadding: PaddingValues, scrollBehavior: ScrollBehavior, modifier: Modifier = Modifier, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt index f202b08..04f7a7d 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt @@ -3,7 +3,6 @@ package io.github.miuzarte.scrcpyforandroid.scaffolds import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable @@ -13,12 +12,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import top.yukonga.miuix.kmp.basic.ButtonDefaults import top.yukonga.miuix.kmp.basic.Slider import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.TextButton -import top.yukonga.miuix.kmp.basic.TextField import top.yukonga.miuix.kmp.extra.SuperArrow import top.yukonga.miuix.kmp.extra.SuperDialog import top.yukonga.miuix.kmp.theme.MiuixTheme @@ -40,7 +37,9 @@ fun SuperSlider( displayFormatter: (Float) -> String = { it.toInt().toString() }, displayText: String? = null, inputTitle: String = title, - inputHint: String = unit, + inputSummary: String = summary, + inputLabel: String = unit, + useLabelAsPlaceholder: Boolean = false, inputInitialValue: String = displayFormatter(value), inputFilter: (String) -> String = { text -> text.filter { it.isDigit() || it == '.' } }, inputValueRange: ClosedFloatingPointRange? = null, @@ -60,7 +59,8 @@ fun SuperSlider( endActions = { val isZeroState = value == 0f && zeroStateText != null val valueText = - if (isZeroState) zeroStateText else (displayText ?: displayFormatter(value)) + if (isZeroState) zeroStateText + else (displayText ?: displayFormatter(value)) val shouldShowUnit = unit.isNotBlank() && (!isZeroState || showUnitWhenZeroState) val text = if (shouldShowUnit) "$valueText $unit" else valueText Text( @@ -86,7 +86,9 @@ fun SuperSlider( SliderInputDialog( showDialog = showInputDialog, title = inputTitle, - summary = inputHint, + summary = inputSummary, + label = inputLabel, + useLabelAsPlaceholder = useLabelAsPlaceholder, initialValue = inputInitialValue, inputFilter = inputFilter, inputValueRange = inputValueRange ?: valueRange, @@ -104,6 +106,8 @@ private fun SliderInputDialog( showDialog: Boolean, title: String, summary: String, + label: String = "", + useLabelAsPlaceholder: Boolean = false, initialValue: String, inputFilter: (String) -> String, inputValueRange: ClosedFloatingPointRange, @@ -119,16 +123,18 @@ private fun SliderInputDialog( onDismissFinished = onDismissFinished, content = { var text by remember(initialValue) { mutableStateOf(initialValue) } - - TextField( + + SuperTextField( modifier = Modifier.padding(bottom = 16.dp), value = text, + label = label, + useLabelAsPlaceholder = useLabelAsPlaceholder, maxLines = 1, onValueChange = { newValue -> text = inputFilter(newValue) }, ) - + Row(horizontalArrangement = Arrangement.SpaceBetween) { TextButton( text = "取消", diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/TouchEventHandler.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/TouchEventHandler.kt new file mode 100644 index 0000000..fc3cfad --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/TouchEventHandler.kt @@ -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, + private val activePointerPositions: LinkedHashMap, + private val activePointerDevicePositions: LinkedHashMap>, + private val pointerLabels: LinkedHashMap, + 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(10) + private val eventPositions = HashMap(10) + private val eventPressures = HashMap(10) + private val justPressedPointerIds = HashSet(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 { + 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, 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) + } + } + +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AdbClientData.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AdbClientData.kt index 5b92a29..fbce077 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AdbClientData.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AdbClientData.kt @@ -1,7 +1,11 @@ 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 { @@ -17,4 +21,31 @@ class AdbClientData(context: Context) : Settings(context, "AdbClient") { 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 = 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)) + } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt index e0f4f8b..e0ee263 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt @@ -1,9 +1,13 @@ 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 kotlinx.coroutines.flow.StateFlow +import kotlinx.parcelize.Parcelize class AppSettings(context: Context) : Settings(context, "AppSettings") { companion object { @@ -87,6 +91,71 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { 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 keepScreenOnWhenStreaming: Boolean, + val devicePreviewCardHeightDp: Int, + val previewVirtualButtonShowText: Boolean, + val virtualButtonsLayout: String, + val customServerUri: 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(KEEP_SCREEN_ON_WHEN_STREAMING) { bundle: Bundle -> bundle.keepScreenOnWhenStreaming }, + 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(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 = 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), + keepScreenOnWhenStreaming = preferences.read(KEEP_SCREEN_ON_WHEN_STREAMING), + 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), + 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 } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt index ebba540..bc0af4b 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt @@ -1,7 +1,11 @@ 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 { @@ -17,4 +21,31 @@ class QuickDevices(context: Context) : Settings(context, "QuickDevices") { 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 = 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)) + } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt index cdc896a..4075e9e 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt @@ -1,6 +1,8 @@ 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 @@ -16,7 +18,9 @@ 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 { @@ -102,11 +106,11 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { ) val VIDEO_BIT_RATE = Pair( intPreferencesKey("video_bit_rate"), - 8000000, + 0, ) val AUDIO_BIT_RATE = Pair( intPreferencesKey("audio_bit_rate"), - 128000, + 0, ) val MAX_FPS = Pair( stringPreferencesKey("max_fps"), @@ -288,6 +292,186 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { 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 = 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() @@ -296,59 +480,61 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { } // TODO: 处理空值 - suspend fun toClientOptions() = ClientOptions( - crop = crop.get(), - recordFilename = recordFilename.get(), - videoCodecOptions = videoCodecOptions.get(), - audioCodecOptions = audioCodecOptions.get(), - videoEncoder = videoEncoder.get(), - audioEncoder = audioEncoder.get(), - cameraId = cameraId.get(), - cameraSize = if (!cameraSizeUseCustom.get()) cameraSize.get() else cameraSizeCustom.get(), - cameraAr = cameraAr.get(), - cameraFps = cameraFps.get().toUShort(), - logLevel = LogLevel.valueOf(logLevel.get().uppercase()), - videoCodec = Codec.fromString(videoCodec.get()), - audioCodec = Codec.fromString(audioCodec.get()), - videoSource = VideoSource.fromString(videoSource.get()), - audioSource = AudioSource.fromString(audioSource.get()), - recordFormat = ClientOptions.RecordFormat.valueOf(recordFormat.get().uppercase()), - cameraFacing = CameraFacing.fromString(cameraFacing.get()), - maxSize = maxSize.get().toUShort(), - videoBitRate = videoBitRate.get(), - audioBitRate = audioBitRate.get(), - maxFps = maxFps.get(), - angle = angle.get(), - captureOrientation = Orientation.fromInt(captureOrientation.get()), + 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( - captureOrientationLock.get().uppercase() + bundle.captureOrientationLock.uppercase() ), - displayOrientation = Orientation.fromInt(displayOrientation.get()), - recordOrientation = Orientation.fromInt(recordOrientation.get()), - displayImePolicy = DisplayImePolicy.valueOf(displayImePolicy.get().uppercase()), - displayId = displayId.get(), - screenOffTimeout = Tick(screenOffTimeout.get()), - showTouches = showTouches.get(), - fullscreen = fullscreen.get(), - control = control.get(), - videoPlayback = videoPlayback.get(), - audioPlayback = audioPlayback.get(), - turnScreenOff = turnScreenOff.get(), - stayAwake = stayAwake.get(), - disableScreensaver = disableScreensaver.get(), - powerOffOnClose = powerOffOnClose.get(), - cleanUp = cleanup.get(), - powerOn = powerOn.get(), - video = video.get(), - audio = audio.get(), - requireAudio = requireAudio.get(), - killAdbOnClose = killAdbOnClose.get(), - cameraHighSpeed = cameraHighSpeed.get(), - list = ListOptions.valueOf(list.get().uppercase()), - audioDup = audioDup.get(), - newDisplay = newDisplay.get(), - startApp = startApp.get(), - vdDestroyContent = vdDestroyContent.get(), - vdSystemDecorations = vdSystemDecorations.get() + 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 ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt index faf3e5d..4485978 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt @@ -18,10 +18,15 @@ 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" @@ -34,6 +39,8 @@ abstract class Settings( emptyPreferences() } ) { + private val settingsScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + data class Pair( val key: Preferences.Key, val defaultValue: T, @@ -41,6 +48,24 @@ abstract class Settings( val name: String get() = key.name } + protected fun Preferences.read(pair: Pair): T = + this[pair.key] ?: pair.defaultValue + + protected interface BundleField { + suspend fun persist(settings: Settings, current: B, new: B) + } + + protected fun bundleField(pair: Pair, selector: (B) -> T): BundleField = + object : BundleField { + 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 方法 */ @@ -90,6 +115,28 @@ abstract class Settings( protected fun setting(pair: Pair) = SettingProperty(pair) + protected fun createBundleState(reader: (Preferences) -> B): StateFlow = + dataStore.data + .map(reader) + .stateIn( + scope = settingsScope, + started = SharingStarted.Eagerly, + initialValue = runBlocking { loadBundle(reader) } + ) + + protected suspend fun loadBundle(reader: (Preferences) -> B): B = + reader(dataStore.data.first()) + + protected suspend fun saveBundle( + current: B, + new: B, + fields: Array>, + ) { + for (field in fields) { + field.persist(this, current, new) + } + } + protected suspend fun getValue(pair: Pair): T = dataStore.data.first()[pair.key] ?: pair.defaultValue @@ -123,4 +170,8 @@ abstract class Settings( } } } + + companion object { + val BUNDLE_SAVE_DELAY = 100.milliseconds + } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt index 95a9881..7bd35bc 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt @@ -2,8 +2,6 @@ package io.github.miuzarte.scrcpyforandroid.widgets import android.annotation.SuppressLint import android.graphics.SurfaceTexture -import android.util.Log -import android.view.MotionEvent import android.view.Surface import android.view.TextureView import androidx.activity.compose.BackHandler @@ -38,12 +36,15 @@ import androidx.compose.material.icons.rounded.Fullscreen import androidx.compose.material.icons.rounded.LinkOff import androidx.compose.material.icons.rounded.Wifi import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf 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.Alignment @@ -54,7 +55,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInteropFilter import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -70,14 +70,16 @@ import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut -import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts 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.scrcpy.Shared.Codec +import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler +import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Storage -import kotlinx.coroutines.CoroutineScope +import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import top.yukonga.miuix.kmp.basic.Button @@ -86,6 +88,7 @@ import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.CardDefaults import top.yukonga.miuix.kmp.basic.CircularProgressIndicator import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.SnackbarHostState import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.TextButton import top.yukonga.miuix.kmp.basic.TextField @@ -98,17 +101,6 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme.isDynamicColor import top.yukonga.miuix.kmp.utils.PressFeedbackType import kotlin.math.roundToInt -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 const val FULLSCREEN_TOUCH_LOG_TAG = "FullscreenTouch" - @Composable internal fun StatusCard( statusLine: String, @@ -119,10 +111,8 @@ internal fun StatusCard( connectedDeviceLabel: String, ) { val appSettings = Storage.appSettings - - val context = LocalContext.current - - val themeBaseIndex by appSettings.themeBaseIndex.asState() + val appSettingsBundle by appSettings.bundleState.collectAsState() + val themeBaseIndex = appSettingsBundle.themeBaseIndex val cleanStatusLine = normalizeStatusLine(statusLine) @@ -359,8 +349,10 @@ internal fun VirtualButtonCard( @Composable internal fun ConfigPanel( busy: Boolean, + snack: SnackbarHostState, audioForwardingSupported: Boolean, cameraMirroringSupported: Boolean, + isQuickConnected: Boolean, onOpenAdvanced: () -> Unit, onStartStopHaptic: (() -> Unit)? = null, onStart: () -> Unit, @@ -368,49 +360,60 @@ internal fun ConfigPanel( sessionInfo: Scrcpy.Session.SessionInfo?, onDisconnect: () -> Unit = {}, ) { - val scrcpyOptions = Storage.scrcpyOptions - val quickDevices = Storage.quickDevices - - val context = LocalContext.current + val scope = rememberCoroutineScope() val sessionStarted = sessionInfo != null - - // Check if device exists in shortcuts - var quickDevicesList by quickDevices.quickDevicesList.asMutableState() - val savedShortcuts = remember(quickDevicesList) { - DeviceShortcuts.unmarshalFrom(quickDevicesList) + + val soBundleShared by scrcpyOptions.bundleState.collectAsState() + val soBundleSharedLatest by rememberUpdatedState(soBundleShared) + var soBundle by rememberSaveable(soBundleShared) { mutableStateOf(soBundleShared) } + val soBundleLatest by rememberUpdatedState(soBundle) + LaunchedEffect(soBundleShared) { + if (soBundle != soBundleShared) { + soBundle = soBundleShared + } } - val isQuickConnected = remember(sessionInfo, savedShortcuts) { - sessionInfo?.let { info -> - savedShortcuts.get(info.host, info.port) == null - } ?: false + LaunchedEffect(soBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (soBundle != soBundleSharedLatest) { + scrcpyOptions.saveBundle(soBundle) + } + } + DisposableEffect(Unit) { + onDispose { + scope.launch { + scrcpyOptions.saveBundle(soBundleLatest) + } + } } - var audio by scrcpyOptions.audio.asMutableState() + val audioBitRateVisibility = rememberSaveable(soBundle) { + soBundle.audio && (soBundle.audioCodec == "opus" || soBundle.audioCodec == "aac") + } - var audioCodec by scrcpyOptions.audioCodec.asMutableState() - val audioCodecItems = remember { Codec.AUDIO.map { it.displayName } } - val audioCodecIndex = Codec.AUDIO.indexOfFirst { - it.string == audioCodec - }.coerceAtLeast(0) - var audioBitRate by scrcpyOptions.audioBitRate.asMutableState() + val audioCodecItems = rememberSaveable { Codec.AUDIO.map { it.displayName } } + val audioCodecIndex = rememberSaveable(soBundle) { + Codec.AUDIO + .indexOfFirst { it.string == soBundle.audioCodec } + .coerceAtLeast(0) + } - var videoCodec by scrcpyOptions.videoCodec.asMutableState() - val videoCodecItems = remember { Codec.VIDEO.map { it.displayName } } - val videoCodecIndex = Codec.VIDEO.indexOfFirst { - it.string == videoCodec - }.coerceAtLeast(0) - var videoBitRate by scrcpyOptions.videoBitRate.asMutableState() - val videoBitRateMbps = videoBitRate / 1_000_000f + val videoCodecItems = rememberSaveable { Codec.VIDEO.map { it.displayName } } + val videoCodecIndex = rememberSaveable(soBundle) { + Codec.VIDEO + .indexOfFirst { it.string == soBundle.videoCodec } + .coerceAtLeast(0) + } SectionSmallTitle("Scrcpy") Card { SuperSwitch( title = "音频转发", summary = "转发设备音频到本机 (Android 11+)", - checked = audio, - onCheckedChange = { audio = it }, - enabled = !sessionStarted && audioForwardingSupported, + checked = soBundle.audio, + onCheckedChange = { soBundle = soBundle.copy(audio = it) }, + enabled = !sessionStarted + && audioForwardingSupported, ) SuperDropdown( @@ -418,28 +421,47 @@ internal fun ConfigPanel( summary = "--audio-codec", items = audioCodecItems, selectedIndex = audioCodecIndex, - onSelectedIndexChange = { audioCodec = Codec.AUDIO[it].string }, - enabled = !sessionStarted && audio, + onSelectedIndexChange = { + val codec = Codec.AUDIO[it] + soBundle = soBundle.copy(audioCodec = codec.string) + if (codec == Codec.FLAC) { + scope.launch { + snack.showSnackbar("注意:FLAC编解码会引入较大的延迟") + } + } + }, + enabled = !sessionStarted && soBundle.audio, ) - AnimatedVisibility(audio && (audioCodec == "opus" || audioCodec == "aac")) { + AnimatedVisibility(audioBitRateVisibility) { SuperSlider( title = "音频码率", summary = "--audio-bit-rate", - value = ScrcpyPresets.AudioBitRate.indexOfOrNearest(audioBitRate / 1000).toFloat(), + value = if (soBundle.audioBitRate <= 0) 0f + else (ScrcpyPresets.AudioBitRate + .indexOfOrNearest(soBundle.audioBitRate / 1000) + 1 + ).toFloat(), onValueChange = { value -> - val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex) - audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1000 + val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.size) + soBundle = soBundle.copy( + audioBitRate = + if (idx == 0) 0 + else ScrcpyPresets.AudioBitRate[idx - 1] * 1000 + ) }, - valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(), - steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0), - enabled = !sessionStarted, + valueRange = 0f..ScrcpyPresets.AudioBitRate.size.toFloat(), + steps = (ScrcpyPresets.AudioBitRate.size - 1).coerceAtLeast(0), unit = "Kbps", - displayText = (audioBitRate / 1000).toString(), - inputInitialValue = (audioBitRate / 1000).toString(), + zeroStateText = "默认", + displayText = (soBundle.audioBitRate / 1_000).toString(), + inputInitialValue = + if (soBundle.audioBitRate <= 0) "" + else (soBundle.audioBitRate / 1_000).toString(), inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 1f..UShort.MAX_VALUE.toFloat(), + inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), onInputConfirm = { raw -> - raw.toIntOrNull()?.takeIf { it > 0 }?.let { audioBitRate = it * 1000 } + raw.toIntOrNull() + ?.takeIf { it >= 0 } + ?.let { soBundle = soBundle.copy(audioBitRate = it * 1000) } }, ) } @@ -449,22 +471,35 @@ internal fun ConfigPanel( summary = "--video-codec", items = videoCodecItems, selectedIndex = videoCodecIndex, - onSelectedIndexChange = { videoCodec = Codec.VIDEO[it].string }, + onSelectedIndexChange = { + val codec = Codec.VIDEO[it] + soBundle = soBundle.copy(videoCodec = codec.string) + if (codec == Codec.AV1) { + scope.launch { + snack.showSnackbar("注意:绝大部分设备不支持AV1硬件编码") + } + } + }, enabled = !sessionStarted, ) + @SuppressLint("DefaultLocale") SuperSlider( title = "视频码率", summary = "--video-bit-rate", - value = videoBitRateMbps, + value = soBundle.videoBitRate / 1_000_000f, onValueChange = { mbps -> - videoBitRate = (mbps * 1_000_000).toInt() + soBundle = soBundle.copy( + videoBitRate = (mbps * 10).roundToInt() * (1_000_000 / 10) + ) }, - valueRange = 0.1f..40f, - steps = 399, - enabled = !sessionStarted, + valueRange = 0f..40f, + steps = 400 - 1, unit = "Mbps", - displayFormatter = { formatBitRate(it) }, - inputInitialValue = formatBitRate(videoBitRateMbps), + zeroStateText = "默认", + displayFormatter = { String.format("%.1f", it) }, + inputInitialValue = + if (soBundle.videoBitRate <= 0) "" + else String.format("%.1f", soBundle.videoBitRate / 1_000_000f), inputFilter = { text -> var dotUsed = false text.filter { ch -> @@ -479,14 +514,17 @@ internal fun ConfigPanel( } } }, - inputValueRange = 0.1f..UInt.MAX_VALUE.toFloat(), + inputValueRange = 0f..UInt.MAX_VALUE.toFloat(), onInputConfirm = { raw -> raw.toFloatOrNull()?.let { parsed -> - if (parsed >= 0.1f) { - videoBitRate = (parsed * 1_000_000).toInt() + if (parsed >= 0f) { + soBundle = soBundle.copy( + videoBitRate = (parsed * 1_000_000f).roundToInt() + ) } } }, + enabled = !sessionStarted, ) SuperArrow( @@ -503,53 +541,34 @@ internal fun ConfigPanel( .padding(bottom = UiSpacing.CardContent), horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), ) { - AnimatedVisibility(!isQuickConnected) { - AnimatedVisibility(!sessionStarted) { - TextButton( - text = "启动", - onClick = { - onStartStopHaptic?.invoke() - onStart() - }, - modifier = Modifier.fillMaxWidth(), - enabled = !busy, - colors = ButtonDefaults.textButtonColorsPrimary(), - ) - } - AnimatedVisibility(sessionStarted) { - TextButton( - text = "停止", - onClick = { - onStartStopHaptic?.invoke() - onStop() - }, - modifier = Modifier.fillMaxWidth(), - enabled = !busy, - ) - } - } - // display them at the same time in quick connection - AnimatedVisibility(isQuickConnected) { - TextButton( - text = "断开", - onClick = { - onStartStopHaptic?.invoke() - onDisconnect() - }, - modifier = Modifier.weight(1f/4f), - enabled = !busy, - ) - TextButton( - text = "启动", - onClick = { - onStartStopHaptic?.invoke() - onStart() - }, - modifier = Modifier.weight(3f/4f), - enabled = !busy, - colors = ButtonDefaults.textButtonColorsPrimary(), - ) - } + if (isQuickConnected) TextButton( + text = "断开", + onClick = { + onStartStopHaptic?.invoke() + onDisconnect() + }, + modifier = Modifier.weight(1f / 4f), + enabled = !busy, + ) + if (!sessionStarted) TextButton( + text = "启动", + onClick = { + onStartStopHaptic?.invoke() + onStart() + }, + modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f), + enabled = !busy, + colors = ButtonDefaults.textButtonColorsPrimary(), + ) + if (sessionStarted) TextButton( + text = "停止", + onClick = { + onStartStopHaptic?.invoke() + onStop() + }, + modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f), + enabled = !busy, + ) } } } @@ -694,21 +713,6 @@ private fun PairingDialog( ) } -@SuppressLint("DefaultLocale") -private fun formatBitRate(value: Float): String = String.format("%.1f", value) - -@Composable -internal fun LogsPanel(lines: List) { - Card { - TextField( - value = lines.joinToString(separator = "\n"), - onValueChange = {}, - readOnly = true, - modifier = Modifier.fillMaxWidth(), - ) - } -} - /** * TouchEventHandler * @@ -716,256 +720,6 @@ internal fun LogsPanel(lines: List) { * - Handles touch event processing for fullscreen control screen * - Manages pointer tracking, coordinate mapping, and touch injection */ -class TouchEventHandler( - private val coroutineScope: CoroutineScope, - private val session: Scrcpy.Session.SessionInfo, - private val touchAreaSize: IntSize, - private val activePointerIds: LinkedHashSet, - private val activePointerPositions: LinkedHashMap, - private val activePointerDevicePositions: LinkedHashMap>, - private val pointerLabels: LinkedHashMap, - 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, -) { - private val eventPointerIds = HashSet(10) - private val eventPositions = HashMap(10) - private val eventPressures = HashMap(10) - private val justPressedPointerIds = HashSet(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 { - 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, 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) - } - } - -} /** * FullscreenControlScreen @@ -1121,14 +875,22 @@ private fun ScrcpyVideoSurface( nativeCore: NativeCoreFacade, session: Scrcpy.Session.SessionInfo?, ) { - val surfaceTag = "video-main" var currentSurface by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() LaunchedEffect(session, currentSurface) { val surface = currentSurface if (session != null && surface != null && surface.isValid) { - nativeCore.registerVideoSurface(surfaceTag, surface) + nativeCore.attachVideoSurface(surface) + } + } + + DisposableEffect(Unit) { + onDispose { + val surface = currentSurface + if (surface != null) { + scope.launch { nativeCore.detachVideoSurface(surface, releaseDecoder = false) } + } } } @@ -1148,7 +910,7 @@ private fun ScrcpyVideoSurface( // Register immediately when surface becomes available if (session != null) { scope.launch { - nativeCore.registerVideoSurface(surfaceTag, newSurface) + nativeCore.attachVideoSurface(newSurface) } } } @@ -1160,9 +922,15 @@ private fun ScrcpyVideoSurface( ) = Unit override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { - // Return false to keep the SurfaceTexture alive - // This prevents the surface from being destroyed when the view is detached - return false + val surface = currentSurface + if (surface != null) { + scope.launch { + nativeCore.detachVideoSurface(surface, releaseDecoder = false) + } + surface.release() + currentSurface = null + } + return true } override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) = Unit From 6c1564f4619b28fba1969fe4d2f5989bf14ce913 Mon Sep 17 00:00:00 2001 From: Miuzarte <982809597@qq.com> Date: Fri, 10 Apr 2026 00:12:35 +0800 Subject: [PATCH 7/8] refactor: improve Snackbar functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增强 Codec 枚举,增加 type 和 lossless 属性以改进编解码器管理 - 引入 SnackbarController - 改进 Settings.kt 中的设置处理,优化状态管理 - 增强 DeviceWidgets,支持编辑设备详情并改进 UI - 重构 ReorderableList 和 VirtualButtons,优化布局和间距 - 重构 Scrpcy 编码器获取逻辑 --- .../scrcpyforandroid/NativeCoreFacade.kt | 51 +- .../constants/ScrcpyPresets.kt | 2 +- .../scrcpyforandroid/constants/ThemeModes.kt | 16 + .../scrcpyforandroid/constants/UiSpacing.kt | 8 +- .../scrcpyforandroid/models/DeviceModels.kt | 16 +- .../nativecore/ScrcpyAudioPlayer.kt | 13 +- .../pages/AdvancedConfigPage.kt | 1068 --------------- .../{DevicePage.kt => DeviceTabScreen.kt} | 435 +++---- .../pages/FullscreenControlScreen.kt | 5 +- .../scrcpyforandroid/pages/MainScreen.kt | 164 ++- .../pages/ReorderDevicesScreen.kt | 8 +- .../pages/ScrcpyAllOptionsScreen.kt | 1153 +++++++++++++++++ .../scrcpyforandroid/pages/SettingsPage.kt | 433 ------- .../scrcpyforandroid/pages/SettingsScreen.kt | 468 +++++++ .../pages/VirtualButtonOrderScreen.kt | 6 +- .../scrcpyforandroid/scaffolds/SuperSlider.kt | 3 +- .../scrcpyforandroid/scrcpy/ClientOptions.kt | 35 +- .../scrcpyforandroid/scrcpy/Scrcpy.kt | 602 +++++---- .../scrcpyforandroid/scrcpy/ServerParams.kt | 2 +- .../scrcpyforandroid/scrcpy/Shared.kt | 42 +- .../services/QuickDeviceStore.kt | 64 - .../services/SnackbarController.kt | 31 + .../scrcpyforandroid/storage/AppSettings.kt | 11 +- .../scrcpyforandroid/storage/Settings.kt | 3 +- .../scrcpyforandroid/widgets/DeviceWidgets.kt | 572 ++++---- .../widgets/ReorderableList.kt | 48 +- .../widgets/VirtualButtons.kt | 2 +- 27 files changed, 2715 insertions(+), 2546 deletions(-) create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ThemeModes.kt delete mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt rename app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/{DevicePage.kt => DeviceTabScreen.kt} (77%) create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt delete mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt delete mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/SnackbarController.kt diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt index 14e5f21..d5e7b1e 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt @@ -8,6 +8,7 @@ import android.view.Surface import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder import io.github.miuzarte.scrcpyforandroid.nativecore.PersistentVideoRenderer import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.util.ArrayDeque @@ -133,8 +134,8 @@ class NativeCoreFacade private constructor() { videoFpsListeners.remove(listener) } - suspend fun scrcpyBackOrScreenOn(action: Int = 0) { - session?.pressBackOrScreenOn(action) + suspend fun scrcpyBackOrTurnScreenOn(action: Int = 0) { + session?.pressBackOrTurnScreenOn(action) } /** @@ -200,6 +201,7 @@ class NativeCoreFacade private constructor() { @Volatile private var instance: NativeCoreFacade? = null + // TODO ??? fun get(context: Context): NativeCoreFacade { return instance ?: synchronized(this) { instance ?: NativeCoreFacade().also { instance = it } @@ -252,16 +254,19 @@ class NativeCoreFacade private constructor() { decoder = null Log.i( TAG, - "createOrReplaceDecoder(): codec=${session.codecName} size=${session.width}x${session.height} persistent=true" + "createOrReplaceDecoder(): " + + "codec=${session.codec?.string ?: "null"}, " + + "size=${session.width}x${session.height}, " + + "persistent=true" ) val newDecoder = AnnexBDecoder( width = session.width, height = session.height, outputSurface = surface, - mimeType = when (session.codecName.lowercase()) { - "h264" -> "video/avc" - "h265" -> "video/hevc" - "av1" -> "video/av01" + mimeType = when (session.codec) { + Codec.H264 -> "video/avc" + Codec.H265 -> "video/hevc" + Codec.AV1 -> "video/av01" else -> "video/avc" }, onOutputSizeChanged = { width, height -> @@ -334,38 +339,6 @@ class NativeCoreFacade private constructor() { } } - /** - * 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. - * - Kept only for documentation parity with the old multi-decoder design. - */ - @Deprecated("TODO: Determine if this is really unnecessary") - private suspend fun ensureVideoConsumerAttached(sessionMgr: Scrcpy.Session) { - 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 releaseAllDecoders() { runCatching { decoder?.release() } decoder = null diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt index 5e01a6a..4c4bf47 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt @@ -58,6 +58,6 @@ fun Preset.indexOfOrNearest(raw: String): Int { 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(32, 64, 96, 128, 160, 192, 256, 320, 384, 512)) // Kbps + 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 } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ThemeModes.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ThemeModes.kt new file mode 100644 index 0000000..5312e96 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ThemeModes.kt @@ -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), + ) +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiSpacing.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiSpacing.kt index c7ee1de..6535117 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiSpacing.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiSpacing.kt @@ -16,8 +16,10 @@ object UiSpacing { val SectionTitleTop = 12.dp val SectionTitleBottom = 6.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 BottomContent = 64.dp - val BottomSheetBottom = 16.dp + val PageBottom = 64.dp + val SheetBottom = 16.dp } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt index d857f54..6e1f9ee 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt @@ -38,7 +38,6 @@ class DeviceShortcuts(val devices: List) : List host: String? = null, port: Int? = null, name: String? = null, - online: Boolean? = null, newPort: Int? = null, updateNameOnlyWhenEmpty: Boolean = false, ): DeviceShortcuts { @@ -48,6 +47,7 @@ class DeviceShortcuts(val devices: List) : List if (idx < 0) return this val old = devices[idx] + val updateById = id != null // 确定最终的属性值 val finalName = when { @@ -55,23 +55,24 @@ class DeviceShortcuts(val devices: List) : List updateNameOnlyWhenEmpty && old.name.isNotBlank() -> old.name else -> name } - val finalPort = newPort ?: old.port - val finalOnline = online ?: old.online + 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 && finalPort == old.port && finalOnline == old.online) + if (finalName == old.name && finalHost == old.host && finalPort == old.port) return this val newList = devices.toMutableList().apply { this[idx] = DeviceShortcut( name = finalName, - host = old.host, + host = finalHost, port = finalPort, - online = finalOnline ) } return DeviceShortcuts( - if (newPort != null && newPort != old.port) + if ((updateById && (finalHost != old.host || finalPort != old.port)) + || (newPort != null && newPort != old.port) + ) newList.distinctBy { it.id } else newList ) @@ -118,7 +119,6 @@ data class DeviceShortcut( val name: String = "", val host: String, val port: Int = Defaults.ADB_PORT, - val online: Boolean = false, ) { val id: String get() = "$host:$port" diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpyAudioPlayer.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpyAudioPlayer.kt index e5d07fa..ea60306 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpyAudioPlayer.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpyAudioPlayer.kt @@ -9,6 +9,7 @@ import android.media.AudioTrack import android.media.MediaCodec import android.media.MediaFormat import android.util.Log +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec import java.nio.ByteBuffer import java.nio.ByteOrder @@ -42,9 +43,9 @@ class ScrcpyAudioPlayer(private val codecId: Int) { }" ) when (codecId) { - AUDIO_CODEC_OPUS -> prepareOpus(data) - AUDIO_CODEC_AAC -> prepareAac(data) - AUDIO_CODEC_FLAC -> prepareFlac(data) + Codec.OPUS.id -> prepareOpus(data) + Codec.AAC.id -> prepareAac(data) + Codec.FLAC.id -> prepareFlac(data) // RAW has no config packet } return @@ -55,7 +56,7 @@ class ScrcpyAudioPlayer(private val codecId: Int) { 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) return } @@ -212,10 +213,6 @@ class ScrcpyAudioPlayer(private val codecId: Int) { companion object { private const val TAG = "ScrcpyAudioPlayer" - const val AUDIO_CODEC_OPUS = 0x6f707573 - const val AUDIO_CODEC_AAC = 0x00616163 - const val AUDIO_CODEC_FLAC = 0x666c6163 - const val AUDIO_CODEC_RAW = 0x00726177 private const val SAMPLE_RATE = 48000 private const val CHANNELS = 2 private const val CODEC_TIMEOUT_US = 10_000L diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt deleted file mode 100644 index 4fd720f..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt +++ /dev/null @@ -1,1068 +0,0 @@ -package io.github.miuzarte.scrcpyforandroid.pages - -import android.annotation.SuppressLint -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.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.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.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.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.models.ScrcpyOptions.Crop -import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.NewDisplay -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.scrcpy.Shared -import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec -import io.github.miuzarte.scrcpyforandroid.storage.Settings -import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions -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.SnackbarHost -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.basic.TopAppBar -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 - -@Composable -internal fun AdvancedConfigScreen( - onBack: () -> Unit, - scrollBehavior: ScrollBehavior, - snack: SnackbarHostState, - scrcpy: Scrcpy, -) { - Scaffold( - topBar = { - TopAppBar( - title = "高级参数", - navigationIcon = { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "返回" - ) - } - }, - scrollBehavior = scrollBehavior, - ) - }, - snackbarHost = { SnackbarHost(snack) }, - ) { contentPadding -> - AdvancedConfigPage( - contentPadding = contentPadding, - scrollBehavior = scrollBehavior, - snack = snack, - scrcpy = scrcpy, - ) - } -} - -@Composable -internal fun AdvancedConfigPage( - contentPadding: PaddingValues, - scrollBehavior: ScrollBehavior, - snack: SnackbarHostState, - scrcpy: Scrcpy, -) { - val focusManager = LocalFocusManager.current - val scope = rememberCoroutineScope() - - var refreshBusy by rememberSaveable { mutableStateOf(false) } - - val soBundleShared by scrcpyOptions.bundleState.collectAsState() - val soBundleSharedLatest by rememberUpdatedState(soBundleShared) - var soBundle by rememberSaveable(soBundleShared) { mutableStateOf(soBundleShared) } - val soBundleLatest by rememberUpdatedState(soBundle) - LaunchedEffect(soBundleShared) { - if (soBundle != soBundleShared) { - soBundle = soBundleShared - } - } - LaunchedEffect(soBundle) { - delay(Settings.BUNDLE_SAVE_DELAY) - if (soBundle != soBundleSharedLatest) { - scrcpyOptions.saveBundle(soBundle) - } - } - DisposableEffect(Unit) { - onDispose { - scope.launch { - scrcpyOptions.saveBundle(soBundleLatest) - } - } - } - - val audioCodecItems = rememberSaveable { Codec.AUDIO.map { it.displayName } } - val audioCodecIndex = rememberSaveable(soBundle) { - Codec.AUDIO - .indexOfFirst { it.string == soBundle.audioCodec } - .coerceAtLeast(0) - } - - val videoCodecItems = rememberSaveable { Codec.VIDEO.map { it.displayName } } - val videoCodecIndex = rememberSaveable(soBundle) { - Codec.VIDEO - .indexOfFirst { it.string == soBundle.videoCodec } - .coerceAtLeast(0) - } - - val videoSourceItems = rememberSaveable { Shared.VideoSource.entries.map { it.string } } - val videoSourceIndex = rememberSaveable(soBundle) { - Shared.VideoSource.entries - .indexOfFirst { it.string == soBundle.videoSource } - .coerceAtLeast(0) - } - - var displayIdInput by rememberSaveable(soBundle) { - mutableStateOf( - if (soBundle.displayId == -1) "" - else soBundle.displayId.toString() - ) - } - - var cameraIdInput by rememberSaveable(soBundle) { - mutableStateOf(soBundle.cameraId) - } - - val cameraFacingItems = rememberSaveable { - listOf("默认") + Shared.CameraFacing.entries - .drop(1) - .map { it.string } - } - val cameraFacingIndex = rememberSaveable(soBundle) { - if (soBundle.cameraFacing.isEmpty()) { - 0 - } else { - val idx = Shared.CameraFacing.entries - .indexOfFirst { it.string == soBundle.cameraFacing } - if (idx > 0) idx else 0 - } - } - - var cameraSizeCustomInput by rememberSaveable(soBundle) { - mutableStateOf(soBundle.cameraSizeCustom) - } - - val cameraSizeDropdownItems = rememberSaveable(scrcpy.cameraSizes) { - listOf("自动", "自定义") + scrcpy.cameraSizes - } - var cameraSizeDropdownIndex by rememberSaveable(soBundle, scrcpy.cameraSizes) { - mutableIntStateOf( - when { - soBundle.cameraSizeUseCustom -> 1 // "自定义" - soBundle.cameraSize.isEmpty() -> 0 // "自动" - soBundle.cameraSize in scrcpy.cameraSizes -> - scrcpy.cameraSizes.indexOf(soBundle.cameraSize) + 2 - - else -> 0 // 默认自动 - } - ) - } - - var cameraArInput by rememberSaveable(soBundle) { - mutableStateOf(soBundle.cameraAr) - } - - val cameraFpsPresetIndex = rememberSaveable(soBundle) { - ScrcpyPresets.CameraFps.indexOfOrNearest(soBundle.cameraFps) - } - - val audioSourceItems = rememberSaveable { - Shared.AudioSource.entries.map { it.string } - } - val audioSourceIndex = rememberSaveable(soBundle) { - Shared.AudioSource.entries - .indexOfFirst { it.string == soBundle.audioSource } - .coerceAtLeast(0) - } - - val maxSizePresetIndex = rememberSaveable(soBundle) { - ScrcpyPresets.MaxSize.indexOfOrNearest(soBundle.maxSize) - } - - val maxFpsPresetIndex = rememberSaveable(soBundle) { - ScrcpyPresets.MaxFPS.indexOfOrNearest(soBundle.maxFps.toIntOrNull() ?: 0) - } - - var videoCodecOptionsInput by rememberSaveable(soBundle) { - mutableStateOf(soBundle.videoCodecOptions) - } - - var audioCodecOptionsInput by rememberSaveable(soBundle) { - mutableStateOf(soBundle.audioCodecOptions) - } - - val videoEncoderDropdownItems = rememberSaveable(scrcpy.videoEncoders) { - listOf("") + scrcpy.videoEncoders - } - val videoEncoderIndex = rememberSaveable(soBundle, scrcpy.videoEncoders) { - (scrcpy.videoEncoders.indexOf(soBundle.videoEncoder) + 1).coerceAtLeast(0) - } - - val audioEncoderDropdownItems = rememberSaveable(scrcpy.audioEncoders) { - listOf("") + scrcpy.audioEncoders - } - val audioEncoderIndex = rememberSaveable(soBundle, scrcpy.audioEncoders) { - (scrcpy.audioEncoders.indexOf(soBundle.audioEncoder) + 1).coerceAtLeast(0) - } - - val videoEncoderEntries = videoEncoderDropdownItems.map { encoderName -> - if (encoderName == "") { - SpinnerEntry(title = "自动") - } else { - SpinnerEntry( - title = encoderName, - summary = scrcpy.videoEncoderTypes[encoderName], - ) - } - } - - val audioEncoderEntries = audioEncoderDropdownItems.map { encoderName -> - if (encoderName == "") { - SpinnerEntry(title = "自动") - } else { - SpinnerEntry( - title = encoderName, - summary = scrcpy.audioEncoderTypes[encoderName], - ) - } - } - - // [x][/] - val (ndWidth, ndHeight, ndDpi) = remember(soBundle) { - NewDisplay.parseFrom(soBundle.newDisplay) - } - var newDisplayWidthInput by rememberSaveable(soBundle) { - mutableStateOf(ndWidth?.toString() ?: "") - } - var newDisplayHeightInput by rememberSaveable(soBundle) { - mutableStateOf(ndHeight?.toString() ?: "") - } - var newDisplayDpiInput by rememberSaveable(soBundle) { - mutableStateOf(ndDpi?.toString() ?: "") - } - - // width:height:x:y - val (cWidth, cHeight, cX, cY) = remember(soBundle) { - Crop.parseFrom(soBundle.crop) - } - var cropWidthInput by rememberSaveable(soBundle) { - mutableStateOf(cWidth?.toString() ?: "") - } - var cropHeightInput by rememberSaveable(soBundle) { - mutableStateOf(cHeight?.toString() ?: "") - } - var cropXInput by rememberSaveable(soBundle) { - mutableStateOf(cX?.toString() ?: "") - } - var cropYInput by rememberSaveable(soBundle) { - mutableStateOf(cY?.toString() ?: "") - } - - var serverParamsPreview by rememberSaveable { mutableStateOf("") } - // 监听选项变化, 自动更新预览 - LaunchedEffect(soBundle) { - val clientOptions = scrcpyOptions.toClientOptions(soBundle) - - try { - clientOptions.validate() - } catch (e: IllegalArgumentException) { - snack.showSnackbar("Invalid options: ${e.message}") - return@LaunchedEffect - } - - serverParamsPreview = clientOptions - .toServerParams(0u) - .toList(simplify = true) - // improve readability using hard line breaks - .joinToString("\n") - } - - LazyColumn( - contentPadding = contentPadding, - scrollBehavior = scrollBehavior, - ) { - item { - Card { - TextField( - value = serverParamsPreview, - onValueChange = {}, - readOnly = true, - modifier = Modifier.fillMaxWidth(), - ) - } - } - - item { - Card { - SuperSwitch( - title = "启动后关闭屏幕", - summary = "--turn-screen-off", - checked = soBundle.turnScreenOff, - onCheckedChange = { - soBundle = soBundle.copy(turnScreenOff = it) - if (it) scope.launch { - // github.com/Genymobile/scrcpy/issues/3376 - // github.com/Genymobile/scrcpy/issues/4587 - // github.com/Genymobile/scrcpy/issues/5676 - snack.showSnackbar("注意:大部分设备在关闭屏幕后刷新率会降低/减半") - } - }, - ) - SuperSwitch( - title = "禁用控制", - summary = "--no-control", - checked = !soBundle.control, - onCheckedChange = { soBundle = soBundle.copy(control = !it) }, - // 拦不住同时点, 弃用 - // enabled = audio || video, - ) - SuperSwitch( - title = "禁用视频", - summary = "--no-video", - checked = !soBundle.video, - onCheckedChange = { soBundle = soBundle.copy(video = !it) }, - // enabled = audio || control, - ) - SuperSwitch( - title = "禁用音频", - summary = "--no-audio", - checked = !soBundle.audio, - onCheckedChange = { soBundle = soBundle.copy(audio = !it) }, - // enabled = control || video, - ) - } - } - - item { - Card { - SuperDropdown( - title = "音频编码", - summary = "--audio-codec", - items = audioCodecItems, - selectedIndex = audioCodecIndex, - onSelectedIndexChange = { - soBundle = soBundle.copy(audioCodec = Codec.AUDIO[it].string) - }, - ) - SuperSlider( - title = "音频码率", - summary = "--audio-bit-rate", - value = if (soBundle.audioBitRate <= 0) 0f - else (ScrcpyPresets.AudioBitRate - .indexOfOrNearest(soBundle.audioBitRate / 1000) + 1 - ).toFloat(), - onValueChange = { value -> - val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.size) - soBundle = soBundle.copy( - audioBitRate = - if (idx == 0) 0 - else ScrcpyPresets.AudioBitRate[idx - 1] * 1000 - ) - }, - valueRange = 0f..ScrcpyPresets.AudioBitRate.size.toFloat(), - steps = (ScrcpyPresets.AudioBitRate.size - 1).coerceAtLeast(0), - unit = "Kbps", - zeroStateText = "默认", - displayText = (soBundle.audioBitRate / 1_000).toString(), - inputInitialValue = - if (soBundle.audioBitRate <= 0) "" - else (soBundle.audioBitRate / 1_000).toString(), - inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), - onInputConfirm = { raw -> - raw.toIntOrNull() - ?.takeIf { it >= 0 } - ?.let { soBundle = soBundle.copy(audioBitRate = it * 1000) } - }, - ) - - SuperDropdown( - title = "视频编码", - summary = "--video-codec", - items = videoCodecItems, - selectedIndex = videoCodecIndex, - onSelectedIndexChange = { - soBundle = soBundle.copy(videoCodec = Codec.VIDEO[it].string) - }, - ) - @SuppressLint("DefaultLocale") - SuperSlider( - title = "视频码率", - summary = "--video-bit-rate", - value = soBundle.videoBitRate / 1_000_000f, - onValueChange = { mbps -> - soBundle = soBundle.copy( - videoBitRate = (mbps * 10).roundToInt() * (1_000_000 / 10) - ) - }, - valueRange = 0f..40f, - steps = 400 - 1, - unit = "Mbps", - zeroStateText = "默认", - displayFormatter = { String.format("%.1f", it) }, - inputInitialValue = - if (soBundle.videoBitRate <= 0) "" - else String.format("%.1f", soBundle.videoBitRate / 1_000_000f), - inputFilter = { text -> - var dotUsed = false - text.filter { ch -> - when { - ch.isDigit() -> true - ch == '.' && !dotUsed -> { - dotUsed = true - true - } - - else -> false - } - } - }, - inputValueRange = 0f..UInt.MAX_VALUE.toFloat(), - onInputConfirm = { raw -> - raw.toFloatOrNull()?.let { parsed -> - if (parsed >= 0f) { - soBundle = soBundle.copy( - videoBitRate = (parsed * 1_000_000f).roundToInt() - ) - } - } - }, - ) - } - } - - item { - Card { - SuperDropdown( - title = "视频来源", - summary = "--video-source", - items = videoSourceItems, - selectedIndex = videoSourceIndex, - onSelectedIndexChange = { - soBundle = soBundle.copy( - videoSource = Shared.VideoSource.entries[it].string - ) - }, - ) - AnimatedVisibility(soBundle.videoSource == "display") { - SuperTextField( - value = displayIdInput, - onValueChange = { displayIdInput = it }, - onFocusLost = { - soBundle = soBundle.copy(displayId = displayIdInput.toIntOrNull() ?: -1) - }, - label = "--display-id", - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - } - AnimatedVisibility(soBundle.videoSource == "camera") { - SuperTextField( - value = cameraIdInput, - onValueChange = { cameraIdInput = it }, - onFocusLost = { - soBundle = soBundle.copy(cameraId = cameraIdInput) - }, - label = "--camera-id", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - } - AnimatedVisibility(soBundle.videoSource == "camera") { - SuperArrow( - title = "重新获取 Camera Sizes", - summary = "--list-camera-sizes", - onClick = { - if (refreshBusy) return@SuperArrow - scope.launch { - refreshBusy = true - try { - scrcpy.refreshCameraSizes() - snack.showSnackbar("Camera Sizes 已刷新") - } catch (e: Exception) { - snack.showSnackbar("刷新失败: ${e.message}") - } finally { - refreshBusy = false - } - } - }, - ) - } - AnimatedVisibility(soBundle.videoSource == "camera") { - SuperDropdown( - title = "摄像头朝向", - summary = "--camera-facing", - items = cameraFacingItems, - selectedIndex = cameraFacingIndex, - onSelectedIndexChange = { - soBundle = soBundle.copy( - cameraFacing = - if (it == 0) "" - else Shared.CameraFacing.entries[it].string - ) - }, - ) - } - AnimatedVisibility(soBundle.videoSource == "camera") { - SuperDropdown( - title = "摄像头分辨率", - summary = "--camera-size", - items = cameraSizeDropdownItems, - selectedIndex = cameraSizeDropdownIndex, - onSelectedIndexChange = { - cameraSizeDropdownIndex = it - when (it) { - 0 -> { - // "自动" - soBundle = soBundle.copy( - cameraSize = "", - cameraSizeUseCustom = false, - ) - cameraSizeCustomInput = "" - } - - 1 -> { - // "自定义" - 进入自定义输入模式 - soBundle = soBundle.copy( - cameraSizeUseCustom = true, - ) - cameraSizeCustomInput = soBundle.cameraSize - } - - else -> { - // 选择列表中的实际分辨率 - soBundle = soBundle.copy( - cameraSize = cameraSizeDropdownItems[it], - cameraSizeUseCustom = false, - ) - cameraSizeCustomInput = "" - } - } - }, - ) - } - // 只在选择"自定义"时显示输入框 - AnimatedVisibility(soBundle.videoSource == "camera" && soBundle.cameraSizeUseCustom) { - SuperTextField( - value = cameraSizeCustomInput, - onValueChange = { cameraSizeCustomInput = it }, - onFocusLost = { - if (cameraSizeCustomInput in scrcpy.cameraSizes) { - // 输入的值存在于列表中, 取消自定义输入 - cameraSizeDropdownIndex = - scrcpy.cameraSizes.indexOf(cameraSizeCustomInput) + 2 - soBundle = soBundle.copy(cameraSizeUseCustom = false) - } else { - soBundle = soBundle.copy(cameraSizeCustom = cameraSizeCustomInput) - } - }, - label = "--camera-size", - useLabelAsPlaceholder = true, - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - } - AnimatedVisibility(soBundle.videoSource == "camera") { - SuperTextField( - value = cameraArInput, - onValueChange = { cameraArInput = it }, - onFocusLost = { soBundle = soBundle.copy(cameraAr = cameraArInput) }, - label = "--camera-ar", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - } - AnimatedVisibility(soBundle.videoSource == "camera") { - SuperSlider( - title = "摄像头帧率", - summary = "--camera-fps", - value = cameraFpsPresetIndex.toFloat(), - onValueChange = { value -> - val idx = value.roundToInt() - .coerceIn(0, ScrcpyPresets.CameraFps.lastIndex) - soBundle = soBundle.copy(cameraFps = ScrcpyPresets.CameraFps[idx]) - }, - valueRange = 0f..ScrcpyPresets.CameraFps.lastIndex.toFloat(), - steps = (ScrcpyPresets.CameraFps.size - 2).coerceAtLeast(0), - unit = "fps", - zeroStateText = "默认", - showUnitWhenZeroState = false, - showKeyPoints = true, - keyPoints = ScrcpyPresets.CameraFps.indices.map { it.toFloat() }, - displayText = soBundle.cameraFps.toString(), - inputSummary = "0 或留空表示默认", - inputInitialValue = soBundle.cameraFps.toString(), - inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), - onInputConfirm = { - soBundle = soBundle.copy( - cameraFps = it.toIntOrNull() ?: run { 0 } - ) - }, - ) - } - AnimatedVisibility(soBundle.videoSource == "camera") { - SuperSwitch( - title = "高帧率模式", - summary = "--camera-high-speed", - checked = soBundle.cameraHighSpeed, - onCheckedChange = { soBundle = soBundle.copy(cameraHighSpeed = it) }, - ) - } - } - } - - item { - Card { - SuperDropdown( - title = "音频来源", - summary = "--audio-source", - items = audioSourceItems, - selectedIndex = audioSourceIndex, - onSelectedIndexChange = { - soBundle = soBundle.copy( - audioSource = Shared.AudioSource.entries[it].string - ) - }, - ) - SuperSwitch( - title = "音频双路输出", - summary = "--audio-dup", - checked = soBundle.audioDup, - onCheckedChange = { soBundle = soBundle.copy(audioDup = it) }, - ) - SuperSwitch( - title = "仅转发不播放", - summary = "--no-audio-playback", - checked = !soBundle.audioPlayback, - onCheckedChange = { soBundle = soBundle.copy(audioPlayback = !it) }, - ) - SuperSwitch( - title = "音频失败时终止", - summary = "--require-audio", - checked = soBundle.requireAudio, - onCheckedChange = { soBundle = soBundle.copy(requireAudio = it) }, - ) - } - } - - item { - Card { - SuperSlider( - title = "最大分辨率", - summary = "--max-size", - value = maxSizePresetIndex.toFloat(), - onValueChange = { - val idx = it.roundToInt() - .coerceIn(0, ScrcpyPresets.MaxSize.lastIndex) - soBundle = soBundle.copy(maxSize = ScrcpyPresets.MaxSize[idx]) - }, - valueRange = 0f..ScrcpyPresets.MaxSize.lastIndex.toFloat(), - steps = (ScrcpyPresets.MaxSize.size - 2).coerceAtLeast(0), - unit = "px", - zeroStateText = "关闭", - showUnitWhenZeroState = false, - showKeyPoints = true, - keyPoints = ScrcpyPresets.MaxSize.indices.map { it.toFloat() }, - displayText = soBundle.maxSize.toString(), - inputTitle = "最大分辨率 (px)", - inputSummary = "0 或留空表示关闭", - inputInitialValue = soBundle.maxSize.takeIf { it != 0 }?.toString() ?: "", - inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 0f..UInt.MAX_VALUE.toFloat(), - onInputConfirm = { - soBundle = soBundle.copy( - maxSize = it.toIntOrNull() ?: run { 0 } - ) - }, - ) - SuperSlider( - title = "最大帧率", - summary = "--max-fps", - value = maxFpsPresetIndex.toFloat(), - onValueChange = { value -> - val idx = value.roundToInt() - .coerceIn(0, ScrcpyPresets.MaxFPS.lastIndex) - soBundle = soBundle.copy( - maxFps = - if (idx == 0) "" - else ScrcpyPresets.MaxFPS[idx].toString() - ) - }, - valueRange = 0f..ScrcpyPresets.MaxFPS.lastIndex.toFloat(), - steps = (ScrcpyPresets.MaxFPS.size - 2).coerceAtLeast(0), - unit = "fps", - zeroStateText = "关闭", - showUnitWhenZeroState = false, - showKeyPoints = true, - keyPoints = ScrcpyPresets.MaxFPS.indices.map { it.toFloat() }, - displayText = soBundle.maxFps, - inputTitle = "最大帧率 (FPS)", - inputSummary = "0 或留空表示关闭", - inputInitialValue = soBundle.maxFps, - inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), - onInputConfirm = { soBundle = soBundle.copy(maxFps = it) }, - ) - } - } - - item { - Card { - SuperArrow( - title = "重新获取编码器列表", - summary = "--list-encoders", - onClick = { - if (refreshBusy) return@SuperArrow - scope.launch { - refreshBusy = true - try { - scrcpy.refreshEncoders() - snack.showSnackbar("编码器列表已刷新") - } catch (e: Exception) { - snack.showSnackbar("刷新失败: ${e.message}") - } finally { - refreshBusy = false - } - } - }, - ) - SuperSpinner( - title = "视频编码器", - summary = "--video-encoder", - items = videoEncoderEntries, - selectedIndex = videoEncoderIndex, - onSelectedIndexChange = { - soBundle = soBundle.copy(videoEncoder = videoEncoderEntries[it].title ?: "") - }, - ) - SuperTextField( - value = videoCodecOptionsInput, - onValueChange = { videoCodecOptionsInput = it }, - onFocusLost = { - soBundle = soBundle.copy(videoCodecOptions = videoCodecOptionsInput) - }, - 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 = { - soBundle = soBundle.copy(audioEncoder = audioEncoderEntries[it].title ?: "") - }, - ) - SuperTextField( - value = audioCodecOptionsInput, - onValueChange = { audioCodecOptionsInput = it }, - onFocusLost = { - soBundle = soBundle.copy(audioCodecOptions = audioCodecOptionsInput) - }, - 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), - ) { - SuperTextField( - label = "width", - value = newDisplayWidthInput, - onValueChange = { newDisplayWidthInput = it }, - onFocusLost = { - soBundle = soBundle.copy( - newDisplay = NewDisplay - .parseFrom( - newDisplayWidthInput, - newDisplayHeightInput, - newDisplayDpiInput - ) - .toString() - ) - }, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Next, - ), - keyboardActions = KeyboardActions( - onNext = { focusManager.moveFocus(FocusDirection.Next) }, - ), - modifier = Modifier.weight(1f), - ) - SuperTextField( - label = "height", - value = newDisplayHeightInput, - onValueChange = { newDisplayHeightInput = it }, - onFocusLost = { - soBundle = soBundle.copy( - newDisplay = NewDisplay - .parseFrom( - newDisplayWidthInput, - newDisplayHeightInput, - newDisplayDpiInput - ) - .toString() - ) - }, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Next, - ), - keyboardActions = KeyboardActions( - onNext = { focusManager.moveFocus(FocusDirection.Next) }, - ), - modifier = Modifier.weight(1f), - ) - SuperTextField( - label = "dpi", - value = newDisplayDpiInput, - onValueChange = { newDisplayDpiInput = it }, - onFocusLost = { - soBundle = soBundle.copy( - newDisplay = NewDisplay - .parseFrom( - newDisplayWidthInput, - newDisplayHeightInput, - newDisplayDpiInput - ) - .toString() - ) - }, - 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), - ) { - SuperTextField( - label = "width", - value = cropWidthInput, - onValueChange = { cropWidthInput = it }, - onFocusLost = { - soBundle = soBundle.copy( - crop = Crop - .parseFrom( - cropWidthInput, - cropHeightInput, - cropXInput, - cropYInput - ) - .toString() - ) - }, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Next, - ), - keyboardActions = KeyboardActions( - onNext = { focusManager.moveFocus(FocusDirection.Next) }, - ), - modifier = Modifier.weight(1f), - ) - SuperTextField( - label = "height", - value = cropHeightInput, - onValueChange = { cropHeightInput = it }, - onFocusLost = { - soBundle = soBundle.copy( - crop = Crop - .parseFrom( - cropWidthInput, - cropHeightInput, - cropXInput, - cropYInput - ) - .toString() - ) - }, - 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), - ) { - SuperTextField( - label = "x", - value = cropXInput, - onValueChange = { cropXInput = it }, - onFocusLost = { - soBundle = soBundle.copy( - crop = Crop - .parseFrom( - cropWidthInput, - cropHeightInput, - cropXInput, - cropYInput - ) - .toString() - ) - }, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Next, - ), - keyboardActions = KeyboardActions( - onNext = { focusManager.moveFocus(FocusDirection.Next) }, - ), - modifier = Modifier.weight(1f), - ) - SuperTextField( - label = "y", - value = cropYInput, - onValueChange = { cropYInput = it }, - onFocusLost = { - soBundle = soBundle.copy( - crop = Crop - .parseFrom( - cropWidthInput, - cropHeightInput, - cropXInput, - cropYInput - ) - .toString() - ) - }, - 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)) } - } -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt similarity index 77% rename from app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt rename to app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt index cf5c7ed..1aea49f 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.runtime.Composable @@ -34,16 +33,17 @@ import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec import io.github.miuzarte.scrcpyforandroid.services.EventLogger import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent +import io.github.miuzarte.scrcpyforandroid.services.SnackbarController import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel -import io.github.miuzarte.scrcpyforandroid.widgets.DeviceEditorScreen -import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTile +import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTileList import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard import io.github.miuzarte.scrcpyforandroid.widgets.QuickConnectCard @@ -52,7 +52,9 @@ import io.github.miuzarte.scrcpyforandroid.widgets.StatusCard import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonCard +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -67,7 +69,6 @@ import top.yukonga.miuix.kmp.basic.ListPopupDefaults import top.yukonga.miuix.kmp.basic.PopupPositionProvider import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.ScrollBehavior -import top.yukonga.miuix.kmp.basic.SnackbarHostState import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.TextField import top.yukonga.miuix.kmp.basic.TopAppBar @@ -88,7 +89,7 @@ fun DeviceTabScreen( nativeCore: NativeCoreFacade, adbService: NativeAdbService, scrcpy: Scrcpy, - snack: SnackbarHostState, + snackbar: SnackbarController, scrollBehavior: ScrollBehavior, onOpenVirtualButtonOrder: () -> Unit, onSessionStartedChange: (Boolean) -> Unit, @@ -139,7 +140,7 @@ fun DeviceTabScreen( nativeCore = nativeCore, adbService = adbService, scrcpy = scrcpy, - snack = snack, + snackbar = snackbar, scrollBehavior = scrollBehavior, onSessionStartedChange = onSessionStartedChange, onOpenAdvancedPage = onOpenAdvancedPage, @@ -154,13 +155,14 @@ fun DeviceTabPage( nativeCore: NativeCoreFacade, adbService: NativeAdbService, scrcpy: Scrcpy, - snack: SnackbarHostState, + snackbar: SnackbarController, scrollBehavior: ScrollBehavior, onSessionStartedChange: (Boolean) -> Unit, onOpenAdvancedPage: () -> Unit, onOpenFullscreenPage: (Scrcpy.Session.SessionInfo) -> Unit, ) { val scope = rememberCoroutineScope() + val taskScope = remember { CoroutineScope(Dispatchers.IO + SupervisorJob()) } val haptics = rememberAppHaptics() val asBundleShared by appSettings.bundleState.collectAsState() @@ -180,7 +182,7 @@ fun DeviceTabPage( } DisposableEffect(Unit) { onDispose { - scope.launch { + taskScope.launch { appSettings.saveBundle(asBundleLatest) } } @@ -203,7 +205,7 @@ fun DeviceTabPage( } DisposableEffect(Unit) { onDispose { - scope.launch { + taskScope.launch { quickDevices.saveBundle(qdBundleLatest) } } @@ -225,7 +227,7 @@ fun DeviceTabPage( var sessionInfoWidth by rememberSaveable { mutableIntStateOf(0) } var sessionInfoHeight by rememberSaveable { mutableIntStateOf(0) } var sessionInfoDeviceName by rememberSaveable { mutableStateOf("") } - var sessionInfoCodec by rememberSaveable { mutableStateOf("") } + var sessionInfoCodec by rememberSaveable { mutableStateOf(null) } var sessionInfoControlEnabled by rememberSaveable { mutableStateOf(false) } var sessionInfo by remember { mutableStateOf(null) @@ -243,7 +245,7 @@ fun DeviceTabPage( height = sessionInfoHeight, deviceName = sessionInfoDeviceName, codecId = 0, - codecName = sessionInfoCodec, + codec = sessionInfoCodec, audioCodecId = 0, controlEnabled = sessionInfoControlEnabled, host = currentTargetHost, @@ -254,7 +256,7 @@ fun DeviceTabPage( } } var previewControlsVisible by rememberSaveable { mutableStateOf(false) } - var editingDevice by rememberSaveable { mutableStateOf(null) } + var editingDeviceId by rememberSaveable { mutableStateOf(null) } var activeDeviceActionId by rememberSaveable { mutableStateOf(null) } var adbConnecting by rememberSaveable { mutableStateOf(false) } @@ -288,6 +290,9 @@ fun DeviceTabPage( qdBundle = qdBundle.copy(quickDevicesList = serialized) } } + val editingDevice = remember(savedShortcuts, editingDeviceId) { + editingDeviceId?.let(savedShortcuts::get) + } /** * Disconnect the current ADB connection and stop any running scrcpy session. @@ -330,14 +335,12 @@ fun DeviceTabPage( clearQuickOnlineForTarget?.let { target -> if (target.host.isNotBlank()) savedShortcuts = savedShortcuts.update( - host = target.host, port = target.port, online = false + host = target.host, port = target.port ) } logMessage?.let { logEvent(it) } if (!showSnackMessage.isNullOrBlank()) { - scope.launch { - snack.showSnackbar(showSnackMessage) - } + snackbar.show(showSnackMessage) } } @@ -447,7 +450,7 @@ fun DeviceTabPage( fun runBusy(label: String, onFinished: (() -> Unit)? = null, block: suspend () -> Unit) { // For non-adb actions (start/stop/pair/list refresh...). if (busy) return - scope.launch(kotlinx.coroutines.Dispatchers.IO) { + taskScope.launch { busy = true try { block() @@ -456,9 +459,7 @@ fun DeviceTabPage( } catch (e: IllegalArgumentException) { val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName logEvent("$label 参数错误: $detail", Log.WARN, e) - scope.launch { - snack.showSnackbar("$label 参数错误: $detail") - } + snackbar.show("$label 参数错误: $detail") } catch (e: Exception) { val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName logEvent("$label 失败: $detail", Log.ERROR, e) @@ -489,7 +490,7 @@ fun DeviceTabPage( ) { // For manual adb operations from user actions. if (adbConnecting) return - scope.launch { + taskScope.launch { onStarted?.invoke() adbConnecting = true try { @@ -499,9 +500,7 @@ fun DeviceTabPage( } catch (e: IllegalArgumentException) { val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName logEvent("$label 参数错误: $detail", Log.WARN, e) - scope.launch { - snack.showSnackbar("$label 参数错误: $detail") - } + snackbar.show("$label 参数错误: $detail") } catch (e: Exception) { val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName logEvent("$label 失败: $detail", Log.ERROR, e) @@ -521,57 +520,6 @@ fun DeviceTabPage( } } - suspend fun refreshEncoderLists() { - if (!adbConnected) return - runCatching { - scrcpy.refreshEncoders() - }.onSuccess { - // Validate current selections - if (soBundleShared.videoEncoder.isNotBlank() && soBundleShared.videoEncoder !in scrcpy.videoEncoders) { - scrcpyOptions.updateBundle { it.copy(videoEncoder = "") } - } - if (soBundleShared.audioEncoder.isNotBlank() && soBundleShared.audioEncoder !in scrcpy.audioEncoders) { - scrcpyOptions.updateBundle { it.copy(audioEncoder = "") } - } - logEvent("编码器列表已刷新: video=${scrcpy.videoEncoders.size} audio=${scrcpy.audioEncoders.size}") - if (scrcpy.videoEncoders.isEmpty() && scrcpy.audioEncoders.isEmpty()) { - logEvent( - "提示: 编码器为空,请检查 server 路径/版本与设备系统日志", - Log.WARN - ) - } - }.onFailure { e -> - logEvent( - "读取编码器列表失败: ${e.message ?: e.javaClass.simpleName}", - Log.ERROR, - e - ) - } - } - - suspend fun refreshCameraSizeLists() { - if (!adbConnected) return - runCatching { - scrcpy.refreshCameraSizes() - }.onSuccess { - // Validate current selection - if ( - soBundleShared.cameraSize.isNotBlank() - && soBundleShared.cameraSize != "custom" - && soBundleShared.cameraSize !in scrcpy.cameraSizes - ) { - scrcpyOptions.updateBundle { it.copy(cameraSize = "") } - } - logEvent("camera sizes 已刷新: count=${scrcpy.cameraSizes.size}") - }.onFailure { e -> - logEvent( - "读取 camera sizes 失败: ${e.message ?: e.javaClass.simpleName}", - Log.ERROR, - e - ) - } - } - suspend fun handleAdbConnected(host: String, port: Int) { currentTargetHost = host currentTargetPort = port @@ -586,8 +534,7 @@ fun DeviceTabPage( connectedDeviceLabel = info.model applyConnectedDeviceCapabilities(info.sdkInt, info.androidRelease) savedShortcuts = savedShortcuts.update( - host = host, - port = port, + host = host, port = port, name = fullLabel, updateNameOnlyWhenEmpty = true ) @@ -603,26 +550,7 @@ fun DeviceTabPage( "android=${info.androidRelease.ifBlank { "unknown" }}, " + "sdk=${info.sdkInt}" ) - scope.launch { - snack.showSnackbar("ADB 已连接") - } - refreshEncoderLists() - refreshCameraSizeLists() - } - - LaunchedEffect(adbConnected, currentTargetHost, currentTargetPort, savedShortcuts.size) { - val activeId = if (adbConnected && currentTargetHost.isNotBlank()) { - "$currentTargetHost:$currentTargetPort" - } else { - null - } - for (index in savedShortcuts.indices) { - val item = savedShortcuts[index] - val shouldOnline = activeId != null && item.id == activeId - if (item.online != shouldOnline) { - savedShortcuts = savedShortcuts.update(id = item.id, online = shouldOnline) - } - } + snackbar.show("ADB 已连接") } LaunchedEffect(adbConnected, currentTargetHost, currentTargetPort) { @@ -643,16 +571,12 @@ fun DeviceTabPage( adbConnected = true statusLine = "$host:$port" logEvent("ADB 自动重连成功: $host:$port") - scope.launch { - snack.showSnackbar("ADB 自动重连成功") - } + snackbar.show("ADB 自动重连成功") } catch (e: Exception) { disconnectAdbConnection() statusLine = "ADB 连接断开" logEvent("ADB 自动重连失败: $e", Log.ERROR) - scope.launch { - snack.showSnackbar("ADB 自动重连失败") - } + snackbar.show("ADB 自动重连失败") break } } @@ -690,9 +614,7 @@ fun DeviceTabPage( runAutoAdbConnect(target.host, target.port) adbConnected = true savedShortcuts = savedShortcuts.update( - host = target.host, - port = target.port, - online = true + host = target.host, port = target.port, ) handleAdbConnected(target.host, target.port) logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}") @@ -732,10 +654,8 @@ fun DeviceTabPage( }?.port if (portToReplace != null) { savedShortcuts = savedShortcuts.update( - host = discoveredHost, - port = portToReplace, + host = discoveredHost, port = portToReplace, newPort = discoveredPort, - online = false ) logEvent( "mDNS 发现新端口,已更新快速设备: $discoveredHost:$portToReplace -> $discoveredHost:$discoveredPort" @@ -751,9 +671,7 @@ fun DeviceTabPage( runAutoAdbConnect(discoveredHost, discoveredPort) adbConnected = true savedShortcuts = savedShortcuts.update( - host = discoveredHost, - port = discoveredPort, - online = true + host = discoveredHost, port = discoveredPort, ) handleAdbConnected(discoveredHost, discoveredPort) logEvent("ADB 自动重连成功: $discoveredHost:$discoveredPort") @@ -779,13 +697,13 @@ fun DeviceTabPage( sessionInfoWidth = sessionInfo?.width ?: 0 sessionInfoHeight = sessionInfo?.height ?: 0 sessionInfoDeviceName = sessionInfo?.deviceName.orEmpty() - sessionInfoCodec = sessionInfo?.codecName.orEmpty() + sessionInfoCodec = sessionInfo?.codec sessionInfoControlEnabled = sessionInfo?.controlEnabled == true } else { sessionInfoWidth = 0 sessionInfoHeight = 0 sessionInfoDeviceName = "" - sessionInfoCodec = "" + sessionInfoCodec = null sessionInfoControlEnabled = false } onSessionStartedChange(sessionInfo != null) @@ -799,28 +717,6 @@ fun DeviceTabPage( } } - if (editingDevice != null) { - DeviceEditorScreen( - contentPadding = contentPadding, - device = editingDevice!!, - onSave = { updated -> - savedShortcuts = savedShortcuts.update( - id = editingDevice!!.id, - host = updated.host, - port = updated.port, - online = updated.online, - ) - editingDevice = null - }, - onDelete = { - savedShortcuts = savedShortcuts.remove(id = editingDevice!!.id) - editingDevice = null - }, - onBack = { editingDevice = null }, - ) - return - } - val devicePreviewCardHeightDp = asBundle.devicePreviewCardHeightDp val previewVirtualButtonShowText = asBundle.previewVirtualButtonShowText @@ -843,27 +739,38 @@ fun DeviceTabPage( ) } - itemsIndexed(savedShortcuts, key = { _, device -> device.id }) { _, device -> - val host = device.host - val port = device.port - val isConnectedTarget = adbConnected - && currentTarget?.host == host - && currentTarget.port == port - - DeviceTile( - device = device, - actionText = if (!isConnectedTarget) "连接" else "断开", + item { + DeviceTileList( + devices = savedShortcuts, + isConnected = { device -> + adbConnected + && currentTarget?.host == device.host + && currentTarget.port == device.port + }, actionEnabled = !busy && !adbConnecting, - actionInProgress = adbConnecting && activeDeviceActionId == device.id, - onLongPress = { editingDevice = device }, - onContentClick = { - scope.launch { - snack.showSnackbar("长按可编辑设备") + actionInProgress = { device -> + adbConnecting && activeDeviceActionId == device.id + }, + editingDeviceId = editingDeviceId, + onClick = {}, + onLongClick = { device -> + val connected = adbConnected + && currentTarget?.host == device.host + && currentTarget.port == device.port + if (connected) { + snackbar.show("无法修改已连接的设备") + } else { + editingDeviceId = device.id } }, - onAction = { + onAction = { device -> haptics.contextClick() - if (!isConnectedTarget) { + val host = device.host + val port = device.port + val connected = adbConnected + && currentTarget?.host == host + && currentTarget.port == port + if (!connected) { runAdbConnect( "连接 ADB", onStarted = { activeDeviceActionId = device.id }, @@ -873,17 +780,15 @@ fun DeviceTabPage( try { connectWithTimeout(host, port) adbConnected = true - isQuickConnected = false // 标记为快速设备连接 + isQuickConnected = false savedShortcuts = savedShortcuts.update( - host = host, port = port, online = true + host = host, port = port, ) handleAdbConnected(host, port) } catch (e: Exception) { statusLine = "ADB 连接失败" logEvent("ADB 连接失败: $e", Log.ERROR) - scope.launch { - snack.showSnackbar("ADB 连接失败") - } + snackbar.show("ADB 连接失败") } } } else { @@ -902,105 +807,118 @@ fun DeviceTabPage( } } }, + onEditorSave = { device, updated -> + savedShortcuts = savedShortcuts.update( + id = device.id, + name = updated.name, + host = updated.host, + port = updated.port, + ) + editingDeviceId = null + }, + onEditorDelete = { device -> + savedShortcuts = savedShortcuts.remove(id = device.id) + editingDeviceId = null + }, + onEditorCancel = { editingDeviceId = null }, ) } - if (!adbConnected) item { + if (!adbConnected) { // "快速连接" - QuickConnectCard( - input = qdBundle.quickConnectInput, - onValueChange = { - qdBundle = qdBundle.copy(quickConnectInput = it) - }, - enabled = !adbConnecting, - onAddDevice = { - val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput) - ?: return@QuickConnectCard - savedShortcuts = savedShortcuts.upsert( - DeviceShortcut(host = target.host, port = target.port) - ) - Log.d( - "SavedShortcuts", - "size: ${savedShortcuts.size}, list: ${qdBundle.quickDevicesList}" - ) - scope.launch { - snack.showSnackbar("已添加设备: ${target.host}:${target.port}") - } - }, - onConnect = { - val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput) - ?: return@QuickConnectCard - runAdbConnect( - "连接 ADB", - onStarted = { activeDeviceActionId = target.toString() }, - onFinished = { activeDeviceActionId = null }, - ) { - disconnectCurrentTargetBeforeConnecting(target.host, target.port) - try { - connectWithTimeout(target.host, target.port) - adbConnected = true - isQuickConnected = true // 标记为快速连接 - savedShortcuts = savedShortcuts.update( - host = target.host, - port = target.port, - online = true - ) - handleAdbConnected(target.host, target.port) - } catch (e: Exception) { - statusLine = "ADB 连接失败" - logEvent("ADB 连接失败: $e", Log.ERROR) - scope.launch { - snack.showSnackbar("ADB 连接失败") + item { + QuickConnectCard( + input = qdBundle.quickConnectInput, + onValueChange = { + qdBundle = qdBundle.copy(quickConnectInput = it) + }, + enabled = !adbConnecting, + onAddDevice = { + val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput) + ?: return@QuickConnectCard + savedShortcuts = savedShortcuts.upsert( + DeviceShortcut(host = target.host, port = target.port) + ) + Log.d( + "SavedShortcuts", + "size: ${savedShortcuts.size}, list: ${qdBundle.quickDevicesList}" + ) + snackbar.show("已添加设备: ${target.host}:${target.port}") + }, + onConnect = { + val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput) + ?: return@QuickConnectCard + runAdbConnect( + "连接 ADB", + onStarted = { activeDeviceActionId = target.toString() }, + onFinished = { activeDeviceActionId = null }, + ) { + disconnectCurrentTargetBeforeConnecting(target.host, target.port) + try { + connectWithTimeout(target.host, target.port) + adbConnected = true + isQuickConnected = true // 标记为快速连接 + savedShortcuts = savedShortcuts.update( + host = target.host, port = target.port, + ) + handleAdbConnected(target.host, target.port) + } catch (e: Exception) { + statusLine = "ADB 连接失败" + logEvent("ADB 连接失败: $e", Log.ERROR) + snackbar.show("ADB 连接失败") } } - } - }, - ) - SectionSmallTitle("无线配对") - // "使用配对码配对设备" - PairingCard( - busy = busy, - autoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen, - onDiscoverTarget = { - adbService.discoverPairingService( - includeLanDevices = adbMdnsLanDiscovery, - ) - }, - onPair = { host, port, code -> - runBusy("执行配对") { - val resolvedHost = host.trim() - val resolvedPort = port.trim().toIntOrNull() ?: return@runBusy - val resolvedCode = code.trim() - val ok = adbService.pair( - resolvedHost, - resolvedPort, - resolvedCode, + }, + ) + } + + item { + SectionSmallTitle("无线配对", showLeadingSpacer = false) + // "使用配对码配对设备" + PairingCard( + busy = busy, + autoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen, + onDiscoverTarget = { + adbService.discoverPairingService( + includeLanDevices = adbMdnsLanDiscovery, ) - logEvent( - if (ok) "配对成功" else "配对失败", - if (ok) Log.INFO else Log.ERROR - ) - scope.launch { - snack.showSnackbar(if (ok) "配对成功" else "配对失败") + }, + onPair = { host, port, code -> + runBusy("执行配对") { + val resolvedHost = host.trim() + val resolvedPort = port.trim().toIntOrNull() ?: return@runBusy + val resolvedCode = code.trim() + val ok = adbService.pair( + resolvedHost, + resolvedPort, + resolvedCode, + ) + logEvent( + if (ok) "配对成功" else "配对失败", + if (ok) Log.INFO else Log.ERROR + ) + snackbar.show(if (ok) "配对成功" else "配对失败") } - } - }, - ) + }, + ) + } } if (adbConnected) { item { + SectionSmallTitle("Scrcpy", showLeadingSpacer = false) ConfigPanel( busy = busy, - snack = snack, + snackbar = snackbar, audioForwardingSupported = audioForwardingSupported, cameraMirroringSupported = cameraMirroringSupported, + adbConnecting = adbConnecting, isQuickConnected = isQuickConnected, onOpenAdvanced = onOpenAdvancedPage, onStartStopHaptic = { haptics.contextClick() }, onStart = { runBusy("启动 scrcpy") { - val options = scrcpyOptions.toClientOptions(soBundleShared) + val options = scrcpyOptions.toClientOptions(soBundleShared).fix() val session = scrcpy.start(options) sessionInfo = session.copy( host = currentTargetHost, @@ -1010,8 +928,8 @@ fun DeviceTabPage( @SuppressLint("DefaultLocale") val videoDetail = if (!options.video) "off" - else if (videoBitRate <= 0) "${session.codecName} ${session.width}x${session.height} @default" - else "${session.codecName} ${session.width}x${session.height} " + + else if (videoBitRate <= 0) "${session.codec?.string ?: "null"} ${session.width}x${session.height} @default" + else "${session.codec?.string ?: "null"} ${session.width}x${session.height} " + "@${String.format("%.1f", videoBitRate / 1_000_000f)}Mbps" val audioDetail = @@ -1025,9 +943,7 @@ fun DeviceTabPage( ", control=${options.control}, turnScreenOff=${options.turnScreenOff}" + ", maxSize=${options.maxSize}, maxFps=${options.maxFps}" ) - scope.launch { - snack.showSnackbar("scrcpy 已启动") - } + snackbar.show("scrcpy 已启动") } }, onStop = { @@ -1036,9 +952,7 @@ fun DeviceTabPage( sessionInfo = null statusLine = "${currentTarget!!.host}:${currentTarget.port}" logEvent("scrcpy 已停止") - scope.launch { - snack.showSnackbar("scrcpy 已停止") - } + snackbar.show("scrcpy 已停止") } }, sessionInfo = sessionInfo, @@ -1094,19 +1008,21 @@ fun DeviceTabPage( } } - if (EventLogger.hasLogs()) item { - Spacer(Modifier.height(UiSpacing.PageItem)) - Card { - TextField( - value = EventLogger.eventLog.joinToString(separator = "\n"), - onValueChange = {}, - readOnly = true, - modifier = Modifier.fillMaxWidth(), - ) + if (EventLogger.hasLogs()) { + item { + SectionSmallTitle("日志", showLeadingSpacer = false) + Card { + TextField( + value = EventLogger.eventLog.joinToString(separator = "\n"), + onValueChange = {}, + readOnly = true, + modifier = Modifier.fillMaxWidth(), + ) + } } } - item { Spacer(Modifier.height(UiSpacing.BottomContent)) } + item { Spacer(Modifier.height(UiSpacing.PageBottom)) } } } @@ -1131,20 +1047,20 @@ private fun DeviceMenuPopup( text = "快速设备排序", optionSize = 3, index = 0, - onClick = onReorderDevices, + onSelectedIndexChange = { onReorderDevices() }, ) DeviceMenuPopupItem( text = "虚拟按钮排序", optionSize = 3, index = 1, - onClick = onOpenVirtualButtonOrder, + onSelectedIndexChange = { onOpenVirtualButtonOrder() }, ) DeviceMenuPopupItem( text = "清空日志", optionSize = 3, index = 2, enabled = canClearLogs, - onClick = onClearLogs, + onSelectedIndexChange = { onClearLogs() }, ) } } @@ -1156,8 +1072,7 @@ private fun DeviceMenuPopupItem( optionSize: Int, index: Int, enabled: Boolean = true, - // TODO: (Int) -> Unit - onClick: () -> Unit, + onSelectedIndexChange: (Int) -> Unit, ) { if (enabled) { DropdownImpl( @@ -1165,7 +1080,7 @@ private fun DeviceMenuPopupItem( optionSize = optionSize, isSelected = false, index = index, - onSelectedIndexChange = { onClick() }, + onSelectedIndexChange = onSelectedIndexChange, ) return } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt index 2c62473..ac35d74 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt @@ -32,7 +32,9 @@ import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions 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 @@ -53,6 +55,7 @@ fun FullscreenControlScreen( val haptics = rememberAppHaptics() val scope = rememberCoroutineScope() + val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } val activity = remember(context) { context as? Activity } @@ -73,7 +76,7 @@ fun FullscreenControlScreen( } DisposableEffect(Unit) { onDispose { - scope.launch { + taskScope.launch { appSettings.saveBundle(asBundleLatest) } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt index 6125900..caaf808 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt @@ -12,9 +12,11 @@ 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 @@ -42,16 +44,20 @@ 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 @@ -61,6 +67,7 @@ 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 @@ -84,6 +91,7 @@ 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) { @@ -101,11 +109,14 @@ fun MainScreen() { val adbService = remember(appContext) { NativeAdbService(appContext) } - val scrcpy = remember(appContext, adbService) { - Scrcpy(appContext, adbService) - } 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( @@ -137,6 +148,75 @@ fun MainScreen() { // 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() @@ -186,75 +266,12 @@ fun MainScreen() { } } - 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 { - scope.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 { - scope.launch { - quickDevices.saveBundle(qdBundleLatest) - } - } - } - var sessionStarted by remember { mutableStateOf(false) } var showReorderDevices by rememberSaveable { mutableStateOf(false) } var fullscreenOrientation by rememberSaveable { mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) } - 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) - } - } - - val themeMode = resolveThemeMode(asBundle.themeBaseIndex, asBundle.monet) - val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) } - val keepScreenOnWhenStreamingEnabled = asBundle.keepScreenOnWhenStreaming // Keep-screen-on is controlled globally, so fullscreen and preview share the same behavior. DisposableEffect(activity, keepScreenOnWhenStreamingEnabled, sessionStarted) { @@ -336,7 +353,7 @@ fun MainScreen() { nativeCore = nativeCore, adbService = adbService, scrcpy = scrcpy, - snack = snackHostState, + snackbar = snackbarController, scrollBehavior = devicesPageScrollBehavior, onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) }, onSessionStartedChange = { sessionStarted = it }, @@ -357,7 +374,7 @@ fun MainScreen() { MainBottomTabDestination.Settings -> SettingsScreen( scrollBehavior = settingsPageScrollBehavior, - snack = snackHostState, + snackbar = snackbarController, onOpenReorderDevices = { showReorderDevices = true }, onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) }, onPickServer = { @@ -382,10 +399,10 @@ fun MainScreen() { } entry(RootScreen.Advanced) { - AdvancedConfigScreen( + ScrcpyAllOptionsScreen( onBack = ::popRoot, scrollBehavior = advancedPageScrollBehavior, - snack = snackHostState, + snackbar = snackbarController, scrcpy = scrcpy, ) } @@ -416,6 +433,13 @@ fun MainScreen() { 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, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ReorderDevicesScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ReorderDevicesScreen.kt index 2006027..779265f 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ReorderDevicesScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ReorderDevicesScreen.kt @@ -19,6 +19,9 @@ 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 @@ -29,6 +32,7 @@ fun ReorderDevicesScreen( onDismissRequest: () -> Unit, ) { val scope = rememberCoroutineScope() + val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } val qdBundleShared by quickDevices.bundleState.collectAsState() val qdBundleSharedLatest by rememberUpdatedState(qdBundleShared) @@ -47,7 +51,7 @@ fun ReorderDevicesScreen( } DisposableEffect(Unit) { onDispose { - scope.launch { + taskScope.launch { quickDevices.saveBundle(qdBundleLatest) } } @@ -85,6 +89,6 @@ fun ReorderDevicesScreen( savedShortcuts = savedShortcuts.move(fromIndex, toIndex) }, ).invoke() - Spacer(Modifier.height(UiSpacing.BottomSheetBottom)) + Spacer(Modifier.height(UiSpacing.SheetBottom)) } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt new file mode 100644 index 0000000..8f9a5c7 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt @@ -0,0 +1,1153 @@ +package io.github.miuzarte.scrcpyforandroid.pages + +import android.annotation.SuppressLint +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.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.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.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.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.models.ScrcpyOptions.Crop +import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.NewDisplay +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.scrcpy.Shared +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec +import io.github.miuzarte.scrcpyforandroid.services.SnackbarController +import io.github.miuzarte.scrcpyforandroid.storage.Settings +import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions +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.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.SnackbarHost +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.basic.TopAppBar +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 + +@Composable +internal fun ScrcpyAllOptionsScreen( + onBack: () -> Unit, + scrollBehavior: ScrollBehavior, + snackbar: SnackbarController, + scrcpy: Scrcpy, +) { + Scaffold( + topBar = { + TopAppBar( + title = "所有参数", + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "返回" + ) + } + }, + scrollBehavior = scrollBehavior, + ) + }, + snackbarHost = { SnackbarHost(snackbar.hostState) }, + ) { contentPadding -> + ScrcpyAllOptionsPage( + contentPadding = contentPadding, + scrollBehavior = scrollBehavior, + snackbar = snackbar, + scrcpy = scrcpy, + ) + } +} + +@Composable +internal fun ScrcpyAllOptionsPage( + contentPadding: PaddingValues, + scrollBehavior: ScrollBehavior, + snackbar: SnackbarController, + scrcpy: Scrcpy, +) { + val focusManager = LocalFocusManager.current + val scope = rememberCoroutineScope() + val taskScope = remember { CoroutineScope(Dispatchers.IO + SupervisorJob()) } + + var refreshBusy by rememberSaveable { mutableStateOf(false) } + var listRefreshVersion by rememberSaveable { mutableIntStateOf(0) } + + val soBundleShared by scrcpyOptions.bundleState.collectAsState() + val soBundleSharedLatest by rememberUpdatedState(soBundleShared) + var soBundle by rememberSaveable(soBundleShared) { mutableStateOf(soBundleShared) } + // 验证配置项合法性用于回滚更改 + var lastValidSoBundle by rememberSaveable(soBundleShared) { mutableStateOf(soBundleShared) } + val soBundleLatest by rememberUpdatedState(soBundle) + LaunchedEffect(soBundleShared) { + if (soBundle != soBundleShared) { + soBundle = soBundleShared + } + lastValidSoBundle = soBundleShared + } + LaunchedEffect(soBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (soBundle != soBundleSharedLatest) { + scrcpyOptions.saveBundle(soBundle) + } + } + DisposableEffect(Unit) { + onDispose { + taskScope.launch { + scrcpyOptions.saveBundle(soBundleLatest) + } + } + } + + val audioCodecItems = rememberSaveable { Codec.AUDIO.map { it.displayName } } + val audioCodecIndex = rememberSaveable(soBundle.audioCodec) { + Codec.AUDIO + .indexOfFirst { it.string == soBundle.audioCodec } + .coerceAtLeast(0) + } + + val videoCodecItems = rememberSaveable { Codec.VIDEO.map { it.displayName } } + val videoCodecIndex = rememberSaveable(soBundle.videoCodec) { + Codec.VIDEO + .indexOfFirst { it.string == soBundle.videoCodec } + .coerceAtLeast(0) + } + + val videoSourceItems = rememberSaveable { Shared.VideoSource.entries.map { it.string } } + val videoSourceIndex = rememberSaveable(soBundle.videoSource) { + Shared.VideoSource.entries + .indexOfFirst { it.string == soBundle.videoSource } + .coerceAtLeast(0) + } + + var displayIdInput by rememberSaveable(soBundle.displayId) { + mutableStateOf( + if (soBundle.displayId == -1) "" + else soBundle.displayId.toString() + ) + } + + var cameraIdInput by rememberSaveable(soBundle.cameraId) { + mutableStateOf(soBundle.cameraId) + } + + val cameraFacingItems = rememberSaveable { + listOf("默认") + Shared.CameraFacing.entries + .drop(1) + .map { it.string } + } + val cameraFacingIndex = rememberSaveable(soBundle.cameraFacing) { + if (soBundle.cameraFacing.isEmpty()) { + 0 + } else { + val idx = Shared.CameraFacing.entries + .indexOfFirst { it.string == soBundle.cameraFacing } + if (idx > 0) idx else 0 + } + } + + var cameraSizeCustomInput by rememberSaveable(soBundle.cameraSizeCustom) { + mutableStateOf(soBundle.cameraSizeCustom) + } + + val cameraSizes = scrcpy.listings.cameraSizes + val cameraSizeDropdownItems = rememberSaveable(cameraSizes, listRefreshVersion) { + listOf("自动", "自定义") + cameraSizes + } + var cameraSizeDropdownIndex by rememberSaveable( + soBundle.cameraSize, + soBundle.cameraSizeUseCustom, + cameraSizes, + listRefreshVersion, + ) { + mutableIntStateOf( + when { + soBundle.cameraSizeUseCustom -> 1 // "自定义" + soBundle.cameraSize.isEmpty() -> 0 // "自动" + soBundle.cameraSize in cameraSizes -> + cameraSizes.indexOf(soBundle.cameraSize) + 2 + + else -> 0 // 默认自动 + } + ) + } + + var cameraArInput by rememberSaveable(soBundle.cameraAr) { + mutableStateOf(soBundle.cameraAr) + } + + val cameraFpsPresetIndex = rememberSaveable(soBundle.cameraFps) { + ScrcpyPresets.CameraFps.indexOfOrNearest(soBundle.cameraFps) + } + + val audioSourceItems = rememberSaveable { + Shared.AudioSource.entries.map { it.string } + } + val audioSourceIndex = rememberSaveable(soBundle.audioSource) { + Shared.AudioSource.entries + .indexOfFirst { it.string == soBundle.audioSource } + .coerceAtLeast(0) + } + + val videoEncoderInfos = scrcpy.listings.videoEncoders + val audioEncoderInfos = scrcpy.listings.audioEncoders + val videoEncoders = remember(videoEncoderInfos) { + videoEncoderInfos.map { it.id } + } + val audioEncoders = remember(audioEncoderInfos) { + audioEncoderInfos.map { it.id } + } + val videoEncoderTypes = remember(videoEncoderInfos) { + videoEncoderInfos.associate { it.id to it.type.s } + } + val audioEncoderTypes = remember(audioEncoderInfos) { + audioEncoderInfos.associate { it.id to it.type.s } + } + + val maxSizePresetIndex = rememberSaveable(soBundle.maxSize) { + ScrcpyPresets.MaxSize.indexOfOrNearest(soBundle.maxSize) + } + + val maxFpsPresetIndex = rememberSaveable(soBundle.maxFps) { + ScrcpyPresets.MaxFPS.indexOfOrNearest(soBundle.maxFps.toIntOrNull() ?: 0) + } + + var videoCodecOptionsInput by rememberSaveable(soBundle.videoCodecOptions) { + mutableStateOf(soBundle.videoCodecOptions) + } + + var audioCodecOptionsInput by rememberSaveable(soBundle.audioCodecOptions) { + mutableStateOf(soBundle.audioCodecOptions) + } + + val videoEncoderDropdownItems = rememberSaveable(videoEncoders, listRefreshVersion) { + listOf("") + videoEncoders + } + val videoEncoderIndex = rememberSaveable(soBundle.videoEncoder, videoEncoders, listRefreshVersion) { + (videoEncoders.indexOf(soBundle.videoEncoder) + 1).coerceAtLeast(0) + } + + val audioEncoderDropdownItems = rememberSaveable(audioEncoders, listRefreshVersion) { + listOf("") + audioEncoders + } + val audioEncoderIndex = rememberSaveable(soBundle.audioEncoder, audioEncoders, listRefreshVersion) { + (audioEncoders.indexOf(soBundle.audioEncoder) + 1).coerceAtLeast(0) + } + + val videoEncoderEntries = videoEncoderDropdownItems.map { encoderName -> + if (encoderName == "") { + SpinnerEntry(title = "自动") + } else { + SpinnerEntry( + title = encoderName, + summary = videoEncoderTypes[encoderName], + ) + } + } + + val audioEncoderEntries = audioEncoderDropdownItems.map { encoderName -> + if (encoderName == "") { + SpinnerEntry(title = "自动") + } else { + SpinnerEntry( + title = encoderName, + summary = audioEncoderTypes[encoderName], + ) + } + } + + // [x][/] + val (ndWidth, ndHeight, ndDpi) = remember(soBundle.newDisplay) { + NewDisplay.parseFrom(soBundle.newDisplay) + } + var newDisplayWidthInput by rememberSaveable(soBundle.newDisplay) { + mutableStateOf(ndWidth?.toString() ?: "") + } + var newDisplayHeightInput by rememberSaveable(soBundle.newDisplay) { + mutableStateOf(ndHeight?.toString() ?: "") + } + var newDisplayDpiInput by rememberSaveable(soBundle.newDisplay) { + mutableStateOf(ndDpi?.toString() ?: "") + } + + // width:height:x:y + val (cWidth, cHeight, cX, cY) = remember(soBundle.crop) { + Crop.parseFrom(soBundle.crop) + } + var cropWidthInput by rememberSaveable(soBundle.crop) { + mutableStateOf(cWidth?.toString() ?: "") + } + var cropHeightInput by rememberSaveable(soBundle.crop) { + mutableStateOf(cHeight?.toString() ?: "") + } + var cropXInput by rememberSaveable(soBundle.crop) { + mutableStateOf(cX?.toString() ?: "") + } + var cropYInput by rememberSaveable(soBundle.crop) { + mutableStateOf(cY?.toString() ?: "") + } + + var serverParamsPreview by rememberSaveable { mutableStateOf("") } + // 监听选项变化, 自动更新预览 + LaunchedEffect(soBundle) { + val clientOptions = scrcpyOptions.toClientOptions(soBundle).fix() + + try { + clientOptions.validate() + } catch (e: IllegalArgumentException) { + if (soBundle != lastValidSoBundle) { + snackbar.show("Invalid options: ${e.message}") + soBundle = lastValidSoBundle + } + return@LaunchedEffect + } + + lastValidSoBundle = soBundle + + serverParamsPreview = clientOptions + .toServerParams(0u) + .toList(simplify = true) + // improve readability using hard line breaks + .joinToString("\n") + } + + LazyColumn( + contentPadding = contentPadding, + scrollBehavior = scrollBehavior, + ) { + item { + Card { + TextField( + value = serverParamsPreview, + onValueChange = {}, + readOnly = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + item { + Card { + SuperSwitch( + title = "启动后关闭屏幕", + summary = "--turn-screen-off", + checked = soBundle.turnScreenOff, + onCheckedChange = { + soBundle = soBundle.copy( + turnScreenOff = it + ) + if (it) { + // github.com/Genymobile/scrcpy/issues/3376 + // github.com/Genymobile/scrcpy/issues/4587 + // github.com/Genymobile/scrcpy/issues/5676 + snackbar.show("注意:大部分设备在关闭屏幕后刷新率会降低/减半") + } + }, + ) + SuperSwitch( + title = "禁用控制", + summary = "--no-control", + checked = !soBundle.control, + onCheckedChange = { + soBundle = soBundle.copy( + control = !it + ) + }, + // 拦不住同时点, 弃用 + // enabled = audio || video, + ) + SuperSwitch( + title = "禁用视频", + summary = "--no-video", + checked = !soBundle.video, + onCheckedChange = { + soBundle = soBundle.copy( + video = !it + ) + }, + // enabled = audio || control, + ) + SuperSwitch( + title = "禁用音频", + summary = "--no-audio", + checked = !soBundle.audio, + onCheckedChange = { + soBundle = soBundle.copy( + audio = !it + ) + }, + // enabled = control || video, + ) + } + } + + item { + Card { + SuperDropdown( + title = "音频编码", + summary = "--audio-codec", + items = audioCodecItems, + selectedIndex = audioCodecIndex, + onSelectedIndexChange = { + soBundle = soBundle.copy( + audioCodec = Codec.AUDIO[it].string + ) + }, + ) + SuperSlider( + title = "音频码率", + summary = "--audio-bit-rate", + value = ScrcpyPresets.AudioBitRate + .indexOfOrNearest(soBundle.audioBitRate / 1000) + .toFloat(), + onValueChange = { value -> + val idx = value.roundToInt() + .coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex) + soBundle = soBundle.copy( + audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1000 + ) + }, + valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(), + steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0), + unit = "Kbps", + zeroStateText = "默认", + displayText = (soBundle.audioBitRate / 1_000).toString(), + inputInitialValue = + if (soBundle.audioBitRate <= 0) "" + else (soBundle.audioBitRate / 1_000).toString(), + inputFilter = { it.filter(Char::isDigit) }, + inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), + onInputConfirm = { raw -> + raw.toIntOrNull() + ?.takeIf { it >= 0 } + ?.let { + soBundle = soBundle.copy( + audioBitRate = it * 1000 + ) + } + }, + ) + + SuperDropdown( + title = "视频编码", + summary = "--video-codec", + items = videoCodecItems, + selectedIndex = videoCodecIndex, + onSelectedIndexChange = { + soBundle = soBundle.copy( + videoCodec = Codec.VIDEO[it].string + ) + }, + ) + @SuppressLint("DefaultLocale") + SuperSlider( + title = "视频码率", + summary = "--video-bit-rate", + value = soBundle.videoBitRate / 1_000_000f, + onValueChange = { mbps -> + soBundle = soBundle.copy( + videoBitRate = (mbps * 10).roundToInt() * (1_000_000 / 10) + ) + }, + valueRange = 0f..40f, + steps = 400 - 1, + unit = "Mbps", + zeroStateText = "默认", + displayFormatter = { String.format("%.1f", it) }, + inputInitialValue = + if (soBundle.videoBitRate <= 0) "" + else String.format("%.1f", soBundle.videoBitRate / 1_000_000f), + inputFilter = { text -> + var dotUsed = false + text.filter { ch -> + when { + ch.isDigit() -> true + ch == '.' && !dotUsed -> { + dotUsed = true + true + } + + else -> false + } + } + }, + inputValueRange = 0f..UInt.MAX_VALUE.toFloat(), + onInputConfirm = { raw -> + raw.toFloatOrNull()?.let { parsed -> + if (parsed >= 0f) { + soBundle = soBundle.copy( + videoBitRate = (parsed * 1_000_000f).roundToInt() + ) + } + } + }, + ) + } + } + + item { + Card { + SuperDropdown( + title = "视频来源", + summary = "--video-source", + items = videoSourceItems, + selectedIndex = videoSourceIndex, + onSelectedIndexChange = { + soBundle = soBundle.copy( + videoSource = Shared.VideoSource.entries[it].string + ) + }, + ) + AnimatedVisibility(soBundle.videoSource == "display") { + Column { + SuperTextField( + value = displayIdInput, + onValueChange = { displayIdInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + displayId = displayIdInput.toIntOrNull() ?: -1 + ) + }, + label = "--display-id", + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier + .fillMaxWidth() + .padding(all = UiSpacing.Large), + ) + SuperSlider( + title = "最大分辨率", + summary = "--max-size", + value = maxSizePresetIndex.toFloat(), + onValueChange = { + val idx = it.roundToInt() + .coerceIn(0, ScrcpyPresets.MaxSize.lastIndex) + soBundle = soBundle.copy( + maxSize = ScrcpyPresets.MaxSize[idx] + ) + }, + valueRange = 0f..ScrcpyPresets.MaxSize.lastIndex.toFloat(), + steps = (ScrcpyPresets.MaxSize.size - 2).coerceAtLeast(0), + unit = "px", + zeroStateText = "关闭", + showUnitWhenZeroState = false, + showKeyPoints = true, + keyPoints = ScrcpyPresets.MaxSize.indices.map { it.toFloat() }, + displayText = soBundle.maxSize.toString(), + inputInitialValue = soBundle.maxSize.takeIf { it != 0 }?.toString() + ?: "", + inputFilter = { it.filter(Char::isDigit) }, + inputValueRange = 0f..UInt.MAX_VALUE.toFloat(), + onInputConfirm = { + soBundle = soBundle.copy( + maxSize = it.toIntOrNull() ?: run { 0 } + ) + }, + ) + SuperSlider( + title = "最大帧率", + summary = "--max-fps", + value = maxFpsPresetIndex.toFloat(), + onValueChange = { value -> + val idx = value.roundToInt() + .coerceIn(0, ScrcpyPresets.MaxFPS.lastIndex) + soBundle = soBundle.copy( + maxFps = + if (idx == 0) "" + else ScrcpyPresets.MaxFPS[idx].toString() + ) + }, + valueRange = 0f..ScrcpyPresets.MaxFPS.lastIndex.toFloat(), + steps = (ScrcpyPresets.MaxFPS.size - 2).coerceAtLeast(0), + unit = "fps", + zeroStateText = "关闭", + showUnitWhenZeroState = false, + showKeyPoints = true, + keyPoints = ScrcpyPresets.MaxFPS.indices.map { it.toFloat() }, + displayText = soBundle.maxFps, + inputInitialValue = soBundle.maxFps, + inputFilter = { it.filter(Char::isDigit) }, + inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), + onInputConfirm = { + soBundle = soBundle.copy( + maxFps = it + ) + }, + ) + } + } + AnimatedVisibility(soBundle.videoSource == "camera") { + Column { + SuperTextField( + value = cameraIdInput, + onValueChange = { cameraIdInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + cameraId = cameraIdInput + ) + }, + label = "--camera-id", + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(all = UiSpacing.Large), + ) + SuperDropdown( + title = "摄像头朝向", + summary = "--camera-facing", + items = cameraFacingItems, + selectedIndex = cameraFacingIndex, + onSelectedIndexChange = { + soBundle = soBundle.copy( + cameraFacing = + if (it == 0) "" + else Shared.CameraFacing.entries[it].string + ) + }, + ) + SuperArrow( + title = "获取 Camera Sizes", + summary = "--list-camera-sizes", + onClick = { + if (refreshBusy) return@SuperArrow + scope.launch { + refreshBusy = true + snackbar.show("获取中") + try { + withContext(Dispatchers.IO) { + scrcpy.listings.getCameraSizes(forceRefresh = true) + } + listRefreshVersion += 1 + snackbar.show("获取成功") + } catch (e: Exception) { + snackbar.show("刷新失败: ${e.message}") + } finally { + refreshBusy = false + } + } + }, + ) + SuperDropdown( + title = "摄像头分辨率", + summary = "--camera-size", + items = cameraSizeDropdownItems, + selectedIndex = cameraSizeDropdownIndex, + onSelectedIndexChange = { + cameraSizeDropdownIndex = it + when (it) { + 0 -> { + // "自动" + soBundle = soBundle.copy( + cameraSize = "", + cameraSizeUseCustom = false, + ) + cameraSizeCustomInput = "" + } + + 1 -> { + // "自定义" - 进入自定义输入模式 + soBundle = soBundle.copy( + cameraSizeUseCustom = true + ) + cameraSizeCustomInput = soBundle.cameraSize + } + + else -> { + // 选择列表中的实际分辨率 + soBundle = soBundle.copy( + cameraSize = cameraSizeDropdownItems[it], + cameraSizeUseCustom = false, + ) + cameraSizeCustomInput = "" + } + } + }, + ) + // 只在选择"自定义"时显示输入框 + AnimatedVisibility(soBundle.cameraSizeUseCustom) { + SuperTextField( + value = cameraSizeCustomInput, + onValueChange = { cameraSizeCustomInput = it }, + onFocusLost = { + if (cameraSizeCustomInput in cameraSizes) { + // 输入的值存在于列表中, 取消自定义输入 + cameraSizeDropdownIndex = cameraSizes + .indexOf(cameraSizeCustomInput) + 2 + soBundle = soBundle.copy( + cameraSizeUseCustom = false + ) + } else { + soBundle = soBundle.copy( + cameraSizeCustom = cameraSizeCustomInput + ) + } + }, + label = "--camera-size", + useLabelAsPlaceholder = true, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(all = UiSpacing.Large), + ) + } + SuperTextField( + value = cameraArInput, + onValueChange = { cameraArInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + cameraAr = cameraArInput + ) + }, + label = "--camera-ar", + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(all = UiSpacing.Large), + ) + SuperSlider( + title = "摄像头帧率", + summary = "--camera-fps", + value = cameraFpsPresetIndex.toFloat(), + onValueChange = { value -> + val idx = value.roundToInt() + .coerceIn(0, ScrcpyPresets.CameraFps.lastIndex) + soBundle = soBundle.copy( + cameraFps = ScrcpyPresets.CameraFps[idx] + ) + }, + valueRange = 0f..ScrcpyPresets.CameraFps.lastIndex.toFloat(), + steps = (ScrcpyPresets.CameraFps.size - 2).coerceAtLeast(0), + unit = "fps", + zeroStateText = "默认", + showKeyPoints = true, + keyPoints = ScrcpyPresets.CameraFps.indices.map { it.toFloat() }, + displayText = soBundle.cameraFps.toString(), + inputInitialValue = + if (soBundle.cameraFps <= 0) "" + else soBundle.cameraFps.toString(), + inputFilter = { it.filter(Char::isDigit) }, + inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), + onInputConfirm = { + soBundle = soBundle.copy( + cameraFps = it.toIntOrNull() ?: run { 0 } + ) + }, + ) + SuperSwitch( + title = "高帧率模式", + summary = "--camera-high-speed", + checked = soBundle.cameraHighSpeed, + onCheckedChange = { + soBundle = soBundle.copy( + cameraHighSpeed = it + ) + }, + ) + } + } + + } + } + + item { + Card { + SuperDropdown( + title = "音频来源", + summary = "--audio-source", + items = audioSourceItems, + selectedIndex = audioSourceIndex, + onSelectedIndexChange = { + soBundle = soBundle.copy( + audioSource = Shared.AudioSource.entries[it].string + ) + }, + ) + SuperSwitch( + title = "音频双路输出", + summary = "--audio-dup", + checked = soBundle.audioDup, + onCheckedChange = { + soBundle = soBundle.copy( + audioDup = it + ) + }, + ) + SuperSwitch( + title = "仅转发不播放", + summary = "--no-audio-playback", + checked = !soBundle.audioPlayback, + onCheckedChange = { + soBundle = soBundle.copy( + audioPlayback = !it + ) + }, + ) + SuperSwitch( + title = "音频转发失败时终止", + summary = "--require-audio", + checked = soBundle.requireAudio, + onCheckedChange = { + soBundle = soBundle.copy( + requireAudio = it + ) + }, + ) + } + } + + item { + Card { + SuperArrow( + title = "获取编码器列表", + summary = "--list-encoders", + onClick = { + if (refreshBusy) return@SuperArrow + scope.launch { + refreshBusy = true + snackbar.show("获取中") + try { + withContext(Dispatchers.IO) { + scrcpy.listings.getVideoEncoders(forceRefresh = true) + } + listRefreshVersion += 1 + snackbar.show("获取成功") + } catch (e: Exception) { + snackbar.show("刷新失败: ${e.message}") + } finally { + refreshBusy = false + } + } + }, + ) + // TODO: 在 SuperSpinner / SuperDropdown 支持展开状态回调后, 在展开时触发获取 + SuperSpinner( + title = "视频编码器", + summary = "--video-encoder", + items = videoEncoderEntries, + selectedIndex = videoEncoderIndex, + onSelectedIndexChange = { + soBundle = soBundle.copy( + videoEncoder = videoEncoderEntries[it].title ?: "" + ) + }, + ) + SuperTextField( + value = videoCodecOptionsInput, + onValueChange = { videoCodecOptionsInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + videoCodecOptions = videoCodecOptionsInput + ) + }, + label = "--video-codec-options", + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(all = UiSpacing.Large), + ) + SuperSpinner( + title = "音频编码器", + summary = "--audio-encoder", + items = audioEncoderEntries, + selectedIndex = audioEncoderIndex, + onSelectedIndexChange = { + soBundle = soBundle.copy( + audioEncoder = audioEncoderEntries[it].title ?: "" + ) + }, + ) + SuperTextField( + value = audioCodecOptionsInput, + onValueChange = { audioCodecOptionsInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + audioCodecOptions = audioCodecOptionsInput + ) + }, + label = "--audio-codec-options", + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(all = UiSpacing.Large), + ) + } + } + + if (soBundle.videoSource == "display") item { + 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 = "--new-display", + fontWeight = FontWeight.Medium, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal), + ) { + SuperTextField( + label = "width", + value = newDisplayWidthInput, + onValueChange = { newDisplayWidthInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + newDisplay = NewDisplay + .parseFrom( + newDisplayWidthInput, + newDisplayHeightInput, + newDisplayDpiInput + ) + .toString() + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Next) }, + ), + modifier = Modifier.weight(1f), + ) + SuperTextField( + label = "height", + value = newDisplayHeightInput, + onValueChange = { newDisplayHeightInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + newDisplay = NewDisplay + .parseFrom( + newDisplayWidthInput, + newDisplayHeightInput, + newDisplayDpiInput + ) + .toString() + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Next) }, + ), + modifier = Modifier.weight(1f), + ) + SuperTextField( + label = "dpi", + value = newDisplayDpiInput, + onValueChange = { newDisplayDpiInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + newDisplay = NewDisplay + .parseFrom( + newDisplayWidthInput, + newDisplayHeightInput, + newDisplayDpiInput + ) + .toString() + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { focusManager.clearFocus() }, + ), + modifier = Modifier.weight(1f), + ) + } + } + } + } + } + + if (soBundle.videoSource == "display") item { + 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 = "--crop", + fontWeight = FontWeight.Medium, + ) + Column(verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal), + ) { + SuperTextField( + label = "width", + value = cropWidthInput, + onValueChange = { cropWidthInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + crop = Crop + .parseFrom( + cropWidthInput, + cropHeightInput, + cropXInput, + cropYInput + ) + .toString() + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Next) }, + ), + modifier = Modifier.weight(1f), + ) + SuperTextField( + label = "height", + value = cropHeightInput, + onValueChange = { cropHeightInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + crop = Crop + .parseFrom( + cropWidthInput, + cropHeightInput, + cropXInput, + cropYInput + ) + .toString() + ) + }, + 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.ContentHorizontal), + ) { + SuperTextField( + label = "x", + value = cropXInput, + onValueChange = { cropXInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + crop = Crop + .parseFrom( + cropWidthInput, + cropHeightInput, + cropXInput, + cropYInput + ) + .toString() + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Next) }, + ), + modifier = Modifier.weight(1f), + ) + SuperTextField( + label = "y", + value = cropYInput, + onValueChange = { cropYInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + crop = Crop + .parseFrom( + cropWidthInput, + cropHeightInput, + cropXInput, + cropYInput + ) + .toString() + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { focusManager.clearFocus() }, + ), + modifier = Modifier.weight(1f), + ) + } + } + } + } + } + } + + item { Spacer(Modifier.height(UiSpacing.PageBottom)) } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt deleted file mode 100644 index 417d151..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt +++ /dev/null @@ -1,433 +0,0 @@ -package io.github.miuzarte.scrcpyforandroid.pages - -import android.content.Intent -import android.os.Process -import androidx.compose.animation.AnimatedVisibility -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.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.storage.AppSettings -import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration -import io.github.miuzarte.scrcpyforandroid.storage.Settings -import io.github.miuzarte.scrcpyforandroid.storage.Storage -import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle -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.SnackbarHostState -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 - -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, monet: Boolean): ColorSchemeMode { - return when (baseIndex.coerceIn(0, 2)) { - 0 -> if (monet) ColorSchemeMode.MonetSystem else ColorSchemeMode.System - 1 -> if (monet) ColorSchemeMode.MonetLight else ColorSchemeMode.Light - else -> if (monet) 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( - scrollBehavior: ScrollBehavior, - snack: SnackbarHostState, - onOpenReorderDevices: () -> Unit, - onOpenVirtualButtonOrder: () -> Unit, - onPickServer: () -> Unit, -) { - Scaffold( - topBar = { - TopAppBar( - title = "设置", - scrollBehavior = scrollBehavior, - ) - }, - ) { pagePadding -> - SettingsPage( - contentPadding = pagePadding, - scrollBehavior = scrollBehavior, - snack = snack, - onOpenReorderDevices = onOpenReorderDevices, - onOpenVirtualButtonOrder = onOpenVirtualButtonOrder, - onPickServer = onPickServer, - ) - } -} - -@Composable -fun SettingsPage( - contentPadding: PaddingValues, - scrollBehavior: ScrollBehavior, - snack: SnackbarHostState, - onOpenReorderDevices: () -> Unit, - onOpenVirtualButtonOrder: () -> Unit, - onPickServer: () -> Unit, -) { - val appContext = LocalContext.current.applicationContext - val appSettings = Storage.appSettings - var needMigration by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - needMigration = PreferenceMigration(appContext).needsMigration() - } - - val context = LocalContext.current - - 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 { - scope.launch { - appSettings.saveBundle(asBundleLatest) - } - } - } - - val baseModeItems = THEME_BASE_OPTIONS.map { it.label } - var serverRemotePathInput by rememberSaveable { - mutableStateOf( - if (asBundleShared.serverRemotePath == AppSettings.SERVER_REMOTE_PATH.defaultValue) "" - else asBundleShared.serverRemotePath - ) - } - LaunchedEffect(asBundleShared.serverRemotePath) { - serverRemotePathInput = - if (asBundleShared.serverRemotePath == AppSettings.SERVER_REMOTE_PATH.defaultValue) "" - else asBundleShared.serverRemotePath - } - - var adbKeyNameInput by rememberSaveable { - mutableStateOf( - if (asBundleShared.adbKeyName == AppSettings.ADB_KEY_NAME.defaultValue) "" - else asBundleShared.adbKeyName - ) - } - LaunchedEffect(asBundleShared.adbKeyName) { - adbKeyNameInput = - if (asBundleShared.adbKeyName == AppSettings.ADB_KEY_NAME.defaultValue) "" - else asBundleShared.adbKeyName - } - - // 设置 - LazyColumn( - contentPadding = contentPadding, - scrollBehavior = scrollBehavior, - ) { - item { - SectionSmallTitle("主题") - Card { - SuperDropdown( - title = "外观模式", - summary = resolveThemeLabel(asBundle.themeBaseIndex, asBundle.monet), - items = baseModeItems, - selectedIndex = asBundle.themeBaseIndex.coerceIn(0, baseModeItems.lastIndex), - onSelectedIndexChange = { - asBundle = asBundle.copy(themeBaseIndex = it) - }, - ) - SuperSwitch( - title = "Monet", - summary = "开启后使用 Monet 动态配色", - checked = asBundle.monet, - onCheckedChange = { - asBundle = asBundle.copy(monet = it) - }, - ) - } - - SectionSmallTitle("投屏") - Card { - SuperSwitch( - title = "启用调试信息", - summary = "在全屏界面显示触点数量、设备分辨率和实时 FPS", - checked = asBundle.fullscreenDebugInfo, - onCheckedChange = { - asBundle = asBundle.copy(fullscreenDebugInfo = it) - }, - ) - SuperSwitch( - title = "投屏时保持屏幕常亮", - summary = "Scrcpy 启动后保持本机屏幕常亮,避免锁屏导致 ADB 断开", - checked = asBundle.keepScreenOnWhenStreaming, - onCheckedChange = { - asBundle = asBundle.copy(keepScreenOnWhenStreaming = 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) - }, - ) - } - - 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 = asBundle.customServerUri, - onValueChange = {}, - readOnly = true, - label = "scrcpy-server-v3.3.4", - useLabelAsPlaceholder = asBundle.customServerUri.isBlank(), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - trailingIcon = { - Row(modifier = Modifier.padding(end = UiSpacing.SectionTitleLeadingGap)) { - if (asBundle.customServerUri.isNotBlank()) - IconButton(onClick = { - asBundle = asBundle.copy(customServerUri = "") - }) { - 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, - ) - 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 = AppSettings.SERVER_REMOTE_PATH.defaultValue, - 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, - ) - 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() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - SuperSwitch( - title = "配对时自动启用发现服务", - summary = "打开配对弹窗后自动搜索可用配对端口", - checked = asBundle.adbPairingAutoDiscoverOnDialogOpen, - onCheckedChange = { - asBundle = asBundle.copy( - adbPairingAutoDiscoverOnDialogOpen = it - ) - }, - ) - SuperSwitch( - title = "自动重连已配对设备", - summary = "自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘)", - checked = asBundle.adbAutoReconnectPairedDevice, - onCheckedChange = { - asBundle = asBundle.copy( - adbAutoReconnectPairedDevice = it - ) - }, - ) - } - - // 这部分应该不会显示出来, - // 应用启动时就会执行迁移与旧数据的删除 - AnimatedVisibility(needMigration) { - SectionSmallTitle("应用") - } - AnimatedVisibility(needMigration) { - Card { - SuperArrow( - title = "恢复旧版本配置", - summary = "从旧版本的 SharedPreferences 恢复至 DataStore", - onClick = { - scope.launch { - val migration = PreferenceMigration(appContext) - migration.migrate(clearSharedPrefs = false) - snack.showSnackbar("迁移完成,应用将重启") - - 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) - } - }, - ) - } - } - - SectionSmallTitle("关于") - 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.BottomContent)) } - } -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt new file mode 100644 index 0000000..e4eb92d --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt @@ -0,0 +1,468 @@ +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) + }, + ) + SuperSwitch( + title = "投屏时保持屏幕常亮", + summary = "Scrcpy 启动后保持本机屏幕常亮,避免锁屏导致 ADB 断开", + checked = asBundle.keepScreenOnWhenStreaming, + onCheckedChange = { + asBundle = asBundle.copy(keepScreenOnWhenStreaming = 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)) } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt index ba3a8ba..7d55607 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt @@ -22,6 +22,9 @@ 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 @@ -67,6 +70,7 @@ internal fun VirtualButtonOrderPage( scrollBehavior: ScrollBehavior, ) { val scope = rememberCoroutineScope() + val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } val asBundleShared by appSettings.bundleState.collectAsState() val asBundleSharedLatest by rememberUpdatedState(asBundleShared) @@ -85,7 +89,7 @@ internal fun VirtualButtonOrderPage( } DisposableEffect(Unit) { onDispose { - scope.launch { + taskScope.launch { appSettings.saveBundle(asBundleLatest) } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt index 04f7a7d..fda87f9 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -122,7 +123,7 @@ private fun SliderInputDialog( onDismissRequest = onDismissRequest, onDismissFinished = onDismissFinished, content = { - var text by remember(initialValue) { mutableStateOf(initialValue) } + var text by rememberSaveable(initialValue) { mutableStateOf(initialValue) } SuperTextField( modifier = Modifier.padding(bottom = 16.dp), diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt index a0e56d6..c568040 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt @@ -11,23 +11,15 @@ 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: 修改配置项时, validate() 判断是否合法, 非法则回退修改 - -// TODO: 管理冲突配置项的关系, 在更多参数页中隐藏冲突项 - -// TODO: 禁用配置项验证, 包括不隐藏冲突配置项 - // TODO: 配置切换 -// TODO: 参数预览框可编辑 - data class ClientOptions( // var serial: String = "", // to server // --crop=width:height:x:y var crop: String = "", // to server - // TODO + // TODO: implement // --record var recordFilename: String = "", @@ -236,7 +228,28 @@ data class ClientOptions( } } - fun validate() { + 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 @@ -494,6 +507,8 @@ data class ClientOptions( ) } } + + return this } fun toServerParams(scid: UInt): ServerParams { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt index 68138c6..f8ec94b 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt @@ -10,6 +10,9 @@ import io.github.miuzarte.scrcpyforandroid.constants.Defaults import io.github.miuzarte.scrcpyforandroid.nativecore.AdbSocketStream import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpyAudioPlayer +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.EncoderType import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import kotlinx.coroutines.Dispatchers @@ -49,7 +52,7 @@ class Scrcpy( private val adbService: NativeAdbService, private val serverAsset: String = DEFAULT_SERVER_ASSET, private val customServerUri: String? = null, - private val serverVersion: String = "3.3.4", + private val serverVersion: String = DEFAULT_SERVER_VERSION, private val serverRemotePath: String = DEFAULT_REMOTE_PATH, ) { private val session = Session(adbService) @@ -64,36 +67,29 @@ class Scrcpy( @Volatile private var audioPlayer: ScrcpyAudioPlayer? = null - // Cached encoder and camera size data - private val _videoEncoders = mutableListOf() - private val _audioEncoders = mutableListOf() - private val _videoEncoderTypes = mutableMapOf() - private val _audioEncoderTypes = mutableMapOf() - private val _cameraSizes = mutableListOf() - - val videoEncoders: List get() = _videoEncoders.toList() - val audioEncoders: List get() = _audioEncoders.toList() - val videoEncoderTypes: Map get() = _videoEncoderTypes.toMap() - val audioEncoderTypes: Map get() = _audioEncoderTypes.toMap() - val cameraSizes: List get() = _cameraSizes.toList() + val listings = Listings() companion object { private const val TAG = "Scrcpy" - const val DEFAULT_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar" + const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v3.3.4" + const val DEFAULT_SERVER_ASSET_NAME = "scrcpy-server-v3.3.4" + const val DEFAULT_SERVER_VERSION = "3.3.4" + const val DEFAULT_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar" // Regex patterns for parsing server output - private val VIDEO_ENCODER_REGEX = Regex("--video-encoder=([\\w.\\-]+)") - private val AUDIO_ENCODER_REGEX = Regex("--audio-encoder=([\\w.\\-]+)") - private val VIDEO_ENCODER_FALLBACK_REGEX = Regex("""--video-encoder=['"]?([^'"\s]+)""") - private val AUDIO_ENCODER_FALLBACK_REGEX = Regex("""--audio-encoder=['"]?([^'"\s]+)""") - private val VIDEO_ENCODER_TYPE_REGEX = - Regex("""--video-codec=\S+\s+--video-encoder=(\S+).*?\((hw|sw)\)""") - private val AUDIO_ENCODER_TYPE_REGEX = - Regex("""--audio-codec=\S+\s+--audio-encoder=(\S+).*?\((hw|sw)\)""") - private val CAMERA_SIZE_REGEX = Regex("--camera-size=([0-9]+x[0-9]+)") - private val CAMERA_SIZE_FALLBACK_REGEX = Regex("\\b([1-9][0-9]{1,4}x[1-9][0-9]{1,4})\\b") - private const val PREVIEW_LINES = 32 + private val VIDEO_ENCODER_INFO_REGEX = + Regex("""--video-codec=(\S+)\s+--video-encoder=(\S+)\s+\((hw|sw)\)(\s+\[vendor])?(?:\s+\(alias for (\S+)\))?""") + private val AUDIO_ENCODER_INFO_REGEX = + Regex("""--audio-codec=(\S+)\s+--audio-encoder=(\S+)\s+\((hw|sw)\)(\s+\[vendor])?(?:\s+\(alias for (\S+)\))?""") + private val DISPLAY_REGEX = + Regex("""--display-id=(\d+)\s+\((\d+)x(\d+)\)""") + private val CAMERA_SIZE_REGEX = + Regex("""\b([1-9][0-9]{1,4}x[1-9][0-9]{1,4})\b""") + private val CAMERA_INFO_REGEX = + Regex("""--camera-id=(\S+)\s+\(([^,]+),\s*([0-9]+x[0-9]+),\s*fps=\[([0-9,\s]+)\]\)""") + private val APP_REGEX = + Regex("""^\s*([*-])\s+(.+?)\s{2,}([A-Za-z0-9._]+)\s*$""", RegexOption.MULTILINE) fun generateScid(): UInt { // Only use 31 bits to avoid issues with signed values on the Java-side @@ -169,7 +165,7 @@ class Scrcpy( Log.i( TAG, "start(): Session started successfully - device=${info.deviceName}, " + - "video=${if (options.video) "${info.codecName} ${info.width}x${info.height}" else "off"}, " + + "video=${if (options.video) "${info.codec?.string ?: "null"} ${info.width}x${info.height}" else "off"}, " + "audio=${if (options.audio) options.audioCodec.string else "off"}, " + "control=${options.control}" ) @@ -218,8 +214,6 @@ class Scrcpy( fun getCurrentSession(): Session.SessionInfo? = currentSession - fun getLastServerCommand(): String? = session.getLastServerCommand() - sealed class ListResult { data class Encoders( val videoEncoders: List, @@ -229,67 +223,176 @@ class Scrcpy( val rawOutput: String = "", ) : ListResult() + data class Displays( + val displays: List, + val rawOutput: String = "", + ) : ListResult() + + data class Cameras( + val cameras: List, + val rawOutput: String = "", + ) : ListResult() + data class CameraSizes( val sizes: List, val rawOutput: String = "", ) : ListResult() + + data class Apps( + val apps: List, + val rawOutput: String = "", + ) : ListResult() } - /** - * Refresh encoder lists from the device. - * Results are cached and can be accessed via videoEncoders, audioEncoders, etc. - * - * @throws Exception if the operation fails - */ - suspend fun refreshEncoders() { - val result = listOptions(ListOptions.ENCODERS) as ListResult.Encoders - _videoEncoders.clear() - _videoEncoders.addAll(result.videoEncoders) - _audioEncoders.clear() - _audioEncoders.addAll(result.audioEncoders) - _videoEncoderTypes.clear() - _videoEncoderTypes.putAll(result.videoEncoderTypes) - _audioEncoderTypes.clear() - _audioEncoderTypes.putAll(result.audioEncoderTypes) + inner class Listings { + private val encodersMutex = Mutex() + private val displaysMutex = Mutex() + private val camerasMutex = Mutex() + private val cameraSizesMutex = Mutex() + private val appsMutex = Mutex() + + @Volatile + private var cachedVideoEncoders: List? = null + + @Volatile + private var cachedAudioEncoders: List? = null + + @Volatile + private var cachedDisplays: List? = null + + @Volatile + private var cachedCameras: List? = null + + @Volatile + private var cachedCameraSizes: List? = null + + @Volatile + private var cachedApps: List? = null + + val videoEncoders: List get() = cachedVideoEncoders.orEmpty() + val audioEncoders: List get() = cachedAudioEncoders.orEmpty() + val displays: List get() = cachedDisplays.orEmpty() + val cameras: List get() = cachedCameras.orEmpty() + val cameraSizes: List get() = cachedCameraSizes.orEmpty() + val apps: List get() = cachedApps.orEmpty() + + suspend fun getVideoEncoders(forceRefresh: Boolean = false): List { + cachedVideoEncoders?.takeUnless { forceRefresh }?.let { return it } + return getEncoders(forceRefresh).first + } + + suspend fun getAudioEncoders(forceRefresh: Boolean = false): List { + cachedAudioEncoders?.takeUnless { forceRefresh }?.let { return it } + return getEncoders(forceRefresh).second + } + + private suspend fun getEncoders(forceRefresh: Boolean = false) + : Pair, List> { + if (!forceRefresh && cachedVideoEncoders != null && cachedAudioEncoders != null) + return cachedVideoEncoders.orEmpty() to cachedAudioEncoders.orEmpty() + + return encodersMutex.withLock { + if (!forceRefresh && cachedVideoEncoders != null && cachedAudioEncoders != null) + return@withLock cachedVideoEncoders.orEmpty() to cachedAudioEncoders.orEmpty() + + val output = executeList(ListOptions.ENCODERS) + val (video, audio) = parseEncoders(output) + cachedVideoEncoders = video + cachedAudioEncoders = audio + logListPreview( + list = ListOptions.ENCODERS, + countSummary = "video=${video.size} audio=${audio.size}", + output = output, + ) + video to audio + } + } + + suspend fun getDisplays(forceRefresh: Boolean = false): List { + cachedDisplays?.takeUnless { forceRefresh }?.let { return it } + return displaysMutex.withLock { + cachedDisplays?.takeUnless { forceRefresh } ?: run { + val output = executeList(ListOptions.DISPLAYS) + val parsed = parseDisplays(output) + cachedDisplays = parsed + logListPreview( + list = ListOptions.DISPLAYS, + countSummary = "displays=${parsed.size}", + output = output, + ) + parsed + } + } + } + + suspend fun getCameras(forceRefresh: Boolean = false): List { + cachedCameras?.takeUnless { forceRefresh }?.let { return it } + return camerasMutex.withLock { + cachedCameras?.takeUnless { forceRefresh } ?: run { + val output = executeList(ListOptions.CAMERAS) + val parsed = parseCameras(output) + cachedCameras = parsed + logListPreview( + list = ListOptions.CAMERAS, + countSummary = "cameras=${parsed.size}", + output = output, + ) + parsed + } + } + } + + suspend fun getCameraSizes(forceRefresh: Boolean = false): List { + cachedCameraSizes?.takeUnless { forceRefresh }?.let { return it } + return cameraSizesMutex.withLock { + cachedCameraSizes?.takeUnless { forceRefresh } ?: run { + val output = executeList(ListOptions.CAMERA_SIZES) + val parsed = parseCameraSizes(output) + .sortedWith(compareByDescending { size -> + size.substringBefore('x').toIntOrNull() ?: 0 + }) + cachedCameraSizes = parsed + logListPreview( + list = ListOptions.CAMERA_SIZES, + countSummary = "sizes=${parsed.size}", + output = output, + ) + parsed + } + } + } + + suspend fun getApps(forceRefresh: Boolean = false): List { + cachedApps?.takeUnless { forceRefresh }?.let { return it } + return appsMutex.withLock { + cachedApps?.takeUnless { forceRefresh } ?: run { + val output = executeList(ListOptions.APPS) + val parsed = parseApps(output) + cachedApps = parsed + logListPreview( + list = ListOptions.APPS, + countSummary = "apps=${parsed.size}", + output = output, + ) + parsed + } + } + } - Log.i(TAG, "refreshEncoders(): video=${_videoEncoders.size}, audio=${_audioEncoders.size}") } - /** - * Refresh camera sizes from the device. - * Results are cached and can be accessed via cameraSizes. - * - * @throws Exception if the operation fails - */ - suspend fun refreshCameraSizes() { - val result = listOptions(ListOptions.CAMERA_SIZES) as ListResult.CameraSizes - _cameraSizes.clear() - _cameraSizes.addAll(result.sizes.sortedWith(compareByDescending { size -> - size.substringBefore('x').toIntOrNull() ?: 0 - })) + private suspend fun executeList(list: ListOptions): String = withContext(Dispatchers.IO) { + require(list != ListOptions.NULL) { "Nothing to do with ListOptions.NULL" } - Log.i(TAG, "refreshCameraSizes(): sizes=${_cameraSizes.size}") - } - - /** - * List various options from the scrcpy server. - * - * @param list The type of list to retrieve (ENCODERS, CAMERA_SIZES, etc.) - * @return ListResult containing the requested information - */ - suspend fun listOptions(list: ListOptions): ListResult = withContext(Dispatchers.IO) { val serverJar = if (customServerUri.isNullOrBlank()) { extractAssetToCache(serverAsset) } else { extractUriToCache(customServerUri.toUri()) } - // Push server jar to device adbService.push(serverJar.toPath(), serverRemotePath) val scid = generateScid() - - // Create ClientOptions for listing val options = ClientOptions( video = false, audio = false, @@ -297,10 +400,7 @@ class Scrcpy( cleanUp = false, list = list, ) - val serverParams = options.toServerParams(scid) - - // Build server command val serverCommand = serverParams.build( "CLASSPATH=$serverRemotePath", "app_process", @@ -310,121 +410,133 @@ class Scrcpy( ) Log.i(TAG, "listOptions(): cmd=$serverCommand") - - // Execute shell command and capture output (merge stderr into stdout) - val output = adbService.shell("$serverCommand 2>&1") - - // Parse output based on list option - return@withContext when (list) { - ListOptions.NULL -> { - throw IllegalArgumentException("Nothing to do with ListOptions.NULL") - } - - ListOptions.ENCODERS -> { - val parsed = parseEncoderLists(output) - val preview = output.lineSequence().take(PREVIEW_LINES).joinToString("\n") - Log.i( - TAG, - "listOptions(ENCODERS): parsed video=${parsed.videoEncoders.size} audio=${parsed.audioEncoders.size}, outputPreview=\n$preview", - ) - ListResult.Encoders( - videoEncoders = parsed.videoEncoders, - audioEncoders = parsed.audioEncoders, - videoEncoderTypes = parsed.videoEncoderTypes, - audioEncoderTypes = parsed.audioEncoderTypes, - rawOutput = output, - ) - } - - ListOptions.DISPLAYS -> { - throw Exception("TODO") - } - - ListOptions.CAMERAS -> { - throw Exception("TODO") - } - - ListOptions.CAMERA_SIZES -> { - val parsed = parseCameraSizeLists(output) - val preview = output.lineSequence().take(PREVIEW_LINES).joinToString("\n") - Log.i( - TAG, - "listOptions(CAMERA_SIZES): parsed sizes=${parsed.sizes.size}, outputPreview=\n$preview", - ) - ListResult.CameraSizes( - sizes = parsed.sizes, - rawOutput = output, - ) - } - - else -> { - throw IllegalArgumentException("Unsupported list option: $list") - } - } + adbService.shell("$serverCommand 2>&1") } - private fun parseEncoderLists(output: String): ParsedEncoders { - val video = LinkedHashSet() - val audio = LinkedHashSet() - val videoTypes = linkedMapOf() - val audioTypes = linkedMapOf() - - VIDEO_ENCODER_REGEX.findAll(output).forEach { match -> - video.add(match.groupValues[1]) - } - AUDIO_ENCODER_REGEX.findAll(output).forEach { match -> - audio.add(match.groupValues[1]) - } - // Fallback for log formats that include codec+encoder in one line. - VIDEO_ENCODER_FALLBACK_REGEX.findAll(output).forEach { match -> - video.add(match.groupValues[1]) - } - AUDIO_ENCODER_FALLBACK_REGEX.findAll(output).forEach { match -> - audio.add(match.groupValues[1]) - } - VIDEO_ENCODER_TYPE_REGEX.findAll(output).forEach { match -> - val name = match.groupValues[1] - val type = match.groupValues[2] - if (name.isNotBlank() && type.isNotBlank() && !videoTypes.containsKey(name)) { - videoTypes[name] = type - } - } - AUDIO_ENCODER_TYPE_REGEX.findAll(output).forEach { match -> - val name = match.groupValues[1] - val type = match.groupValues[2] - if (name.isNotBlank() && type.isNotBlank() && !audioTypes.containsKey(name)) { - audioTypes[name] = type - } - } - - return ParsedEncoders( - videoEncoders = video.toList(), - audioEncoders = audio.toList(), - videoEncoderTypes = videoTypes, - audioEncoderTypes = audioTypes, - ) + private fun logListPreview(list: ListOptions, countSummary: String, output: String) { + val preview = output.lineSequence().take(32).joinToString("\n") + Log.i(TAG, "listOptions($list): parsed $countSummary, outputPreview=\n$preview") } - private fun parseCameraSizeLists(output: String): ParsedCameraSizes { + private fun parseEncoders(output: String): Pair, List> { + val videoInfos = linkedMapOf() + val audioInfos = linkedMapOf() + + VIDEO_ENCODER_INFO_REGEX.findAll(output).forEach { match -> + val info = EncoderInfo( + codec = Codec.fromString(match.groupValues[1], Codec.Type.VIDEO), + id = match.groupValues[2], + type = if (match.groupValues[3] == EncoderType.HARDWARE.s) { + EncoderType.HARDWARE + } else { + EncoderType.SOFTWARE + }, + isVendor = match.groupValues[4].isNotBlank(), + aliasOf = match.groupValues[5].ifBlank { null }, + ) + videoInfos.putIfAbsent(info.id, info) + } + + AUDIO_ENCODER_INFO_REGEX.findAll(output).forEach { match -> + val info = EncoderInfo( + codec = Codec.fromString(match.groupValues[1], Codec.Type.AUDIO), + id = match.groupValues[2], + type = if (match.groupValues[3] == EncoderType.HARDWARE.s) { + EncoderType.HARDWARE + } else { + EncoderType.SOFTWARE + }, + isVendor = match.groupValues[4].isNotBlank(), + aliasOf = match.groupValues[5].ifBlank { null }, + ) + audioInfos.putIfAbsent(info.id, info) + } + + return videoInfos.values.toList() to audioInfos.values.toList() + } + + private fun parseDisplays(output: String): List { + val displays = LinkedHashSet() + DISPLAY_REGEX.findAll(output).forEach { match -> + displays.add( + DisplayInfo( + id = match.groupValues[1].toInt(), + width = match.groupValues[2].toInt(), + height = match.groupValues[3].toInt(), + ) + ) + } + return displays.toList() + } + + private fun parseCameras(output: String): List { + val cameras = LinkedHashSet() + CAMERA_INFO_REGEX.findAll(output).forEach { match -> + val facing = match.groupValues[2] + val activeSize = match.groupValues[3] + val fpsValues = match.groupValues[4] + .split(',') + .mapNotNull { it.trim().toIntOrNull() } + + cameras.add( + CameraInfo( + id = match.groupValues[1], + facing = CameraFacing.fromString(facing), + activeSize = activeSize, + fps = fpsValues.map(Int::toUShort), + ) + ) + } + return cameras.toList() + } + + private fun parseCameraSizes(output: String): List { val sizes = LinkedHashSet() CAMERA_SIZE_REGEX.findAll(output).forEach { match -> sizes.add(match.groupValues[1]) } - CAMERA_SIZE_FALLBACK_REGEX.findAll(output).forEach { match -> - sizes.add(match.groupValues[1]) - } - return ParsedCameraSizes(sizes = sizes.toList()) + return sizes.toList() } - private data class ParsedEncoders( - val videoEncoders: List, - val audioEncoders: List, - val videoEncoderTypes: Map, - val audioEncoderTypes: Map, + private fun parseApps(output: String): List { + val apps = LinkedHashSet() + APP_REGEX.findAll(output).forEach { match -> + apps.add( + AppInfo( + system = match.groupValues[1] == "*", + label = match.groupValues[2].trim(), + packageName = match.groupValues[3].trim(), + ) + ) + } + return apps.toList() + } + + data class EncoderInfo( + val codec: Codec, + val id: String, + val type: EncoderType, + val isVendor: Boolean, + val aliasOf: String? = null, ) - private data class ParsedCameraSizes( - val sizes: List, + data class CameraInfo( + val id: String, + val facing: CameraFacing, + val activeSize: String, + val fps: List, + ) + + data class DisplayInfo( + val id: Int, + val width: Int, + val height: Int, + ) + + data class AppInfo( + val system: Boolean, + val label: String, + val packageName: String, ) private suspend fun executeServer( @@ -502,8 +614,6 @@ class Scrcpy( @Volatile private var audioReaderThread: Thread? = null - @Volatile - private var lastServerCommand: String? = null private val serverLogBuffer = ArrayDeque() suspend fun start( @@ -516,7 +626,6 @@ class Scrcpy( val socketName = socketNameFor(scid.toInt()) try { - lastServerCommand = serverCommand val serverStream = adbService.openShellStream(serverCommand) val serverLogThread = startServerLogThread(serverStream, socketName) Thread.sleep(SERVER_BOOT_DELAY_MS) @@ -567,27 +676,58 @@ class Scrcpy( } val deviceName = readDeviceName(firstInput) - val audioCodecId = - if (options.audio) audioCodecIdFromName(options.audioCodec.string) - else 0 - val codecId: Int + val audioCodecId = if (options.audio) { + val aInput = checkNotNull(audioInput) + when (val streamCodecId = aInput.readInt()) { + AUDIO_DISABLED -> { + Log.w(TAG, "audio disabled by server") + if (options.requireAudio) { + throw IllegalStateException( + "Audio is required but was disabled by the server" + ) + } + 0 + } + + AUDIO_ERROR -> { + Log.e(TAG, "audio stream configuration error from server") + if (options.requireAudio) { + throw IllegalStateException( + "Audio is required but the server failed to configure audio capture" + ) + } + 0 + } + + else -> { + Log.i( + TAG, + "audio stream codec=0x${streamCodecId.toUInt().toString(16)}" + ) + streamCodecId + } + } + } else { + 0 + } + val videoCodecId: Int val width: Int val height: Int if (options.video) { val vInput = checkNotNull(videoInput) - codecId = vInput.readInt() + videoCodecId = vInput.readInt() width = vInput.readInt() height = vInput.readInt() } else { - codecId = 0 + videoCodecId = 0 width = 0 height = 0 } val sessionInfo = SessionInfo( deviceName = deviceName, - codecId = codecId, - codecName = codecName(codecId), + codecId = videoCodecId, + codec = Codec.fromId(videoCodecId, Codec.Type.VIDEO), width = width, height = height, audioCodecId = audioCodecId, @@ -669,9 +809,7 @@ class Scrcpy( } } - suspend fun clearVideoConsumer() = mutex.withLock { - videoConsumer = null - } + suspend fun clearVideoConsumer() = mutex.withLock { videoConsumer = null } suspend fun attachAudioConsumer(consumer: (AudioPacket) -> Unit): Unit = mutex.withLock { val session = activeSession ?: throw IllegalStateException("scrcpy session not started") @@ -682,31 +820,6 @@ class Scrcpy( audioReaderThread = thread(start = true, name = "scrcpy-audio-reader") { try { - val streamCodecId = try { - aInput.readInt() - } catch (e: Exception) { - Log.w(TAG, "audio codec header read failed", e) - return@thread - } - when (streamCodecId) { - AUDIO_DISABLED -> { - Log.w(TAG, "audio disabled by server") - return@thread - } - - AUDIO_ERROR -> { - Log.e(TAG, "audio stream configuration error from server") - return@thread - } - - else -> { - Log.i( - TAG, - "audio stream codec=0x${streamCodecId.toUInt().toString(16)}" - ) - } - } - while (activeSession === session && !aStream.closed) { try { val ptsAndFlags = aInput.readLong() @@ -742,18 +855,20 @@ class Scrcpy( } } - suspend fun clearAudioConsumer() = mutex.withLock { - audioConsumer = null - } + suspend fun clearAudioConsumer() = mutex.withLock { audioConsumer = null } - suspend fun injectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) = - mutex.withLock { - try { - requireControlWriter().injectKeycode(action, keycode, repeat, metaState) - } catch (e: IllegalStateException) { - Log.w(TAG, "injectKeycode(): control channel not available", e) - } + suspend fun injectKeycode( + action: Int, + keycode: Int, + repeat: Int = 0, + metaState: Int = 0, + ) = mutex.withLock { + try { + requireControlWriter().injectKeycode(action, keycode, repeat, metaState) + } catch (e: IllegalStateException) { + Log.w(TAG, "injectKeycode(): control channel not available", e) } + } suspend fun injectText(text: String) = mutex.withLock { try { @@ -798,7 +913,7 @@ class Scrcpy( screenHeight: Int, hScroll: Float, vScroll: Float, - buttons: Int + buttons: Int, ) = mutex.withLock { try { requireControlWriter().injectScroll( @@ -815,9 +930,9 @@ class Scrcpy( } } - suspend fun pressBackOrScreenOn(action: Int = KeyEvent.ACTION_DOWN) = mutex.withLock { + suspend fun pressBackOrTurnScreenOn(action: Int = KeyEvent.ACTION_DOWN) = mutex.withLock { try { - requireControlWriter().pressBackOrScreenOn(action) + requireControlWriter().pressBackOrTurnScreenOn(action) } catch (e: IllegalStateException) { Log.w(TAG, "pressBackOrScreenOn(): control channel not available", e) } @@ -865,8 +980,6 @@ class Scrcpy( fun isStarted(): Boolean = activeSession != null - fun getLastServerCommand(): String? = lastServerCommand - private fun requireControlWriter(): ControlWriter { val session = activeSession ?: throw IllegalStateException("scrcpy control channel not available") @@ -948,27 +1061,10 @@ class Scrcpy( return buffer.copyOf(length).toString(Charsets.UTF_8) } - private fun codecName(codecId: Int) = - when (codecId) { - VIDEO_CODEC_H264 -> "h264" - VIDEO_CODEC_H265 -> "h265" - VIDEO_CODEC_AV1 -> "av1" - else -> "unknown" - } - - private fun audioCodecIdFromName(name: String) = - when (name.lowercase()) { - "opus" -> AUDIO_CODEC_OPUS - "aac" -> AUDIO_CODEC_AAC - "raw" -> AUDIO_CODEC_RAW - "flac" -> AUDIO_CODEC_FLAC - else -> 0 - } - data class SessionInfo( val deviceName: String, val codecId: Int, - val codecName: String, + val codec: Codec?, val width: Int, val height: Int, val audioCodecId: Int = 0, @@ -1100,7 +1196,7 @@ class Scrcpy( } @Synchronized - fun pressBackOrScreenOn(action: Int) { + fun pressBackOrTurnScreenOn(action: Int) { output.writeByte(TYPE_BACK_OR_SCREEN_ON) output.writeByte(action) output.flush() @@ -1151,14 +1247,6 @@ class Scrcpy( private const val PACKET_FLAG_KEY_FRAME = 1L shl 62 private const val PACKET_PTS_MASK = (1L shl 62) - 1 - private const val VIDEO_CODEC_H264 = 0x68323634 - private const val VIDEO_CODEC_H265 = 0x68323635 - private const val VIDEO_CODEC_AV1 = 0x00617631 - private const val AUDIO_CODEC_OPUS = 0x6f707573 - private const val AUDIO_CODEC_AAC = 0x00616163 - private const val AUDIO_CODEC_FLAC = 0x666c6163 - private const val AUDIO_CODEC_RAW = 0x00726177 - private const val AUDIO_DISABLED = 0 private const val AUDIO_ERROR = 1 diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt index f8b54d6..0097b4b 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt @@ -125,7 +125,7 @@ data class ServerParams( cmd.add("audio=false") } if (audioBitRate > 0) { - if (audioCodec.isLossyAudio()!!) { + if (!audioCodec.isLossless) { // 比官方实现多个判断编解码器类型 cmd.add("audio_bit_rate=$audioBitRate") } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt index 71cede1..07d4fcb 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt @@ -38,7 +38,6 @@ class Shared { fun Duration.toTick(): Tick = Tick(this.inWholeMicroseconds) } - enum class ListOptions(val value: Int) { NULL(0x0), ENCODERS(0x1), // --list-encoders @@ -52,6 +51,11 @@ class Shared { 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"), @@ -60,26 +64,27 @@ class Shared { ERROR("error"); } - enum class Codec(val string: String, val displayName: String) { - H264("h264", "H.264"), // default, ignore when passing - H265("h265", "H.265"), - AV1("av1", "AV1"), - OPUS("opus", "Opus"), // default, ignore when passing - AAC("aac", "AAC"), - FLAC("flac", "FLAC"), - RAW("raw", "RAW"); // wav raw + 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), - fun isLossyAudio(): Boolean? = when (this) { - OPUS, AAC -> true - FLAC, RAW -> false - else -> null - } + 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 = listOf(H264, H265, AV1) - val AUDIO = listOf(OPUS, AAC, FLAC, RAW) + 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) } @@ -87,6 +92,9 @@ class Shared { Type.VIDEO -> H264 Type.AUDIO -> OPUS } + + fun fromId(value: Int, type: Type? = null): Codec? = + entries.find { it.id == value && (type == null || it.type == type) } } } @@ -177,4 +185,4 @@ class Shared { FALLBACK("fallback"), HIDE("hide"); } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt deleted file mode 100644 index c89c6db..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt +++ /dev/null @@ -1,64 +0,0 @@ -package io.github.miuzarte.scrcpyforandroid.services - -import android.content.Context -import androidx.core.content.edit -import io.github.miuzarte.scrcpyforandroid.constants.AppPreferenceKeys -import io.github.miuzarte.scrcpyforandroid.constants.Defaults -import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut - -internal fun loadQuickDevices(context: Context): List { - val raw = context.getSharedPreferences(AppPreferenceKeys.PREFS_NAME, Context.MODE_PRIVATE) - .getString(AppPreferenceKeys.QUICK_DEVICES, "") - .orEmpty() - - if (raw.isBlank()) return emptyList() - - val result = mutableListOf() - raw.lineSequence().forEach { line -> - val parts = line.split("|", limit = 3) - when (parts.size) { - 3 -> { - val name = parts[0].trim() - val host = parts[1].trim() - val port = parts[2].trim().toIntOrNull() ?: Defaults.ADB_PORT - if (host.isNotBlank()) { - result.add( - DeviceShortcut( - 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(":", Defaults.ADB_PORT.toString()).trim() - .toIntOrNull() ?: Defaults.ADB_PORT - if (host.isNotBlank()) { - result.add( - DeviceShortcut( - name = name, - host = host, - port = port, - online = false, - ), - ) - } - } - } - } - return result -} - -internal fun saveQuickDevices(context: Context, quickDevices: List) { - val raw = quickDevices.joinToString("\n") { "${it.name}|${it.host}|${it.port}" } - context.getSharedPreferences(AppPreferenceKeys.PREFS_NAME, Context.MODE_PRIVATE) - .edit { - putString(AppPreferenceKeys.QUICK_DEVICES, raw) - } -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/SnackbarController.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/SnackbarController.kt new file mode 100644 index 0000000..d23a244 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/SnackbarController.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt index e0ee263..6eb5c96 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt @@ -6,6 +6,7 @@ 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 @@ -47,9 +48,13 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { stringPreferencesKey("custom_server_uri"), "" ) + val CUSTOM_SERVER_VERSION = Pair( + stringPreferencesKey("custom_server_version"), + "" + ) val SERVER_REMOTE_PATH = Pair( stringPreferencesKey("server_remote_path"), - "/data/local/tmp/scrcpy-server.jar" + Scrcpy.DEFAULT_REMOTE_PATH, ) val ADB_KEY_NAME = Pair( stringPreferencesKey("adb_key_name"), @@ -83,6 +88,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { // 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 @@ -102,6 +108,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { val previewVirtualButtonShowText: Boolean, val virtualButtonsLayout: String, val customServerUri: String, + val customServerVersion: String, val serverRemotePath: String, val adbKeyName: String, val adbPairingAutoDiscoverOnDialogOpen: Boolean, @@ -120,6 +127,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { 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 }, @@ -139,6 +147,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { 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( diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt index 4485978..fd030eb 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt @@ -8,6 +8,7 @@ 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 @@ -155,7 +156,7 @@ abstract class Settings( val scope = rememberCoroutineScope() val state = asState(pair) - return remember(state.value) { + return rememberSaveable(state.value) { object : MutableState { override var value: T get() = state.value diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt index 7bd35bc..024e4df 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt @@ -50,6 +50,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput @@ -75,10 +76,13 @@ import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler +import io.github.miuzarte.scrcpyforandroid.services.SnackbarController import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -88,7 +92,6 @@ import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.CardDefaults import top.yukonga.miuix.kmp.basic.CircularProgressIndicator import top.yukonga.miuix.kmp.basic.Icon -import top.yukonga.miuix.kmp.basic.SnackbarHostState import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.TextButton import top.yukonga.miuix.kmp.basic.TextField @@ -157,7 +160,7 @@ internal fun StatusCard( ), secondSmall = StatusSmallCardSpec( "编解码器", - sessionInfo.codecName, + sessionInfo.codec?.displayName ?: "null", ), ) } @@ -295,7 +298,7 @@ internal fun PreviewCard( Box( modifier = Modifier .align(Alignment.BottomEnd) - .padding(UiSpacing.CardContent), + .padding(UiSpacing.ContentVertical), ) { Button( onClick = { @@ -341,7 +344,7 @@ internal fun VirtualButtonCard( onAction = onAction, modifier = Modifier .fillMaxWidth() - .padding(UiSpacing.CardContent), + .padding(UiSpacing.ContentVertical), ) } } @@ -349,9 +352,10 @@ internal fun VirtualButtonCard( @Composable internal fun ConfigPanel( busy: Boolean, - snack: SnackbarHostState, + snackbar: SnackbarController, audioForwardingSupported: Boolean, cameraMirroringSupported: Boolean, + adbConnecting: Boolean, isQuickConnected: Boolean, onOpenAdvanced: () -> Unit, onStartStopHaptic: (() -> Unit)? = null, @@ -360,7 +364,7 @@ internal fun ConfigPanel( sessionInfo: Scrcpy.Session.SessionInfo?, onDisconnect: () -> Unit = {}, ) { - val scope = rememberCoroutineScope() + val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } val sessionStarted = sessionInfo != null @@ -381,7 +385,7 @@ internal fun ConfigPanel( } DisposableEffect(Unit) { onDispose { - scope.launch { + taskScope.launch { scrcpyOptions.saveBundle(soBundleLatest) } } @@ -405,7 +409,6 @@ internal fun ConfigPanel( .coerceAtLeast(0) } - SectionSmallTitle("Scrcpy") Card { SuperSwitch( title = "音频转发", @@ -425,33 +428,29 @@ internal fun ConfigPanel( val codec = Codec.AUDIO[it] soBundle = soBundle.copy(audioCodec = codec.string) if (codec == Codec.FLAC) { - scope.launch { - snack.showSnackbar("注意:FLAC编解码会引入较大的延迟") - } + snackbar.show("注意:FLAC编解码会引入较大的延迟") } }, enabled = !sessionStarted && soBundle.audio, ) AnimatedVisibility(audioBitRateVisibility) { - SuperSlider( - title = "音频码率", - summary = "--audio-bit-rate", - value = if (soBundle.audioBitRate <= 0) 0f - else (ScrcpyPresets.AudioBitRate - .indexOfOrNearest(soBundle.audioBitRate / 1000) + 1 - ).toFloat(), - onValueChange = { value -> - val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.size) - soBundle = soBundle.copy( - audioBitRate = - if (idx == 0) 0 - else ScrcpyPresets.AudioBitRate[idx - 1] * 1000 - ) - }, - valueRange = 0f..ScrcpyPresets.AudioBitRate.size.toFloat(), - steps = (ScrcpyPresets.AudioBitRate.size - 1).coerceAtLeast(0), - unit = "Kbps", - zeroStateText = "默认", + SuperSlider( + title = "音频码率", + summary = "--audio-bit-rate", + value = ScrcpyPresets.AudioBitRate + .indexOfOrNearest(soBundle.audioBitRate / 1000) + .toFloat(), + onValueChange = { value -> + val idx = value.roundToInt() + .coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex) + soBundle = soBundle.copy( + audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1000 + ) + }, + valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(), + steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0), + unit = "Kbps", + zeroStateText = "默认", displayText = (soBundle.audioBitRate / 1_000).toString(), inputInitialValue = if (soBundle.audioBitRate <= 0) "" @@ -475,9 +474,7 @@ internal fun ConfigPanel( val codec = Codec.VIDEO[it] soBundle = soBundle.copy(videoCodec = codec.string) if (codec == Codec.AV1) { - scope.launch { - snack.showSnackbar("注意:绝大部分设备不支持AV1硬件编码") - } + snackbar.show("注意:绝大部分设备不支持AV1硬件编码") } }, enabled = !sessionStarted, @@ -528,8 +525,8 @@ internal fun ConfigPanel( ) SuperArrow( - title = "高级参数", - summary = "更多 scrcpy 启动参数", + title = "更多参数", + summary = "所有 scrcpy 参数", onClick = onOpenAdvanced, enabled = !sessionStarted, ) @@ -537,8 +534,8 @@ internal fun ConfigPanel( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), + .padding(horizontal = UiSpacing.ContentVertical) + .padding(bottom = UiSpacing.ContentVertical), horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), ) { if (isQuickConnected) TextButton( @@ -601,9 +598,10 @@ private fun PairingDialog( var code by rememberSaveable(showDialog) { mutableStateOf("") } var discoveringPort by rememberSaveable(showDialog) { mutableStateOf(false) } val scope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current suspend fun doDiscover() { - if (onDiscoverTarget == null || discoveringPort || !enabled) return + if (!(enabled && onDiscoverTarget != null && !discoveringPort)) return discoveringPort = true val found = withContext(Dispatchers.IO) { onDiscoverTarget.invoke() } if (found != null) { @@ -619,13 +617,6 @@ private fun PairingDialog( } } - fun clearInputs() { - host = "" - port = "" - code = "" - discoveringPort = false - } - SuperDialog( show = showDialog, title = "使用配对码配对设备", @@ -634,80 +625,94 @@ private fun PairingDialog( onDismissRequest() }, onDismissFinished = { - clearInputs() onDismissFinished() }, content = { - TextField( - value = host, - onValueChange = { host = it }, - label = "IP 地址", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = UiSpacing.CardContent), - ) - TextField( - value = port, - onValueChange = { port = it.filter(Char::isDigit) }, - label = "端口", - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier - .fillMaxWidth() - .padding(bottom = UiSpacing.CardContent), - ) - TextField( - value = code, - onValueChange = { code = it }, - label = "WLAN 配对码", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = UiSpacing.CardContent), - ) - - TextButton( - text = if (discoveringPort) "发现中..." else "自动发现", - onClick = { - if (onDiscoverTarget == null || discoveringPort || !enabled) return@TextButton - scope.launch { - doDiscover() - } - }, - enabled = enabled && onDiscoverTarget != null && !discoveringPort, - modifier = Modifier - .fillMaxWidth() - .padding( - top = UiSpacing.Medium, - bottom = UiSpacing.CardContent, + Column( + verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical), + ) { + TextField( + value = host, + onValueChange = { host = it }, + label = "IP 地址", + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, ), - ) - Row( - modifier = Modifier - .padding(bottom = UiSpacing.PopupHorizontal), - horizontalArrangement = Arrangement.spacedBy(UiSpacing.PopupHorizontal), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Next) }, + ), + modifier = Modifier.fillMaxWidth() + ) + TextField( + value = port, + onValueChange = { port = it.filter(Char::isDigit) }, + label = "端口", + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Next) }, + ), + modifier = Modifier.fillMaxWidth() + ) + TextField( + value = code, + onValueChange = { code = it }, + label = "WLAN 配对码", + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { focusManager.clearFocus() }, + ), + modifier = Modifier.fillMaxWidth() + ) + } + + Spacer(Modifier.height(UiSpacing.ContentVertical * 2)) + + Column( + verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical), ) { TextButton( - text = "取消", + text = if (!discoveringPort) "自动发现" else "发现中...", onClick = { - onDismissRequest() + if (enabled && onDiscoverTarget != null && !discoveringPort) + scope.launch { doDiscover() } }, - modifier = Modifier.weight(1f), - ) - TextButton( - text = "配对", - onClick = { - onConfirm(host.trim(), port.trim(), code.trim()) - onDismissRequest() - }, - enabled = enabled && - host.trim().isNotBlank() && - port.trim().isNotBlank() && - code.trim().isNotBlank(), - modifier = Modifier.weight(1f), - colors = ButtonDefaults.textButtonColorsPrimary(), + enabled = enabled && onDiscoverTarget != null && !discoveringPort, + modifier = Modifier.fillMaxWidth(), ) + Row( + horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal), + ) { + TextButton( + text = "取消", + onClick = { + onDismissRequest() + }, + modifier = Modifier.weight(1f), + ) + TextButton( + text = "配对", + onClick = { + onConfirm(host.trim(), port.trim(), code.trim()) + onDismissRequest() + }, + enabled = enabled && + host.trim().isNotBlank() && + port.trim().isNotBlank() && + code.trim().isNotBlank(), + modifier = Modifier.weight(1f), + colors = ButtonDefaults.textButtonColorsPrimary(), + ) + } } }, ) @@ -816,9 +821,9 @@ fun FullscreenControlScreen( Box( modifier = Modifier .align(Alignment.TopStart) - .padding(start = UiSpacing.CardContent, top = UiSpacing.CardContent) + .padding(start = UiSpacing.ContentVertical, top = UiSpacing.ContentVertical) .background(Color.Black.copy(alpha = 0.5f)) - .padding(horizontal = UiSpacing.CardContent, vertical = UiSpacing.Medium), + .padding(horizontal = UiSpacing.ContentVertical, vertical = UiSpacing.Medium), ) { Column(verticalArrangement = Arrangement.spacedBy(UiSpacing.Tiny)) { Text( @@ -877,6 +882,7 @@ private fun ScrcpyVideoSurface( ) { var currentSurface by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() + val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } LaunchedEffect(session, currentSurface) { val surface = currentSurface @@ -889,7 +895,7 @@ private fun ScrcpyVideoSurface( onDispose { val surface = currentSurface if (surface != null) { - scope.launch { nativeCore.detachVideoSurface(surface, releaseDecoder = false) } + taskScope.launch { nativeCore.detachVideoSurface(surface, releaseDecoder = false) } } } } @@ -924,7 +930,7 @@ private fun ScrcpyVideoSurface( override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { val surface = currentSurface if (surface != null) { - scope.launch { + taskScope.launch { nativeCore.detachVideoSurface(surface, releaseDecoder = false) } surface.release() @@ -944,48 +950,70 @@ private fun ScrcpyVideoSurface( @Composable internal fun DeviceTile( device: DeviceShortcut, - actionText: String, + isConnected: Boolean, actionEnabled: Boolean, actionInProgress: Boolean, - onLongPress: () -> Unit, - onContentClick: () -> Unit, + editing: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, onAction: () -> Unit, + onEditorSave: (DeviceShortcut) -> Unit, + onEditorDelete: () -> Unit, + onEditorCancel: () -> Unit, ) { val haptics = rememberAppHaptics() + var name by rememberSaveable(device.id) { mutableStateOf(device.name) } + var host by rememberSaveable(device.id) { mutableStateOf(device.host) } + var port by rememberSaveable(device.id) { mutableStateOf(device.port.toString()) } + + LaunchedEffect(device) { + name = device.name + host = device.host + port = device.port.toString() + } + Card( colors = CardDefaults.defaultColors( - color = if (device.online) MiuixTheme.colorScheme.surfaceContainer - else MiuixTheme.colorScheme.surfaceContainer.copy(alpha = 0.6f), + color = + if (isConnected) MiuixTheme.colorScheme.surfaceContainer + else MiuixTheme.colorScheme.surfaceContainer.copy(alpha = 0.6f), ), - pressFeedbackType = PressFeedbackType.Sink, + pressFeedbackType = if (!editing) PressFeedbackType.Sink else PressFeedbackType.None, onClick = haptics.contextClick, ) { Row( modifier = Modifier .fillMaxWidth() - .combinedClickable( - onClick = onContentClick, - onLongClick = onLongPress, + .then( + if (!isConnected) + Modifier.combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ) + else Modifier ) .padding(UiSpacing.PageItem), verticalAlignment = Alignment.CenterVertically, ) { Row( modifier = Modifier - .weight(1f) - .padding(end = UiSpacing.CardContent), + .weight(1f), verticalAlignment = Alignment.CenterVertically, ) { + // statu dot Box( modifier = Modifier .size(8.dp) .background( - color = if (device.online) Color(0xFF44C74F) else MiuixTheme.colorScheme.outline, + color = + if (isConnected) Color(0xFF44C74F) + else MiuixTheme.colorScheme.outline, shape = CircleShape, ), ) Spacer(modifier = Modifier.width(UiSpacing.PageItem)) - Column(modifier = Modifier.weight(1f)) { + // device name/address + Column { Text( device.name.ifBlank { device.host }, fontWeight = FontWeight.SemiBold, @@ -1003,20 +1031,121 @@ internal fun DeviceTile( } } - Row( - verticalAlignment = Alignment.CenterVertically, - ) { + Row(verticalAlignment = Alignment.CenterVertically) { if (actionInProgress) { CircularProgressIndicator(progress = null) Spacer(Modifier.width(UiSpacing.Medium)) } TextButton( - text = actionText, + text = if (!isConnected) "连接" else "断开", onClick = onAction, enabled = actionEnabled && !actionInProgress, ) } } + + AnimatedVisibility(editing) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = UiSpacing.ContentVertical) + .padding(bottom = UiSpacing.ContentVertical), + verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical) + ) { + SuperTextField( + value = name, + onValueChange = { name = it }, + label = "设备名称", + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + SuperTextField( + value = host, + onValueChange = { host = it }, + label = "IP 地址", + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + SuperTextField( + value = port, + onValueChange = { port = it.filter(Char::isDigit) }, + label = "端口", + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical), + ) { + TextButton( + text = "取消", + onClick = onEditorCancel, + modifier = Modifier.weight(1f), + ) + TextButton( + text = "删除", + onClick = onEditorDelete, + modifier = Modifier.weight(1f), + ) + TextButton( + text = "保存", + onClick = { + val trimmedHost = host.trim() + if (trimmedHost.isNotBlank()) { + onEditorSave( + DeviceShortcut( + name = name.trim(), + host = trimmedHost, + port = port.toIntOrNull() ?: Defaults.ADB_PORT, + ), + ) + } + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.textButtonColorsPrimary(), + ) + } + } + } + } +} + +@Composable +internal fun DeviceTileList( + devices: List, + isConnected: (DeviceShortcut) -> Boolean, + actionEnabled: Boolean, + actionInProgress: (DeviceShortcut) -> Boolean, + editingDeviceId: String?, + onClick: (DeviceShortcut) -> Unit, + onLongClick: (DeviceShortcut) -> Unit, + onAction: (DeviceShortcut) -> Unit, + onEditorSave: (DeviceShortcut, DeviceShortcut) -> Unit, + onEditorDelete: (DeviceShortcut) -> Unit, + onEditorCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical), + ) { + devices.forEach { device -> + DeviceTile( + device = device, + isConnected = isConnected(device), + actionEnabled = actionEnabled, + actionInProgress = actionInProgress(device), + editing = editingDeviceId == device.id, + onClick = { onClick(device) }, + onLongClick = { onLongClick(device) }, + onAction = { onAction(device) }, + onEditorSave = { updated -> onEditorSave(device, updated) }, + onEditorDelete = { onEditorDelete(device) }, + onEditorCancel = onEditorCancel, + ) + } } } @@ -1029,161 +1158,58 @@ internal fun QuickConnectCard( onAddDevice: () -> Unit, enabled: Boolean = true, ) { - val scope = rememberCoroutineScope() val focusManager = LocalFocusManager.current Card( colors = CardDefaults.defaultColors(color = MiuixTheme.colorScheme.primaryContainer), - pressFeedbackType = if (enabled) PressFeedbackType.Tilt else PressFeedbackType.None, + pressFeedbackType = + if (enabled) PressFeedbackType.Tilt + else PressFeedbackType.None, + insideMargin = PaddingValues(UiSpacing.Content), ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding( - start = UiSpacing.Large, - end = UiSpacing.Large, - top = UiSpacing.PageItem, - bottom = UiSpacing.Small, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - Icons.Rounded.AddLink, - contentDescription = "快速连接", - tint = MiuixTheme.colorScheme.onPrimaryContainer, - ) - Spacer(modifier = Modifier.width(UiSpacing.Medium)) - Text( - "快速连接", - fontWeight = FontWeight.Bold, - color = MiuixTheme.colorScheme.onPrimaryContainer, - ) - } - SuperTextField( - value = input, - onValueChange = onValueChange, - label = "IP:PORT", - enabled = enabled, - useLabelAsPlaceholder = true, - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.SectionTitleLeadingGap), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - onFocusLost = onFocusChange, - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), - ) { - TextButton( - text = "添加设备", - onClick = onAddDevice, - modifier = Modifier.weight(1f), - enabled = enabled, - ) - TextButton( - text = "连接", - onClick = onConnect, - modifier = Modifier.weight(1f), - enabled = enabled, - colors = ButtonDefaults.textButtonColorsPrimary(), - ) - } - } -} - -@Composable -internal fun DeviceEditorScreen( - contentPadding: PaddingValues, - device: DeviceShortcut, - onSave: (DeviceShortcut) -> Unit, - onDelete: () -> Unit, - onBack: () -> Unit, -) { - var name by rememberSaveable(device.id) { mutableStateOf(device.name) } - var host by rememberSaveable(device.id) { mutableStateOf(device.host) } - var port by rememberSaveable(device.id) { mutableStateOf(device.port.toString()) } - - BackHandler(enabled = true, onBack = onBack) - - Column( - modifier = Modifier - .fillMaxSize() - .padding(contentPadding) - .padding(UiSpacing.PageHorizontal), - ) { - SectionSmallTitle("编辑设备") - Card { - TextField( - value = name, - onValueChange = { name = it }, - label = "设备名称", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(top = UiSpacing.CardContent), - ) - TextField( - value = host, - onValueChange = { host = it }, - label = "IP 地址", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(top = UiSpacing.CardContent), - ) - TextField( - value = port, - onValueChange = { port = it.filter(Char::isDigit) }, - label = "端口", - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(top = UiSpacing.CardContent), - ) + Column(verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical)) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(UiSpacing.CardContent), + modifier = Modifier.padding(horizontal = UiSpacing.Small), horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), + ) { + Icon( + Icons.Rounded.AddLink, + contentDescription = "快速连接", + tint = MiuixTheme.colorScheme.onPrimaryContainer, + ) + Text( + "快速连接", + fontWeight = FontWeight.Bold, + color = MiuixTheme.colorScheme.onPrimaryContainer, + ) + } + SuperTextField( + value = input, + onValueChange = onValueChange, + label = "IP:PORT", + enabled = enabled, + useLabelAsPlaceholder = true, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + onFocusLost = onFocusChange, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal), ) { TextButton( - text = "返回", - onClick = onBack, + text = "添加设备", + onClick = onAddDevice, modifier = Modifier.weight(1f), + enabled = enabled, ) TextButton( - text = "删除", - onClick = onDelete, - modifier = Modifier.weight(1f), - ) - TextButton( - text = "保存", - onClick = { - val p = port.toIntOrNull() ?: Defaults.ADB_PORT - val h = host.trim() - if (h.isNotBlank()) { - onSave( - DeviceShortcut( - name = name.trim(), - host = h, - port = p, - online = device.online, - ), - ) - } - }, + text = "连接", + onClick = onConnect, modifier = Modifier.weight(1f), + enabled = enabled, colors = ButtonDefaults.textButtonColorsPrimary(), ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ReorderableList.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ReorderableList.kt index 86c0fb6..de22c61 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ReorderableList.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ReorderableList.kt @@ -78,44 +78,40 @@ class ReorderableList( ) { Row( verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(UiSpacing.Small) ) { - if (item.icon != null) { - Icon( - item.icon, - contentDescription = item.title - ) - Spacer(Modifier.padding(horizontal = 4.dp)) - } + if (item.icon != null) Icon( + imageVector = item.icon, + contentDescription = item.title + ) Column { Text( text = item.title, color = MiuixTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) - if (item.subtitle.isNotBlank()) { - Text( - text = item.subtitle, - color = MiuixTheme.colorScheme.onSurfaceVariantSummary, - fontSize = 13.sp, - ) - } + if (item.subtitle.isNotBlank()) Text( + text = item.subtitle, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + fontSize = 13.sp, + ) } } Row( verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(UiSpacing.Small) ) { - if (showCheckbox) { - Checkbox( - state = if (item.checked) ToggleableState.On else ToggleableState.Off, - onClick = { - onCheckboxChange?.invoke(item.id, !item.checked) - }, - enabled = item.checkboxEnabled - ) - Spacer(Modifier.padding(horizontal = 4.dp)) - } + if (showCheckbox) Checkbox( + state = if (item.checked) ToggleableState.On else ToggleableState.Off, + onClick = { + onCheckboxChange?.invoke(item.id, !item.checked) + }, + enabled = item.checkboxEnabled + ) IconButton( - onClick = {}, + onClick = { + haptics.contextClick() + }, modifier = Modifier .draggableHandle( onDragStarted = { @@ -193,7 +189,7 @@ class ReorderableList( ) } } - Spacer(Modifier.padding(UiSpacing.CardContent)) + Spacer(Modifier.padding(UiSpacing.ContentVertical)) Text( text = item.title, color = MiuixTheme.colorScheme.onSurface, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt index e130f30..57585ef 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt @@ -302,7 +302,7 @@ class VirtualButtonBar( action.icon, contentDescription = action.title, modifier = Modifier - .padding(end = UiSpacing.CardContent), + .padding(end = UiSpacing.ContentVertical), ) }, title = action.title, From e17e1badaf70a1bebcad84634ad659f4fe658499 Mon Sep 17 00:00:00 2001 From: Miuzarte <982809597@qq.com> Date: Fri, 10 Apr 2026 01:19:20 +0800 Subject: [PATCH 8/8] refactor: impl more scrcpy options; remove unused class/constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除不再需要的 `AppDefaults` 与 `AppPreferenceKeys` - 实现了 `--show-touches`, `--no-power-on`, `--power-off-on-close`, `--disable-screensaver`, `--screen-off-timeout=seconds`, `--require-audio`, `--no-vd-destroy-content`, `--no-vd-system-decorations` --- TODO.md | 16 - .../scrcpyforandroid/constants/AppDefaults.kt | 82 --- .../constants/AppPreferenceKeys.kt | 79 --- .../scrcpyforandroid/constants/Defaults.kt | 1 - .../scrcpyforandroid/pages/DeviceTabScreen.kt | 31 +- .../scrcpyforandroid/pages/MainScreen.kt | 18 - .../pages/ScrcpyAllOptionsScreen.kt | 104 +++- .../scrcpyforandroid/pages/SettingsScreen.kt | 8 - .../scrcpyforandroid/scaffolds/LazyColumn.kt | 14 +- .../scrcpyforandroid/scaffolds/SuperSlider.kt | 7 + .../services/AppSettingsStore.kt | 528 ------------------ .../scrcpyforandroid/services/EventLogger.kt | 6 +- .../scrcpyforandroid/storage/AppSettings.kt | 8 - .../storage/PreferenceMigration.kt | 374 ++++++++----- 14 files changed, 371 insertions(+), 905 deletions(-) delete mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppDefaults.kt delete mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppPreferenceKeys.kt delete mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppSettingsStore.kt diff --git a/TODO.md b/TODO.md index 97448bf..5581187 100644 --- a/TODO.md +++ b/TODO.md @@ -1,26 +1,10 @@ # TODO -## CURRENT - -- Refactoring - -## WIDGETS - -- `SuperTextField` Click to pop a dialog with custom notes/summary - ## 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). -- `-t, --show-touches` 显示受控机的物理触控 Enable "show touches" on start, restore the initial value on exit. It only shows physical touches (not clicks from scrcpy). -- `--no-power-on` 开始投屏时不唤醒屏幕 Do not power on the device on start. -- `--power-off-on-close` 结束投屏时息屏 Turn the device screen off when closing scrcpy. -- `--disable-screensaver` 投屏时禁用自动息屏 Disable screensaver while scrcpy is running. -- `--screen-off-timeout=seconds` 投屏过程中的息屏时间 Set the screen off timeout while scrcpy is running (restore the initial value on exit). -- `--require-audio` This option makes scrcpy fail if audio is enabled but does not work. -- `--no-vd-destroy-content` 投屏结束关闭虚拟显示器时不结束进程 Disable virtual display "destroy content on removal" flag. With this option, when the virtual display is closed, the running apps are moved to the main display rather than being destroyed. -- `--no-vd-system-decorations` Disable virtual display system decorations flag. ## FEATURES diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppDefaults.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppDefaults.kt deleted file mode 100644 index a6c1103..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppDefaults.kt +++ /dev/null @@ -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 -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppPreferenceKeys.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppPreferenceKeys.kt deleted file mode 100644 index 198bd21..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppPreferenceKeys.kt +++ /dev/null @@ -1,79 +0,0 @@ -package io.github.miuzarte.scrcpyforandroid.constants - -object AppPreferenceKeys { - 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 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" -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/Defaults.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/Defaults.kt index eecfdb6..c5cc2f0 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/Defaults.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/Defaults.kt @@ -1,6 +1,5 @@ package io.github.miuzarte.scrcpyforandroid.constants object Defaults { - const val EVENT_LOG_LINES = 512 const val ADB_PORT = 5555; } \ No newline at end of file diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt index 1aea49f..e54427d 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt @@ -1,7 +1,9 @@ package io.github.miuzarte.scrcpyforandroid.pages import android.annotation.SuppressLint +import android.app.Activity import android.util.Log +import android.view.WindowManager import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -22,6 +24,7 @@ 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 io.github.miuzarte.scrcpyforandroid.NativeCoreFacade import io.github.miuzarte.scrcpyforandroid.constants.Defaults @@ -92,7 +95,6 @@ fun DeviceTabScreen( snackbar: SnackbarController, scrollBehavior: ScrollBehavior, onOpenVirtualButtonOrder: () -> Unit, - onSessionStartedChange: (Boolean) -> Unit, onOpenReorderDevices: () -> Unit, onOpenAdvancedPage: () -> Unit, onOpenFullscreenPage: (Scrcpy.Session.SessionInfo) -> Unit, @@ -142,7 +144,6 @@ fun DeviceTabScreen( scrcpy = scrcpy, snackbar = snackbar, scrollBehavior = scrollBehavior, - onSessionStartedChange = onSessionStartedChange, onOpenAdvancedPage = onOpenAdvancedPage, onOpenFullscreenPage = onOpenFullscreenPage, ) @@ -157,13 +158,14 @@ fun DeviceTabPage( scrcpy: Scrcpy, snackbar: SnackbarController, scrollBehavior: ScrollBehavior, - onSessionStartedChange: (Boolean) -> Unit, onOpenAdvancedPage: () -> Unit, onOpenFullscreenPage: (Scrcpy.Session.SessionInfo) -> Unit, ) { + val context = LocalContext.current val scope = rememberCoroutineScope() val taskScope = remember { CoroutineScope(Dispatchers.IO + SupervisorJob()) } val haptics = rememberAppHaptics() + val activity = remember(context) { context as? Activity } val asBundleShared by appSettings.bundleState.collectAsState() val asBundleSharedLatest by rememberUpdatedState(asBundleShared) @@ -214,6 +216,23 @@ fun DeviceTabPage( // read only val soBundleShared by scrcpyOptions.bundleState.collectAsState() + fun setKeepScreenOn(enabled: Boolean) { + val window = activity?.window ?: return + window.decorView.post { + if (enabled) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + } + + DisposableEffect(activity) { + onDispose { + setKeepScreenOn(false) + } + } + // Run adb operations on a dedicated single thread. // Try to avoid blocking UI/recomposition and keeps adb call ordering deterministic. @@ -323,6 +342,7 @@ fun DeviceTabPage( ) { // Also stops scrcpy. runCatching { scrcpy.stop() } + setKeepScreenOn(false) runCatching { adbService.disconnect() } adbConnected = false currentTargetHost = "" @@ -706,7 +726,6 @@ fun DeviceTabPage( sessionInfoCodec = null sessionInfoControlEnabled = false } - onSessionStartedChange(sessionInfo != null) } fun sendVirtualButtonAction(action: VirtualButtonAction) { @@ -920,6 +939,9 @@ fun DeviceTabPage( runBusy("启动 scrcpy") { val options = scrcpyOptions.toClientOptions(soBundleShared).fix() val session = scrcpy.start(options) + if (options.disableScreensaver) { + setKeepScreenOn(true) + } sessionInfo = session.copy( host = currentTargetHost, port = currentTargetPort @@ -949,6 +971,7 @@ fun DeviceTabPage( onStop = { runBusy("停止 scrcpy") { scrcpy.stop() + setKeepScreenOn(false) sessionInfo = null statusLine = "${currentTarget!!.host}:${currentTarget.port}" logEvent("scrcpy 已停止") diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt index caaf808..82753dc 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt @@ -5,7 +5,6 @@ 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 @@ -266,27 +265,11 @@ fun MainScreen() { } } - var sessionStarted by remember { mutableStateOf(false) } var showReorderDevices by rememberSaveable { mutableStateOf(false) } var fullscreenOrientation by rememberSaveable { mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) } - val keepScreenOnWhenStreamingEnabled = asBundle.keepScreenOnWhenStreaming - // 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) { @@ -356,7 +339,6 @@ fun MainScreen() { snackbar = snackbarController, scrollBehavior = devicesPageScrollBehavior, onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) }, - onSessionStartedChange = { sessionStarted = it }, onOpenReorderDevices = { showReorderDevices = true }, onOpenAdvancedPage = { rootBackStack.add(RootScreen.Advanced) }, onOpenFullscreenPage = { session -> diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt index 8f9a5c7..9b2c488 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt @@ -264,14 +264,19 @@ internal fun ScrcpyAllOptionsPage( val videoEncoderDropdownItems = rememberSaveable(videoEncoders, listRefreshVersion) { listOf("") + videoEncoders } - val videoEncoderIndex = rememberSaveable(soBundle.videoEncoder, videoEncoders, listRefreshVersion) { - (videoEncoders.indexOf(soBundle.videoEncoder) + 1).coerceAtLeast(0) - } + val videoEncoderIndex = + rememberSaveable(soBundle.videoEncoder, videoEncoders, listRefreshVersion) { + (videoEncoders.indexOf(soBundle.videoEncoder) + 1).coerceAtLeast(0) + } val audioEncoderDropdownItems = rememberSaveable(audioEncoders, listRefreshVersion) { listOf("") + audioEncoders } - val audioEncoderIndex = rememberSaveable(soBundle.audioEncoder, audioEncoders, listRefreshVersion) { + val audioEncoderIndex = rememberSaveable( + soBundle.audioEncoder, + audioEncoders, + listRefreshVersion + ) { (audioEncoders.indexOf(soBundle.audioEncoder) + 1).coerceAtLeast(0) } @@ -419,6 +424,77 @@ internal fun ScrcpyAllOptionsPage( }, // enabled = control || video, ) + SuperSlider( + title = "投屏过程中受控机的息屏时间", + summary = "--screen-off-timeout", + value = soBundle.screenOffTimeout.coerceAtLeast(0).toFloat(), + onValueChange = { + soBundle = soBundle.copy( + screenOffTimeout = it.roundToInt() + .takeIf { value -> value > 0 } + ?.toLong() ?: -1 + ) + }, + valueRange = 0f..600f, + steps = 600 - 1, + unit = "s", + zeroStateText = "默认", + displayText = + if (soBundle.screenOffTimeout <= 0) "默认" + else soBundle.screenOffTimeout.toString(), + inputInitialValue = + if (soBundle.screenOffTimeout <= 0) "" + else soBundle.screenOffTimeout.toString(), + inputFilter = { it.filter(Char::isDigit) }, + inputValueRange = 0f..86400f, + onInputConfirm = { + soBundle = soBundle.copy( + screenOffTimeout = it.toLongOrNull() + ?.takeIf { value -> value > 0 } + ?: -1 + ) + }, + ) + SuperSwitch( + title = "开始投屏时不唤醒屏幕", + summary = "--no-power-on", + checked = !soBundle.powerOn, + onCheckedChange = { + soBundle = soBundle.copy( + powerOn = !it + ) + }, + ) + SuperSwitch( + title = "结束投屏时息屏", + summary = "--power-off-on-close", + checked = soBundle.powerOffOnClose, + onCheckedChange = { + soBundle = soBundle.copy( + powerOffOnClose = it + ) + }, + ) + SuperSwitch( + title = "显示物理触控", + summary = "--show-touches", + checked = soBundle.showTouches, + onCheckedChange = { + soBundle = soBundle.copy( + showTouches = it + ) + }, + ) + SuperSwitch( + title = "投屏时保持本机屏幕唤醒", + summary = "--disable-screensaver", + checked = soBundle.disableScreensaver, + onCheckedChange = { + soBundle = soBundle.copy( + disableScreensaver = it + ) + }, + ) } } @@ -784,6 +860,26 @@ internal fun ScrcpyAllOptionsPage( ) }, ) + SuperSwitch( + title = "关闭虚拟显示器时保留内容", + summary = "--no-vd-destroy-content", + checked = !soBundle.vdDestroyContent, + onCheckedChange = { + soBundle = soBundle.copy( + vdDestroyContent = !it + ) + }, + ) + SuperSwitch( + title = "禁用虚拟显示器系统装饰", + summary = "--no-vd-system-decorations", + checked = !soBundle.vdSystemDecorations, + onCheckedChange = { + soBundle = soBundle.copy( + vdSystemDecorations = !it + ) + }, + ) } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt index e4eb92d..9e9b533 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt @@ -194,14 +194,6 @@ fun SettingsPage( asBundle = asBundle.copy(fullscreenDebugInfo = it) }, ) - SuperSwitch( - title = "投屏时保持屏幕常亮", - summary = "Scrcpy 启动后保持本机屏幕常亮,避免锁屏导致 ADB 断开", - checked = asBundle.keepScreenOnWhenStreaming, - onCheckedChange = { - asBundle = asBundle.copy(keepScreenOnWhenStreaming = it) - }, - ) SuperSlider( title = "预览卡高度", summary = "设备页预览卡高度", diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/LazyColumn.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/LazyColumn.kt index 4e781a0..23b3e22 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/LazyColumn.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/LazyColumn.kt @@ -32,18 +32,16 @@ fun LazyColumn( content: LazyListScope.() -> Unit, ) { val focusManager = LocalFocusManager.current - val focusClearModifier = if (clearFocusOnTap) { - Modifier.pointerInput(Unit) { - detectTapGestures(onTap = { focusManager.clearFocus() }) - } - } else { - Modifier - } LazyColumn( modifier = modifier .fillMaxSize() - .then(focusClearModifier) + .then( + if (clearFocusOnTap) + Modifier.pointerInput(Unit) { + detectTapGestures(onTap = { focusManager.clearFocus() }) + } else Modifier + ) .overScrollVertical() .nestedScroll(scrollBehavior.nestedScrollConnection) .padding(contentPadding), diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt index fda87f9..1581228 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -12,6 +13,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import top.yukonga.miuix.kmp.basic.ButtonDefaults import top.yukonga.miuix.kmp.basic.Slider @@ -131,6 +134,10 @@ private fun SliderInputDialog( label = label, useLabelAsPlaceholder = useLabelAsPlaceholder, maxLines = 1, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), onValueChange = { newValue -> text = inputFilter(newValue) }, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppSettingsStore.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppSettingsStore.kt deleted file mode 100644 index d9d2c1e..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppSettingsStore.kt +++ /dev/null @@ -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, - ) - } -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/EventLogger.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/EventLogger.kt index 84204d3..ae02188 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/EventLogger.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/EventLogger.kt @@ -17,6 +17,8 @@ import java.util.Locale object EventLogger { private const val LOG_TAG = "EventLogger" + const val MAX_LINES = 512 + private val _eventLog: SnapshotStateList = mutableStateListOf() /** @@ -36,8 +38,8 @@ object EventLogger { _eventLog.add(0, "[$timestamp] $message") // Rotate logs if exceeds max size - if (_eventLog.size > Defaults.EVENT_LOG_LINES) { - _eventLog.removeRange(Defaults.EVENT_LOG_LINES, _eventLog.size) + if (_eventLog.size > MAX_LINES) { + _eventLog.removeRange(MAX_LINES, _eventLog.size) } // Log to Android logcat diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt index 6eb5c96..fff143f 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt @@ -28,10 +28,6 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { booleanPreferencesKey("show_fullscreen_virtual_buttons"), true ) - val KEEP_SCREEN_ON_WHEN_STREAMING = Pair( - booleanPreferencesKey("keep_screen_on_when_streaming"), - false - ) val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair( intPreferencesKey("device_preview_card_height_dp"), 320 @@ -81,7 +77,6 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { // Scrcpy Settings val fullscreenDebugInfo by setting(FULLSCREEN_DEBUG_INFO) val showFullscreenVirtualButtons by setting(SHOW_FULLSCREEN_VIRTUAL_BUTTONS) - val keepScreenOnWhenStreaming by setting(KEEP_SCREEN_ON_WHEN_STREAMING) 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) @@ -103,7 +98,6 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { val monet: Boolean, val fullscreenDebugInfo: Boolean, val showFullscreenVirtualButtons: Boolean, - val keepScreenOnWhenStreaming: Boolean, val devicePreviewCardHeightDp: Int, val previewVirtualButtonShowText: Boolean, val virtualButtonsLayout: String, @@ -122,7 +116,6 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { bundleField(MONET) { bundle: Bundle -> bundle.monet }, bundleField(FULLSCREEN_DEBUG_INFO) { bundle: Bundle -> bundle.fullscreenDebugInfo }, bundleField(SHOW_FULLSCREEN_VIRTUAL_BUTTONS) { bundle: Bundle -> bundle.showFullscreenVirtualButtons }, - bundleField(KEEP_SCREEN_ON_WHEN_STREAMING) { bundle: Bundle -> bundle.keepScreenOnWhenStreaming }, 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 }, @@ -142,7 +135,6 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { monet = preferences.read(MONET), fullscreenDebugInfo = preferences.read(FULLSCREEN_DEBUG_INFO), showFullscreenVirtualButtons = preferences.read(SHOW_FULLSCREEN_VIRTUAL_BUTTONS), - keepScreenOnWhenStreaming = preferences.read(KEEP_SCREEN_ON_WHEN_STREAMING), devicePreviewCardHeightDp = preferences.read(DEVICE_PREVIEW_CARD_HEIGHT_DP), previewVirtualButtonShowText = preferences.read(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT), virtualButtonsLayout = preferences.read(VIRTUAL_BUTTONS_LAYOUT), diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/PreferenceMigration.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/PreferenceMigration.kt index 266ddc4..47455f7 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/PreferenceMigration.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/PreferenceMigration.kt @@ -4,8 +4,6 @@ import android.content.Context import android.content.SharedPreferences import android.util.Log import androidx.core.content.edit -import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults -import io.github.miuzarte.scrcpyforandroid.constants.AppPreferenceKeys import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -16,7 +14,7 @@ private const val TAG = "PreferenceMigration" */ class PreferenceMigration(private val appContext: Context) { private val appSharedPrefs: SharedPreferences by lazy { - appContext.getSharedPreferences(AppPreferenceKeys.PREFS_NAME, Context.MODE_PRIVATE) + appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) } private val sharedPrefs: SharedPreferences by lazy { @@ -80,80 +78,75 @@ class PreferenceMigration(private val appContext: Context) { // Theme Settings migrateInt( - AppPreferenceKeys.THEME_BASE_INDEX, - AppDefaults.THEME_BASE_INDEX, - appSettings.themeBaseIndex + THEME_BASE_INDEX, + AppSettings.THEME_BASE_INDEX.defaultValue, + appSettings.themeBaseIndex, ) migrateBoolean( - AppPreferenceKeys.MONET, - AppDefaults.MONET, - appSettings.monet + MONET, + AppSettings.MONET.defaultValue, + appSettings.monet, ) // Scrcpy Settings migrateBoolean( - AppPreferenceKeys.FULLSCREEN_DEBUG_INFO, - AppDefaults.FULLSCREEN_DEBUG_INFO, - appSettings.fullscreenDebugInfo + FULLSCREEN_DEBUG_INFO, + AppSettings.FULLSCREEN_DEBUG_INFO.defaultValue, + appSettings.fullscreenDebugInfo, ) migrateBoolean( - AppPreferenceKeys.SHOW_FULLSCREEN_VIRTUAL_BUTTONS, - AppDefaults.SHOW_FULLSCREEN_VIRTUAL_BUTTONS, - appSettings.showFullscreenVirtualButtons - ) - migrateBoolean( - AppPreferenceKeys.KEEP_SCREEN_ON_WHEN_STREAMING, - AppDefaults.KEEP_SCREEN_ON_WHEN_STREAMING, - appSettings.keepScreenOnWhenStreaming + SHOW_FULLSCREEN_VIRTUAL_BUTTONS, + AppSettings.SHOW_FULLSCREEN_VIRTUAL_BUTTONS.defaultValue, + appSettings.showFullscreenVirtualButtons, ) migrateInt( - AppPreferenceKeys.DEVICE_PREVIEW_CARD_HEIGHT_DP, - AppDefaults.DEVICE_PREVIEW_CARD_HEIGHT_DP, - appSettings.devicePreviewCardHeightDp + DEVICE_PREVIEW_CARD_HEIGHT_DP, + AppSettings.DEVICE_PREVIEW_CARD_HEIGHT_DP.defaultValue, + appSettings.devicePreviewCardHeightDp, ) migrateBoolean( - AppPreferenceKeys.PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT, - AppDefaults.PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT, - appSettings.previewVirtualButtonShowText + PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT, + AppSettings.PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT.defaultValue, + appSettings.previewVirtualButtonShowText, ) migrateString( - AppPreferenceKeys.VIRTUAL_BUTTONS_LAYOUT, - AppDefaults.VIRTUAL_BUTTONS_LAYOUT, - appSettings.virtualButtonsLayout + VIRTUAL_BUTTONS_LAYOUT, + AppSettings.VIRTUAL_BUTTONS_LAYOUT.defaultValue, + appSettings.virtualButtonsLayout, ) // Scrcpy Server Settings migrateString( - AppPreferenceKeys.CUSTOM_SERVER_URI, - AppDefaults.CUSTOM_SERVER_URI ?: "", - appSettings.customServerUri + CUSTOM_SERVER_URI, + AppSettings.CUSTOM_SERVER_URI.defaultValue, + appSettings.customServerUri, ) migrateString( - AppPreferenceKeys.SERVER_REMOTE_PATH, - AppDefaults.SERVER_REMOTE_PATH_INPUT, - appSettings.serverRemotePath + SERVER_REMOTE_PATH, + AppSettings.SERVER_REMOTE_PATH.defaultValue, + appSettings.serverRemotePath, ) // ADB Settings migrateString( - AppPreferenceKeys.ADB_KEY_NAME, - AppDefaults.ADB_KEY_NAME_INPUT, - appSettings.adbKeyName + ADB_KEY_NAME, + AppSettings.ADB_KEY_NAME.defaultValue, + appSettings.adbKeyName, ) migrateBoolean( - AppPreferenceKeys.ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN, - AppDefaults.ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN, - appSettings.adbPairingAutoDiscoverOnDialogOpen + ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN, + AppSettings.ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN.defaultValue, + appSettings.adbPairingAutoDiscoverOnDialogOpen, ) migrateBoolean( - AppPreferenceKeys.ADB_AUTO_RECONNECT_PAIRED_DEVICE, - AppDefaults.ADB_AUTO_RECONNECT_PAIRED_DEVICE, - appSettings.adbAutoReconnectPairedDevice + ADB_AUTO_RECONNECT_PAIRED_DEVICE, + AppSettings.ADB_AUTO_RECONNECT_PAIRED_DEVICE.defaultValue, + appSettings.adbAutoReconnectPairedDevice, ) migrateBoolean( - AppPreferenceKeys.ADB_MDNS_LAN_DISCOVERY, - AppDefaults.ADB_MDNS_LAN_DISCOVERY, - appSettings.adbMdnsLanDiscovery + ADB_MDNS_LAN_DISCOVERY, + AppSettings.ADB_MDNS_LAN_DISCOVERY.defaultValue, + appSettings.adbMdnsLanDiscovery, ) Log.d(TAG, "AppSettings migration completed") @@ -167,114 +160,115 @@ class PreferenceMigration(private val appContext: Context) { // Audio & Video Codecs migrateString( - AppPreferenceKeys.AUDIO_CODEC, - AppDefaults.AUDIO_CODEC, - scrcpyOptions.audioCodec + AUDIO_CODEC, + ScrcpyOptions.AUDIO_CODEC.defaultValue, + scrcpyOptions.audioCodec, ) migrateString( - AppPreferenceKeys.VIDEO_CODEC, - AppDefaults.VIDEO_CODEC, - scrcpyOptions.videoCodec + VIDEO_CODEC, + ScrcpyOptions.VIDEO_CODEC.defaultValue, + scrcpyOptions.videoCodec, ) // Bit Rates val audioBitRateKbps = sharedPrefs.getInt( - AppPreferenceKeys.AUDIO_BIT_RATE_KBPS, - AppDefaults.AUDIO_BIT_RATE_KBPS + AUDIO_BIT_RATE_KBPS, + ScrcpyOptions.AUDIO_BIT_RATE.defaultValue / 1_000, ) - scrcpyOptions.audioBitRate.set(audioBitRateKbps * 1000) // Convert to bps + scrcpyOptions.audioBitRate.set(audioBitRateKbps * 1_000) // Convert to bps val videoBitRateMbps = sharedPrefs.getFloat( - AppPreferenceKeys.VIDEO_BIT_RATE_MBPS, - AppDefaults.VIDEO_BIT_RATE_MBPS + VIDEO_BIT_RATE_MBPS, + (ScrcpyOptions.VIDEO_BIT_RATE.defaultValue / 1_000_000).toFloat(), ) scrcpyOptions.videoBitRate.set((videoBitRateMbps * 1_000_000).toInt()) // Control Options migrateBoolean( - AppPreferenceKeys.TURN_SCREEN_OFF, - AppDefaults.TURN_SCREEN_OFF, - scrcpyOptions.turnScreenOff + TURN_SCREEN_OFF, + ScrcpyOptions.TURN_SCREEN_OFF.defaultValue, + scrcpyOptions.turnScreenOff, ) migrateBoolean( - AppPreferenceKeys.NO_CONTROL, - AppDefaults.NO_CONTROL + NO_CONTROL, + !ScrcpyOptions.CONTROL.defaultValue, ) { value -> scrcpyOptions.control.set(!value) // Invert logic } migrateBoolean( - AppPreferenceKeys.NO_VIDEO, - AppDefaults.NO_VIDEO + NO_VIDEO, + !ScrcpyOptions.VIDEO.defaultValue, ) { value -> scrcpyOptions.video.set(!value) // Invert logic } // Video Source val videoSourcePreset = sharedPrefs.getString( - AppPreferenceKeys.VIDEO_SOURCE_PRESET, - AppDefaults.VIDEO_SOURCE_PRESET - ).orEmpty().ifBlank { AppDefaults.VIDEO_SOURCE_PRESET } + VIDEO_SOURCE_PRESET, + ScrcpyOptions.VIDEO_SOURCE.defaultValue, + ).orEmpty().ifBlank { ScrcpyOptions.VIDEO_SOURCE.defaultValue } scrcpyOptions.videoSource.set(videoSourcePreset) migrateString( - AppPreferenceKeys.DISPLAY_ID, - AppDefaults.DISPLAY_ID + DISPLAY_ID, + ScrcpyOptions.DISPLAY_ID.defaultValue.toString(), ) { value -> value.toIntOrNull()?.let { scrcpyOptions.displayId.set(it) } } // Camera Settings migrateString( - AppPreferenceKeys.CAMERA_ID, - AppDefaults.CAMERA_ID, - scrcpyOptions.cameraId + CAMERA_ID, + ScrcpyOptions.CAMERA_ID.defaultValue, + scrcpyOptions.cameraId, ) migrateString( - AppPreferenceKeys.CAMERA_FACING_PRESET, - AppDefaults.CAMERA_FACING_PRESET, - scrcpyOptions.cameraFacing + CAMERA_FACING_PRESET, + ScrcpyOptions.CAMERA_FACING.defaultValue, + scrcpyOptions.cameraFacing, ) migrateString( - AppPreferenceKeys.CAMERA_SIZE_PRESET, - AppDefaults.CAMERA_SIZE_PRESET + CAMERA_SIZE_PRESET, + ScrcpyOptions.CAMERA_SIZE.defaultValue, ) { value -> if (value == "custom") { val customSize = sharedPrefs.getString( - AppPreferenceKeys.CAMERA_SIZE_CUSTOM, - AppDefaults.CAMERA_SIZE_CUSTOM + CAMERA_SIZE_CUSTOM, + ScrcpyOptions.CAMERA_SIZE_CUSTOM.defaultValue, ).orEmpty() - scrcpyOptions.cameraSize.set(customSize) + scrcpyOptions.cameraSizeCustom.set(customSize) + scrcpyOptions.cameraSizeUseCustom.set(true) } else { scrcpyOptions.cameraSize.set(value) } } migrateString( - AppPreferenceKeys.CAMERA_AR, - AppDefaults.CAMERA_AR, - scrcpyOptions.cameraAr + CAMERA_AR, + ScrcpyOptions.CAMERA_AR.defaultValue, + scrcpyOptions.cameraAr, ) migrateString( - AppPreferenceKeys.CAMERA_FPS, - AppDefaults.CAMERA_FPS + CAMERA_FPS, + ScrcpyOptions.CAMERA_FPS.defaultValue.toString(), ) { value -> value.toIntOrNull()?.let { scrcpyOptions.cameraFps.set(it) } } migrateBoolean( - AppPreferenceKeys.CAMERA_HIGH_SPEED, - AppDefaults.CAMERA_HIGH_SPEED, - scrcpyOptions.cameraHighSpeed + CAMERA_HIGH_SPEED, + ScrcpyOptions.CAMERA_HIGH_SPEED.defaultValue, + scrcpyOptions.cameraHighSpeed, ) // Audio Source val audioSourcePreset = sharedPrefs.getString( - AppPreferenceKeys.AUDIO_SOURCE_PRESET, - AppDefaults.AUDIO_SOURCE_PRESET - ).orEmpty().ifBlank { AppDefaults.AUDIO_SOURCE_PRESET } + AUDIO_SOURCE_PRESET, + "auto", + ).orEmpty().ifBlank { "auto" } if (audioSourcePreset == "custom") { val customSource = sharedPrefs.getString( - AppPreferenceKeys.AUDIO_SOURCE_CUSTOM, - AppDefaults.AUDIO_SOURCE_CUSTOM + AUDIO_SOURCE_CUSTOM, + ScrcpyOptions.AUDIO_SOURCE.defaultValue, ).orEmpty() scrcpyOptions.audioSource.set(customSource) } else { @@ -282,69 +276,69 @@ class PreferenceMigration(private val appContext: Context) { } migrateBoolean( - AppPreferenceKeys.AUDIO_DUP, - AppDefaults.AUDIO_DUP, - scrcpyOptions.audioDup + AUDIO_DUP, + ScrcpyOptions.AUDIO_DUP.defaultValue, + scrcpyOptions.audioDup, ) migrateBoolean( - AppPreferenceKeys.NO_AUDIO_PLAYBACK, - AppDefaults.NO_AUDIO_PLAYBACK + NO_AUDIO_PLAYBACK, + !ScrcpyOptions.AUDIO_PLAYBACK.defaultValue, ) { value -> scrcpyOptions.audioPlayback.set(!value) // Invert logic } migrateBoolean( - AppPreferenceKeys.REQUIRE_AUDIO, - AppDefaults.REQUIRE_AUDIO, - scrcpyOptions.requireAudio + REQUIRE_AUDIO, + ScrcpyOptions.REQUIRE_AUDIO.defaultValue, + scrcpyOptions.requireAudio, ) // Max Size & FPS migrateString( - AppPreferenceKeys.MAX_SIZE_INPUT, - AppDefaults.MAX_SIZE_INPUT + MAX_SIZE_INPUT, + ScrcpyOptions.MAX_SIZE.defaultValue.toString(), ) { value -> value.toIntOrNull()?.let { scrcpyOptions.maxSize.set(it) } } migrateString( - AppPreferenceKeys.MAX_FPS_INPUT, - AppDefaults.MAX_FPS_INPUT, - scrcpyOptions.maxFps + MAX_FPS_INPUT, + ScrcpyOptions.MAX_FPS.defaultValue, + scrcpyOptions.maxFps, ) // Encoders & Codec Options migrateString( - AppPreferenceKeys.VIDEO_ENCODER, - AppDefaults.VIDEO_ENCODER, - scrcpyOptions.videoEncoder + VIDEO_ENCODER, + ScrcpyOptions.VIDEO_ENCODER.defaultValue, + scrcpyOptions.videoEncoder, ) migrateString( - AppPreferenceKeys.VIDEO_CODEC_OPTION, - AppDefaults.VIDEO_CODEC_OPTION, - scrcpyOptions.videoCodecOptions + VIDEO_CODEC_OPTION, + ScrcpyOptions.VIDEO_CODEC_OPTIONS.defaultValue, + scrcpyOptions.videoCodecOptions, ) migrateString( - AppPreferenceKeys.AUDIO_ENCODER, - AppDefaults.AUDIO_ENCODER, - scrcpyOptions.audioEncoder + AUDIO_ENCODER, + ScrcpyOptions.AUDIO_ENCODER.defaultValue, + scrcpyOptions.audioEncoder, ) migrateString( - AppPreferenceKeys.AUDIO_CODEC_OPTION, - AppDefaults.AUDIO_CODEC_OPTION, - scrcpyOptions.audioCodecOptions + AUDIO_CODEC_OPTION, + ScrcpyOptions.AUDIO_CODEC_OPTIONS.defaultValue, + scrcpyOptions.audioCodecOptions, ) // New Display val newDisplayWidth = sharedPrefs.getString( - AppPreferenceKeys.NEW_DISPLAY_WIDTH, - AppDefaults.NEW_DISPLAY_WIDTH + NEW_DISPLAY_WIDTH, + "", ).orEmpty() val newDisplayHeight = sharedPrefs.getString( - AppPreferenceKeys.NEW_DISPLAY_HEIGHT, - AppDefaults.NEW_DISPLAY_HEIGHT + NEW_DISPLAY_HEIGHT, + "", ).orEmpty() val newDisplayDpi = sharedPrefs.getString( - AppPreferenceKeys.NEW_DISPLAY_DPI, - AppDefaults.NEW_DISPLAY_DPI + NEW_DISPLAY_DPI, + "", ).orEmpty() if (newDisplayWidth.isNotBlank() && newDisplayHeight.isNotBlank()) { @@ -358,34 +352,33 @@ class PreferenceMigration(private val appContext: Context) { // Crop val cropWidth = sharedPrefs.getString( - AppPreferenceKeys.CROP_WIDTH, - AppDefaults.CROP_WIDTH + CROP_WIDTH, + "", ).orEmpty() val cropHeight = sharedPrefs.getString( - AppPreferenceKeys.CROP_HEIGHT, - AppDefaults.CROP_HEIGHT + CROP_HEIGHT, + "", ).orEmpty() val cropX = sharedPrefs.getString( - AppPreferenceKeys.CROP_X, - AppDefaults.CROP_X + CROP_X, + "", ).orEmpty() val cropY = sharedPrefs.getString( - AppPreferenceKeys.CROP_Y, - AppDefaults.CROP_Y + CROP_Y, + "", ).orEmpty() - if (cropWidth.isNotBlank() && cropHeight.isNotBlank() && - cropX.isNotBlank() && cropY.isNotBlank() + if (cropWidth.isNotBlank() && cropHeight.isNotBlank() + && cropX.isNotBlank() && cropY.isNotBlank() ) { scrcpyOptions.crop.set("${cropWidth}:${cropHeight}:${cropX}:${cropY}") } - // Audio enabled flag (convert to audio option) - val audioEnabled = sharedPrefs.getBoolean( - AppPreferenceKeys.AUDIO_ENABLED, - AppDefaults.AUDIO_ENABLED + migrateBoolean( + AUDIO_ENABLED, + ScrcpyOptions.AUDIO.defaultValue, + scrcpyOptions.audio, ) - scrcpyOptions.audio.set(audioEnabled) Log.d(TAG, "ScrcpyOptions migration completed") } @@ -398,7 +391,7 @@ class PreferenceMigration(private val appContext: Context) { // Migrate quick devices list val quickDevicesRaw = appSharedPrefs.getString( - AppPreferenceKeys.QUICK_DEVICES, + QUICK_DEVICES, "" ).orEmpty() if (quickDevicesRaw.isNotBlank()) { @@ -407,27 +400,27 @@ class PreferenceMigration(private val appContext: Context) { // Migrate quick connect input migrateString( - AppPreferenceKeys.QUICK_CONNECT_INPUT, - AppDefaults.QUICK_CONNECT_INPUT, - quickDevices.quickConnectInput + 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") } @@ -464,6 +457,15 @@ class PreferenceMigration(private val appContext: Context) { 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, @@ -481,4 +483,82 @@ class PreferenceMigration(private val appContext: Context) { 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" + } }