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/MAINPAGE_REFACTORING_ANALYSIS.md b/MAINPAGE_REFACTORING_ANALYSIS.md new file mode 100644 index 0000000..e69de29 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..5581187 --- /dev/null +++ b/TODO.md @@ -0,0 +1,20 @@ +# TODO + +## PARAMS + +- orientation locking +- `-r, --record=file.mp4` 投屏时录制到文件 Record screen to file. The format is determined by the --record-format option if set, or by the file extension. +- `--record-format` 录制格式 Force recording format (mp4, mkv, m4a, mka, opus, aac, flac or wav). + +## FEATURES + +- 设置项连接设备后马上启用scrcpy会话 + +### LOWER PRIORITY + +顺序无关 + +- 多配置切换 +- [音频延迟](https://developer.android.com/ndk/guides/audio/audio-latency) +- 横屏布局 +- 原生悬浮窗 [Petterpx/FloatingX](https://github.com/Petterpx/FloatingX) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4b5d668..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") @@ -23,8 +24,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 { @@ -97,6 +98,8 @@ 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") + 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 c9fda86..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,15 +4,28 @@ 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 class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + // initialize settings singleton + Storage.init(applicationContext) + + val migration = PreferenceMigration(applicationContext) + runBlocking { + if (migration.needsMigration()) + migration.migrate(clearSharedPrefs = true) + } + 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 f69196a..d5e7b1e 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt @@ -1,41 +1,35 @@ 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 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 -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. * - * 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: + * - Surface/Decoder management for video rendering + * - Video size and FPS monitoring */ -class NativeCoreFacade(private val appContext: Context) { +class NativeCoreFacade private constructor() { + @Volatile + var session: Scrcpy.Session? = null + private set - private val adbService = NativeAdbService(appContext) - private val sessionManager = ScrcpySessionManager(adbService) - private val executor = Executors.newSingleThreadExecutor() - private val surfaceMap = ConcurrentHashMap() - private val surfaceIdentityMap = ConcurrentHashMap() - private val decoderMap = ConcurrentHashMap() + private val sessionLifecycleMutex = Mutex() + 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()) @@ -47,383 +41,79 @@ class NativeCoreFacade(private val appContext: Context) { private var packetCount: Long = 0 @Volatile - private var audioPlayer: ScrcpyAudioPlayer? = null + private var currentSessionInfo: Scrcpy.Session.SessionInfo? = null - @Volatile - private var currentSessionInfo: ScrcpySessionInfo? = null - - fun close() { - releaseAllDecoders() - runCatching { sessionManager.stop() } - runCatching { adbService.close() } - executor.shutdown() + 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. */ - 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 attachVideoSurface(surface: Surface) { + sessionLifecycleMutex.withLock { + if (!surface.isValid) { + Log.w(TAG, "attachVideoSurface(): skip invalid surface") return } + val newId = System.identityHashCode(surface) + if (activeSurfaceId == newId && decoder != null) { + return + } + Log.i(TAG, "attachVideoSurface(): surfaceId=$newId oldSurfaceId=$activeSurfaceId") + activeSurfaceId = newId + renderer.attachDisplaySurface(surface) + val session = currentSessionInfo ?: return + val currentDecoder = decoder + if (currentDecoder != null) { + Log.i(TAG, "attachVideoSurface(): try switch decoder output to persistent surface") + val switched = currentDecoder.switchOutputSurface(renderer.getDecoderSurface()) + Log.i(TAG, "attachVideoSurface(): switchOutputSurface success=$switched") + if (switched) { + return + } + } + createOrReplaceDecoder(session) } - createOrReplaceDecoder(tag, surface, session) } - /** - * Unregister the rendering Surface previously bound to `tag`. + * Unregister the active rendering [surface]. * * - If a stale surface reference is supplied (identity mismatch), the request is ignored. - * - 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. */ - 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 detachVideoSurface(surface: Surface? = null, releaseDecoder: Boolean = false) { + sessionLifecycleMutex.withLock { + val currentId = activeSurfaceId + 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) - }" + "detachVideoSurface(): skip stale request 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, - ), + Log.i( + TAG, + "detachVideoSurface(): surfaceId=$requestId releaseDecoder=$releaseDecoder" ) - 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 - ) + activeSurfaceId = null + renderer.detachDisplaySurface(surface, releaseSurface = false) + if (releaseDecoder) { + Log.i(TAG, "detachVideoSurface(): releasing decoder with destroyed surface") + decoder?.release() + decoder = null } } } @@ -444,118 +134,80 @@ class NativeCoreFacade(private val appContext: Context) { videoFpsListeners.remove(listener) } - fun scrcpyBackOrScreenOn(action: Int = 0) { - ioExecute { - runCatching { sessionManager.pressBackOrScreenOn(action) } + suspend fun scrcpyBackOrTurnScreenOn(action: Int = 0) { + session?.pressBackOrTurnScreenOn(action) + } + + /** + * Called by Scrcpy.kt when a session starts. + * Sets up video decoders for registered surfaces. + */ + suspend fun onScrcpySessionStarted( + session: Scrcpy.Session.SessionInfo, + sessionMgr: Scrcpy.Session + ) = sessionLifecycleMutex.withLock { + this.session = sessionMgr + currentSessionInfo = session + releaseAllDecoders() + synchronized(bootstrapLock) { + bootstrapPackets.clear() + latestConfigPacket = null + } + if (activeSurfaceId != null) { + Log.i(TAG, "onScrcpySessionStarted(): bind decoder to persistent surface") + createOrReplaceDecoder(session) + } + packetCount = 0 + sessionMgr.attachVideoConsumer { packet -> + cacheBootstrapPacket(packet) + packetCount += 1 + if (packetCount == 1L || packetCount % 120L == 0L) { + Log.i( + TAG, + "videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoder=${decoder != null}" + ) + } + val currentDecoder = decoder ?: return@attachVideoConsumer + if (activeSurfaceId == null) return@attachVideoConsumer + runCatching { + currentDecoder.feedAnnexB( + packet.data, + packet.ptsUs, + packet.isKeyFrame, + packet.isConfig + ) + } } } - private fun extractAssetToCache(assetPath: String): File { - val clean = assetPath.removePrefix("/") - 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 { + session = null + 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 private var instance: NativeCoreFacade? = null + // TODO ??? fun get(context: Context): NativeCoreFacade { return instance ?: synchronized(this) { - instance ?: NativeCoreFacade(context.applicationContext).also { instance = it } + instance ?: NativeCoreFacade().also { instance = it } } } - fun defaultStartRequest( - customServerUri: String?, - maxSize: Int, - videoBitRate: Int, - remotePath: String, - videoCodec: String = "h264", - audio: Boolean = true, - audioCodec: String = "opus", - audioBitRate: Int = 128_000, - maxFps: Float = 0f, - noControl: Boolean = false, - videoEncoder: String = "", - videoCodecOptions: String = "", - audioEncoder: String = "", - audioCodecOptions: String = "", - audioDup: Boolean = false, - audioSource: String = "", - videoSource: String = "display", - cameraId: String = "", - cameraFacing: String = "", - cameraSize: String = "", - cameraAr: String = "", - cameraFps: Int = 0, - cameraHighSpeed: Boolean = false, - noAudioPlayback: Boolean = false, - requireAudio: Boolean = false, - turnScreenOff: Boolean = false, - noVideo: Boolean = false, - newDisplay: String = "", - displayId: Int? = null, - crop: String = "", - ): ScrcpyStartRequest { - return ScrcpyStartRequest( - serverAsset = DEFAULT_SERVER_ASSET, - customServerUri = customServerUri, - serverVersion = "3.3.4", - serverRemotePath = remotePath, - maxSize = maxSize, - videoBitRate = videoBitRate, - videoCodec = videoCodec, - audio = audio, - audioCodec = audioCodec, - audioBitRate = audioBitRate, - maxFps = maxFps, - noControl = noControl, - videoEncoder = videoEncoder, - videoCodecOptions = videoCodecOptions, - audioEncoder = audioEncoder, - audioCodecOptions = audioCodecOptions, - audioDup = audioDup, - audioSource = audioSource, - videoSource = videoSource, - cameraId = cameraId, - cameraFacing = cameraFacing, - cameraSize = cameraSize, - cameraAr = cameraAr, - cameraFps = cameraFps, - cameraHighSpeed = cameraHighSpeed, - noAudioPlayback = noAudioPlayback, - requireAudio = requireAudio, - turnScreenOff = turnScreenOff, - noVideo = noVideo, - newDisplay = newDisplay, - displayId = displayId, - crop = crop, - ) - } - - fun nowLogPrefix(): String { - val stamp = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) - return "[$stamp]" - } } private data class CachedPacket( @@ -588,7 +240,7 @@ class NativeCoreFacade(private val appContext: Context) { } /** - * Create or replace a decoder bound to `surface` for `session`. + * Create or replace the active decoder bound to [surface] for [session]. * * - Chooses MIME type from `session.codec` and constructs an [AnnexBDecoder]. * - The decoder's `onOutputSizeChanged` callback publishes size changes to @@ -596,23 +248,27 @@ class NativeCoreFacade(private val appContext: Context) { * - Newly created decoders are fed with any cached bootstrap packets to allow * 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(session: Scrcpy.Session.SessionInfo) { + val surface = renderer.getDecoderSurface() + decoder?.release() + decoder = null Log.i( TAG, - "createOrReplaceDecoder(): tag=$tag codec=$mime size=${session.width}x${session.height}" + "createOrReplaceDecoder(): " + + "codec=${session.codec?.string ?: "null"}, " + + "size=${session.width}x${session.height}, " + + "persistent=true" ) - val decoder = AnnexBDecoder( + val newDecoder = AnnexBDecoder( width = session.width, height = session.height, outputSurface = surface, - mimeType = mime, + mimeType = when (session.codec) { + Codec.H264 -> "video/avc" + Codec.H265 -> "video/hevc" + Codec.AV1 -> "video/av01" + else -> "video/avc" + }, onOutputSizeChanged = { width, height -> val current = currentSessionInfo if (current == null || (current.width == width && current.height == height)) { @@ -637,8 +293,8 @@ class NativeCoreFacade(private val appContext: Context) { } }, ) - decoderMap[tag] = decoder - replayBootstrapPackets(decoder) + decoder = newDecoder + replayBootstrapPackets(newDecoder) } private fun replayBootstrapPackets(decoder: AnnexBDecoder) { @@ -654,7 +310,7 @@ class NativeCoreFacade(private val appContext: Context) { } } - private fun cacheBootstrapPacket(packet: ScrcpySessionManager.VideoPacket) { + private fun cacheBootstrapPacket(packet: Scrcpy.Session.VideoPacket) { val cached = CachedPacket( data = packet.data.copyOf(), ptsUs = packet.ptsUs, @@ -683,130 +339,8 @@ class NativeCoreFacade(private val appContext: Context) { } } - /** - * Attach a single consumer to the session manager to deliver incoming video packets - * to all active decoders. - * - * - Called when a session is active and at least one decoder exists. Packets are - * cached into [bootstrapPackets] to allow late-attaching decoders to catch up. - * - The consumer iterates [decoderMap] and feeds each decoder. Errors are - * isolated with `runCatching` so one codec failure doesn't stop others. - */ - private fun ensureVideoConsumerAttached() { - sessionManager.attachVideoConsumer { packet -> - cacheBootstrapPacket(packet) - packetCount += 1 - if (packetCount == 1L || packetCount % 120L == 0L) { - Log.i( - TAG, - "videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoders=${decoderMap.size}" - ) - } - decoderMap.forEach { (tag, decoder) -> - if (!surfaceIdentityMap.containsKey(tag)) { - return@forEach - } - runCatching { - decoder.feedAnnexB( - packet.data, - packet.ptsUs, - packet.isKeyFrame, - packet.isConfig - ) - } - } - } - } - - private fun releaseAllDecoders() { - decoderMap.values.forEach { decoder -> - runCatching { decoder.release() } - } - 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) + runCatching { decoder?.release() } + decoder = null } } - -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/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 5272f06..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppPreferenceKeys.kt +++ /dev/null @@ -1,81 +0,0 @@ -package io.github.miuzarte.scrcpyforandroid.constants - -object AppPreferenceKeys { - const val PREFS_NAME = "scrcpy_app_prefs" - const val NATIVE_ADB_KEY_PREFS_NAME = "nativecore_adb_rsa" - const val NATIVE_ADB_PRIVATE_KEY = "priv" - - // Devices - const val QUICK_DEVICES = "quick_devices" - const val QUICK_CONNECT_INPUT = "quick_connect_input" - - const val PAIR_HOST = "pair_host" - const val PAIR_PORT = "pair_port" - const val PAIR_CODE = "pair_code" - - const val AUDIO_ENABLED = "audio_enabled" - const val AUDIO_CODEC = "audio_codec" - const val AUDIO_BIT_RATE_INPUT = "audio_bit_rate_input" - const val AUDIO_BIT_RATE_KBPS = "audio_bit_rate_kbps" - const val VIDEO_CODEC = "video_codec" - const val VIDEO_BIT_RATE_MBPS = "video_bit_rate_mbps" - const val VIDEO_BIT_RATE_INPUT = "video_bit_rate_input" - - const val TURN_SCREEN_OFF = "turn_screen_off" - const val NO_CONTROL = "no_control" - const val NO_VIDEO = "no_video" - - const val VIDEO_SOURCE_PRESET = "video_source_preset" - const val DISPLAY_ID = "display_id" - - const val CAMERA_ID = "camera_id" - const val CAMERA_FACING_PRESET = "camera_facing_preset" - const val CAMERA_SIZE_PRESET = "camera_size_preset" - const val CAMERA_SIZE_CUSTOM = "camera_size_custom" - const val CAMERA_AR = "camera_ar" - const val CAMERA_FPS = "camera_fps" - const val CAMERA_HIGH_SPEED = "camera_high_speed" - - const val AUDIO_SOURCE_PRESET = "audio_source_preset" - const val AUDIO_SOURCE_CUSTOM = "audio_source_custom" - const val AUDIO_DUP = "audio_dup" - const val NO_AUDIO_PLAYBACK = "no_audio_playback" - const val REQUIRE_AUDIO = "require_audio" - - const val MAX_SIZE_INPUT = "max_size_input" - const val MAX_FPS_INPUT = "max_fps_input" - - const val VIDEO_ENCODER = "video_encoder" - const val VIDEO_CODEC_OPTION = "video_codec_options" - const val AUDIO_ENCODER = "audio_encoder" - const val AUDIO_CODEC_OPTION = "audio_codec_options" - - const val NEW_DISPLAY_WIDTH = "new_display_width" - const val NEW_DISPLAY_HEIGHT = "new_display_height" - const val NEW_DISPLAY_DPI = "new_display_dpi" - - const val CROP_WIDTH = "crop_width" - const val CROP_HEIGHT = "crop_height" - const val CROP_X = "crop_x" - const val CROP_Y = "crop_y" - - // Settings - const val THEME_BASE_INDEX = "theme_base_index" - const val MONET = "monet" - - const val FULLSCREEN_DEBUG_INFO = "fullscreen_debug_info" - const val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = "show_fullscreen_virtual_buttons" - const val KEEP_SCREEN_ON_WHEN_STREAMING = "keep_screen_on_when_streaming" - const val DEVICE_PREVIEW_CARD_HEIGHT_DP = "device_preview_card_height_dp" - const val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = "preview_virtual_button_show_text" - const val VIRTUAL_BUTTONS_LAYOUT = "virtual_buttons_layout" - - const val CUSTOM_SERVER_URI = "custom_server_uri" - - const val SERVER_REMOTE_PATH = "server_remote_path" - - const val ADB_KEY_NAME = "adb_key_name" - const val ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN = "adb_pairing_auto_discover_on_dialog_open" - const val ADB_AUTO_RECONNECT_PAIRED_DEVICE = "adb_auto_reconnect_paired_device" - const val ADB_MDNS_LAN_DISCOVERY = "adb_mdns_lan_discovery" -} 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..c5cc2f0 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/Defaults.kt @@ -0,0 +1,5 @@ +package io.github.miuzarte.scrcpyforandroid.constants + +object Defaults { + const val ADB_PORT = 5555; +} \ No newline at end of file diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt index daa7025..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 @@ -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) - val MaxFPS = listOf(0, 24, 30, 45, 60, 90, 120) - val AudioBitRate = listOf(32, 64, 96, 128, 160, 192, 256, 320, 384, 512) +/** + * 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(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 18b099a..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 @@ -1,18 +1,188 @@ package io.github.miuzarte.scrcpyforandroid.models -internal data class ConnectionTarget( - val host: String, - val port: Int, -) +import io.github.miuzarte.scrcpyforandroid.constants.Defaults -/** - * 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, +// 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, + newPort: Int? = null, + updateNameOnlyWhenEmpty: Boolean = false, + ): DeviceShortcuts { + val idx = if (id != null) getIndex(id) + else if (host != null && port != null) getIndex(host, port) + else -1 + + if (idx < 0) return this + val old = devices[idx] + val updateById = id != null + + // 确定最终的属性值 + val finalName = when { + name == null -> old.name + updateNameOnlyWhenEmpty && old.name.isNotBlank() -> old.name + else -> name + } + val finalHost = if (updateById) host ?: old.host else old.host + val finalPort = if (updateById) port ?: old.port else newPort ?: old.port + + // 若无任何变化,返回原实例 + if (finalName == old.name && finalHost == old.host && finalPort == old.port) + return this + + val newList = devices.toMutableList().apply { + this[idx] = DeviceShortcut( + name = finalName, + host = finalHost, + port = finalPort, + ) + } + return DeviceShortcuts( + if ((updateById && (finalHost != old.host || finalPort != old.port)) + || (newPort != null && newPort != old.port) + ) + newList.distinctBy { it.id } + else newList + ) + } + + fun upsert( + shortcut: DeviceShortcut, + index: Int? = null, + ): DeviceShortcuts { + val existingIdx = getIndex(shortcut.id) + val newList = devices.toMutableList() + if (existingIdx >= 0) { + newList[existingIdx] = shortcut + } else { + if (index != null) newList.add(index, shortcut) + else newList.add(shortcut) + } + return DeviceShortcuts(newList) + } + + fun move(fromIndex: Int, toIndex: Int): DeviceShortcuts { + if (fromIndex !in devices.indices || toIndex !in devices.indices) return this + if (fromIndex == toIndex) return this + val mutable = devices.toMutableList() + val item = mutable.removeAt(fromIndex) + // 如果目标位置在原位置之后,移除后列表长度减1,因此目标索引需减1 + val target = if (toIndex > fromIndex) toIndex - 1 else toIndex + mutable.add(target, item) + return DeviceShortcuts(mutable) + } + + // 删除指定设备 + fun remove(id: String) = DeviceShortcuts(devices.filterNot { it.id == id }) + + // 清空所有设备 + fun clear() = DeviceShortcuts(emptyList()) + + // 复制当前实例 + fun copy(devices: List = this.devices): DeviceShortcuts = + DeviceShortcuts(devices) +} + +data class DeviceShortcut( + val name: String = "", val host: String, - val port: Int, - val online: Boolean, -) + val port: Int = Defaults.ADB_PORT, +) { + val id: String get() = "$host:$port" + + fun marshalToString( + separator: String = DEFAULT_SEPARATOR, + ): String = listOf( + name.trim(), host.trim(), port.toString() + ).joinToString( + separator = separator + ) + + companion object { + const val DEFAULT_SEPARATOR = "|" + fun unmarshalFrom( + s: String, + separator: String = DEFAULT_SEPARATOR, + ): DeviceShortcut? { + val parts = s.split(separator, limit = 3) + return when (parts.size) { + 3 -> { + val name = parts[0].trim() + val host = parts[1].trim() + val port = parts[2].trim().toIntOrNull() ?: Defaults.ADB_PORT + if (host.isNotBlank()) DeviceShortcut( + name = name, + host = host, + port = port, + ) + else null + } + + else -> null + } + } + } +} + +data class ConnectionTarget( + val host: String, + val port: Int = Defaults.ADB_PORT, +) { + override fun toString(): String = "$host:$port" + + companion object { + fun unmarshalFrom(s: String): ConnectionTarget? { + val parts = s.split(":", limit = 2) + return when (parts.size) { + 2 -> ConnectionTarget( + host = parts[0].trim(), + port = parts[1].trim().toIntOrNull() ?: Defaults.ADB_PORT, + ) + + 1 -> ConnectionTarget( + host = parts[0].trim(), + port = Defaults.ADB_PORT, + ) + + 0 -> ConnectionTarget( + host = s.trim(), + port = Defaults.ADB_PORT, + ) + + else -> null + } + } + } +} 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 ed0806c..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,9 +4,9 @@ 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.constants.AppDefaults -import io.github.miuzarte.scrcpyforandroid.constants.AppPreferenceKeys +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 @@ -45,13 +45,13 @@ 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 @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 +60,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 +78,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() @@ -102,39 +102,52 @@ internal class DirectAdbTransport(private val context: Context) { } /** - * Load persisted RSA keypair from shared preferences, or generate a new one. + * Load persisted RSA keypair from DataStore, or generate a new one. * Returns (privateKey, publicX509Bytes). */ - private fun loadOrCreate(): Pair { - val prefs = context.getSharedPreferences( - AppPreferenceKeys.NATIVE_ADB_KEY_PREFS_NAME, - Context.MODE_PRIVATE - ) - val privB64 = prefs.getString(AppPreferenceKeys.NATIVE_ADB_PRIVATE_KEY, 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( - AppPreferenceKeys.NATIVE_ADB_PRIVATE_KEY, - 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) } @@ -203,7 +216,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( @@ -380,7 +393,12 @@ internal class DirectAdbConnection( } } - fun isAlive(): Boolean = !closed && !socket.isClosed && socket.isConnected + fun isAlive(): Boolean { + val isClosed = socket.isClosed + val isConnected = socket.isConnected + Log.d(TAG, "isClose: $isClosed, isConnected: $isConnected") + return !closed && !isClosed && isConnected + } override fun close() { if (!closed) { @@ -548,7 +566,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..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,18 +2,26 @@ 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 /** * 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. + * + * All network operations are executed on Dispatchers.IO. */ class NativeAdbService(appContext: Context) { - private val transport = DirectAdbTransport(appContext) + private val mutex = Mutex() @Volatile private var connection: DirectAdbConnection? = null @@ -30,14 +38,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 +53,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 +65,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,63 +82,81 @@ 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 { - Log.i(TAG, "connect(): host=$host port=$port") - val existing = connection - if (existing != null && existing.isAlive() && connectedHost == host && connectedPort == port) { - return true - } - disconnect() - try { - val conn = transport.connect(host, port) - connection = conn - connectedHost = host - connectedPort = port - return true - } catch (e: Exception) { - Log.e(TAG, "connect(): failed host=$host port=$port", e) - val detail = e.message ?: "${e.javaClass.simpleName} (no message)" - throw IllegalStateException("ADB connect failed to $host:$port -> $detail", e) + suspend fun connect( + host: String, + port: Int, + timeout: Duration = Duration.INFINITE, + ) = 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() + + 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. */ - @Synchronized - fun disconnect() { + suspend fun disconnect() = withContext(Dispatchers.IO) { + mutex.withLock { + disconnectInternal() + } + } + + suspend fun isConnected(): Boolean = mutex.withLock { + connection?.isAlive() == true + } + + /** + * Execute a shell command on the connected device and return stdout text. + */ + suspend fun shell(command: String): String = mutex.withLock { + val response = requireConnection().shell(command) + Log.d(TAG, "command: $command, response: $response") + response + } + + suspend fun openShellStream(command: String): AdbSocketStream = mutex.withLock { + requireConnection().openStream("shell:$command") + } + + suspend fun push(localPath: Path, remotePath: String) = mutex.withLock { + requireConnection().push(localPath.toFile().readBytes(), remotePath) + } + + suspend fun openAbstractSocket(name: String): AdbSocketStream = mutex.withLock { + requireConnection().openStream("localabstract:$name") + } + + suspend fun close() { + disconnect() + } + + private fun disconnectInternal() { runCatching { connection?.close() } 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/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/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/nativecore/ScrcpySessionManager.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpySessionManager.kt deleted file mode 100644 index 62930c6..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpySessionManager.kt +++ /dev/null @@ -1,1197 +0,0 @@ -package io.github.miuzarte.scrcpyforandroid.nativecore - -import android.util.Log -import android.view.KeyEvent -import java.io.BufferedInputStream -import java.io.BufferedReader -import java.io.DataInputStream -import java.io.DataOutputStream -import java.io.EOFException -import java.io.InputStreamReader -import java.nio.file.Path -import java.security.SecureRandom -import java.util.ArrayDeque -import kotlin.concurrent.thread -import kotlin.math.roundToInt - -class ScrcpySessionManager(private val adbService: NativeAdbService) { - @Volatile - private var activeSession: ActiveSession? = null - - @Volatile - private var videoConsumer: ((VideoPacket) -> Unit)? = null - - @Volatile - private var videoReaderThread: Thread? = null - - @Volatile - private var audioConsumer: ((AudioPacket) -> Unit)? = null - - @Volatile - private var audioReaderThread: Thread? = null - - @Volatile - private var lastServerCommand: String? = null - private val serverLogBuffer = ArrayDeque() - - /** - * 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: - * - This is synchronized 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. - */ - @Synchronized - fun start(serverJarPath: Path, options: ScrcpyStartOptions): SessionInfo { - stop() - synchronized(this) { - serverLogBuffer.clear() - } - val targetPath = options.serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH } - val scid = random.nextInt(Int.MAX_VALUE) - val socketName = socketNameFor(scid) - - try { - adbService.push(serverJarPath, targetPath) - val cmd = buildServerCommand(targetPath, scid, options) - lastServerCommand = cmd - Log.i( - TAG, - "start(): socket=$socketName codec=${options.videoCodec} audio=${options.audio} audioCodec=${options.audioCodec}" - ) - val serverStream = adbService.openShellStream(cmd) - val serverLogThread = startServerLogThread(serverStream, socketName) - Thread.sleep(SERVER_BOOT_DELAY_MS) - - // The first socket always carries device meta (and dummy byte). - val firstStream = openAbstractSocketWithRetry(socketName, expectDummyByte = true) - val firstInput = DataInputStream(BufferedInputStream(firstStream.inputStream)) - - var videoStream: AdbSocketStream? = null - var videoInput: DataInputStream? = null - var audioStream: AdbSocketStream? = null - var audioInput: DataInputStream? = null - var controlStream: AdbSocketStream? = null - - when { - options.video -> { - videoStream = firstStream - videoInput = firstInput - } - - options.audio -> { - audioStream = firstStream - audioInput = firstInput - } - - options.control -> { - controlStream = firstStream - } - - else -> { - throw IllegalArgumentException("At least one of video/audio/control must be enabled") - } - } - - if (options.video && videoStream == null) { - val vStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false) - videoStream = vStream - videoInput = DataInputStream(BufferedInputStream(vStream.inputStream)) - } - - if (options.audio && audioStream == null) { - val aStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false) - audioStream = aStream - audioInput = DataInputStream(BufferedInputStream(aStream.inputStream)) - } - - if (options.control && controlStream == null) { - controlStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false) - } - - val deviceName = readDeviceName(firstInput) - val audioCodecId = if (options.audio) audioCodecIdFromName(options.audioCodec) else 0 - val codecId: Int - val width: Int - val height: Int - if (options.video) { - val vInput = checkNotNull(videoInput) - codecId = vInput.readInt() - width = vInput.readInt() - height = vInput.readInt() - } else { - codecId = 0 - width = 0 - height = 0 - } - - val sessionInfo = SessionInfo( - deviceName = deviceName, - codecId = codecId, - codecName = codecName(codecId), - width = width, - height = height, - audioCodecId = audioCodecId, - controlEnabled = controlStream != null, - ) - - activeSession = ActiveSession( - info = sessionInfo, - socketName = socketName, - serverStream = serverStream, - serverLogThread = serverLogThread, - videoStream = videoStream, - videoInput = videoInput, - audioStream = audioStream, - audioInput = audioInput, - controlStream = controlStream, - controlWriter = controlStream?.outputStream?.let { - ScrcpyControlWriter( - DataOutputStream(it) - ) - }, - ) - return sessionInfo - } catch (t: Throwable) { - val tail = snapshotServerLogs() - 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. - */ - @Synchronized - fun attachVideoConsumer(consumer: (VideoPacket) -> Unit) { - val session = activeSession ?: throw IllegalStateException("scrcpy session not started") - val vInput = session.videoInput ?: return - val vStream = session.videoStream ?: return - videoConsumer = consumer - if (videoReaderThread?.isAlive == true) { - return - } - - videoReaderThread = thread(start = true, name = "scrcpy-video-reader") { - while (activeSession === session && !vStream.closed) { - try { - val ptsAndFlags = vInput.readLong() - val packetSize = vInput.readInt() - if (packetSize <= 0) { - continue - } - - val payload = ByteArray(packetSize) - vInput.readFully(payload) - - val config = (ptsAndFlags and PACKET_FLAG_CONFIG) != 0L - val keyFrame = (ptsAndFlags and PACKET_FLAG_KEY_FRAME) != 0L - val ptsUs = ptsAndFlags and PACKET_PTS_MASK - videoConsumer?.invoke( - VideoPacket( - data = payload, - ptsUs = ptsUs, - isConfig = config, - isKeyFrame = keyFrame, - ), - ) - } catch (_: EOFException) { - break - } catch (_: InterruptedException) { - // Ignore transient interrupts while session remains active. - if (activeSession !== session || vStream.closed) { - break - } - Thread.interrupted() - } catch (e: Exception) { - Log.w(TAG, "video reader failed", e) - break - } - } - } - } - - @Synchronized - fun clearVideoConsumer() { - videoConsumer = null - } - - /** - * 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. - */ - @Synchronized - fun attachAudioConsumer(consumer: (AudioPacket) -> Unit) { - val session = activeSession ?: throw IllegalStateException("scrcpy session not started") - val aInput = session.audioInput ?: return // audio disabled or unavailable - val aStream = session.audioStream ?: return - audioConsumer = consumer - if (audioReaderThread?.isAlive == true) return - - audioReaderThread = thread(start = true, name = "scrcpy-audio-reader") { - val streamCodecId = try { - aInput.readInt() - } catch (e: Exception) { - Log.w(TAG, "audio codec header read failed", e) - return@thread - } - when (streamCodecId) { - AUDIO_DISABLED -> { - Log.w(TAG, "audio disabled by server") - return@thread - } - - AUDIO_ERROR -> { - Log.e(TAG, "audio stream configuration error from server") - return@thread - } - - else -> { - Log.i(TAG, "audio stream codec=0x${streamCodecId.toUInt().toString(16)}") - } - } - if (session.info.audioCodecId != 0 && streamCodecId != session.info.audioCodecId) { - Log.w( - TAG, - "audio codec mismatch: requested=0x${ - session.info.audioCodecId.toUInt().toString(16) - } stream=0x${streamCodecId.toUInt().toString(16)}", - ) - } - - while (activeSession === session && !aStream.closed) { - try { - val ptsAndFlags = aInput.readLong() - val packetSize = aInput.readInt() - if (packetSize <= 0) continue - - val payload = ByteArray(packetSize) - aInput.readFully(payload) - - val isConfig = (ptsAndFlags and PACKET_FLAG_CONFIG) != 0L - val ptsUs = ptsAndFlags and PACKET_PTS_MASK - audioConsumer?.invoke( - AudioPacket( - data = payload, - ptsUs = ptsUs, - isConfig = isConfig - ) - ) - } catch (_: EOFException) { - break - } catch (_: InterruptedException) { - if (activeSession !== session || aStream.closed) break - Thread.interrupted() - } catch (e: Exception) { - Log.w(TAG, "audio reader failed", e) - break - } - } - } - } - - @Synchronized - fun clearAudioConsumer() { - audioConsumer = null - } - - /** - * Inject a keycode event to the control channel. - * - * - Requires an active control channel; throws if absent. - * - Synchronized to serialize control writes. - */ - @Synchronized - fun injectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) { - requireControlWriter().injectKeycode(action, keycode, repeat, metaState) - } - - @Synchronized - fun injectText(text: String) { - requireControlWriter().injectText(text) - } - - /** - * 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. - * - Synchronized to serialize control writes. - */ - @Synchronized - fun injectTouch( - action: Int, - pointerId: Long, - x: Int, - y: Int, - screenWidth: Int, - screenHeight: Int, - pressure: Float, - actionButton: Int, - buttons: Int, - ) { - requireControlWriter().injectTouch( - action, - pointerId, - x, - y, - screenWidth, - screenHeight, - pressure, - actionButton, - buttons - ) - } - - @Synchronized - fun injectScroll( - x: Int, - y: Int, - screenWidth: Int, - screenHeight: Int, - hScroll: Float, - vScroll: Float, - buttons: Int - ) { - requireControlWriter().injectScroll( - x, - y, - screenWidth, - screenHeight, - hScroll, - vScroll, - buttons - ) - } - - @Synchronized - fun pressBackOrScreenOn(action: Int = KeyEvent.ACTION_DOWN) { - requireControlWriter().pressBackOrScreenOn(action) - } - - @Synchronized - fun setDisplayPower(on: Boolean) { - requireControlWriter().setDisplayPower(on) - } - - /** - * 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. - */ - @Synchronized - fun stop() { - val session = activeSession ?: return - activeSession = null - videoConsumer = null - audioConsumer = null - - if (Thread.currentThread() !== videoReaderThread) { - runCatching { videoReaderThread?.interrupt() } - runCatching { videoReaderThread?.join(300) } - } - videoReaderThread = null - - if (Thread.currentThread() !== audioReaderThread) { - runCatching { audioReaderThread?.interrupt() } - runCatching { audioReaderThread?.join(300) } - } - audioReaderThread = null - - runCatching { session.controlStream?.close() } - runCatching { session.audioStream?.close() } - runCatching { session.videoStream?.close() } - runCatching { session.serverStream.close() } - if (Thread.currentThread() !== session.serverLogThread) { - runCatching { session.serverLogThread.interrupt() } - runCatching { session.serverLogThread.join(300) } - } - } - - fun isStarted(): Boolean = activeSession != null - - fun getLastServerCommand(): String? = lastServerCommand - - @Synchronized - fun listEncoders(serverJarPath: Path, options: ScrcpyStartOptions): EncoderLists { - val targetPath = options.serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH } - adbService.push(serverJarPath, targetPath) - val cmd = buildServerCommand( - targetPath = targetPath, - scid = 0x1234abcd, - options = options.copy( - video = false, - audio = false, - control = false, - listEncoders = true, - cleanup = false, - ), - ) - Log.i(TAG, "listEncoders(): cmd=$cmd") - // scrcpy encoder list is printed in logs, so merge stderr into stdout. - val output = adbService.shell("$cmd 2>&1") - val parsed = parseEncoderLists(output) - val preview = output.lineSequence().take(40).joinToString("\n") - Log.i( - TAG, - "listEncoders(): parsed video=${parsed.videoEncoders.size} audio=${parsed.audioEncoders.size}, outputPreview=\n$preview", - ) - return parsed.copy(rawOutput = output) - } - - @Synchronized - fun listCameraSizes(serverJarPath: Path, options: ScrcpyStartOptions): CameraSizeLists { - val targetPath = options.serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH } - adbService.push(serverJarPath, targetPath) - val cmd = buildServerCommand( - targetPath = targetPath, - scid = 0x2234abcd, - options = options.copy( - video = false, - audio = false, - control = false, - listCameraSizes = true, - cleanup = false, - ), - ) - Log.i(TAG, "listCameraSizes(): cmd=$cmd") - val output = adbService.shell("$cmd 2>&1") - val parsed = parseCameraSizeLists(output) - val preview = output.lineSequence().take(40).joinToString("\n") - Log.i( - TAG, - "listCameraSizes(): parsed sizes=${parsed.sizes.size}, outputPreview=\n$preview", - ) - return parsed.copy(rawOutput = output) - } - - private fun requireControlWriter(): ScrcpyControlWriter { - return activeSession?.controlWriter - ?: throw IllegalStateException("scrcpy control channel not available") - } - - private fun buildServerCommand( - targetPath: String, - scid: Int, - options: ScrcpyStartOptions - ): String { - val serverArgs = buildServerArgs(scid, options) - return "CLASSPATH=$targetPath app_process / com.genymobile.scrcpy.Server ${options.serverVersion} $serverArgs" - } - - private fun buildServerArgs(scid: Int, options: ScrcpyStartOptions): String { - val videoSource = options.videoSource.trim().ifBlank { "display" } - val isCameraSource = videoSource.equals("camera", ignoreCase = true) - val cameraId = options.cameraId.trim() - val hasCameraId = cameraId.isNotBlank() - val hasExplicitCameraSize = options.cameraSize.trim().isNotBlank() - val hasValidCameraFps = options.cameraFps > 0 - - val args = listOf( - ServerArg( - type = ServerArgType.STRING, - key = "scid", - value = String.format("%08x", scid), - ), - ServerArg( - type = ServerArgType.STRING, - key = "log_level", - value = "debug", - ), - ServerArg( - type = ServerArgType.BOOLEAN, - key = "tunnel_forward", - value = true, - ), - ServerArg( - type = ServerArgType.BOOLEAN, - key = "cleanup", - value = options.cleanup, - ), - ServerArg( - type = ServerArgType.BOOLEAN, - key = "send_device_meta", - value = true, - ), - ServerArg( - type = ServerArgType.BOOLEAN, - key = "send_frame_meta", - value = true, - ), - ServerArg( - type = ServerArgType.BOOLEAN, - key = "send_dummy_byte", - value = true, - ), - ServerArg( - type = ServerArgType.BOOLEAN, - key = "send_codec_meta", - value = true, - ), - ServerArg( - type = ServerArgType.BOOLEAN, - key = "video", - value = options.video, - ), - ServerArg( - type = ServerArgType.BOOLEAN, - key = "audio", - value = options.audio, - ), - ServerArg( - type = ServerArgType.BOOLEAN, - key = "control", - value = options.control, - ), - ServerArg( - type = ServerArgType.STRING, - key = "video_source", - value = videoSource, - ), - ServerArg( - type = ServerArgType.NUMBER, - key = "max_size", - value = options.maxSize, - ), - ServerArg( - type = ServerArgType.STRING, - key = "video_codec", - value = options.videoCodec, - ), - ServerArg( - type = ServerArgType.NUMBER, - key = "video_bit_rate", - value = options.videoBitRate, - ), - 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(",")}" - } - } - - private fun parseEncoderLists(output: String): EncoderLists { - val video = LinkedHashSet() - val audio = LinkedHashSet() - val videoTypes = linkedMapOf() - val audioTypes = linkedMapOf() - VIDEO_ENCODER_REGEX.findAll(output).forEach { match -> - video.add(match.groupValues[1]) - } - AUDIO_ENCODER_REGEX.findAll(output).forEach { match -> - audio.add(match.groupValues[1]) - } - // Fallback for log formats that include codec+encoder in one line. - VIDEO_ENCODER_FALLBACK_REGEX.findAll(output).forEach { match -> - video.add(match.groupValues[1]) - } - AUDIO_ENCODER_FALLBACK_REGEX.findAll(output).forEach { match -> - audio.add(match.groupValues[1]) - } - VIDEO_ENCODER_TYPE_REGEX.findAll(output).forEach { match -> - val name = match.groupValues[1] - val type = match.groupValues[2] - if (name.isNotBlank() && type.isNotBlank() && !videoTypes.containsKey(name)) { - videoTypes[name] = type - } - } - AUDIO_ENCODER_TYPE_REGEX.findAll(output).forEach { match -> - val name = match.groupValues[1] - val type = match.groupValues[2] - if (name.isNotBlank() && type.isNotBlank() && !audioTypes.containsKey(name)) { - audioTypes[name] = type - } - } - return EncoderLists( - videoEncoders = video.toList(), - audioEncoders = audio.toList(), - videoEncoderTypes = videoTypes, - audioEncoderTypes = audioTypes, - ) - } - - private fun parseCameraSizeLists(output: String): CameraSizeLists { - val sizes = LinkedHashSet() - CAMERA_SIZE_REGEX.findAll(output).forEach { match -> - sizes.add(match.groupValues[1]) - } - CAMERA_SIZE_FALLBACK_REGEX.findAll(output).forEach { match -> - sizes.add(match.groupValues[1]) - } - return CameraSizeLists(sizes = sizes.toList()) - } - - private fun startServerLogThread(serverStream: AdbSocketStream, socketName: String): Thread { - return thread(start = true, name = "scrcpy-server-log") { - try { - BufferedReader( - InputStreamReader( - serverStream.inputStream, - Charsets.UTF_8 - ) - ).use { reader -> - while (true) { - val line = reader.readLine() ?: break - appendServerLog(line) - Log.i(TAG, "[server:$socketName] $line") - } - } - } catch (e: Exception) { - if (activeSession != null) { - Log.w(TAG, "server log thread failed", e) - } - } - } - } - - @Synchronized - private fun appendServerLog(line: String) { - if (serverLogBuffer.size >= SERVER_LOG_BUFFER_MAX_LINES) { - serverLogBuffer.removeFirst() - } - serverLogBuffer.addLast(line) - } - - @Synchronized - private fun snapshotServerLogs(maxLines: Int = 120): String { - if (serverLogBuffer.isEmpty()) { - return "" - } - val take = maxLines.coerceIn(1, SERVER_LOG_BUFFER_MAX_LINES) - return serverLogBuffer.toList().takeLast(take).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 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 deleted file mode 100644 index e0db43a..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt +++ /dev/null @@ -1,684 +0,0 @@ -package io.github.miuzarte.scrcpyforandroid.pages - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets -import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing -import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn -import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide -import kotlinx.coroutines.launch -import top.yukonga.miuix.kmp.basic.Card -import top.yukonga.miuix.kmp.basic.ScrollBehavior -import top.yukonga.miuix.kmp.basic.SnackbarHostState -import top.yukonga.miuix.kmp.basic.SpinnerEntry -import top.yukonga.miuix.kmp.basic.Text -import top.yukonga.miuix.kmp.basic.TextField -import top.yukonga.miuix.kmp.extra.SuperArrow -import top.yukonga.miuix.kmp.extra.SuperDropdown -import top.yukonga.miuix.kmp.extra.SuperSpinner -import top.yukonga.miuix.kmp.extra.SuperSwitch -import kotlin.math.roundToInt - -private val AUDIO_SOURCE_OPTIONS = listOf( - "output" to "output", - "playback" to "playback", - "mic" to "mic", - "mic-unprocessed" to "mic-unprocessed", - "mic-camcorder" to "mic-camcorder", - "mic-voice-recognition" to "mic-voice-recognition", - "mic-voice-communication" to "mic-voice-communication", - "voice-call" to "voice-call", - "voice-call-uplink" to "voice-call-uplink", - "voice-call-downlink" to "voice-call-downlink", - "voice-performance" to "voice-performance", - "custom" to "自定义", -) - -private val VIDEO_SOURCE_OPTIONS = listOf( - "display" to "display", - "camera" to "camera", -) - -private val CAMERA_FACING_OPTIONS = listOf( - "" to "默认", - "front" to "front", - "back" to "back", - "external" to "external", -) - -private val CAMERA_FPS_PRESETS = listOf(0, 10, 15, 24, 30, 60, 120, 240, 480, 960) - -@Composable -internal fun AdvancedConfigPage( - contentPadding: PaddingValues, - scrollBehavior: ScrollBehavior, - snackbarHostState: SnackbarHostState, - sessionStarted: Boolean, - audioEnabled: Boolean, - noControl: Boolean, - onNoControlChange: (Boolean) -> Unit, - audioDup: Boolean, - onAudioDupChange: (Boolean) -> Unit, - audioSourcePreset: String, - onAudioSourcePresetChange: (String) -> Unit, - audioSourceCustom: String, - onAudioSourceCustomChange: (String) -> Unit, - videoSourcePreset: String, - onVideoSourcePresetChange: (String) -> Unit, - cameraIdInput: String, - onCameraIdInputChange: (String) -> Unit, - cameraFacingPreset: String, - onCameraFacingPresetChange: (String) -> Unit, - cameraSizePreset: String, - onCameraSizePresetChange: (String) -> Unit, - cameraSizeCustom: String, - onCameraSizeCustomChange: (String) -> Unit, - cameraSizeDropdownItems: List, - cameraSizeIndex: Int, - cameraArInput: String, - onCameraArInputChange: (String) -> Unit, - cameraFpsInput: String, - onCameraFpsInputChange: (String) -> Unit, - cameraHighSpeed: Boolean, - onCameraHighSpeedChange: (Boolean) -> Unit, - noAudioPlayback: Boolean, - onNoAudioPlaybackChange: (Boolean) -> Unit, - noVideo: Boolean, - onNoVideoChange: (Boolean) -> Unit, - requireAudio: Boolean, - onRequireAudioChange: (Boolean) -> Unit, - turnScreenOff: Boolean, - onTurnScreenOffChange: (Boolean) -> Unit, - maxSizeInput: String, - onMaxSizeInputChange: (String) -> Unit, - maxFpsInput: String, - onMaxFpsInputChange: (String) -> Unit, - videoEncoderDropdownItems: List, - videoEncoderTypeMap: Map, - videoEncoderIndex: Int, - onVideoEncoderChange: (String) -> Unit, - videoCodecOptions: String, - onVideoCodecOptionsChange: (String) -> Unit, - audioEncoderDropdownItems: List, - audioEncoderTypeMap: Map, - audioEncoderIndex: Int, - onAudioEncoderChange: (String) -> Unit, - audioCodecOptions: String, - onAudioCodecOptionsChange: (String) -> Unit, - onRefreshEncoders: () -> Unit, - onRefreshCameraSizes: () -> Unit, - newDisplayWidth: String, - onNewDisplayWidthChange: (String) -> Unit, - newDisplayHeight: String, - onNewDisplayHeightChange: (String) -> Unit, - newDisplayDpi: String, - onNewDisplayDpiChange: (String) -> Unit, - displayIdInput: String, - onDisplayIdInputChange: (String) -> Unit, - cropWidth: String, - onCropWidthChange: (String) -> Unit, - cropHeight: String, - onCropHeightChange: (String) -> Unit, - cropX: String, - onCropXChange: (String) -> Unit, - cropY: String, - onCropYChange: (String) -> Unit, -) { - val focusManager = LocalFocusManager.current - val scope = rememberCoroutineScope() - val maxSizePresetIndex = - presetIndexFromInputForAdvancedPage(maxSizeInput, ScrcpyPresets.MaxSize) - val maxFpsPresetIndex = presetIndexFromInputForAdvancedPage(maxFpsInput, ScrcpyPresets.MaxFPS) - val audioSourceItems = AUDIO_SOURCE_OPTIONS.map { it.second } - val audioSourceIndex = AUDIO_SOURCE_OPTIONS.indexOfFirst { it.first == audioSourcePreset } - .let { if (it >= 0) it else 0 } - val videoSourceItems = VIDEO_SOURCE_OPTIONS.map { it.second } - val videoSourceIndex = VIDEO_SOURCE_OPTIONS.indexOfFirst { it.first == videoSourcePreset } - .let { if (it >= 0) it else 0 } - val cameraFacingItems = CAMERA_FACING_OPTIONS.map { it.second } - val cameraFacingIndex = CAMERA_FACING_OPTIONS.indexOfFirst { it.first == cameraFacingPreset } - .let { if (it >= 0) it else 0 } - val cameraFpsPresetIndex = - presetIndexFromInputForAdvancedPage(cameraFpsInput, CAMERA_FPS_PRESETS) - val videoEncoderEntries = videoEncoderDropdownItems.map { encoderName -> - if (encoderName == "默认") { - SpinnerEntry(title = encoderName) - } else { - val type = resolveEncoderTypeLabel(videoEncoderTypeMap[encoderName]) - SpinnerEntry( - title = encoderName, - summary = type.ifBlank { null }, - ) - } - } - val audioEncoderEntries = audioEncoderDropdownItems.map { encoderName -> - if (encoderName == "默认") { - SpinnerEntry(title = encoderName) - } else { - val type = resolveEncoderTypeLabel(audioEncoderTypeMap[encoderName]) - SpinnerEntry( - title = encoderName, - summary = type.ifBlank { null }, - ) - } - } - - // 高级参数 - AppPageLazyColumn( - contentPadding = contentPadding, - scrollBehavior = scrollBehavior, - ) { - item { - Card { - SuperSwitch( - title = "启动后关闭屏幕", - summary = "--turn-screen-off", - checked = turnScreenOff, - onCheckedChange = { value -> - onTurnScreenOffChange(value) - if (value) scope.launch { - // github.com/Genymobile/scrcpy/issues/3376 - // github.com/Genymobile/scrcpy/issues/4587 - // github.com/Genymobile/scrcpy/issues/5676 - snackbarHostState.showSnackbar("注意:大部分设备在关闭屏幕后刷新率会降低/减半") - } - }, - enabled = !sessionStarted && !noControl, - ) - SuperSwitch( - title = "禁用控制", - summary = "--no-control", - checked = noControl, - onCheckedChange = onNoControlChange, - enabled = !sessionStarted, - ) - SuperSwitch( - title = "禁用视频", - summary = "--no-video", - checked = noVideo, - onCheckedChange = onNoVideoChange, - enabled = !sessionStarted, - ) - } - } - - item { - Card { - SuperDropdown( - title = "视频来源", - summary = "--video-source", - items = videoSourceItems, - selectedIndex = videoSourceIndex, - onSelectedIndexChange = { index -> - onVideoSourcePresetChange(VIDEO_SOURCE_OPTIONS[index].first) - }, - enabled = !sessionStarted, - ) - if (videoSourcePreset == "display") { - TextField( - value = displayIdInput, - onValueChange = onDisplayIdInputChange, - label = "--display-id", - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - } - if (videoSourcePreset == "camera") { - TextField( - value = cameraIdInput, - onValueChange = onCameraIdInputChange, - label = "--camera-id", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - SuperArrow( - title = "重新获取 Camera Sizes", - summary = "--list-camera-sizes", - onClick = onRefreshCameraSizes, - enabled = !sessionStarted, - ) - SuperDropdown( - title = "摄像头朝向", - summary = "--camera-facing", - items = cameraFacingItems, - selectedIndex = cameraFacingIndex, - onSelectedIndexChange = { index -> - onCameraFacingPresetChange(CAMERA_FACING_OPTIONS[index].first) - }, - enabled = !sessionStarted, - ) - SuperDropdown( - title = "摄像头分辨率", - summary = "--camera-size", - items = cameraSizeDropdownItems, - selectedIndex = cameraSizeIndex.coerceIn( - 0, - (cameraSizeDropdownItems.size - 1).coerceAtLeast(0) - ), - onSelectedIndexChange = { index -> - onCameraSizePresetChange( - when (index) { - 0 -> "" - cameraSizeDropdownItems.lastIndex -> "custom" - else -> cameraSizeDropdownItems[index] - }, - ) - }, - enabled = !sessionStarted, - ) - if (cameraSizePreset == "custom") { - TextField( - value = cameraSizeCustom, - onValueChange = onCameraSizeCustomChange, - label = "--camera-size", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - } - TextField( - value = cameraArInput, - onValueChange = onCameraArInputChange, - label = "--camera-ar", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - SuperSlide( - title = "摄像头帧率", - summary = "--camera-fps", - value = cameraFpsPresetIndex.toFloat(), - onValueChange = { value -> - val idx = value.roundToInt().coerceIn(0, CAMERA_FPS_PRESETS.lastIndex) - val preset = CAMERA_FPS_PRESETS[idx] - onCameraFpsInputChange(if (preset == 0) "" else preset.toString()) - }, - valueRange = 0f..CAMERA_FPS_PRESETS.lastIndex.toFloat(), - steps = (CAMERA_FPS_PRESETS.size - 2).coerceAtLeast(0), - enabled = !sessionStarted, - unit = "fps", - zeroStateText = "默认", - showUnitWhenZeroState = false, - showKeyPoints = true, - keyPoints = CAMERA_FPS_PRESETS.indices.map { it.toFloat() }, - displayText = cameraFpsInput, - inputHint = "0 或留空表示默认", - inputInitialValue = cameraFpsInput, - inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 0f..Float.MAX_VALUE, - onInputConfirm = { - val normalized = it.ifBlank { "" } - onCameraFpsInputChange(if (normalized == "0") "" else normalized) - }, - ) - SuperSwitch( - title = "高帧率模式", - summary = "--camera-high-speed", - checked = cameraHighSpeed, - onCheckedChange = onCameraHighSpeedChange, - enabled = !sessionStarted, - ) - } - } - } - - item { - Card { - SuperDropdown( - title = "音频来源", - summary = "--audio-source", - items = audioSourceItems, - selectedIndex = audioSourceIndex, - onSelectedIndexChange = { index -> - onAudioSourcePresetChange(AUDIO_SOURCE_OPTIONS[index].first) - }, - enabled = !sessionStarted && audioEnabled, - ) - if (audioSourcePreset == "custom") { - TextField( - value = audioSourceCustom, - onValueChange = onAudioSourceCustomChange, - label = "--audio-source", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - } - SuperSwitch( - title = "音频双路输出", - summary = "--audio-dup", - checked = audioDup, - onCheckedChange = onAudioDupChange, - enabled = !sessionStarted && audioEnabled, - ) - SuperSwitch( - title = "仅转发不播放", - summary = "--no-audio-playback", - checked = noAudioPlayback, - onCheckedChange = onNoAudioPlaybackChange, - enabled = !sessionStarted && audioEnabled, - ) - SuperSwitch( - title = "音频失败时终止 [TODO]", - summary = "--require-audio", - checked = requireAudio, - onCheckedChange = onRequireAudioChange, - enabled = false, - ) - } - } - - item { - Card { - SuperSlide( - title = "最大分辨率", - summary = "--max-size", - value = maxSizePresetIndex.toFloat(), - onValueChange = { value -> - val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxSize.lastIndex) - val preset = ScrcpyPresets.MaxSize[idx] - onMaxSizeInputChange(if (preset == 0) "" else preset.toString()) - }, - valueRange = 0f..ScrcpyPresets.MaxSize.lastIndex.toFloat(), - steps = (ScrcpyPresets.MaxSize.size - 2).coerceAtLeast(0), - enabled = !sessionStarted, - unit = "px", - zeroStateText = "关闭", - showUnitWhenZeroState = false, - showKeyPoints = true, - keyPoints = ScrcpyPresets.MaxSize.indices.map { it.toFloat() }, - displayText = maxSizeInput, - inputHint = "0 或留空表示关闭", - inputInitialValue = maxSizeInput, - inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 0f..Float.MAX_VALUE, - onInputConfirm = { - val normalized = it.ifBlank { "" } - onMaxSizeInputChange(normalized) - }, - ) - SuperSlide( - title = "最大帧率", - summary = "--max-fps", - value = maxFpsPresetIndex.toFloat(), - onValueChange = { value -> - val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxFPS.lastIndex) - val preset = ScrcpyPresets.MaxFPS[idx] - onMaxFpsInputChange(if (preset == 0) "" else preset.toString()) - }, - valueRange = 0f..ScrcpyPresets.MaxFPS.lastIndex.toFloat(), - steps = (ScrcpyPresets.MaxFPS.size - 2).coerceAtLeast(0), - enabled = !sessionStarted, - unit = "fps", - zeroStateText = "关闭", - showUnitWhenZeroState = false, - showKeyPoints = true, - keyPoints = ScrcpyPresets.MaxFPS.indices.map { it.toFloat() }, - displayText = maxFpsInput, - inputHint = "0 或留空表示关闭", - inputInitialValue = maxFpsInput, - inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 0f..Float.MAX_VALUE, - onInputConfirm = { - val normalized = it.ifBlank { "" } - onMaxFpsInputChange(normalized) - }, - ) - } - } - - item { - Card { - SuperArrow( - title = "重新获取编码器列表", - summary = "--list-encoders", - onClick = onRefreshEncoders, - enabled = !sessionStarted, - ) - SuperSpinner( - title = "视频编码器", - summary = "--video-encoder", - items = videoEncoderEntries, - selectedIndex = videoEncoderIndex, - onSelectedIndexChange = { index -> - onVideoEncoderChange(if (index == 0) "" else videoEncoderDropdownItems[index]) - }, - enabled = !sessionStarted, - ) - TextField( - value = videoCodecOptions, - onValueChange = onVideoCodecOptionsChange, - label = "--video-codec-options", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - SuperSpinner( - title = "音频编码器", - summary = "--audio-encoder", - items = audioEncoderEntries, - selectedIndex = audioEncoderIndex, - onSelectedIndexChange = { index -> - onAudioEncoderChange(if (index == 0) "" else audioEncoderDropdownItems[index]) - }, - enabled = !sessionStarted && audioEnabled, - ) - TextField( - value = audioCodecOptions, - onValueChange = onAudioCodecOptionsChange, - label = "--audio-codec-options", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - } - } - - item { - Card { - Text( - text = "--new-display", - modifier = Modifier - .padding(horizontal = UiSpacing.CardTitle) - .padding( - top = UiSpacing.CardContent, - bottom = UiSpacing.FieldLabelBottom, - ), - fontWeight = FontWeight.Medium, - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), - ) { - TextField( - value = newDisplayWidth, - onValueChange = onNewDisplayWidthChange, - label = "width", - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Next, - ), - keyboardActions = KeyboardActions( - onNext = { focusManager.moveFocus(FocusDirection.Next) }, - ), - modifier = Modifier.weight(1f), - ) - TextField( - value = newDisplayHeight, - onValueChange = onNewDisplayHeightChange, - label = "height", - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Next, - ), - keyboardActions = KeyboardActions( - onNext = { focusManager.moveFocus(FocusDirection.Next) }, - ), - modifier = Modifier.weight(1f), - ) - TextField( - value = newDisplayDpi, - onValueChange = onNewDisplayDpiChange, - label = "dpi", - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions( - onDone = { focusManager.clearFocus() }, - ), - modifier = Modifier.weight(1f), - ) - } - } - } - - item { - Card { - Text( - text = "--crop", - modifier = Modifier - .padding(horizontal = UiSpacing.CardTitle) - .padding( - top = UiSpacing.CardContent, - bottom = UiSpacing.FieldLabelBottom, - ), - fontWeight = FontWeight.Medium, - ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), - ) { - TextField( - value = cropWidth, - onValueChange = onCropWidthChange, - label = "width", - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Next, - ), - keyboardActions = KeyboardActions( - onNext = { focusManager.moveFocus(FocusDirection.Next) }, - ), - modifier = Modifier.weight(1f), - ) - TextField( - value = cropHeight, - onValueChange = onCropHeightChange, - label = "height", - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Next, - ), - keyboardActions = KeyboardActions( - onNext = { focusManager.moveFocus(FocusDirection.Next) }, - ), - modifier = Modifier.weight(1f), - ) - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), - ) { - TextField( - value = cropX, - onValueChange = onCropXChange, - label = "x", - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Next, - ), - keyboardActions = KeyboardActions( - onNext = { focusManager.moveFocus(FocusDirection.Next) }, - ), - modifier = Modifier.weight(1f), - ) - TextField( - value = cropY, - onValueChange = onCropYChange, - label = "y", - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions( - onDone = { focusManager.clearFocus() }, - ), - modifier = Modifier.weight(1f), - ) - } - } - } - } - - // TODO: 放进 [AppPageLazyColumn] 里 - item { Spacer(Modifier.height(UiSpacing.BottomContent)) } - } -} - -private fun presetIndexFromInputForAdvancedPage(raw: String, presets: List): Int { - if (raw.isBlank()) return 0 - val value = raw.toIntOrNull() ?: return 0 - val exact = presets.indexOf(value) - if (exact >= 0) return exact - val nearest = presets.withIndex().minByOrNull { (_, preset) -> kotlin.math.abs(preset - value) } - return nearest?.index ?: 0 -} - -private fun resolveEncoderTypeLabel(raw: String?): String { - return when (raw?.trim()?.lowercase()) { - "hw" -> "hw" - "sw" -> "sw" - else -> "" - } -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt deleted file mode 100644 index b343bb6..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt +++ /dev/null @@ -1,1365 +0,0 @@ -package io.github.miuzarte.scrcpyforandroid.pages - -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.height -import androidx.compose.foundation.lazy.itemsIndexed -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.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.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.scaffolds.AppPageLazyColumn -import io.github.miuzarte.scrcpyforandroid.services.DevicePageSettings -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.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.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 -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 -import kotlinx.coroutines.withTimeout -import top.yukonga.miuix.kmp.basic.ScrollBehavior -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 -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 DEVICE_SHORTCUT_SEPARATOR = "\u001F" -private const val LOG_TAG = "DevicePage" - -private val DeviceShortcutStateListSaver = - listSaver, String>( - save = { list -> - list.map { item -> - listOf( - item.id, - item.name, - item.host, - item.port.toString(), - if (item.online) "1" else "0", - ).joinToString(DEVICE_SHORTCUT_SEPARATOR) - } - }, - restore = { saved -> - saved.mapNotNull { line -> - val parts = line.split(DEVICE_SHORTCUT_SEPARATOR) - if (parts.size != 5) return@mapNotNull null - val port = parts[3].toIntOrNull() ?: return@mapNotNull null - DeviceShortcut( - id = parts[0], - name = parts[1], - host = parts[2], - port = port, - online = parts[4] == "1", - ) - }.toMutableStateList() - }, - ) - -private val StringStateListSaver = - listSaver, String>( - save = { it.toList() }, - restore = { it.toMutableStateList() }, - ) - -@Composable -fun DeviceTabScreen( - contentPadding: PaddingValues, - nativeCore: NativeCoreFacade, - snack: SnackbarHostState, - scrollBehavior: ScrollBehavior, - virtualButtonsLayout: String, - showPreviewVirtualButtonText: Boolean, - previewCardHeightDp: Int, - themeBaseIndex: Int, - customServerUri: String?, - serverRemotePath: String, - onServerRemotePathChange: (String) -> Unit, - videoCodec: String, - onVideoCodecChange: (String) -> Unit, - audioEnabled: Boolean, - onAudioEnabledChange: (Boolean) -> Unit, - audioCodec: String, - onAudioCodecChange: (String) -> Unit, - noControl: Boolean, - onNoControlChange: (Boolean) -> Unit, - videoEncoder: String, - onVideoEncoderChange: (String) -> Unit, - videoCodecOptions: String, - onVideoCodecOptionsChange: (String) -> Unit, - audioEncoder: String, - onAudioEncoderChange: (String) -> Unit, - audioCodecOptions: String, - onAudioCodecOptionsChange: (String) -> Unit, - audioDup: Boolean, - onAudioDupChange: (Boolean) -> Unit, - audioSourcePreset: String, - onAudioSourcePresetChange: (String) -> Unit, - audioSourceCustom: String, - onAudioSourceCustomChange: (String) -> Unit, - videoSourcePreset: String, - onVideoSourcePresetChange: (String) -> Unit, - cameraIdInput: String, - onCameraIdInputChange: (String) -> Unit, - cameraFacingPreset: String, - onCameraFacingPresetChange: (String) -> Unit, - cameraSizePreset: String, - onCameraSizePresetChange: (String) -> Unit, - cameraSizeCustom: String, - onCameraSizeCustomChange: (String) -> Unit, - cameraArInput: String, - onCameraArInputChange: (String) -> Unit, - cameraFpsInput: String, - onCameraFpsInputChange: (String) -> Unit, - cameraHighSpeed: Boolean, - onCameraHighSpeedChange: (Boolean) -> Unit, - noAudioPlayback: Boolean, - onNoAudioPlaybackChange: (Boolean) -> Unit, - noVideo: Boolean, - requireAudio: Boolean, - onRequireAudioChange: (Boolean) -> Unit, - turnScreenOff: Boolean, - onTurnScreenOffChange: (Boolean) -> Unit, - maxSizeInput: String, - onMaxSizeInputChange: (String) -> Unit, - maxFpsInput: String, - onMaxFpsInputChange: (String) -> Unit, - newDisplayWidth: String, - onNewDisplayWidthChange: (String) -> Unit, - newDisplayHeight: String, - onNewDisplayHeightChange: (String) -> Unit, - newDisplayDpi: String, - onNewDisplayDpiChange: (String) -> Unit, - displayIdInput: String, - onDisplayIdInputChange: (String) -> Unit, - cropWidth: String, - onCropWidthChange: (String) -> Unit, - cropHeight: String, - onCropHeightChange: (String) -> Unit, - cropX: String, - onCropXChange: (String) -> Unit, - cropY: String, - onCropYChange: (String) -> Unit, - videoEncoderOptions: List, - onVideoEncoderOptionsChange: (List) -> Unit, - onVideoEncoderTypeMapChange: (Map) -> Unit, - audioEncoderOptions: List, - onAudioEncoderOptionsChange: (List) -> Unit, - onAudioEncoderTypeMapChange: (Map) -> Unit, - cameraSizeOptions: List, - onCameraSizeOptionsChange: (List) -> Unit, - onSessionStartedChange: (Boolean) -> Unit, - onRefreshEncodersActionChange: ((() -> Unit)?) -> Unit, - onRefreshCameraSizesActionChange: ((() -> Unit)?) -> Unit, - onClearLogsActionChange: ((() -> Unit)?) -> Unit, - onCanClearLogsChange: (Boolean) -> Unit, - onOpenReorderDevicesActionChange: ((() -> Unit)?) -> Unit, - onOpenAdvancedPage: () -> Unit, - onOpenFullscreenPage: (ScrcpySessionInfo) -> Unit, - adbPairingAutoDiscoverOnDialogOpen: Boolean, - adbAutoReconnectPairedDevice: Boolean, - adbMdnsLanDiscoveryEnabled: Boolean, -) { - val context = LocalContext.current - val haptics = rememberAppHaptics() - val virtualButtonLayout = remember(virtualButtonsLayout) { - VirtualButtonActions.splitLayout(VirtualButtonActions.parseStoredLayout(virtualButtonsLayout)) - } - 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 currentTargetHost by rememberSaveable { mutableStateOf("") } - var currentTargetPort by rememberSaveable { mutableIntStateOf(AppDefaults.ADB_PORT) } - var connectedDeviceLabel by rememberSaveable { mutableStateOf("未连接") } - var sessionInfoWidth by rememberSaveable { mutableIntStateOf(0) } - var sessionInfoHeight by rememberSaveable { mutableIntStateOf(0) } - var sessionInfoDeviceName by rememberSaveable { mutableStateOf("") } - var sessionInfoCodec by rememberSaveable { mutableStateOf("") } - var sessionInfoControlEnabled by rememberSaveable { mutableStateOf(false) } - var sessionInfo by remember { - mutableStateOf(null) - } - LaunchedEffect( - sessionInfoWidth, - sessionInfoHeight, - sessionInfoDeviceName, - sessionInfoCodec, - sessionInfoControlEnabled - ) { - sessionInfo = if (sessionInfoDeviceName.isNotBlank()) { - ScrcpySessionInfo( - width = sessionInfoWidth, - height = sessionInfoHeight, - deviceName = sessionInfoDeviceName, - codec = sessionInfoCodec, - controlEnabled = sessionInfoControlEnabled, - ) - } else { - null - } - } - var previewControlsVisible by rememberSaveable { mutableStateOf(false) } - var editingDeviceId by rememberSaveable { mutableStateOf(null) } - var activeDeviceActionId by rememberSaveable { mutableStateOf(null) } - var showReorderSheet by rememberSaveable { mutableStateOf(false) } - var adbConnecting by rememberSaveable { mutableStateOf(false) } - - var connectHost by rememberSaveable { mutableStateOf("") } - var connectPort by rememberSaveable { mutableStateOf(AppDefaults.ADB_PORT.toString()) } - var quickConnectInput by rememberSaveable { mutableStateOf(initialSettings.quickConnectInput) } - var audioForwardingSupported by rememberSaveable { mutableStateOf(true) } - var cameraMirroringSupported by rememberSaveable { mutableStateOf(true) } - - var bitRateMbps by rememberSaveable { mutableFloatStateOf(initialSettings.videoBitRateMbps) } - var bitRateInput by rememberSaveable { mutableStateOf(initialSettings.videoBitRateInput) } - var audioBitRateKbps by rememberSaveable { mutableIntStateOf(initialSettings.audioBitRateKbps) } - val currentTarget = if (currentTargetHost.isNotBlank()) ConnectionTarget( - currentTargetHost, - currentTargetPort - ) else null - - val eventLog = rememberSaveable(saver = StringStateListSaver) { mutableStateListOf() } - val quickDevices = - rememberSaveable(saver = DeviceShortcutStateListSaver) { mutableStateListOf() } - 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) - } - } - - /** - * 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]. - * - This ensures UI coroutines are never blocked by synchronous native I/O. - * - * Side effects: - * - 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. - * - 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). - * - * Usage notes: - * - Prefer calling this from UI code wrapped by `runAdbConnect`/`runBusy` where appropriate - * so the UI busy/connect gates are respected. - * - This function is idempotent from the UI state perspective: calling it when already - * disconnected will simply reset UI fields and not throw. - */ - suspend fun disconnectAdbConnection( - clearQuickOnlineForTarget: ConnectionTarget? = currentTarget, - logMessage: String? = null, - showSnackMessage: String? = null, - ) { - withContext(adbWorkerDispatcher) { - // Also stops scrcpy. - runCatching { nativeCore.scrcpyStop() } - runCatching { nativeCore.adbDisconnect() } - } - adbConnected = false - currentTargetHost = "" - currentTargetPort = AppDefaults.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) - } - } - logMessage?.let { logEvent(it) } - if (!showSnackMessage.isNullOrBlank()) { - scope.launch { - snack.showSnackbar(showSnackMessage) - } - } - } - - suspend fun disconnectCurrentTargetBeforeConnecting(newHost: String, newPort: Int) { - // Force old target cleanup before switching to another endpoint. - val current = currentTarget - if (!adbConnected || current == null) return - if (current.host == newHost && current.port == newPort) return - - sessionReconnectBlacklistHosts += current.host - logEvent("切换连接目标,先断开当前设备: ${current.host}:${current.port}") - disconnectAdbConnection(clearQuickOnlineForTarget = current) - } - - fun applyConnectedDeviceCapabilities(sdkInt: Int, release: String) { - val audioSupported = sdkInt !in 0..<30 - audioForwardingSupported = audioSupported - if (!audioSupported && audioEnabled) { - onAudioEnabledChange(false) - logEvent( - "设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持音频转发,已自动关闭", - Log.WARN - ) - } - val cameraSupported = sdkInt !in 0..<31 - cameraMirroringSupported = cameraSupported - if (!cameraSupported && videoSourcePreset == "camera") { - onVideoSourcePresetChange("display") - logEvent( - "设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持 camera mirroring,已切换为 display", - Log.WARN - ) - } - } - - /** - * Attempt to connect to an adb endpoint within a short timeout. - * - * Execution: - * - Runs `nativeCore.adbConnect(host, port)` on [adbWorkerDispatcher] and wraps it with - * [withTimeout] to avoid hanging forever. Returns true on success, false / throws on failure - * depending on the underlying native behavior. - * - * Why this exists: - * - 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 { - return withContext(adbWorkerDispatcher) { - withTimeout(ADB_CONNECT_TIMEOUT_MS) { - nativeCore.adbConnect(host, port) - } - } - } - - /** - * Validate that the current ADB connection is still alive. - * - * Behavior: - * - Runs on [adbWorkerDispatcher] with a short timeout. - * - First checks `nativeCore.adbIsConnected()` to avoid unnecessary shell calls. - * - Executes a lightweight `adb shell` command (`echo -n 1`) to verify the remote side is - * responsive. Returns true only when both checks succeed. - * - * Notes for reliability: - * - Some devices may accept TCP connections but have a hung adb-server process; the shell - * echo check helps detect that state. - */ - suspend fun keepAliveCheck(host: String, port: Int): Boolean { - return withContext(adbWorkerDispatcher) { - withTimeout(ADB_KEEPALIVE_TIMEOUT_MS) { - val connected = nativeCore.adbIsConnected() - if (!connected) { - return@withTimeout false - } - runCatching { - nativeCore.adbShell("echo -n 1") - true - }.getOrElse { false } - } - } - } - - /** - * Quickly test TCP reachability to an endpoint. - * - * - Uses a plain Socket connect on [Dispatchers.IO] with a very short timeout. - * - This is useful before attempting an adb connect to avoid long native timeouts. - * - Returns true when TCP handshake succeeds within [ADB_TCP_PROBE_TIMEOUT_MS]. - */ - suspend fun probeTcpReachable(host: String, port: Int): Boolean { - return withContext(Dispatchers.IO) { - runCatching { - Socket().use { socket -> - socket.connect(InetSocketAddress(host, port), ADB_TCP_PROBE_TIMEOUT_MS) - true - } - }.getOrDefault(false) - } - } - - /** - * Execute a suspend [block] while toggling the `busy` UI gate. - * - * - Intended for non-adb user actions (UI-level operations) that should disable - * certain controls while active (e.g. scrcpy start/stop, pairing, listing). - * - Errors are logged and surfaced via a snackbar where appropriate. The snackbar - * is launched asynchronously so the outer coroutine can continue to unwind. - * - Ensures `busy` is reset in `finally` so the UI recovers even on exceptions. - */ - fun runBusy(label: String, onFinished: (() -> Unit)? = null, block: suspend () -> Unit) { - // For non-adb actions (start/stop/pair/list refresh...). - if (busy) return - scope.launch { - busy = true - try { - block() - } catch (_: TimeoutCancellationException) { - logEvent("$label 超时", Log.WARN) - } catch (e: IllegalArgumentException) { - val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName - logEvent("$label 参数错误: $detail", Log.WARN, e) - scope.launch { - snack.showSnackbar("$label 参数错误: $detail") - } - } catch (e: Exception) { - val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName - logEvent("$label 失败: $detail", Log.ERROR, e) - } finally { - busy = false - onFinished?.invoke() - } - } - } - - /** - * Execute a manual ADB-related suspend [block] while toggling `adbConnecting`. - * - * Purpose: - * - Called from explicit user actions (pressing "connect" / "disconnect"). - * - Keeps the UI responsive by marking only user-initiated connect operations as in-progress. - * - * Concurrency notes: - * - Background auto-reconnect attempts deliberately DO NOT set `adbConnecting` so that - * 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) { - // For manual adb operations from user actions. - if (adbConnecting) return - scope.launch { - adbConnecting = true - try { - block() - } catch (_: TimeoutCancellationException) { - logEvent("$label 超时", Log.WARN) - } catch (e: IllegalArgumentException) { - val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName - logEvent("$label 参数错误: $detail", Log.WARN, e) - scope.launch { - snack.showSnackbar("$label 参数错误: $detail") - } - } catch (e: Exception) { - val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName - logEvent("$label 失败: $detail", Log.ERROR, e) - } finally { - adbConnecting = false - onFinished?.invoke() - } - } - } - - suspend fun runAutoAdbConnect(host: String, port: Int): Boolean { - return 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() { - if (!adbConnected) return - val remotePath = serverRemotePath.trim().ifBlank { AppDefaults.SERVER_REMOTE_PATH } - runCatching { - nativeCore.scrcpyListEncoders( - customServerUri = customServerUri, - remotePath = remotePath, - ) - }.onSuccess { lists -> - onVideoEncoderOptionsChange(lists.videoEncoders) - onAudioEncoderOptionsChange(lists.audioEncoders) - onVideoEncoderTypeMapChange(lists.videoEncoderTypes) - onAudioEncoderTypeMapChange(lists.audioEncoderTypes) - if (videoEncoder.isNotBlank() && videoEncoder !in videoEncoderOptions) { - onVideoEncoderChange("") - } - if (audioEncoder.isNotBlank() && audioEncoder !in audioEncoderOptions) { - onAudioEncoderChange("") - } - logEvent("编码器列表已刷新: video=${lists.videoEncoders.size} audio=${lists.audioEncoders.size}") - if (lists.videoEncoders.isEmpty() && lists.audioEncoders.isEmpty()) { - logEvent("提示: 编码器为空,请检查 server 路径/版本与设备系统日志", Log.WARN) - val preview = lists.rawOutput.lineSequence().take(20).joinToString(" | ") - if (preview.isNotBlank()) { - logEvent("编码器原始输出: $preview", Log.DEBUG) - } - } - }.onFailure { e -> - onVideoEncoderOptionsChange(emptyList()) - onAudioEncoderOptionsChange(emptyList()) - onVideoEncoderTypeMapChange(emptyMap()) - onAudioEncoderTypeMapChange(emptyMap()) - logEvent("读取编码器列表失败: ${e.message ?: e.javaClass.simpleName}", Log.ERROR, e) - } - } - - fun refreshCameraSizeLists() { - if (!adbConnected) return - val remotePath = serverRemotePath.trim().ifBlank { AppDefaults.SERVER_REMOTE_PATH } - runCatching { - nativeCore.scrcpyListCameraSizes( - customServerUri = customServerUri, - remotePath = remotePath, - ) - }.onSuccess { lists -> - onCameraSizeOptionsChange(lists.sizes) - if (cameraSizePreset.isNotBlank() && cameraSizePreset != "custom" && cameraSizePreset !in lists.sizes) { - onCameraSizePresetChange("") - } - logEvent("camera sizes 已刷新: count=${lists.sizes.size}") - if (lists.sizes.isEmpty()) { - val preview = lists.rawOutput.lineSequence().take(20).joinToString(" | ") - if (preview.isNotBlank()) { - logEvent("camera sizes 原始输出: $preview", Log.DEBUG) - } - } - }.onFailure { e -> - onCameraSizeOptionsChange(emptyList()) - logEvent("读取 camera sizes 失败: ${e.message ?: e.javaClass.simpleName}", Log.ERROR, e) - } - } - - fun handleAdbConnected(host: String, port: Int) { - currentTargetHost = host - currentTargetPort = port - - val info = fetchConnectedDeviceInfo(nativeCore, host, port) - val fullLabel = if (info.serial.isNotBlank()) { - "${info.model} (${info.serial})" - } else { - info.model - } - - connectedDeviceLabel = info.model - applyConnectedDeviceCapabilities(info.sdkInt, info.androidRelease) - updateQuickDeviceNameIfEmpty(context, quickDevices, host, port, fullLabel) - connectHost = host - connectPort = port.toString() - statusLine = "$host:$port" - - logEvent("ADB 已连接: model=${info.model}, serial=${info.serial.ifBlank { "unknown" }}, manufacturer=${info.manufacturer.ifBlank { "unknown" }}, brand=${info.brand.ifBlank { "unknown" }}, device=${info.device.ifBlank { "unknown" }}, android=${info.androidRelease.ifBlank { "unknown" }}, sdk=${info.sdkInt}") - scope.launch { - snack.showSnackbar("ADB 已连接") - } - refreshEncoderLists() - refreshCameraSizeLists() - } - - LaunchedEffect(bitRateInput) { - val parsed = bitRateInput.toFloatOrNull() ?: return@LaunchedEffect - bitRateMbps = parsed.coerceAtLeast(0.1f) - } - - LaunchedEffect( - quickConnectInput, - 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()) { - quickDevices.clear() - quickDevices.addAll(loadQuickDevices(context)) - } - } - - LaunchedEffect(adbConnected, currentTargetHost, currentTargetPort, quickDevices.size) { - val activeId = if (adbConnected && currentTargetHost.isNotBlank()) { - "$currentTargetHost:$currentTargetPort" - } else { - null - } - for (index in quickDevices.indices) { - val item = quickDevices[index] - val shouldOnline = activeId != null && item.id == activeId - if (item.online != shouldOnline) { - quickDevices[index] = item.copy(online = shouldOnline) - } - } - } - - LaunchedEffect(adbConnected, currentTargetHost, currentTargetPort) { - if (!adbConnected || currentTargetHost.isBlank()) return@LaunchedEffect - - // Keep-alive loop for current target. - // On failure: try to reconnect once; if failed, fully disconnect and reset UI state. - val host = currentTargetHost - val port = currentTargetPort - while (adbConnected && currentTargetHost == host && currentTargetPort == port) { - delay(ADB_KEEPALIVE_INTERVAL_MS) - val alive = runCatching { keepAliveCheck(host, port) }.getOrElse { false } - if (alive) continue - - logEvent("ADB 长连接中断,尝试自动重连: $host:$port", Log.WARN) - val reconnected = runCatching { connectWithTimeout(host, port) }.getOrElse { false } - adbConnected = reconnected - if (reconnected) { - statusLine = "$host:$port" - logEvent("ADB 自动重连成功: $host:$port") - scope.launch { - snack.showSnackbar("ADB 自动重连成功") - } - } else { - disconnectAdbConnection() - statusLine = "ADB 连接断开" - logEvent("ADB 自动重连失败: $host:$port", Log.ERROR) - scope.launch { - snack.showSnackbar("ADB 自动重连失败") - } - break - } - } - } - - LaunchedEffect(adbConnected, adbAutoReconnectPairedDevice, adbMdnsLanDiscoveryEnabled) { - if (adbConnected || !adbAutoReconnectPairedDevice) return@LaunchedEffect - - // Background auto reconnect pipeline: - // 1) try quick list targets with reachable TCP ports - // 2) fallback to mDNS discovery - val quickConnectTriedOnce = mutableSetOf() - while (!adbConnected && adbAutoReconnectPairedDevice) { - if (busy || adbConnecting || sessionInfo != null) { - delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS) - continue - } - - val quickCandidates = quickDevices.toList() - if (quickCandidates.isNotEmpty()) { - for (target in quickCandidates) { - if (adbConnected || adbConnecting) break - if (sessionReconnectBlacklistHosts.contains(target.host)) continue - val targetKey = "${target.host}:${target.port}" - if (quickConnectTriedOnce.contains(targetKey)) continue - - val portReachable = probeTcpReachable(target.host, target.port) - if (!portReachable) continue - - quickConnectTriedOnce += targetKey - val ok = runAutoAdbConnect(target.host, target.port) - adbConnected = ok - upsertQuickDevice( - context, - quickDevices, - target.host, - target.port, - ok - ) - if (ok) { - handleAdbConnected(target.host, target.port) - logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}") - break - } - } - if (adbConnected) break - } - - val discovered = withContext(Dispatchers.IO) { - nativeCore.adbDiscoverConnectService( - timeoutMs = ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS, - includeLanDevices = adbMdnsLanDiscoveryEnabled, - ) - } - - if (discovered == null) { - delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS) - continue - } - - val (discoveredHost, discoveredPort) = discovered - if (sessionReconnectBlacklistHosts.contains(discoveredHost)) { - delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS) - continue - } - val knownDevice = quickDevices.firstOrNull { it.host == discoveredHost } - if (knownDevice == null) { - delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS) - continue - } - val portToReplace = quickDevices.firstOrNull { - it.host == discoveredHost && - it.port != AppDefaults.ADB_PORT && - it.port != discoveredPort - }?.port - if (portToReplace != null) { - replaceQuickDevicePort( - context = context, - quickDevices = quickDevices, - host = discoveredHost, - oldPort = portToReplace, - newPort = discoveredPort, - online = false, - ) - logEvent( - "mDNS 发现新端口,已更新快速设备: $discoveredHost:$portToReplace -> $discoveredHost:$discoveredPort" - ) - } - - if (adbConnected || adbConnecting) { - delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS) - continue - } - - val ok = runAutoAdbConnect(discoveredHost, discoveredPort) - adbConnected = ok - upsertQuickDevice( - context, - quickDevices, - discoveredHost, - discoveredPort, - ok - ) - if (ok) { - handleAdbConnected(discoveredHost, discoveredPort) - logEvent("ADB 自动重连成功: $discoveredHost:$discoveredPort") - } else { - logEvent("ADB 自动重连失败: $discoveredHost:$discoveredPort", Log.WARN) - } - - delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS) - } - } - - DisposableEffect(nativeCore) { - val listener: (Int, Int) -> Unit = { width, height -> - sessionInfo = sessionInfo?.copy(width = width, height = height) - } - nativeCore.addVideoSizeListener(listener) - onDispose { - nativeCore.removeVideoSizeListener(listener) - } - } - - LaunchedEffect(sessionInfo) { - if (sessionInfo != null) { - sessionInfoWidth = sessionInfo?.width ?: 0 - sessionInfoHeight = sessionInfo?.height ?: 0 - sessionInfoDeviceName = sessionInfo?.deviceName.orEmpty() - sessionInfoCodec = sessionInfo?.codec.orEmpty() - sessionInfoControlEnabled = sessionInfo?.controlEnabled == true - } else { - sessionInfoWidth = 0 - sessionInfoHeight = 0 - sessionInfoDeviceName = "" - sessionInfoCodec = "" - sessionInfoControlEnabled = false - } - onSessionStartedChange(sessionInfo != null) - } - - DisposableEffect(Unit) { - onRefreshEncodersActionChange { - runBusy("刷新编码器") { refreshEncoderLists() } - } - onRefreshCameraSizesActionChange { - runBusy("刷新 Camera Sizes") { refreshCameraSizeLists() } - } - onClearLogsActionChange { - eventLog.clear() - } - onOpenReorderDevicesActionChange { - showReorderSheet = true - } - onDispose { - onRefreshEncodersActionChange(null) - onRefreshCameraSizesActionChange(null) - onClearLogsActionChange(null) - onCanClearLogsChange(false) - onOpenReorderDevicesActionChange(null) - } - } - - SuperBottomSheet( - show = showReorderSheet, - title = "快速设备排序", - onDismissRequest = { showReorderSheet = false }, - ) { - val list = remember { - ReorderableList( - { - quickDevices.map { device -> - ReorderableList.Item( - id = device.id, - title = device.name.ifBlank { device.host }, - subtitle = "${device.host}:${device.port}", - ) - } - }, - 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) - }, - ) - } - list() - Spacer(Modifier.height(UiSpacing.BottomSheetBottom)) - } - - fun sendVirtualButtonAction(action: VirtualButtonAction) { - val keycode = action.keycode ?: return - runBusy("发送 ${action.title}") { - nativeCore.scrcpyInjectKeycode(0, keycode) - nativeCore.scrcpyInjectKeycode(1, keycode) - } - } - - if (editingDeviceId != null) { - val device = quickDevices.firstOrNull { it.id == editingDeviceId } - if (device != null) { - DeviceEditorScreen( - contentPadding = contentPadding, - device = device, - onSave = { updated -> - val idx = quickDevices.indexOfFirst { it.id == device.id } - if (idx >= 0) { - quickDevices[idx] = updated.copy(online = quickDevices[idx].online) - saveQuickDevices(context, quickDevices) - } - editingDeviceId = null - }, - onDelete = { - quickDevices.removeAll { it.id == device.id } - saveQuickDevices(context, quickDevices) - editingDeviceId = null - }, - onBack = { editingDeviceId = null }, - ) - return - } - editingDeviceId = null - } - - // 设备 - AppPageLazyColumn( - contentPadding = contentPadding, - scrollBehavior = scrollBehavior, - ) { - item { - StatusCard( - statusLine = statusLine, - adbConnected = adbConnected, - streaming = sessionInfo != null, - sessionInfo = sessionInfo, - busyLabel = null, - connectedDeviceLabel = connectedDeviceLabel, - themeBaseIndex = themeBaseIndex, - ) - } - - itemsIndexed(quickDevices, key = { _, device -> device.id }) { _, device -> - val host = device.host - val port = device.port - val isConnectedTarget = - adbConnected && currentTarget?.host == host && currentTarget.port == port - - DeviceTile( - device = device, - actionText = if (isConnectedTarget) "断开" else "连接", - actionEnabled = !busy && !adbConnecting, - actionInProgress = adbConnecting && activeDeviceActionId == device.id, - onLongPress = { editingDeviceId = device.id }, - onContentClick = { - scope.launch { - snack.showSnackbar("长按可编辑设备") - } - }, - onAction = { - haptics.contextClick() - if (isConnectedTarget) { - activeDeviceActionId = device.id - runAdbConnect("断开 ADB", onFinished = { activeDeviceActionId = null }) { - sessionReconnectBlacklistHosts += host - disconnectAdbConnection( - clearQuickOnlineForTarget = ConnectionTarget(host, port), - logMessage = "ADB 已断开: ${device.name}", - 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 连接失败") - } - } - } - } - }, - ) - } - - if (!adbConnected) item { - // "快速连接" - QuickConnectCard( - input = quickConnectInput, - onInputChange = { quickConnectInput = it }, - enabled = !adbConnecting, - onAddDevice = { - val target = parseQuickTarget(quickConnectInput) ?: return@QuickConnectCard - upsertQuickDevice( - context, - quickDevices, - target.host, - target.port, - online = false - ) - scope.launch { - snack.showSnackbar("已添加设备: ${target.host}:${target.port}") - } - }, - onConnect = { - val target = parseQuickTarget(quickConnectInput) ?: return@QuickConnectCard - runAdbConnect("连接 ADB") { - disconnectCurrentTargetBeforeConnecting(target.host, target.port) - val ok = connectWithTimeout(target.host, target.port) - adbConnected = ok - upsertQuickDevice(context, quickDevices, target.host, target.port, ok) - if (ok) { - handleAdbConnected(target.host, target.port) - } else { - statusLine = "ADB 连接失败" - logEvent("ADB 连接失败: ${target.host}:${target.port}", Log.ERROR) - scope.launch { - snack.showSnackbar("ADB 连接失败") - } - } - } - }, - ) - SectionSmallTitle("无线配对") - // "使用配对码配对设备" - PairingCard( - busy = busy, - autoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen, - onDiscoverTarget = { - nativeCore.adbDiscoverPairingService( - includeLanDevices = adbMdnsLanDiscoveryEnabled, - ) - }, - onPair = { host, port, code -> - runBusy("执行配对") { - val resolvedHost = host.trim() - val resolvedPort = port.toIntOrNull() ?: return@runBusy - val resolvedCode = code.trim() - val ok = nativeCore.adbPair( - resolvedHost, - resolvedPort, - resolvedCode, - ) - logEvent( - if (ok) "配对成功" else "配对失败", - if (ok) Log.INFO else Log.ERROR - ) - scope.launch { - snack.showSnackbar(if (ok) "配对成功" else "配对失败") - } - } - }, - ) - } - - if (adbConnected) { - item { - ConfigPanel( - busy = busy, - bitRateMbps = bitRateMbps, - onBitRateSliderChange = { - bitRateMbps = it - @SuppressLint("DefaultLocale") - bitRateInput = String.format("%.1f", it) - }, - onBitRateInputChange = { bitRateInput = it }, - audioBitRateKbps = audioBitRateKbps, - onAudioBitRateChange = { audioBitRateKbps = it }, - videoCodec = videoCodec, - onVideoCodecChange = onVideoCodecChange, - audioEnabled = audioEnabled, - onAudioEnabledChange = onAudioEnabledChange, - audioForwardingSupported = audioForwardingSupported, - audioCodec = audioCodec, - onAudioCodecChange = onAudioCodecChange, - onOpenAdvanced = onOpenAdvancedPage, - onStartStopHaptic = { haptics.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 session = nativeCore.scrcpyStart( - NativeCoreFacade.defaultStartRequest( - customServerUri = customServerUri, - maxSize = maxSize, - maxFps = maxFps, - videoBitRate = bitRateBps, - remotePath = serverRemotePath.trim(), - videoCodec = videoCodec, - audio = audioEnabled, - audioCodec = audioCodec, - audioBitRate = audioBitRateBps, - noControl = noControl, - videoEncoder = videoEncoder, - videoCodecOptions = videoCodecOptions, - audioEncoder = audioEncoder, - audioCodecOptions = audioCodecOptions, - audioDup = audioDup, - audioSource = resolvedAudioSource, - videoSource = resolvedVideoSource, - cameraId = resolvedCameraId, - cameraFacing = resolvedCameraFacing, - cameraSize = resolvedCameraSize, - cameraAr = resolvedCameraAr, - cameraFps = resolvedCameraFps, - cameraHighSpeed = cameraHighSpeed, - noAudioPlayback = noAudioPlayback, - noVideo = noVideo, - requireAudio = requireAudio, - turnScreenOff = effectiveTurnScreenOff, - newDisplay = newDisplayArg, - displayId = displayId, - crop = crop, - ), - ) - sessionInfo = session - statusLine = "scrcpy 运行中" - @SuppressLint("DefaultLocale") - val videoDetail = if (noVideo) { - "off" - } else { - "${session.codec} ${session.width}x${session.height} @${ - String.format( - "%.1f", - bitRateMbps - ) - }Mbps" - } - val audioDetail = if (!audioEnabled) { - "off" - } else { - val playback = if (noAudioPlayback) "(no-playback)" else "" - "$audioCodec ${audioBitRateKbps}kbps source=${resolvedAudioSource.ifBlank { "default" }}$playback" - } - logEvent("scrcpy 已启动: device=${session.deviceName}, video=$videoDetail, audio=$audioDetail, control=${!noControl}, turnScreenOff=$effectiveTurnScreenOff, maxSize=${if (maxSize > 0) maxSize else "auto"}, maxFps=${if (maxFps > 0f) maxFps else "auto"}") - scope.launch { - snack.showSnackbar("scrcpy 已启动") - } - nativeCore.getLastScrcpyServerCommand()?.let { command -> - logEvent("scrcpy-server args: $command") - } - } - }, - onStop = { - runBusy("停止 scrcpy") { - nativeCore.scrcpyStop() - sessionInfo = null - statusLine = - currentTarget?.let { "${it.host}:${it.port}" } ?: "ADB 已连接" - logEvent("scrcpy 已停止") - scope.launch { - snack.showSnackbar("scrcpy 已停止") - } - } - }, - sessionStarted = sessionInfo != null, - ) - } - - if ( - sessionInfo != null && - sessionInfo!!.width > 0 && - sessionInfo!!.height > 0 - ) { - item { - PreviewCard( - sessionInfo = sessionInfo, - nativeCore = nativeCore, - previewHeightDp = previewCardHeightDp.coerceAtLeast(120), - controlsVisible = previewControlsVisible, - onTapped = { - previewControlsVisible = !previewControlsVisible - }, - onOpenFullscreen = { - val info = sessionInfo ?: return@PreviewCard - onOpenFullscreenPage(info) - }, - onOpenFullscreenHaptic = { haptics.contextClick() }, - ) - } - item { - VirtualButtonCard( - busy = busy, - outsideActions = virtualButtonLayout.first, - moreActions = virtualButtonLayout.second, - showText = showPreviewVirtualButtonText, - onAction = ::sendVirtualButtonAction, - ) - } - } - } - - if (eventLog.isNotEmpty()) item { - Spacer(Modifier.height(UiSpacing.PageItem)) - LogsPanel(lines = eventLog) - } - - // TODO: 放进 [AppPageLazyColumn] 里 - item { Spacer(Modifier.height(UiSpacing.BottomContent)) } - } -} - -private fun buildNewDisplayArg(width: String, height: String, dpi: String): String { - val w = width.toIntOrNull()?.takeIf { it > 0 } - val h = height.toIntOrNull()?.takeIf { it > 0 } - val d = dpi.toIntOrNull()?.takeIf { it > 0 } - val sizePart = if (w != null && h != null) "${w}x${h}" else "" - return when { - sizePart.isNotEmpty() && d != null -> "$sizePart/$d" - sizePart.isNotEmpty() -> sizePart - d != null -> "/$d" - else -> "" - } -} - -private fun buildCropArg(width: String, height: String, x: String, y: String): String { - val w = width.toIntOrNull()?.takeIf { it > 0 } ?: return "" - val h = height.toIntOrNull()?.takeIf { it > 0 } ?: return "" - val ox = x.toIntOrNull()?.takeIf { it >= 0 } ?: return "" - val oy = y.toIntOrNull()?.takeIf { it >= 0 } ?: return "" - return "$w:$h:$ox:$oy" -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt new file mode 100644 index 0000000..e54427d --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt @@ -0,0 +1,1124 @@ +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 +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +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 +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.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.DeviceTileList +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.SectionSmallTitle +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 +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.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 + +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 + +@Composable +fun DeviceTabScreen( + nativeCore: NativeCoreFacade, + adbService: NativeAdbService, + scrcpy: Scrcpy, + snackbar: SnackbarController, + scrollBehavior: ScrollBehavior, + onOpenVirtualButtonOrder: () -> 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, + snackbar = snackbar, + scrollBehavior = scrollBehavior, + onOpenAdvancedPage = onOpenAdvancedPage, + onOpenFullscreenPage = onOpenFullscreenPage, + ) + } +} + +@Composable +fun DeviceTabPage( + contentPadding: PaddingValues, + nativeCore: NativeCoreFacade, + adbService: NativeAdbService, + scrcpy: Scrcpy, + snackbar: SnackbarController, + scrollBehavior: ScrollBehavior, + 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) + 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) + } + } + } + + // 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. + + 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("未连接") } + var sessionInfoWidth by rememberSaveable { mutableIntStateOf(0) } + var sessionInfoHeight by rememberSaveable { mutableIntStateOf(0) } + var sessionInfoDeviceName by rememberSaveable { mutableStateOf("") } + var sessionInfoCodec by rememberSaveable { mutableStateOf(null) } + var sessionInfoControlEnabled by rememberSaveable { mutableStateOf(false) } + var sessionInfo by remember { + mutableStateOf(null) + } + LaunchedEffect( + sessionInfoWidth, + sessionInfoHeight, + sessionInfoDeviceName, + sessionInfoCodec, + sessionInfoControlEnabled + ) { + sessionInfo = if (sessionInfoDeviceName.isNotBlank()) { + Scrcpy.Session.SessionInfo( + width = sessionInfoWidth, + height = sessionInfoHeight, + deviceName = sessionInfoDeviceName, + codecId = 0, + codec = sessionInfoCodec, + audioCodecId = 0, + controlEnabled = sessionInfoControlEnabled, + host = currentTargetHost, + port = currentTargetPort, + ) + } else { + null + } + } + var previewControlsVisible by rememberSaveable { mutableStateOf(false) } + var editingDeviceId by rememberSaveable { mutableStateOf(null) } + var activeDeviceActionId by rememberSaveable { mutableStateOf(null) } + var adbConnecting by rememberSaveable { mutableStateOf(false) } + + var audioForwardingSupported by rememberSaveable { mutableStateOf(true) } + var cameraMirroringSupported by rememberSaveable { mutableStateOf(true) } + + val currentTarget = + if (currentTargetHost.isNotBlank()) + ConnectionTarget(currentTargetHost, currentTargetPort) + else null + + val sessionReconnectBlacklistHosts = remember { mutableSetOf() } + + val virtualButtonLayout = remember(asBundle.virtualButtonsLayout) { + VirtualButtonActions.splitLayout( + VirtualButtonActions.parseStoredLayout(asBundle.virtualButtonsLayout) + ) + } + + var savedShortcuts by remember { + mutableStateOf(DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList)) + } + + LaunchedEffect(qdBundle.quickDevicesList) { + savedShortcuts = DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList) + } + // save changes when [savedShortcuts] was modified + LaunchedEffect(savedShortcuts) { + val serialized = savedShortcuts.marshalToString() + if (serialized != qdBundle.quickDevicesList) { + 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. + * + * Concurrency / thread boundary: + * - 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 `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. + * - 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). + * + * Usage notes: + * - Prefer calling this from UI code wrapped by `runAdbConnect`/`runBusy` where appropriate + * so the UI busy/connect gates are respected. + * - This function is idempotent from the UI state perspective: calling it when already + * disconnected will simply reset UI fields and not throw. + */ + suspend fun disconnectAdbConnection( + clearQuickOnlineForTarget: ConnectionTarget? = currentTarget, + logMessage: String? = null, + showSnackMessage: String? = null, + ) { + // Also stops scrcpy. + runCatching { scrcpy.stop() } + setKeepScreenOn(false) + runCatching { adbService.disconnect() } + adbConnected = false + currentTargetHost = "" + currentTargetPort = Defaults.ADB_PORT + audioForwardingSupported = true + cameraMirroringSupported = true + sessionInfo = null + statusLine = "未连接" + connectedDeviceLabel = "未连接" + clearQuickOnlineForTarget?.let { target -> + if (target.host.isNotBlank()) + savedShortcuts = savedShortcuts.update( + host = target.host, port = target.port + ) + } + logMessage?.let { logEvent(it) } + if (!showSnackMessage.isNullOrBlank()) { + snackbar.show(showSnackMessage) + } + } + + suspend fun disconnectCurrentTargetBeforeConnecting(newHost: String, newPort: Int) { + // Force old target cleanup before switching to another endpoint. + val current = currentTarget + if (!adbConnected || current == null) return + if (current.host == newHost && current.port == newPort) return + + sessionReconnectBlacklistHosts += current.host + disconnectAdbConnection(clearQuickOnlineForTarget = current) + } + + fun applyConnectedDeviceCapabilities(sdkInt: Int, release: String) { + val audioSupported = sdkInt !in 0..<30 + audioForwardingSupported = audioSupported + if (!audioSupported && soBundleShared.audio) { + scope.launch { + scrcpyOptions.updateBundle { it.copy(audio = false) } + } + logEvent( + "设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持音频转发,已自动关闭", + Log.WARN + ) + } + val cameraSupported = sdkInt !in 0..<31 + cameraMirroringSupported = cameraSupported + if (!cameraSupported && soBundleShared.videoSource == "camera") { + scope.launch { + scrcpyOptions.updateBundle { it.copy(videoSource = "display") } + } + logEvent( + "设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持 camera mirroring,已切换为 display", + Log.WARN + ) + } + } + + /** + * Attempt to connect to an adb endpoint within a short timeout. + * + * Execution: + * - Runs `nativeCore.adbConnect(host, port)` on [adbWorkerDispatcher] and wraps it with + * [withTimeout] to avoid hanging forever. Returns true on success, false / throws on failure + * depending on the underlying native behavior. + * + * Why this exists: + * - 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) { + return withTimeout(ADB_CONNECT_TIMEOUT_MS) { + adbService.connect(host, port) + } + } + + /** + * Validate that the current ADB connection is still alive. + * + * Behavior: + * - Runs on [adbWorkerDispatcher] with a short timeout. + * - First checks `nativeCore.adbIsConnected()` to avoid unnecessary shell calls. + * - Executes a lightweight `adb shell` command (`echo -n 1`) to verify the remote side is + * responsive. Returns true only when both checks succeed. + * + * Notes for reliability: + * - Some devices may accept TCP connections but have a hung adb-server process; the shell + * echo check helps detect that state. + */ + suspend fun keepAliveCheck(host: String, port: Int): Boolean { + return withTimeout(ADB_KEEPALIVE_TIMEOUT_MS) { + val connected = adbService.isConnected() + if (!connected) { + return@withTimeout false + } + return@withTimeout true + } + } + + /** + * Quickly test TCP reachability to an endpoint. + * + * - Uses a plain Socket connect on [Dispatchers.IO] with a very short timeout. + * - This is useful before attempting an adb connect to avoid long native timeouts. + * - Returns true when TCP handshake succeeds within [ADB_TCP_PROBE_TIMEOUT_MS]. + */ + suspend fun probeTcpReachable(host: String, port: Int): Boolean { + return withContext(Dispatchers.IO) { + runCatching { + Socket().use { socket -> + socket.connect(InetSocketAddress(host, port), ADB_TCP_PROBE_TIMEOUT_MS) + true + } + }.getOrDefault(false) + } + } + + /** + * Execute a suspend [block] while toggling the `busy` UI gate. + * + * - Intended for non-adb user actions (UI-level operations) that should disable + * certain controls while active (e.g. scrcpy start/stop, pairing, listing). + * - Errors are logged and surfaced via a snackbar where appropriate. The snackbar + * is launched asynchronously so the outer coroutine can continue to unwind. + * - Ensures `busy` is reset in `finally` so the UI recovers even on exceptions. + */ + fun runBusy(label: String, onFinished: (() -> Unit)? = null, block: suspend () -> Unit) { + // For non-adb actions (start/stop/pair/list refresh...). + if (busy) return + taskScope.launch { + busy = true + try { + block() + } catch (_: TimeoutCancellationException) { + logEvent("$label 超时", Log.WARN) + } catch (e: IllegalArgumentException) { + val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName + logEvent("$label 参数错误: $detail", Log.WARN, e) + snackbar.show("$label 参数错误: $detail") + } catch (e: Exception) { + val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName + logEvent("$label 失败: $detail", Log.ERROR, e) + } finally { + busy = false + onFinished?.invoke() + } + } + } + + /** + * Execute a manual ADB-related suspend [block] while toggling `adbConnecting`. + * + * Purpose: + * - Called from explicit user actions (pressing "connect" / "disconnect"). + * - Keeps the UI responsive by marking only user-initiated connect operations as in-progress. + * + * Concurrency notes: + * - Background auto-reconnect attempts deliberately DO NOT set `adbConnecting` so that + * UI controls remain actionable while background retries occur. + * - Errors and timeouts are logged and surfaced similarly to `runBusy`. + */ + fun runAdbConnect( + label: String, + onStarted: (() -> Unit)? = null, + onFinished: (() -> Unit)? = null, + block: suspend () -> Unit, + ) { + // For manual adb operations from user actions. + if (adbConnecting) return + taskScope.launch { + onStarted?.invoke() + adbConnecting = true + try { + block() + } catch (_: TimeoutCancellationException) { + logEvent("$label 超时", Log.WARN) + } catch (e: IllegalArgumentException) { + val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName + logEvent("$label 参数错误: $detail", Log.WARN, e) + snackbar.show("$label 参数错误: $detail") + } catch (e: Exception) { + val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName + logEvent("$label 失败: $detail", Log.ERROR, e) + } finally { + adbConnecting = false + onFinished?.invoke() + } + } + } + + 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) + } + } + + suspend fun handleAdbConnected(host: String, port: Int) { + currentTargetHost = host + currentTargetPort = port + + val info = fetchConnectedDeviceInfo(adbService, host, port) + val fullLabel = if (info.serial.isNotBlank()) { + "${info.model} (${info.serial})" + } else { + info.model + } + + connectedDeviceLabel = info.model + applyConnectedDeviceCapabilities(info.sdkInt, info.androidRelease) + 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}" + ) + snackbar.show("ADB 已连接") + } + + LaunchedEffect(adbConnected, currentTargetHost, currentTargetPort) { + if (!adbConnected || currentTargetHost.isBlank()) return@LaunchedEffect + + // Keep-alive loop for current target. + // On failure: try to reconnect once; if failed, fully disconnect and reset UI state. + val host = currentTargetHost + val port = currentTargetPort + while (adbConnected && currentTargetHost == host && currentTargetPort == port) { + delay(ADB_KEEPALIVE_INTERVAL_MS) + val alive = runCatching { keepAliveCheck(host, port) }.getOrElse { false } + if (alive) continue + + logEvent("ADB 长连接中断,尝试自动重连: $host:$port", Log.WARN) + try { + connectWithTimeout(host, port) + adbConnected = true + statusLine = "$host:$port" + logEvent("ADB 自动重连成功: $host:$port") + snackbar.show("ADB 自动重连成功") + } catch (e: Exception) { + disconnectAdbConnection() + statusLine = "ADB 连接断开" + logEvent("ADB 自动重连失败: $e", Log.ERROR) + snackbar.show("ADB 自动重连失败") + break + } + } + } + + val adbPairingAutoDiscoverOnDialogOpen = asBundle.adbPairingAutoDiscoverOnDialogOpen + val adbAutoReconnectPairedDevice = asBundle.adbAutoReconnectPairedDevice + val adbMdnsLanDiscovery = asBundle.adbMdnsLanDiscovery + LaunchedEffect(adbConnected, adbAutoReconnectPairedDevice, adbMdnsLanDiscovery) { + if (adbConnected || !adbAutoReconnectPairedDevice) return@LaunchedEffect + + // Background auto reconnect pipeline: + // 1) try quick list targets with reachable TCP ports + // 2) fallback to mDNS discovery + val quickConnectTriedOnce = mutableSetOf() + while (!adbConnected && adbAutoReconnectPairedDevice) { + if (busy || adbConnecting || sessionInfo != null) { + delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS) + continue + } + + val quickCandidates = savedShortcuts.toList() + if (quickCandidates.isNotEmpty()) { + for (target in quickCandidates) { + if (adbConnected || adbConnecting) break + if (sessionReconnectBlacklistHosts.contains(target.host)) continue + val targetKey = "${target.host}:${target.port}" + if (quickConnectTriedOnce.contains(targetKey)) continue + + val portReachable = probeTcpReachable(target.host, target.port) + if (!portReachable) continue + + quickConnectTriedOnce += targetKey + try { + runAutoAdbConnect(target.host, target.port) + adbConnected = true + savedShortcuts = savedShortcuts.update( + host = target.host, port = target.port, + ) + handleAdbConnected(target.host, target.port) + logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}") + } catch (_: Exception) { + } + break + } + if (adbConnected) break + } + + val discovered = withContext(Dispatchers.IO) { + adbService.discoverConnectService( + timeoutMs = ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS, + includeLanDevices = adbMdnsLanDiscovery, + ) + } + + if (discovered == null) { + delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS) + continue + } + + val (discoveredHost, discoveredPort) = discovered + if (sessionReconnectBlacklistHosts.contains(discoveredHost)) { + delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS) + continue + } + val knownDevice = savedShortcuts.firstOrNull { it.host == discoveredHost } + if (knownDevice == null) { + delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS) + continue + } + val portToReplace = savedShortcuts.firstOrNull { + it.host == discoveredHost && + it.port != Defaults.ADB_PORT && + it.port != discoveredPort + }?.port + if (portToReplace != null) { + savedShortcuts = savedShortcuts.update( + host = discoveredHost, port = portToReplace, + newPort = discoveredPort, + ) + logEvent( + "mDNS 发现新端口,已更新快速设备: $discoveredHost:$portToReplace -> $discoveredHost:$discoveredPort" + ) + } + + if (adbConnected || adbConnecting) { + delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS) + continue + } + + try { + runAutoAdbConnect(discoveredHost, discoveredPort) + adbConnected = true + savedShortcuts = savedShortcuts.update( + host = discoveredHost, port = discoveredPort, + ) + handleAdbConnected(discoveredHost, discoveredPort) + logEvent("ADB 自动重连成功: $discoveredHost:$discoveredPort") + } catch (_: Exception) { + } + + delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS) + } + } + + DisposableEffect(nativeCore) { + val listener: (Int, Int) -> Unit = { width, height -> + sessionInfo = sessionInfo?.copy(width = width, height = height) + } + nativeCore.addVideoSizeListener(listener) + onDispose { + nativeCore.removeVideoSizeListener(listener) + } + } + + LaunchedEffect(sessionInfo) { + if (sessionInfo != null) { + sessionInfoWidth = sessionInfo?.width ?: 0 + sessionInfoHeight = sessionInfo?.height ?: 0 + sessionInfoDeviceName = sessionInfo?.deviceName.orEmpty() + sessionInfoCodec = sessionInfo?.codec + sessionInfoControlEnabled = sessionInfo?.controlEnabled == true + } else { + sessionInfoWidth = 0 + sessionInfoHeight = 0 + sessionInfoDeviceName = "" + sessionInfoCodec = null + sessionInfoControlEnabled = false + } + } + + fun sendVirtualButtonAction(action: VirtualButtonAction) { + val keycode = action.keycode ?: return + runBusy("发送 ${action.title}") { + nativeCore.session?.injectKeycode(0, keycode) + nativeCore.session?.injectKeycode(1, keycode) + } + } + + val devicePreviewCardHeightDp = asBundle.devicePreviewCardHeightDp + val previewVirtualButtonShowText = asBundle.previewVirtualButtonShowText + + val audioBitRate = soBundleShared.audioBitRate + val videoBitRate = soBundleShared.videoBitRate + + // 设备 + LazyColumn( + contentPadding = contentPadding, + scrollBehavior = scrollBehavior, + ) { + item { + StatusCard( + statusLine = statusLine, + adbConnected = adbConnected, + streaming = sessionInfo != null, + sessionInfo = sessionInfo, + busyLabel = null, + connectedDeviceLabel = connectedDeviceLabel, + ) + } + + item { + DeviceTileList( + devices = savedShortcuts, + isConnected = { device -> + adbConnected + && currentTarget?.host == device.host + && currentTarget.port == device.port + }, + actionEnabled = !busy && !adbConnecting, + 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 = { device -> + haptics.contextClick() + 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 }, + onFinished = { activeDeviceActionId = null }, + ) { + disconnectCurrentTargetBeforeConnecting(host, port) + try { + connectWithTimeout(host, port) + adbConnected = true + isQuickConnected = false + savedShortcuts = savedShortcuts.update( + host = host, port = port, + ) + handleAdbConnected(host, port) + } catch (e: Exception) { + statusLine = "ADB 连接失败" + logEvent("ADB 连接失败: $e", Log.ERROR) + snackbar.show("ADB 连接失败") + } + } + } else { + activeDeviceActionId = device.id + runAdbConnect( + "断开 ADB", + onStarted = { activeDeviceActionId = device.id }, + onFinished = { activeDeviceActionId = null }, + ) { + sessionReconnectBlacklistHosts += host + disconnectAdbConnection( + clearQuickOnlineForTarget = ConnectionTarget(host, port), + logMessage = "ADB 已断开: ${device.name}", + showSnackMessage = "ADB 已断开", + ) + } + } + }, + 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 { + 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 连接失败") + } + } + }, + ) + } + + item { + SectionSmallTitle("无线配对", showLeadingSpacer = false) + // "使用配对码配对设备" + 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, + ) + 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, + snackbar = snackbar, + audioForwardingSupported = audioForwardingSupported, + cameraMirroringSupported = cameraMirroringSupported, + adbConnecting = adbConnecting, + isQuickConnected = isQuickConnected, + onOpenAdvanced = onOpenAdvancedPage, + onStartStopHaptic = { haptics.contextClick() }, + onStart = { + 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 + ) + statusLine = "scrcpy 运行中" + @SuppressLint("DefaultLocale") + val videoDetail = + if (!options.video) "off" + 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 = + 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" + + ", control=${options.control}, turnScreenOff=${options.turnScreenOff}" + + ", maxSize=${options.maxSize}, maxFps=${options.maxFps}" + ) + snackbar.show("scrcpy 已启动") + } + }, + onStop = { + runBusy("停止 scrcpy") { + scrcpy.stop() + setKeepScreenOn(false) + sessionInfo = null + statusLine = "${currentTarget!!.host}:${currentTarget.port}" + logEvent("scrcpy 已停止") + snackbar.show("scrcpy 已停止") + } + }, + sessionInfo = sessionInfo, + onDisconnect = { + runAdbConnect( + "断开 ADB", + onStarted = {}, + onFinished = {}, + ) { + currentTarget?.let { target -> + sessionReconnectBlacklistHosts += target.host + disconnectAdbConnection( + clearQuickOnlineForTarget = target, + logMessage = "ADB 已断开", + showSnackMessage = "ADB 已断开", + ) + } + } + }, + ) + } + + if ( + sessionInfo != null && + sessionInfo!!.width > 0 && + sessionInfo!!.height > 0 + ) { + item { + PreviewCard( + sessionInfo = sessionInfo, + nativeCore = nativeCore, + previewHeightDp = devicePreviewCardHeightDp.coerceAtLeast(120), + controlsVisible = previewControlsVisible, + onTapped = { + previewControlsVisible = !previewControlsVisible + }, + onOpenFullscreen = { + val info = sessionInfo ?: return@PreviewCard + onOpenFullscreenPage(info) + }, + ) + } + + item { + VirtualButtonCard( + busy = busy, + outsideActions = virtualButtonLayout.first, + moreActions = virtualButtonLayout.second, + showText = previewVirtualButtonShowText, + onAction = ::sendVirtualButtonAction, + ) + } + } + } + + 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.PageBottom)) } + } +} + +@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, + onSelectedIndexChange = { onReorderDevices() }, + ) + DeviceMenuPopupItem( + text = "虚拟按钮排序", + optionSize = 3, + index = 1, + onSelectedIndexChange = { onOpenVirtualButtonOrder() }, + ) + DeviceMenuPopupItem( + text = "清空日志", + optionSize = 3, + index = 2, + enabled = canClearLogs, + onSelectedIndexChange = { onClearLogs() }, + ) + } + } +} + +@Composable +private fun DeviceMenuPopupItem( + text: String, + optionSize: Int, + index: Int, + enabled: Boolean = true, + onSelectedIndexChange: (Int) -> Unit, +) { + if (enabled) { + DropdownImpl( + text = text, + optionSize = optionSize, + isSelected = false, + index = index, + onSelectedIndexChange = onSelectedIndexChange, + ) + 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 53% 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 b0b301b..ac35d74 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 @@ -20,56 +25,78 @@ 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.haptics.rememberAppHaptics +import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy +import io.github.miuzarte.scrcpyforandroid.storage.Settings +import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen import io.github.miuzarte.scrcpyforandroid.widgets.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 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, - virtualButtonsLayout: String, - showDebugInfo: Boolean, - showVirtualButtons: Boolean, 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 context = LocalContext.current + val haptics = rememberAppHaptics() + val scope = rememberCoroutineScope() + val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } + val activity = remember(context) { context as? Activity } - val virtualButtonLayout = remember(virtualButtonsLayout) { - VirtualButtonActions.splitLayout(VirtualButtonActions.parseStoredLayout(virtualButtonsLayout)) + + val asBundleShared by appSettings.bundleState.collectAsState() + val asBundleSharedLatest by rememberUpdatedState(asBundleShared) + var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) } + val asBundleLatest by rememberUpdatedState(asBundle) + LaunchedEffect(asBundleShared) { + if (asBundle != asBundleShared) { + asBundle = asBundleShared + } } - val bar = remember(virtualButtonLayout) { + LaunchedEffect(asBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (asBundle != asBundleSharedLatest) { + appSettings.saveBundle(asBundle) + } + } + DisposableEffect(Unit) { + onDispose { + taskScope.launch { + appSettings.saveBundle(asBundleLatest) + } + } + } + + val buttonItems = remember(asBundle.virtualButtonsLayout) { + VirtualButtonActions.splitLayout( + VirtualButtonActions.parseStoredLayout(asBundle.virtualButtonsLayout) + ) + } + val fullscreenDebugInfo = asBundle.fullscreenDebugInfo + val showFullscreenVirtualButtons = asBundle.showFullscreenVirtualButtons + + val bar = remember(buttonItems) { VirtualButtonBar( - outsideActions = virtualButtonLayout.first, - moreActions = virtualButtonLayout.second, - ) - } - var session by remember(launch) { - mutableStateOf( - ScrcpySessionInfo( - width = launch.width, - height = launch.height, - deviceName = launch.deviceName.ifBlank { "设备" }, - codec = launch.codec.ifBlank { "unknown" }, - controlEnabled = true, - ), + outsideActions = buttonItems.first, + moreActions = buttonItems.second, ) } + var currentFps by remember { mutableFloatStateOf(0f) } DisposableEffect(activity) { @@ -94,7 +121,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) @@ -113,9 +139,15 @@ fun FullscreenControlPage( } } - fun sendKeycode(keycode: Int) { - nativeCore.scrcpyInjectKeycode(0, keycode) - nativeCore.scrcpyInjectKeycode(1, keycode) + suspend fun sendKeycode(keycode: Int) { + runCatching { + withContext(Dispatchers.IO) { + nativeCore.session?.injectKeycode(0, keycode) + nativeCore.session?.injectKeycode(1, keycode) + } + }.onFailure { e -> + android.util.Log.w("FullscreenControlPage", "sendKeycode failed for keycode=$keycode", e) + } } Scaffold(contentWindowInsets = WindowInsets(0, 0, 0, 0)) { contentPadding -> @@ -127,30 +159,34 @@ fun FullscreenControlPage( FullscreenControlScreen( session = session, nativeCore = nativeCore, - onDismiss = onDismiss, - showDebugInfo = showDebugInfo, + onDismiss = onBack, + showDebugInfo = fullscreenDebugInfo, currentFps = currentFps, enableBackHandler = false, onInjectTouch = { action, pointerId, x, y, pressure, buttons -> - nativeCore.scrcpyInjectTouch( - action = action, - pointerId = pointerId, - x = x, - y = y, - screenWidth = session.width, - screenHeight = session.height, - pressure = pressure, - 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, + ) + } }, ) - if (showVirtualButtons) { + if (showFullscreenVirtualButtons) { 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 deleted file mode 100644 index 3b3fd80..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt +++ /dev/null @@ -1,876 +0,0 @@ -package io.github.miuzarte.scrcpyforandroid.pages - -import android.app.Activity -import android.content.Intent -import android.content.pm.ActivityInfo -import android.net.Uri -import android.os.SystemClock -import android.view.WindowManager -import android.widget.Toast -import androidx.activity.compose.BackHandler -import androidx.activity.compose.PredictiveBackHandler -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.core.spring -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.rounded.Devices -import androidx.compose.material.icons.rounded.MoreVert -import androidx.compose.material.icons.rounded.Settings -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.listSaver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.saveable.rememberSaveableStateHolder -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.navigation3.runtime.NavKey -import androidx.navigation3.runtime.entryProvider -import androidx.navigation3.runtime.rememberDecoratedNavEntries -import androidx.navigation3.ui.NavDisplay -import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade -import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults -import io.github.miuzarte.scrcpyforandroid.constants.UiMotion -import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing -import io.github.miuzarte.scrcpyforandroid.services.MainSettings -import io.github.miuzarte.scrcpyforandroid.services.loadDevicePageSettings -import io.github.miuzarte.scrcpyforandroid.services.loadMainSettings -import io.github.miuzarte.scrcpyforandroid.services.saveMainSettings -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import top.yukonga.miuix.kmp.basic.DropdownImpl -import top.yukonga.miuix.kmp.basic.Icon -import top.yukonga.miuix.kmp.basic.IconButton -import top.yukonga.miuix.kmp.basic.ListPopupColumn -import top.yukonga.miuix.kmp.basic.ListPopupDefaults -import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior -import top.yukonga.miuix.kmp.basic.NavigationBar -import top.yukonga.miuix.kmp.basic.NavigationBarItem -import top.yukonga.miuix.kmp.basic.PopupPositionProvider -import top.yukonga.miuix.kmp.basic.Scaffold -import top.yukonga.miuix.kmp.basic.SnackbarHost -import top.yukonga.miuix.kmp.basic.SnackbarHostState -import top.yukonga.miuix.kmp.basic.Text -import top.yukonga.miuix.kmp.basic.TopAppBar -import top.yukonga.miuix.kmp.extra.SuperListPopup -import top.yukonga.miuix.kmp.theme.MiuixTheme -import top.yukonga.miuix.kmp.theme.ThemeController - -private enum class MainTabDestination( - val title: String, - val label: String, - val icon: ImageVector, -) { - Device(title = "设备", label = "设备", icon = Icons.Rounded.Devices), - Settings(title = "设置", label = "设置", icon = Icons.Rounded.Settings), -} - -private sealed interface RootScreen : NavKey { - data object Home : RootScreen - data object Advanced : RootScreen - data object VirtualButtonOrder : RootScreen - data class Fullscreen(val launch: FullscreenControlLaunch) : RootScreen -} - -@Composable -fun MainPage() { - val context = LocalContext.current - val activity = remember(context) { context as? Activity } - val initialOrientation = remember(activity) { - activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED - } - val nativeCore = remember(context) { NativeCoreFacade.get(context.applicationContext) } - val initialSettings = remember(context) { loadMainSettings(context) } - val initialDeviceSettings = remember(context) { loadDevicePageSettings(context) } - val snackHostState = remember { SnackbarHostState() } - val tabs = remember { MainTabDestination.entries } - val pagerState = rememberPagerState( - initialPage = MainTabDestination.Device.ordinal, - pageCount = { tabs.size }) - val currentTab = tabs[pagerState.currentPage] - val saveableStateHolder = rememberSaveableStateHolder() - val scope = rememberCoroutineScope() - val rootBackStack = remember { mutableStateListOf(RootScreen.Home) } - val currentRootScreen = rootBackStack.lastOrNull() as? RootScreen ?: RootScreen.Home - val deviceScrollBehavior = - MiuixScrollBehavior(canScroll = { currentTab == MainTabDestination.Device }) - val settingsScrollBehavior = - MiuixScrollBehavior(canScroll = { currentTab == MainTabDestination.Settings }) - val advancedScrollBehavior = MiuixScrollBehavior( - canScroll = { - currentRootScreen is RootScreen.Advanced || currentRootScreen is RootScreen.VirtualButtonOrder - }, - ) - val stringListSaver = listSaver, String>( - save = { value -> ArrayList(value) }, - restore = { restored -> restored.toList() }, - ) - - var audioEnabled by rememberSaveable { mutableStateOf(initialSettings.audioEnabled) } - var audioCodec by rememberSaveable { mutableStateOf(initialSettings.audioCodec) } - var videoCodec by rememberSaveable { mutableStateOf(initialSettings.videoCodec) } - var themeBaseIndex by rememberSaveable { mutableIntStateOf(initialSettings.themeBaseIndex) } - var monetEnabled by rememberSaveable { mutableStateOf(initialSettings.monetEnabled) } - var fullscreenDebugInfoEnabled by rememberSaveable { mutableStateOf(initialSettings.fullscreenDebugInfoEnabled) } - var showFullscreenVirtualButtons by rememberSaveable { mutableStateOf(initialSettings.showFullscreenVirtualButtons) } - var showPreviewVirtualButtonText by rememberSaveable { mutableStateOf(initialSettings.showPreviewVirtualButtonText) } - var keepScreenOnWhenStreamingEnabled by rememberSaveable { mutableStateOf(initialSettings.keepScreenOnWhenStreamingEnabled) } - var devicePreviewCardHeightDp by rememberSaveable { mutableIntStateOf(initialSettings.devicePreviewCardHeightDp) } - var virtualButtonsLayout by rememberSaveable { mutableStateOf(initialSettings.virtualButtonsLayout) } - var customServerUri by rememberSaveable { mutableStateOf(initialSettings.customServerUri) } - var serverRemotePath by rememberSaveable { mutableStateOf(initialSettings.serverRemotePath) } - var adbKeyName by rememberSaveable { mutableStateOf(initialSettings.adbKeyName) } - var adbPairingAutoDiscoverOnDialogOpen by rememberSaveable { - mutableStateOf(initialSettings.adbPairingAutoDiscoverOnDialogOpen) - } - var adbAutoReconnectPairedDevice by rememberSaveable { - mutableStateOf(initialSettings.adbAutoReconnectPairedDevice) - } - var adbMdnsLanDiscoveryEnabled by rememberSaveable { - mutableStateOf(initialSettings.adbMdnsLanDiscoveryEnabled) - } - var noControl by rememberSaveable { mutableStateOf(initialDeviceSettings.noControl) } - var videoEncoder by rememberSaveable { mutableStateOf(initialDeviceSettings.videoEncoder) } - var videoCodecOptions by rememberSaveable { mutableStateOf(initialDeviceSettings.videoCodecOptions) } - var audioEncoder by rememberSaveable { mutableStateOf(initialDeviceSettings.audioEncoder) } - var audioCodecOptions by rememberSaveable { mutableStateOf(initialDeviceSettings.audioCodecOptions) } - var audioDup by rememberSaveable { mutableStateOf(initialDeviceSettings.audioDup) } - var audioSourcePreset by rememberSaveable { mutableStateOf(initialDeviceSettings.audioSourcePreset) } - var audioSourceCustom by rememberSaveable { mutableStateOf(initialDeviceSettings.audioSourceCustom) } - var videoSourcePreset by rememberSaveable { mutableStateOf(initialDeviceSettings.videoSourcePreset) } - var cameraIdInput by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraIdInput) } - var cameraFacingPreset by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraFacingPreset) } - var cameraSizePreset by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraSizePreset) } - var cameraSizeCustom by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraSizeCustom) } - var cameraArInput by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraAr) } - var cameraFpsInput by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraFps) } - var cameraHighSpeed by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraHighSpeed) } - var noAudioPlayback by rememberSaveable { mutableStateOf(initialDeviceSettings.noAudioPlayback) } - var noVideo by rememberSaveable { mutableStateOf(initialDeviceSettings.noVideo) } - var requireAudio by rememberSaveable { mutableStateOf(initialDeviceSettings.requireAudio) } - var turnScreenOff by rememberSaveable { mutableStateOf(initialDeviceSettings.turnScreenOff) } - var maxSizeInput by rememberSaveable { mutableStateOf(initialDeviceSettings.maxSizeInput) } - var maxFpsInput by rememberSaveable { mutableStateOf(initialDeviceSettings.maxFpsInput) } - var newDisplayWidth by rememberSaveable { mutableStateOf(initialDeviceSettings.newDisplayWidth) } - var newDisplayHeight by rememberSaveable { mutableStateOf(initialDeviceSettings.newDisplayHeight) } - var newDisplayDpi by rememberSaveable { mutableStateOf(initialDeviceSettings.newDisplayDpi) } - var displayIdInput by rememberSaveable { mutableStateOf(initialDeviceSettings.displayIdInput) } - var cropWidth by rememberSaveable { mutableStateOf(initialDeviceSettings.cropWidth) } - var cropHeight by rememberSaveable { mutableStateOf(initialDeviceSettings.cropHeight) } - var cropX by rememberSaveable { mutableStateOf(initialDeviceSettings.cropX) } - var cropY by rememberSaveable { mutableStateOf(initialDeviceSettings.cropY) } - val videoEncoderOptions = remember { mutableStateListOf() } - 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) - } - val themeMode = resolveThemeMode(themeBaseIndex, monetEnabled) - val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) } - - // Restore system orientation when MainPage leaves composition. - DisposableEffect(activity) { - onDispose { - activity?.requestedOrientation = initialOrientation - } - } - - // Keep-screen-on is controlled globally, so fullscreen and preview share the same behavior. - DisposableEffect(activity, keepScreenOnWhenStreamingEnabled, sessionStarted) { - val window = activity?.window - val shouldKeepScreenOn = keepScreenOnWhenStreamingEnabled && sessionStarted - if (window != null && shouldKeepScreenOn) { - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } - onDispose { - if (window != null && shouldKeepScreenOn) { - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } - } - } - - // Fullscreen route can force orientation based on stream ratio; all other routes are portrait. - LaunchedEffect(activity, currentRootScreen, fullscreenOrientation) { - val targetOrientation = when (currentRootScreen) { - is RootScreen.Fullscreen -> fullscreenOrientation - else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } - activity?.requestedOrientation = targetOrientation - } - - LaunchedEffect( - audioEnabled, - audioCodec, - videoCodec, - themeBaseIndex, - monetEnabled, - fullscreenDebugInfoEnabled, - showFullscreenVirtualButtons, - showPreviewVirtualButtonText, - keepScreenOnWhenStreamingEnabled, - devicePreviewCardHeightDp, - virtualButtonsLayout, - customServerUri, - serverRemotePath, - adbKeyName, - adbPairingAutoDiscoverOnDialogOpen, - adbAutoReconnectPairedDevice, - adbMdnsLanDiscoveryEnabled, - ) { - saveMainSettings( - context, - MainSettings( - audioEnabled = audioEnabled, - audioCodec = audioCodec, - videoCodec = videoCodec, - themeBaseIndex = themeBaseIndex, - monetEnabled = monetEnabled, - fullscreenDebugInfoEnabled = fullscreenDebugInfoEnabled, - showFullscreenVirtualButtons = showFullscreenVirtualButtons, - showPreviewVirtualButtonText = showPreviewVirtualButtonText, - keepScreenOnWhenStreamingEnabled = keepScreenOnWhenStreamingEnabled, - devicePreviewCardHeightDp = devicePreviewCardHeightDp, - virtualButtonsLayout = virtualButtonsLayout, - customServerUri = customServerUri, - serverRemotePath = serverRemotePath, - adbKeyName = adbKeyName, - adbPairingAutoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen, - adbAutoReconnectPairedDevice = adbAutoReconnectPairedDevice, - adbMdnsLanDiscoveryEnabled = adbMdnsLanDiscoveryEnabled, - ), - ) - } - - LaunchedEffect(adbKeyName) { - nativeCore.setAdbKeyName(adbKeyName.ifBlank { AppDefaults.ADB_KEY_NAME }) - } - - fun popRoot() { - if (rootBackStack.size > 1) { - rootBackStack.removeAt(rootBackStack.lastIndex) - } - } - - // Unified back behavior: - // 1) pop inner route - // 2) switch tab back to Device - // 3) double-back to exit and disconnect adb/scrcpy - fun handleBackNavigation() { - if (rootBackStack.size > 1) { - popRoot() - } else if (pagerState.currentPage != MainTabDestination.Device.ordinal) { - scope.launch { - pagerState.animateScrollToPage( - page = MainTabDestination.Device.ordinal, - animationSpec = spring( - dampingRatio = UiMotion.PAGE_SWITCH_DAMPING_RATIO, - stiffness = UiMotion.PAGE_SWITCH_STIFFNESS, - ), - ) - } - } else { - val now = SystemClock.elapsedRealtime() - if (now - lastExitBackPressAtMs > 2_000L) { - lastExitBackPressAtMs = now - Toast.makeText(context, "再按一次返回键退出", Toast.LENGTH_SHORT).show() - return - } - lastExitBackPressAtMs = 0L - scope.launch { - withContext(Dispatchers.IO) { - runCatching { nativeCore.scrcpyStop() } - runCatching { nativeCore.adbDisconnect() } - } - activity?.finish() - } - } - } - - val canNavigateBack = rootBackStack.size > 1 || - pagerState.currentPage != MainTabDestination.Device.ordinal - - BackHandler(enabled = currentRootScreen !is RootScreen.Fullscreen) { - handleBackNavigation() - } - - PredictiveBackHandler( - enabled = canNavigateBack && currentRootScreen !is RootScreen.Fullscreen - ) { progress -> - try { - progress.collect { } - handleBackNavigation() - } catch (_: CancellationException) { - // Gesture was cancelled by the system/user. - } - } - - val picker = - rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> - if (uri == null) return@rememberLauncherForActivityResult - runCatching { - context.contentResolver.takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION - ) - } - customServerUri = uri.toString() - } - - val rootEntryProvider = entryProvider { - entry(RootScreen.Home) { - Scaffold( - bottomBar = { - NavigationBar { - tabs.forEach { tab -> - NavigationBarItem( - selected = currentTab == tab, - onClick = { - scope.launch { - pagerState.animateScrollToPage( - page = tab.ordinal, - animationSpec = spring( - dampingRatio = UiMotion.PAGE_SWITCH_DAMPING_RATIO, - stiffness = UiMotion.PAGE_SWITCH_STIFFNESS, - ), - ) - } - }, - icon = tab.icon, - label = tab.label, - ) - } - } - }, - snackbarHost = { SnackbarHost(snackHostState) }, - ) { contentPadding -> - HorizontalPager( - modifier = Modifier - .fillMaxSize() - .padding(bottom = contentPadding.calculateBottomPadding()), - state = pagerState, - beyondViewportPageCount = 1, - ) { page -> - val tab = tabs[page] - saveableStateHolder.SaveableStateProvider(tab.name) { - when (tab) { - MainTabDestination.Device -> Scaffold( - topBar = { - TopAppBar( - title = tab.title, - actions = { - IconButton( - onClick = { showDeviceMenu = true }, - holdDownState = showDeviceMenu, - ) { - Icon( - Icons.Rounded.MoreVert, - contentDescription = "更多" - ) - } - DeviceMenuPopup( - show = showDeviceMenu, - canClearLogs = canClearLogs, - onDismissRequest = { showDeviceMenu = false }, - onReorderDevices = { - openReorderDevicesAction?.invoke() - showDeviceMenu = false - }, - onOpenVirtualButtonOrder = { - rootBackStack.add(RootScreen.VirtualButtonOrder) - showDeviceMenu = false - }, - onClearLogs = { - clearLogsAction?.invoke() - showDeviceMenu = false - }, - ) - }, - scrollBehavior = deviceScrollBehavior, - ) - }, - ) { pagePadding -> - DeviceTabScreen( - contentPadding = pagePadding, - nativeCore = nativeCore, - snack = snackHostState, - scrollBehavior = deviceScrollBehavior, - virtualButtonsLayout = virtualButtonsLayout, - showPreviewVirtualButtonText = showPreviewVirtualButtonText, - previewCardHeightDp = devicePreviewCardHeightDp, - themeBaseIndex = themeBaseIndex, - customServerUri = customServerUri, - serverRemotePath = serverRemotePath, - onServerRemotePathChange = { serverRemotePath = it }, - videoCodec = videoCodec, - onVideoCodecChange = { videoCodec = it }, - audioEnabled = audioEnabled, - onAudioEnabledChange = { audioEnabled = it }, - audioCodec = audioCodec, - onAudioCodecChange = { audioCodec = it }, - noControl = noControl, - onNoControlChange = { - noControl = it - if (it) { - turnScreenOff = false - } - }, - videoEncoder = videoEncoder, - onVideoEncoderChange = { videoEncoder = it }, - videoCodecOptions = videoCodecOptions, - onVideoCodecOptionsChange = { videoCodecOptions = it }, - audioEncoder = audioEncoder, - onAudioEncoderChange = { audioEncoder = it }, - audioCodecOptions = audioCodecOptions, - onAudioCodecOptionsChange = { audioCodecOptions = it }, - audioDup = audioDup, - onAudioDupChange = { audioDup = it }, - audioSourcePreset = audioSourcePreset, - onAudioSourcePresetChange = { audioSourcePreset = it }, - audioSourceCustom = audioSourceCustom, - onAudioSourceCustomChange = { audioSourceCustom = it }, - videoSourcePreset = videoSourcePreset, - onVideoSourcePresetChange = { videoSourcePreset = it }, - cameraIdInput = cameraIdInput, - onCameraIdInputChange = { cameraIdInput = it }, - cameraFacingPreset = cameraFacingPreset, - onCameraFacingPresetChange = { cameraFacingPreset = it }, - cameraSizePreset = cameraSizePreset, - onCameraSizePresetChange = { cameraSizePreset = it }, - cameraSizeCustom = cameraSizeCustom, - onCameraSizeCustomChange = { cameraSizeCustom = it }, - cameraArInput = cameraArInput, - onCameraArInputChange = { cameraArInput = it }, - cameraFpsInput = cameraFpsInput, - onCameraFpsInputChange = { cameraFpsInput = it }, - cameraHighSpeed = cameraHighSpeed, - onCameraHighSpeedChange = { cameraHighSpeed = it }, - noAudioPlayback = noAudioPlayback, - onNoAudioPlaybackChange = { noAudioPlayback = it }, - noVideo = noVideo, - requireAudio = requireAudio, - onRequireAudioChange = { requireAudio = it }, - turnScreenOff = turnScreenOff, - onTurnScreenOffChange = { turnScreenOff = it }, - maxSizeInput = maxSizeInput, - onMaxSizeInputChange = { maxSizeInput = it }, - maxFpsInput = maxFpsInput, - onMaxFpsInputChange = { maxFpsInput = it }, - newDisplayWidth = newDisplayWidth, - onNewDisplayWidthChange = { newDisplayWidth = it }, - newDisplayHeight = newDisplayHeight, - onNewDisplayHeightChange = { newDisplayHeight = it }, - newDisplayDpi = newDisplayDpi, - onNewDisplayDpiChange = { newDisplayDpi = it }, - displayIdInput = displayIdInput, - onDisplayIdInputChange = { displayIdInput = it }, - cropWidth = cropWidth, - onCropWidthChange = { cropWidth = it }, - cropHeight = cropHeight, - onCropHeightChange = { cropHeight = it }, - cropX = cropX, - onCropXChange = { cropX = it }, - cropY = cropY, - onCropYChange = { cropY = it }, - videoEncoderOptions = videoEncoderOptions, - onVideoEncoderOptionsChange = { - videoEncoderOptions.clear() - videoEncoderOptions.addAll(it) - }, - onVideoEncoderTypeMapChange = { - videoEncoderTypeMap.clear() - videoEncoderTypeMap.putAll(it) - }, - audioEncoderOptions = audioEncoderOptions, - onAudioEncoderOptionsChange = { - audioEncoderOptions.clear() - audioEncoderOptions.addAll(it) - }, - onAudioEncoderTypeMapChange = { - audioEncoderTypeMap.clear() - audioEncoderTypeMap.putAll(it) - }, - cameraSizeOptions = cameraSizeOptions, - onCameraSizeOptionsChange = { - cameraSizeOptions.clear() - cameraSizeOptions.addAll(it) - }, - onSessionStartedChange = { sessionStarted = it }, - onRefreshEncodersActionChange = { refreshEncodersAction = it }, - onRefreshCameraSizesActionChange = { - refreshCameraSizesAction = it - }, - onClearLogsActionChange = { clearLogsAction = it }, - onCanClearLogsChange = { canClearLogs = it }, - onOpenReorderDevicesActionChange = { - openReorderDevicesAction = it - }, - onOpenAdvancedPage = { rootBackStack.add(RootScreen.Advanced) }, - onOpenFullscreenPage = { session -> - fullscreenOrientation = - if (session.width >= session.height) { - ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - } else { - ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } - rootBackStack.add( - RootScreen.Fullscreen( - launch = FullscreenControlLaunch( - deviceName = session.deviceName, - width = session.width, - height = session.height, - codec = session.codec, - ), - ), - ) - }, - adbPairingAutoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen, - adbAutoReconnectPairedDevice = adbAutoReconnectPairedDevice, - adbMdnsLanDiscoveryEnabled = adbMdnsLanDiscoveryEnabled, - ) - } - - MainTabDestination.Settings -> Scaffold( - topBar = { - TopAppBar( - title = tab.title, - scrollBehavior = settingsScrollBehavior, - ) - }, - ) { pagePadding -> - SettingsScreen( - contentPadding = pagePadding, - themeBaseIndex = themeBaseIndex, - onThemeBaseIndexChange = { themeBaseIndex = it }, - monetEnabled = monetEnabled, - onMonetEnabledChange = { monetEnabled = it }, - fullscreenDebugInfoEnabled = fullscreenDebugInfoEnabled, - onFullscreenDebugInfoEnabledChange = { - fullscreenDebugInfoEnabled = it - }, - keepScreenOnWhenStreamingEnabled = keepScreenOnWhenStreamingEnabled, - onKeepScreenOnWhenStreamingEnabledChange = { - keepScreenOnWhenStreamingEnabled = it - }, - devicePreviewCardHeightDp = devicePreviewCardHeightDp, - onDevicePreviewCardHeightDpChange = { - devicePreviewCardHeightDp = it.coerceAtLeast(120) - }, - onOpenReorderDevices = { - openReorderDevicesAction?.invoke() - }, - onOpenVirtualButtonOrder = { - rootBackStack.add(RootScreen.VirtualButtonOrder) - }, - showFullscreenVirtualButtons = showFullscreenVirtualButtons, - onShowFullscreenVirtualButtonsChange = { - showFullscreenVirtualButtons = it - }, - customServerUri = customServerUri, - onPickServer = { - picker.launch( - arrayOf( - "application/java-archive", - "application/octet-stream", - "*/*" - ) - ) - }, - onClearServer = { customServerUri = null }, - serverRemotePath = serverRemotePath, - onServerRemotePathChange = { serverRemotePath = it }, - adbKeyName = adbKeyName, - onAdbKeyNameChange = { adbKeyName = it }, - adbPairingAutoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen, - onAdbPairingAutoDiscoverOnDialogOpenChange = { - adbPairingAutoDiscoverOnDialogOpen = it - }, - adbAutoReconnectPairedDevice = adbAutoReconnectPairedDevice, - onAdbAutoReconnectPairedDeviceChange = { - adbAutoReconnectPairedDevice = it - }, - scrollBehavior = settingsScrollBehavior, - ) - } - } - } - } - } - } - - entry(RootScreen.Advanced) { - val videoEncoderDropdownItems = listOf("默认") + videoEncoderOptions - val audioEncoderDropdownItems = listOf("默认") + audioEncoderOptions - val videoEncoderIndex = (videoEncoderOptions.indexOf(videoEncoder) + 1).coerceAtLeast(0) - val audioEncoderIndex = (audioEncoderOptions.indexOf(audioEncoder) + 1).coerceAtLeast(0) - - Scaffold( - topBar = { - TopAppBar( - title = "高级参数", - navigationIcon = { - IconButton(onClick = { popRoot() }) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "返回" - ) - } - }, - scrollBehavior = advancedScrollBehavior, - ) - }, - snackbarHost = { SnackbarHost(snackHostState) }, - ) { pagePadding -> - AdvancedConfigPage( - contentPadding = pagePadding, - scrollBehavior = advancedScrollBehavior, - sessionStarted = sessionStarted, - snackbarHostState = snackHostState, - audioEnabled = audioEnabled, - noControl = noControl, - onNoControlChange = { - noControl = it - if (it) { - turnScreenOff = false - } - }, - audioDup = audioDup, - onAudioDupChange = { audioDup = it }, - audioSourcePreset = audioSourcePreset, - onAudioSourcePresetChange = { audioSourcePreset = it }, - audioSourceCustom = audioSourceCustom, - onAudioSourceCustomChange = { audioSourceCustom = it }, - videoSourcePreset = videoSourcePreset, - onVideoSourcePresetChange = { videoSourcePreset = it }, - cameraIdInput = cameraIdInput, - onCameraIdInputChange = { cameraIdInput = it }, - cameraFacingPreset = cameraFacingPreset, - onCameraFacingPresetChange = { cameraFacingPreset = it }, - cameraSizePreset = cameraSizePreset, - onCameraSizePresetChange = { cameraSizePreset = it }, - cameraSizeCustom = cameraSizeCustom, - onCameraSizeCustomChange = { cameraSizeCustom = it }, - cameraSizeDropdownItems = listOf("默认") + cameraSizeOptions + listOf("自定义"), - cameraSizeIndex = when (cameraSizePreset) { - "custom" -> cameraSizeOptions.size + 1 - in cameraSizeOptions -> cameraSizeOptions.indexOf(cameraSizePreset) + 1 - else -> 0 - }, - cameraArInput = cameraArInput, - onCameraArInputChange = { cameraArInput = it }, - cameraFpsInput = cameraFpsInput, - onCameraFpsInputChange = { cameraFpsInput = it }, - cameraHighSpeed = cameraHighSpeed, - onCameraHighSpeedChange = { cameraHighSpeed = it }, - noAudioPlayback = noAudioPlayback, - onNoAudioPlaybackChange = { noAudioPlayback = it }, - noVideo = noVideo, - onNoVideoChange = { noVideo = it }, - requireAudio = requireAudio, - onRequireAudioChange = { requireAudio = it }, - turnScreenOff = turnScreenOff, - onTurnScreenOffChange = { turnScreenOff = it }, - maxSizeInput = maxSizeInput, - onMaxSizeInputChange = { maxSizeInput = it }, - maxFpsInput = maxFpsInput, - onMaxFpsInputChange = { maxFpsInput = it }, - videoEncoderDropdownItems = videoEncoderDropdownItems, - videoEncoderTypeMap = videoEncoderTypeMap, - videoEncoderIndex = videoEncoderIndex, - onVideoEncoderChange = { videoEncoder = it }, - videoCodecOptions = videoCodecOptions, - onVideoCodecOptionsChange = { videoCodecOptions = it }, - audioEncoderDropdownItems = audioEncoderDropdownItems, - audioEncoderTypeMap = audioEncoderTypeMap, - audioEncoderIndex = audioEncoderIndex, - onAudioEncoderChange = { audioEncoder = it }, - audioCodecOptions = audioCodecOptions, - onAudioCodecOptionsChange = { audioCodecOptions = it }, - onRefreshEncoders = { refreshEncodersAction?.invoke() }, - onRefreshCameraSizes = { refreshCameraSizesAction?.invoke() }, - newDisplayWidth = newDisplayWidth, - onNewDisplayWidthChange = { newDisplayWidth = it }, - newDisplayHeight = newDisplayHeight, - onNewDisplayHeightChange = { newDisplayHeight = it }, - newDisplayDpi = newDisplayDpi, - onNewDisplayDpiChange = { newDisplayDpi = it }, - displayIdInput = displayIdInput, - onDisplayIdInputChange = { displayIdInput = it }, - cropWidth = cropWidth, - onCropWidthChange = { cropWidth = it }, - cropHeight = cropHeight, - onCropHeightChange = { cropHeight = it }, - cropX = cropX, - onCropXChange = { cropX = it }, - cropY = cropY, - onCropYChange = { cropY = it }, - ) - } - } - - entry(RootScreen.VirtualButtonOrder) { - Scaffold( - modifier = Modifier.nestedScroll(advancedScrollBehavior.nestedScrollConnection), - topBar = { - TopAppBar( - title = "虚拟按钮排序", - navigationIcon = { - IconButton(onClick = { popRoot() }) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "返回" - ) - } - }, - scrollBehavior = advancedScrollBehavior, - ) - }, - ) { pagePadding -> - VirtualButtonOrderPage( - contentPadding = pagePadding, - scrollBehavior = advancedScrollBehavior, - layoutString = virtualButtonsLayout, - onLayoutChange = { layout -> - virtualButtonsLayout = layout - }, - showPreviewText = showPreviewVirtualButtonText, - onShowPreviewTextChange = { showPreviewVirtualButtonText = it }, - ) - } - } - - entry { screen -> - FullscreenControlPage( - launch = screen.launch, - nativeCore = nativeCore, - virtualButtonsLayout = virtualButtonsLayout, - showDebugInfo = fullscreenDebugInfoEnabled, - showVirtualButtons = showFullscreenVirtualButtons, - onVideoSizeChanged = { width, height -> - fullscreenOrientation = if (width >= height) { - ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - } else { - ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } - }, - onDismiss = { popRoot() }, - ) - } - } - - val rootEntries = rememberDecoratedNavEntries( - backStack = rootBackStack, - entryProvider = rootEntryProvider, - ) - - MiuixTheme(controller = themeController) { - NavDisplay( - entries = rootEntries, - onBack = { popRoot() }, - ) - } -} - -@Composable -private fun DeviceMenuPopup( - show: Boolean, - canClearLogs: Boolean, - onDismissRequest: () -> Unit, - onReorderDevices: () -> Unit, - onOpenVirtualButtonOrder: () -> Unit, - onClearLogs: () -> Unit, -) { - SuperListPopup( - show = show, - popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider, - alignment = PopupPositionProvider.Align.TopEnd, - onDismissRequest = onDismissRequest, - enableWindowDim = false, - ) { - ListPopupColumn { - DeviceMenuPopupItem( - text = "快速设备排序", - optionSize = 3, - index = 0, - onClick = onReorderDevices, - ) - DeviceMenuPopupItem( - text = "虚拟按钮排序", - optionSize = 3, - index = 1, - onClick = onOpenVirtualButtonOrder, - ) - DeviceMenuPopupItem( - text = "清空日志", - optionSize = 3, - index = 2, - enabled = canClearLogs, - onClick = onClearLogs, - ) - } - } -} - -@Composable -private fun DeviceMenuPopupItem( - text: String, - optionSize: Int, - index: Int, - enabled: Boolean = true, - // TODO: (Int) -> Unit - onClick: () -> Unit, -) { - if (enabled) { - DropdownImpl( - text = text, - optionSize = optionSize, - isSelected = false, - index = index, - onSelectedIndexChange = { onClick() }, - ) - return - } - - val additionalTopPadding = if (index == 0) UiSpacing.PopupHorizontal else UiSpacing.PageItem - val additionalBottomPadding = - if (index == optionSize - 1) UiSpacing.PopupHorizontal else UiSpacing.PageItem - Text( - text = text, - fontSize = MiuixTheme.textStyles.body1.fontSize, - fontWeight = FontWeight.Medium, - color = MiuixTheme.colorScheme.disabledOnSecondaryVariant, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.PopupHorizontal) - .padding(top = additionalTopPadding, bottom = additionalBottomPadding), - ) -} 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..82753dc --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt @@ -0,0 +1,431 @@ +package io.github.miuzarte.scrcpyforandroid.pages + +import android.app.Activity +import android.content.Intent +import android.content.pm.ActivityInfo +import android.net.Uri +import android.os.SystemClock +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.activity.compose.PredictiveBackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.spring +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Devices +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberDecoratedNavEntries +import androidx.navigation3.ui.NavDisplay +import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade +import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes +import io.github.miuzarte.scrcpyforandroid.constants.UiMotion +import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts +import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService +import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy +import io.github.miuzarte.scrcpyforandroid.services.SnackbarController +import io.github.miuzarte.scrcpyforandroid.storage.AppSettings +import io.github.miuzarte.scrcpyforandroid.storage.Settings +import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings +import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior +import top.yukonga.miuix.kmp.basic.NavigationBar +import top.yukonga.miuix.kmp.basic.NavigationBarItem +import top.yukonga.miuix.kmp.basic.Scaffold +import top.yukonga.miuix.kmp.basic.SnackbarHost +import top.yukonga.miuix.kmp.basic.SnackbarHostState +import top.yukonga.miuix.kmp.theme.ColorSchemeMode +import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.theme.ThemeController + +private enum class MainBottomTabDestination( + val label: String, + val icon: ImageVector, +) { + Device(label = "设备", icon = Icons.Rounded.Devices), + Settings(label = "设置", icon = Icons.Rounded.Settings); +} + +sealed interface RootScreen : NavKey { + data object Home : RootScreen + data object Advanced : RootScreen + data object VirtualButtonOrder : RootScreen + data class Fullscreen(val session: Scrcpy.Session.SessionInfo) : RootScreen +} + +@Composable +fun MainScreen() { + val context = LocalContext.current + val appContext = context.applicationContext + val scope = rememberCoroutineScope() + val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } + + val activity = remember(context) { context as? Activity } + val initialOrientation = remember(activity) { + activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + DisposableEffect(activity) { + onDispose { + activity?.requestedOrientation = initialOrientation + } + } + + val nativeCore = remember(appContext) { + NativeCoreFacade.get(appContext) + } + val adbService = remember(appContext) { + NativeAdbService(appContext) + } + + val snackHostState = remember { SnackbarHostState() } + val snackbarController = remember(scope, snackHostState) { + SnackbarController( + scope = scope, + hostState = snackHostState, + ) + } + val saveableStateHolder = rememberSaveableStateHolder() + val tabs = remember { MainBottomTabDestination.entries } + val pagerState = rememberPagerState( + initialPage = MainBottomTabDestination.Device.ordinal, + pageCount = { tabs.size }) + val currentTab = tabs[pagerState.currentPage] + val rootBackStack = remember { mutableStateListOf(RootScreen.Home) } + val currentRootScreen = rootBackStack.lastOrNull() as? RootScreen ?: RootScreen.Home + val devicesPageScrollBehavior = MiuixScrollBehavior( + canScroll = { currentTab == MainBottomTabDestination.Device }) + val settingsPageScrollBehavior = MiuixScrollBehavior( + canScroll = { currentTab == MainBottomTabDestination.Settings }) + val advancedPageScrollBehavior = MiuixScrollBehavior( + canScroll = { + when (currentRootScreen) { + is RootScreen.Advanced -> true + is RootScreen.VirtualButtonOrder -> true + else -> false + } + }) + + fun popRoot() { + if (rootBackStack.size > 1) + rootBackStack.removeAt(rootBackStack.lastIndex) + } + + // Unified back behavior: + // 1) pop inner route + // 2) switch tab back to Device + // 3) double-back to exit and disconnect adb/scrcpy + var lastExitBackPressAtMs by rememberSaveable { mutableLongStateOf(0L) } + + val asBundleShared by appSettings.bundleState.collectAsState() + val asBundleSharedLatest by rememberUpdatedState(asBundleShared) + var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) } + val asBundleLatest by rememberUpdatedState(asBundle) + LaunchedEffect(asBundleShared) { + if (asBundle != asBundleShared) { + asBundle = asBundleShared + } + } + LaunchedEffect(asBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (asBundle != asBundleSharedLatest) { + appSettings.saveBundle(asBundle) + } + } + DisposableEffect(Unit) { + onDispose { + taskScope.launch { + appSettings.saveBundle(asBundleLatest) + } + } + } + + val qdBundleShared by quickDevices.bundleState.collectAsState() + val qdBundleSharedLatest by rememberUpdatedState(qdBundleShared) + var qdBundle by rememberSaveable(qdBundleShared) { mutableStateOf(qdBundleShared) } + val qdBundleLatest by rememberUpdatedState(qdBundle) + LaunchedEffect(qdBundleShared) { + if (qdBundle != qdBundleShared) { + qdBundle = qdBundleShared + } + } + LaunchedEffect(qdBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (qdBundle != qdBundleSharedLatest) { + quickDevices.saveBundle(qdBundle) + } + } + DisposableEffect(Unit) { + onDispose { + taskScope.launch { + quickDevices.saveBundle(qdBundleLatest) + } + } + } + + val customServerUri = asBundle.customServerUri + .ifBlank { null } + val customServerVersion = asBundle.customServerVersion + .ifBlank { Scrcpy.DEFAULT_SERVER_VERSION } + val serverRemotePath = asBundle.serverRemotePath + .ifBlank { AppSettings.SERVER_REMOTE_PATH.defaultValue } + val scrcpy = remember( + appContext, + adbService, + customServerUri, + customServerVersion, + serverRemotePath, + ) { + Scrcpy( + appContext = appContext, + adbService = adbService, + customServerUri = customServerUri, + serverVersion = customServerVersion, + serverRemotePath = serverRemotePath, + ) + } + + fun handleBackNavigation() { + if (rootBackStack.size > 1) { + popRoot() + } else if (pagerState.currentPage != MainBottomTabDestination.Device.ordinal) { + scope.launch { + pagerState.animateScrollToPage( + page = MainBottomTabDestination.Device.ordinal, + animationSpec = spring( + dampingRatio = UiMotion.PAGE_SWITCH_DAMPING_RATIO, + stiffness = UiMotion.PAGE_SWITCH_STIFFNESS, + ), + ) + } + } else { + val now = SystemClock.elapsedRealtime() + if (now - lastExitBackPressAtMs > 2_000L) { + lastExitBackPressAtMs = now + Toast.makeText(context, "再按一次返回键退出", Toast.LENGTH_SHORT).show() + return + } + lastExitBackPressAtMs = 0L + scope.launch { + withContext(Dispatchers.IO) { + runCatching { scrcpy.stop() } + runCatching { adbService.disconnect() } + } + activity?.finish() + } + } + } + + val canNavigateBack = rootBackStack.size > 1 + || pagerState.currentPage != MainBottomTabDestination.Device.ordinal + + BackHandler(enabled = currentRootScreen !is RootScreen.Fullscreen) { + handleBackNavigation() + } + + PredictiveBackHandler( + enabled = canNavigateBack && currentRootScreen !is RootScreen.Fullscreen + ) { progress -> + try { + progress.collect { } + handleBackNavigation() + } catch (_: CancellationException) { + // Gesture was cancelled by the system/user. + } + } + + var showReorderDevices by rememberSaveable { mutableStateOf(false) } + var fullscreenOrientation by rememberSaveable { + mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) + } + + // Fullscreen route can force orientation based on stream ratio; all other routes are portrait. + LaunchedEffect(activity, currentRootScreen, fullscreenOrientation) { + val targetOrientation = when (currentRootScreen) { + is RootScreen.Fullscreen -> fullscreenOrientation + else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + activity?.requestedOrientation = targetOrientation + } + + LaunchedEffect(asBundle.adbKeyName) { + adbService.keyName = asBundle.adbKeyName.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue } + } + val picker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + if (uri == null) return@rememberLauncherForActivityResult + runCatching { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + asBundle = asBundle.copy(customServerUri = uri.toString()) + } + + val rootEntryProvider = entryProvider { + entry(RootScreen.Home) { + Scaffold( + bottomBar = { + NavigationBar { + tabs.forEach { tab -> + NavigationBarItem( + selected = currentTab == tab, + onClick = { + scope.launch { + pagerState.animateScrollToPage( + page = tab.ordinal, + animationSpec = spring( + dampingRatio = UiMotion.PAGE_SWITCH_DAMPING_RATIO, + stiffness = UiMotion.PAGE_SWITCH_STIFFNESS, + ), + ) + } + }, + icon = tab.icon, + label = tab.label, + ) + } + } + }, + snackbarHost = { SnackbarHost(snackHostState) }, + ) { contentPadding -> + HorizontalPager( + modifier = Modifier + .fillMaxSize() + .padding(bottom = contentPadding.calculateBottomPadding()), + state = pagerState, + beyondViewportPageCount = 1, + ) { page -> + val tab = tabs[page] + saveableStateHolder.SaveableStateProvider(tab.name) { + when (tab) { + MainBottomTabDestination.Device -> DeviceTabScreen( + nativeCore = nativeCore, + adbService = adbService, + scrcpy = scrcpy, + snackbar = snackbarController, + scrollBehavior = devicesPageScrollBehavior, + onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) }, + onOpenReorderDevices = { showReorderDevices = true }, + onOpenAdvancedPage = { rootBackStack.add(RootScreen.Advanced) }, + onOpenFullscreenPage = { session -> + fullscreenOrientation = + if (session.width >= session.height) { + ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } else { + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + rootBackStack.add( + RootScreen.Fullscreen(session), + ) + }, + ) + + MainBottomTabDestination.Settings -> SettingsScreen( + scrollBehavior = settingsPageScrollBehavior, + snackbar = snackbarController, + onOpenReorderDevices = { showReorderDevices = true }, + onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) }, + onPickServer = { + picker.launch( + arrayOf( + "application/java-archive", + "application/octet-stream", + "*/*" + ) + ) + }, + ) + } + } + } + + ReorderDevicesScreen( + show = showReorderDevices, + onDismissRequest = { showReorderDevices = false }, + ) + } + } + + entry(RootScreen.Advanced) { + ScrcpyAllOptionsScreen( + onBack = ::popRoot, + scrollBehavior = advancedPageScrollBehavior, + snackbar = snackbarController, + scrcpy = scrcpy, + ) + } + + entry(RootScreen.VirtualButtonOrder) { + VirtualButtonOrderScreen( + onBack = ::popRoot, + scrollBehavior = advancedPageScrollBehavior, + ) + } + + entry { screen -> + FullscreenControlScreen( + onBack = ::popRoot, + session = screen.session, + nativeCore = nativeCore, + onVideoSizeChanged = { width, height -> + fullscreenOrientation = + if (width >= height) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + else ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + }, + ) + } + } + + val rootEntries = rememberDecoratedNavEntries( + backStack = rootBackStack, + entryProvider = rootEntryProvider, + ) + + val themeMode = when (asBundle.themeBaseIndex.coerceIn(0, ThemeModes.baseOptions.lastIndex)) { + 1 -> if (!asBundle.monet) ColorSchemeMode.Light else ColorSchemeMode.MonetLight + 2 -> if (!asBundle.monet) ColorSchemeMode.Dark else ColorSchemeMode.MonetDark + else -> if (!asBundle.monet) ColorSchemeMode.System else ColorSchemeMode.MonetSystem + } + val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) } + + MiuixTheme(controller = themeController) { + NavDisplay( + entries = rootEntries, + onBack = ::popRoot, + ) + } +} 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..779265f --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ReorderDevicesScreen.kt @@ -0,0 +1,94 @@ +package io.github.miuzarte.scrcpyforandroid.pages + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing +import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts +import io.github.miuzarte.scrcpyforandroid.storage.Settings +import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices +import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import top.yukonga.miuix.kmp.extra.SuperBottomSheet + +@Composable +fun ReorderDevicesScreen( + show: Boolean, + onDismissRequest: () -> Unit, +) { + val scope = rememberCoroutineScope() + val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } + + val qdBundleShared by quickDevices.bundleState.collectAsState() + val qdBundleSharedLatest by rememberUpdatedState(qdBundleShared) + var qdBundle by rememberSaveable(qdBundleShared) { mutableStateOf(qdBundleShared) } + val qdBundleLatest by rememberUpdatedState(qdBundle) + LaunchedEffect(qdBundleShared) { + if (qdBundle != qdBundleShared) { + qdBundle = qdBundleShared + } + } + LaunchedEffect(qdBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (qdBundle != qdBundleSharedLatest) { + quickDevices.saveBundle(qdBundle) + } + } + DisposableEffect(Unit) { + onDispose { + taskScope.launch { + quickDevices.saveBundle(qdBundleLatest) + } + } + } + + var savedShortcuts by remember { + mutableStateOf(DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList)) + } + LaunchedEffect(qdBundle.quickDevicesList) { + savedShortcuts = DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList) + } + LaunchedEffect(savedShortcuts) { + val serialized = savedShortcuts.marshalToString() + if (serialized != qdBundle.quickDevicesList) { + qdBundle = qdBundle.copy(quickDevicesList = serialized) + } + } + + SuperBottomSheet( + show = show, + title = "快速设备排序", + onDismissRequest = onDismissRequest, + ) { + ReorderableList( + itemsProvider = { + savedShortcuts.map { device -> + ReorderableList.Item( + id = device.id, + title = device.name.ifBlank { device.host }, + subtitle = "${device.host}:${device.port}", + ) + } + }, + onSettle = { fromIndex, toIndex -> + savedShortcuts = savedShortcuts.move(fromIndex, toIndex) + }, + ).invoke() + Spacer(Modifier.height(UiSpacing.SheetBottom)) + } +} 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..9b2c488 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt @@ -0,0 +1,1249 @@ +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, + ) + 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 + ) + }, + ) + } + } + + 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 + ) + }, + ) + 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 + ) + }, + ) + } + } + + } + } + + 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 fdad761..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt +++ /dev/null @@ -1,272 +0,0 @@ -package io.github.miuzarte.scrcpyforandroid.pages - -import android.content.Intent -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Clear -import androidx.compose.material.icons.rounded.FileOpen -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.core.net.toUri -import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults -import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing -import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn -import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide -import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle -import top.yukonga.miuix.kmp.basic.Card -import top.yukonga.miuix.kmp.basic.Icon -import top.yukonga.miuix.kmp.basic.IconButton -import top.yukonga.miuix.kmp.basic.ScrollBehavior -import top.yukonga.miuix.kmp.basic.Text -import top.yukonga.miuix.kmp.basic.TextField -import top.yukonga.miuix.kmp.extra.SuperArrow -import top.yukonga.miuix.kmp.extra.SuperDropdown -import top.yukonga.miuix.kmp.extra.SuperSwitch -import top.yukonga.miuix.kmp.theme.ColorSchemeMode -import kotlin.math.roundToInt - -private data class ThemeModeOption( - val label: String, - val mode: ColorSchemeMode, -) - -private val THEME_BASE_OPTIONS = listOf( - ThemeModeOption("跟随系统", ColorSchemeMode.System), - ThemeModeOption("浅色", ColorSchemeMode.Light), - ThemeModeOption("深色", ColorSchemeMode.Dark), -) - -fun resolveThemeMode(baseIndex: Int, monetEnabled: Boolean): ColorSchemeMode { - return when (baseIndex.coerceIn(0, 2)) { - 0 -> if (monetEnabled) ColorSchemeMode.MonetSystem else ColorSchemeMode.System - 1 -> if (monetEnabled) ColorSchemeMode.MonetLight else ColorSchemeMode.Light - else -> if (monetEnabled) ColorSchemeMode.MonetDark else ColorSchemeMode.Dark - } -} - -private fun resolveThemeLabel(baseIndex: Int, monetEnabled: Boolean): String { - val base = THEME_BASE_OPTIONS.getOrNull(baseIndex.coerceIn(0, 2))?.label ?: "跟随系统" - return if (monetEnabled) "Monet($base)" else base -} - -@Composable -fun SettingsScreen( - contentPadding: PaddingValues, - themeBaseIndex: Int, - onThemeBaseIndexChange: (Int) -> Unit, - monetEnabled: Boolean, - onMonetEnabledChange: (Boolean) -> Unit, - fullscreenDebugInfoEnabled: Boolean, - onFullscreenDebugInfoEnabledChange: (Boolean) -> Unit, - keepScreenOnWhenStreamingEnabled: Boolean, - onKeepScreenOnWhenStreamingEnabledChange: (Boolean) -> Unit, - devicePreviewCardHeightDp: Int, - onDevicePreviewCardHeightDpChange: (Int) -> Unit, - showFullscreenVirtualButtons: Boolean, - onOpenReorderDevices: () -> Unit, - onOpenVirtualButtonOrder: () -> Unit, - onShowFullscreenVirtualButtonsChange: (Boolean) -> Unit, - customServerUri: String?, - onPickServer: () -> Unit, - onClearServer: () -> Unit, - serverRemotePath: String, - onServerRemotePathChange: (String) -> Unit, - adbKeyName: String, - onAdbKeyNameChange: (String) -> Unit, - adbPairingAutoDiscoverOnDialogOpen: Boolean, - onAdbPairingAutoDiscoverOnDialogOpenChange: (Boolean) -> Unit, - adbAutoReconnectPairedDevice: Boolean, - onAdbAutoReconnectPairedDeviceChange: (Boolean) -> Unit, - scrollBehavior: ScrollBehavior, -) { - val baseModeItems = THEME_BASE_OPTIONS.map { it.label } - val context = LocalContext.current - - // 设置 - AppPageLazyColumn( - contentPadding = contentPadding, - scrollBehavior = scrollBehavior, - ) { - item { - SectionSmallTitle("主题") - Card { - SuperDropdown( - title = "外观模式", - summary = resolveThemeLabel(themeBaseIndex, monetEnabled), - items = baseModeItems, - selectedIndex = themeBaseIndex.coerceIn(0, baseModeItems.lastIndex), - onSelectedIndexChange = onThemeBaseIndexChange, - ) - SuperSwitch( - title = "Monet", - summary = "开启后使用 Monet 动态配色", - checked = monetEnabled, - onCheckedChange = onMonetEnabledChange, - ) - } - - SectionSmallTitle("投屏") - Card { - SuperSwitch( - title = "启用调试信息", - summary = "在全屏界面显示触点数量、设备分辨率和实时 FPS", - checked = fullscreenDebugInfoEnabled, - onCheckedChange = onFullscreenDebugInfoEnabledChange, - ) - SuperSwitch( - title = "投屏时保持屏幕常亮", - summary = "Scrcpy 启动后保持本机屏幕常亮,避免锁屏导致 ADB 断开", - checked = keepScreenOnWhenStreamingEnabled, - onCheckedChange = onKeepScreenOnWhenStreamingEnabledChange, - ) - SuperSlide( - title = "预览卡高度", - summary = "设备页预览卡高度", - value = devicePreviewCardHeightDp.toFloat(), - onValueChange = { - onDevicePreviewCardHeightDpChange( - it.roundToInt().coerceAtLeast(120) - ) - }, - valueRange = 160f..600f, - steps = 439, - unit = "dp", - displayFormatter = { it.roundToInt().toString() }, - inputInitialValue = devicePreviewCardHeightDp.toString(), - inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 120f..Float.MAX_VALUE, - onInputConfirm = { raw -> - raw.toIntOrNull() - ?.let { onDevicePreviewCardHeightDpChange(it.coerceAtLeast(120)) } - }, - ) - SuperArrow( - title = "快速设备排序", - summary = "手动排序设备页的快速设备", - onClick = onOpenReorderDevices, - ) - SuperArrow( - title = "虚拟按钮排序", - summary = "手动排序预览/全屏时的虚拟按钮,并选择哪些按钮展示在外", - onClick = onOpenVirtualButtonOrder, - ) - SuperSwitch( - title = "全屏显示虚拟按钮", - summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮", - checked = showFullscreenVirtualButtons, - onCheckedChange = onShowFullscreenVirtualButtonsChange, - ) - } - - SectionSmallTitle("scrcpy-server") - Card { - Spacer(modifier = Modifier.padding(top = UiSpacing.CardContent)) - Text( - text = "自定义 binary", - modifier = Modifier - .padding(horizontal = UiSpacing.CardTitle) - .padding(bottom = UiSpacing.FieldLabelBottom), - fontWeight = FontWeight.Medium, - ) - TextField( - value = customServerUri ?: "", - onValueChange = {}, - readOnly = true, - label = "scrcpy-server-v3.3.4", - useLabelAsPlaceholder = customServerUri == null, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - trailingIcon = { - Row(modifier = Modifier.padding(end = UiSpacing.SectionTitleLeadingGap)) { - if (customServerUri != null) IconButton(onClick = onClearServer) { - Icon(Icons.Rounded.Clear, contentDescription = "清空") - } - IconButton(onClick = onPickServer) { - Icon(Icons.Rounded.FileOpen, contentDescription = "选择文件") - } - } - }, - ) - Text( - text = "Remote Path", - modifier = Modifier - .padding(horizontal = UiSpacing.CardTitle) - .padding(bottom = UiSpacing.FieldLabelBottom), - fontWeight = FontWeight.Medium, - ) - TextField( - value = serverRemotePath, - onValueChange = onServerRemotePathChange, - label = AppDefaults.SERVER_REMOTE_PATH, - useLabelAsPlaceholder = true, - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - } - - SectionSmallTitle("ADB") - Card { - Text( - text = "自定义 ADB 密钥名", - modifier = Modifier - .padding(horizontal = UiSpacing.CardTitle) - .padding(top = UiSpacing.CardContent, bottom = UiSpacing.FieldLabelBottom), - fontWeight = FontWeight.Medium, - ) - TextField( - value = adbKeyName, - onValueChange = onAdbKeyNameChange, - label = AppDefaults.ADB_KEY_NAME, - useLabelAsPlaceholder = true, - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - SuperSwitch( - title = "配对时自动启用发现服务", - summary = "打开配对弹窗后自动搜索可用配对端口", - checked = adbPairingAutoDiscoverOnDialogOpen, - onCheckedChange = onAdbPairingAutoDiscoverOnDialogOpenChange, - ) - SuperSwitch( - title = "自动重连已配对设备", - summary = "自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘)", - checked = adbAutoReconnectPairedDevice, - onCheckedChange = onAdbAutoReconnectPairedDeviceChange, - ) - } - - SectionSmallTitle("关于") - Card { - SuperArrow( - title = "前往仓库", - summary = "github.com/Miuzarte/ScrcpyForAndroid", - onClick = { - val intent = Intent( - Intent.ACTION_VIEW, - "https://github.com/Miuzarte/ScrcpyForAndroid".toUri() - ) - context.startActivity(intent) - }, - ) - } - } - - // TODO: 放进 [AppPageLazyColumn] 里 - item { Spacer(Modifier.height(UiSpacing.BottomContent)) } - } -} 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..9e9b533 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt @@ -0,0 +1,460 @@ +package io.github.miuzarte.scrcpyforandroid.pages + +import android.content.Intent +import android.os.Process +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Clear +import androidx.compose.material.icons.rounded.FileOpen +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.core.net.toUri +import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes +import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing +import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn +import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider +import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField +import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy +import io.github.miuzarte.scrcpyforandroid.services.SnackbarController +import io.github.miuzarte.scrcpyforandroid.storage.AppSettings +import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration +import io.github.miuzarte.scrcpyforandroid.storage.Settings +import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings +import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.Scaffold +import top.yukonga.miuix.kmp.basic.ScrollBehavior +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.basic.TextField +import top.yukonga.miuix.kmp.basic.TopAppBar +import top.yukonga.miuix.kmp.extra.SuperArrow +import top.yukonga.miuix.kmp.extra.SuperDropdown +import top.yukonga.miuix.kmp.extra.SuperSwitch +import top.yukonga.miuix.kmp.theme.ColorSchemeMode +import kotlin.math.roundToInt +import kotlin.system.exitProcess + +@Composable +fun SettingsScreen( + scrollBehavior: ScrollBehavior, + snackbar: SnackbarController, + onOpenReorderDevices: () -> Unit, + onOpenVirtualButtonOrder: () -> Unit, + onPickServer: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = "设置", + scrollBehavior = scrollBehavior, + ) + }, + ) { pagePadding -> + SettingsPage( + contentPadding = pagePadding, + scrollBehavior = scrollBehavior, + snackbar = snackbar, + onOpenReorderDevices = onOpenReorderDevices, + onOpenVirtualButtonOrder = onOpenVirtualButtonOrder, + onPickServer = onPickServer, + ) + } +} + +@Composable +fun SettingsPage( + contentPadding: PaddingValues, + scrollBehavior: ScrollBehavior, + snackbar: SnackbarController, + onOpenReorderDevices: () -> Unit, + onOpenVirtualButtonOrder: () -> Unit, + onPickServer: () -> Unit, +) { + val context = LocalContext.current + val appContext = context.applicationContext + var needMigration by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + needMigration = PreferenceMigration(appContext).needsMigration() + } + + val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } + val scope = rememberCoroutineScope() + + val asBundleShared by appSettings.bundleState.collectAsState() + val asBundleSharedLatest by rememberUpdatedState(asBundleShared) + var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) } + val asBundleLatest by rememberUpdatedState(asBundle) + LaunchedEffect(asBundleShared) { + if (asBundle != asBundleShared) { + asBundle = asBundleShared + } + } + LaunchedEffect(asBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (asBundle != asBundleSharedLatest) { + appSettings.saveBundle(asBundle) + } + } + DisposableEffect(Unit) { + onDispose { + taskScope.launch { + appSettings.saveBundle(asBundleLatest) + } + } + } + + val themeItems = rememberSaveable { ThemeModes.baseOptions.map { it.label } } + + val customServerVersionShowInput = rememberSaveable(asBundle.customServerUri) { + asBundle.customServerUri.isNotBlank() + } + var customServerVersionInput by rememberSaveable(asBundle.customServerVersion) { + mutableStateOf(asBundle.customServerVersion) + } + var serverRemotePathInput by rememberSaveable(asBundle.serverRemotePath) { + mutableStateOf( + if (asBundle.serverRemotePath == AppSettings.SERVER_REMOTE_PATH.defaultValue) "" + else asBundle.serverRemotePath + ) + } + + var adbKeyNameInput by rememberSaveable(asBundle.adbKeyName) { + mutableStateOf( + if (asBundle.adbKeyName == AppSettings.ADB_KEY_NAME.defaultValue) "" + else asBundle.adbKeyName + ) + } + + // 设置 + LazyColumn( + contentPadding = contentPadding, + scrollBehavior = scrollBehavior, + ) { + item { + SectionSmallTitle("主题", showLeadingSpacer = false) + Card { + SuperDropdown( + title = "外观模式", + summary = ThemeModes.baseOptions + .getOrNull(asBundle.themeBaseIndex.coerceIn(0, ThemeModes.baseOptions.lastIndex)) + ?.label + ?: "跟随系统", + items = themeItems, + selectedIndex = asBundle.themeBaseIndex.coerceIn(0, ThemeModes.baseOptions.lastIndex), + onSelectedIndexChange = { + asBundle = asBundle.copy(themeBaseIndex = it) + }, + ) + SuperSwitch( + title = "Monet", + summary = "开启后使用 Monet 动态配色", + checked = asBundle.monet, + onCheckedChange = { + asBundle = asBundle.copy(monet = it) + }, + ) + } + } + + item { + SectionSmallTitle("投屏") + Card { + SuperSwitch( + title = "启用调试信息", + summary = "在全屏界面悬浮显示分辨率、帧率和触点信息", + checked = asBundle.fullscreenDebugInfo, + onCheckedChange = { + asBundle = asBundle.copy(fullscreenDebugInfo = it) + }, + ) + SuperSlider( + title = "预览卡高度", + summary = "设备页预览卡高度", + value = asBundle.devicePreviewCardHeightDp.toFloat(), + onValueChange = { + asBundle = asBundle.copy( + devicePreviewCardHeightDp = it.roundToInt().coerceAtLeast(120) + ) + }, + valueRange = 160f..600f, + steps = 600 - 160 - 2, + unit = "dp", + displayFormatter = { it.roundToInt().toString() }, + inputInitialValue = asBundle.devicePreviewCardHeightDp.toString(), + inputFilter = { it.filter(Char::isDigit) }, + inputValueRange = 120f..UShort.MAX_VALUE.toFloat(), + onInputConfirm = { input -> + input.toIntOrNull()?.let { + asBundle = asBundle.copy( + devicePreviewCardHeightDp = it.coerceAtLeast(120) + ) + } + }, + ) + SuperArrow( + title = "快速设备排序", + summary = "手动排序设备页的快速设备", + onClick = onOpenReorderDevices, + ) + SuperArrow( + title = "虚拟按钮排序", + summary = "手动排序预览/全屏时的虚拟按钮,并选择哪些按钮展示在外", + onClick = onOpenVirtualButtonOrder, + ) + SuperSwitch( + title = "全屏显示虚拟按钮", + summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮", + checked = asBundle.showFullscreenVirtualButtons, + onCheckedChange = { + asBundle = asBundle.copy(showFullscreenVirtualButtons = it) + }, + ) + } + } + + item { + SectionSmallTitle("scrcpy-server", showLeadingSpacer = false) + Card { + Column( + modifier = Modifier.padding(vertical = UiSpacing.Large), + verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical), + ) { + Column( + modifier = Modifier.padding(horizontal = UiSpacing.Large), + verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium), + ) { + Text( + text = "自定义 binary", + fontWeight = FontWeight.Medium, + ) + TextField( + value = asBundle.customServerUri, + onValueChange = {}, + readOnly = true, + label = Scrcpy.DEFAULT_SERVER_ASSET_NAME, + useLabelAsPlaceholder = true, + modifier = Modifier.fillMaxWidth(), + trailingIcon = { + Row( + modifier = Modifier + .padding(end = UiSpacing.Medium), + ) { + if (asBundle.customServerUri.isNotBlank()) + IconButton( + onClick = { + asBundle = asBundle.copy( + customServerUri = "", + customServerVersion = "", + ) + }, + ) { + Icon( + imageVector = Icons.Rounded.Clear, + contentDescription = "清空", + ) + } + IconButton(onClick = onPickServer) { + Icon( + imageVector = Icons.Rounded.FileOpen, + contentDescription = "选择文件", + ) + } + } + }, + ) + } + AnimatedVisibility(customServerVersionShowInput) { + Column( + modifier = Modifier.padding(horizontal = UiSpacing.Large), + verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium), + ) { + Text( + text = "自定义 binary version", + fontWeight = FontWeight.Medium, + ) + SuperTextField( + value = customServerVersionInput, + onValueChange = { customServerVersionInput = it }, + onFocusLost = { + if (customServerVersionInput == AppSettings.CUSTOM_SERVER_VERSION.defaultValue) + customServerVersionInput = "" + asBundle = asBundle.copy( + customServerVersion = customServerVersionInput + .ifBlank { AppSettings.CUSTOM_SERVER_VERSION.defaultValue } + ) + }, + label = Scrcpy.DEFAULT_SERVER_VERSION, + useLabelAsPlaceholder = true, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + Column( + modifier = Modifier.padding(horizontal = UiSpacing.Large), + verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium), + ) { + Text( + text = "Remote Path", + fontWeight = FontWeight.Medium, + ) + SuperTextField( + value = serverRemotePathInput, + onValueChange = { serverRemotePathInput = it }, + onFocusLost = { + if (serverRemotePathInput == AppSettings.SERVER_REMOTE_PATH.defaultValue) + serverRemotePathInput = "" + asBundle = asBundle.copy( + serverRemotePath = serverRemotePathInput + .ifBlank { AppSettings.SERVER_REMOTE_PATH.defaultValue } + ) + }, + label = Scrcpy.DEFAULT_REMOTE_PATH, + useLabelAsPlaceholder = true, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + + item { + SectionSmallTitle("ADB", showLeadingSpacer = false) + Card { + Column( + modifier = Modifier.padding(vertical = UiSpacing.Large), + verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical), + ) { + Column( + modifier = Modifier.padding(horizontal = UiSpacing.Large), + verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium), + ) { + Text( + text = "自定义 ADB 密钥名", + fontWeight = FontWeight.Medium, + ) + SuperTextField( + value = adbKeyNameInput, + onValueChange = { adbKeyNameInput = it }, + onFocusLost = { + if (adbKeyNameInput == AppSettings.ADB_KEY_NAME.defaultValue) + adbKeyNameInput = "" + asBundle = asBundle.copy( + adbKeyName = adbKeyNameInput + .ifBlank { AppSettings.ADB_KEY_NAME.defaultValue } + ) + }, + label = AppSettings.ADB_KEY_NAME.defaultValue, + useLabelAsPlaceholder = true, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + SuperSwitch( + title = "配对时自动启用发现服务", + summary = "打开配对弹窗后自动搜索可用配对端口", + checked = asBundle.adbPairingAutoDiscoverOnDialogOpen, + onCheckedChange = { + asBundle = asBundle.copy( + adbPairingAutoDiscoverOnDialogOpen = it + ) + }, + ) + SuperSwitch( + title = "自动重连已配对设备", + summary = "自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘)", + checked = asBundle.adbAutoReconnectPairedDevice, + onCheckedChange = { + asBundle = asBundle.copy( + adbAutoReconnectPairedDevice = it + ) + }, + ) + } + } + + if (needMigration) item { + // 这部分应该不会显示出来, + // 应用启动时就会执行迁移与旧数据的删除 + SectionSmallTitle("应用", showLeadingSpacer = false) + Card { + SuperArrow( + title = "恢复旧版本配置", + summary = "从旧版本的 SharedPreferences 恢复至 DataStore", + onClick = { + scope.launch { + val migration = PreferenceMigration(appContext) + migration.migrate(clearSharedPrefs = false) + snackbar.show("迁移完成,应用将重启") + + delay(1000) + + val intent = context.packageManager.getLaunchIntentForPackage( + context.packageName + ) + intent?.apply { + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK + or Intent.FLAG_ACTIVITY_CLEAR_TASK + ) + } + context.startActivity(intent) + + Process.killProcess(Process.myPid()) + exitProcess(0) + } + }, + ) + } + } + + item { + SectionSmallTitle("关于", showLeadingSpacer = false) + Card { + SuperArrow( + title = "前往仓库", + summary = "github.com/Miuzarte/ScrcpyForAndroid", + onClick = { + context.startActivity( + Intent( + Intent.ACTION_VIEW, + "https://github.com/Miuzarte/ScrcpyForAndroid".toUri(), + ) + ) + }, + ) + } + } + + item { Spacer(Modifier.height(UiSpacing.PageBottom)) } + } +} 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 9f4f3cf..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 io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn -import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList -import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction -import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions -import top.yukonga.miuix.kmp.basic.Card -import top.yukonga.miuix.kmp.basic.ScrollBehavior -import top.yukonga.miuix.kmp.extra.SuperSwitch - -@Composable -internal fun VirtualButtonOrderPage( - contentPadding: PaddingValues, - scrollBehavior: ScrollBehavior, - layoutString: String, - onLayoutChange: (String) -> Unit, - showPreviewText: Boolean, - onShowPreviewTextChange: (Boolean) -> Unit, -) { - var buttonItems by remember(layoutString) { - mutableStateOf(VirtualButtonActions.parseStoredLayout(layoutString)) - } - - fun emitChanges() { - onLayoutChange(VirtualButtonActions.encodeStoredLayout(buttonItems)) - } - - AppPageLazyColumn( - contentPadding = contentPadding, - scrollBehavior = scrollBehavior, - ) { - // 按钮显示文本开关 - item { - Card { - SuperSwitch( - title = "按钮显示文本", - summary = "超过3个建议关闭,只对预览卡下方的虚拟按钮生效", - checked = showPreviewText, - onCheckedChange = onShowPreviewTextChange, - ) - } - } - - item { - ReorderableList( - itemsProvider = { - buttonItems.map { item -> - val action = item.action - ReorderableList.Item( - id = action.id, - icon = action.icon, - title = if (action.keycode == null) action.title else "${action.title} (${action.keycode})", - subtitle = if (item.showOutside) "显示在外部" else "显示在更多菜单内", - checked = item.showOutside, - checkboxEnabled = action != VirtualButtonAction.MORE, - ) - } - }, - orientation = ReorderableList.Orientation.Column, - onSettle = { fromIndex, toIndex -> - buttonItems = buttonItems.toMutableList().apply { - add(toIndex, removeAt(fromIndex)) - } - emitChanges() - }, - showCheckbox = true, - onCheckboxChange = { id, checked -> - buttonItems = buttonItems.map { item -> - if (item.action.id == id) { - item.copy(showOutside = checked) - } else { - item - } - } - emitChanges() - }, - )() - } - } -} 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..7d55607 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt @@ -0,0 +1,160 @@ +package io.github.miuzarte.scrcpyforandroid.pages + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn +import io.github.miuzarte.scrcpyforandroid.storage.Settings +import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings +import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList +import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction +import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.Scaffold +import top.yukonga.miuix.kmp.basic.ScrollBehavior +import top.yukonga.miuix.kmp.basic.TopAppBar +import top.yukonga.miuix.kmp.extra.SuperSwitch + +@Composable +internal fun VirtualButtonOrderScreen( + onBack: () -> Unit, + scrollBehavior: ScrollBehavior, +) { + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + TopAppBar( + title = "虚拟按钮排序", + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "返回" + ) + } + }, + scrollBehavior = scrollBehavior, + ) + }, + ) { pagePadding -> + VirtualButtonOrderPage( + contentPadding = pagePadding, + scrollBehavior = scrollBehavior, + ) + } +} + +@Composable +internal fun VirtualButtonOrderPage( + contentPadding: PaddingValues, + scrollBehavior: ScrollBehavior, +) { + val scope = rememberCoroutineScope() + val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } + + val asBundleShared by appSettings.bundleState.collectAsState() + val asBundleSharedLatest by rememberUpdatedState(asBundleShared) + var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) } + val asBundleLatest by rememberUpdatedState(asBundle) + LaunchedEffect(asBundleShared) { + if (asBundle != asBundleShared) { + asBundle = asBundleShared + } + } + LaunchedEffect(asBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (asBundle != asBundleSharedLatest) { + appSettings.saveBundle(asBundle) + } + } + DisposableEffect(Unit) { + onDispose { + taskScope.launch { + appSettings.saveBundle(asBundleLatest) + } + } + } + + var buttonItems by remember(asBundle.virtualButtonsLayout) { + mutableStateOf(VirtualButtonActions.parseStoredLayout(asBundle.virtualButtonsLayout)) + } + + LazyColumn( + contentPadding = contentPadding, + scrollBehavior = scrollBehavior, + ) { + // 按钮显示文本开关 + item { + Card { + SuperSwitch( + title = "按钮显示文本", + summary = "超过3个建议关闭,只对预览卡下方的虚拟按钮生效", + checked = asBundle.previewVirtualButtonShowText, + onCheckedChange = { + asBundle = asBundle.copy(previewVirtualButtonShowText = it) + }, + ) + } + } + + item { + ReorderableList( + itemsProvider = { + buttonItems.map { item -> + val action = item.action + ReorderableList.Item( + id = action.id, + icon = action.icon, + title = if (action.keycode == null) action.title else "${action.title} (${action.keycode})", + subtitle = if (item.showOutside) "显示在外部" else "显示在更多菜单内", + checked = item.showOutside, + checkboxEnabled = action != VirtualButtonAction.MORE, + ) + } + }, + orientation = ReorderableList.Orientation.Column, + onSettle = { fromIndex, toIndex -> + buttonItems = buttonItems.toMutableList().apply { + add(toIndex, removeAt(fromIndex)) + } + asBundle = asBundle.copy( + virtualButtonsLayout = VirtualButtonActions.encodeStoredLayout(buttonItems) + ) + }, + showCheckbox = true, + onCheckboxChange = { id, checked -> + buttonItems = buttonItems.map { item -> + if (item.action.id == id) { + item.copy(showOutside = checked) + } else { + item + } + } + asBundle = asBundle.copy( + virtualButtonsLayout = VirtualButtonActions.encodeStoredLayout(buttonItems) + ) + }, + )() + } + } +} 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 87% 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..23b3e22 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, @@ -32,18 +32,16 @@ fun AppPageLazyColumn( 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/SuperSlide.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt similarity index 55% 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..1581228 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,28 +2,30 @@ package io.github.miuzarte.scrcpyforandroid.scaffolds import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.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 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 io.github.miuzarte.scrcpyforandroid.constants.UiSpacing +import androidx.compose.ui.unit.dp import top.yukonga.miuix.kmp.basic.ButtonDefaults import top.yukonga.miuix.kmp.basic.Slider import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.TextButton -import top.yukonga.miuix.kmp.basic.TextField import top.yukonga.miuix.kmp.extra.SuperArrow import top.yukonga.miuix.kmp.extra.SuperDialog import top.yukonga.miuix.kmp.theme.MiuixTheme @Composable -fun SuperSlide( +fun SuperSlider( title: String, summary: String, value: Float, @@ -39,7 +41,9 @@ fun SuperSlide( 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, @@ -59,7 +63,8 @@ fun SuperSlide( 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( @@ -82,60 +87,81 @@ 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) - } - TextField( - value = valueText, - onValueChange = { valueText = inputFilter(it) }, - label = inputHint, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier - .fillMaxWidth() - .padding(top = UiSpacing.Large), + SliderInputDialog( + showDialog = showInputDialog, + title = inputTitle, + summary = inputSummary, + label = inputLabel, + useLabelAsPlaceholder = useLabelAsPlaceholder, + 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, + label: String = "", + useLabelAsPlaceholder: Boolean = false, + 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 rememberSaveable(initialValue) { mutableStateOf(initialValue) } + + SuperTextField( + modifier = Modifier.padding(bottom = 16.dp), + value = text, + label = label, + useLabelAsPlaceholder = useLabelAsPlaceholder, + maxLines = 1, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + onValueChange = { newValue -> + text = inputFilter(newValue) + }, ) - Row( - modifier = Modifier - .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 new file mode 100644 index 0000000..c568040 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt @@ -0,0 +1,565 @@ +package io.github.miuzarte.scrcpyforandroid.scrcpy + +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.AudioSource +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.DisplayImePolicy +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.LogLevel +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Orientation +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.OrientationLock +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource + +// TODO: 配置切换 + +data class ClientOptions( + // var serial: String = "", // to server + + // --crop=width:height:x:y + var crop: String = "", // to server + + // TODO: implement + // --record + var recordFilename: String = "", + + // var windowTitle: String = "", + // var pushTarget: String = "", + // var renderDriver: String = "", + + // --video-codec-options + var videoCodecOptions: String = "", // to server + // --audio-codec-options + var audioCodecOptions: String = "", // to server + // --video-encoder + var videoEncoder: String = "", // to server + // --audio-encoder + var audioEncoder: String = "", // to server + + // --camera-id + var cameraId: String = "", // to server + // --camera-size + var cameraSize: String = "", // to server + // --camera-ar + var cameraAr: String = "", // to server + // --camera-fps + var cameraFps: UShort = 0u, // to server + + var logLevel: LogLevel = LogLevel.INFO, // to server + + // --video-codec + var videoCodec: Codec = Codec.H264, // to server + // --audio-codec + var audioCodec: Codec = Codec.OPUS, // to server + // --video-source + var videoSource: VideoSource = VideoSource.DISPLAY, // to server + // --audio-source + var audioSource: AudioSource = AudioSource.AUTO, // to server + + // --record-format + var recordFormat: RecordFormat = RecordFormat.AUTO, + + // var keyboardInputMode: KeyboardInputMode, + // var mouseInputMode: MouseInputMode, + // var gamepadInputMode: GamepadInputMode, + + // sc_mouse_bindings(pri, sec) + // var mouseBindings: MouseBindings, + + // --camera-facing + var cameraFacing: CameraFacing = CameraFacing.ANY, // to server + + // var portRange: PortRange, // sc_port_range(first, last) // to server + // var tunnelHost: UInt, // to server + // var tunnelPort: UShort, // to server + + // OR of enum sc_shortcut_mod values + // var shortcutMods: ShortcutMod, + + // --max-size + var maxSize: UShort = 0u, // to server + + // --video-bit-rate + var videoBitRate: Int = 0, // to server + // --audio-bit-rate + var audioBitRate: Int = 0, // to server + + // --max-fps + var maxFps: String = "", // float to be parsed by the server + var angle: String = "", // float to be parsed by the server + + // --capture-orientation=.* + var captureOrientation: Orientation = Orientation.ORIENT_0, // to server + // --capture-orientation=@.* + var captureOrientationLock: OrientationLock = OrientationLock.UNLOCKED, // to server + + // --display-orientation + var displayOrientation: Orientation = Orientation.ORIENT_0, + // --record-orientation + var recordOrientation: Orientation = Orientation.ORIENT_0, + + // --display-ime-policy + var displayImePolicy: DisplayImePolicy = DisplayImePolicy.UNDEFINED, // to server + + // var windowX: Short, + // var windowY: Short, + // var windowWidth: UShort, + // var windowHeight: UShort, + + // --display-id + // -1 for empty text field + var displayId: Int = -1, // to server + + // var videoBuffer: Tick, + // var audioBuffer: Tick, + // var audioOutputBuffer: Tick, + // var timeLimit: Tick, + + // --screen-off-timeout + var screenOffTimeout: Tick = Tick(-1), // to server + + // var otg: Boolean, + + // --show-touches + var showTouches: Boolean = false, // to server + // 开始后立即进入全屏 + // --fullscreen + var fullscreen: Boolean = false, + + // var alwaysOnTop: Boolean, + + // --no-control + var control: Boolean = true, // to server + // --no-playback + // --no-video-playback + var videoPlayback: Boolean = true, + // --no-playback + // --no-audio-playback + var audioPlayback: Boolean = true, + // --turn-screen-off + var turnScreenOff: Boolean = false, + + // [sc_key_inject_mode] + // var keyInjectMode: KeyInjectMode, + + // var windowBorderless: Boolean, + // var mipmaps: Boolean, + + // --stay-awake + var stayAwake: Boolean = false, // to server + + // --force-adb-forward + // var forceAdbForward: Boolean, // to server + + // --disable-screensaver + var disableScreensaver: Boolean = false, + + // var forwardKeyRepeat: Boolean, + // var legacyPaste: Boolean, + + // --power-off-on-close + var powerOffOnClose: Boolean = false, // to server + + // --no-clipboard-autosync + // var clipboardAutosync: Boolean = true, // to server + + // --no-downsize-on-error + // var downsizeOnError: Boolean = true, // to server + + // var tcpip: Boolean, // to server + // var tcpipDst: String = "", // to server + // var selectUsb: Boolean, // to server + // var selectTcpip: Boolean, // to server + + // --no-cleanup + var cleanUp: Boolean = true, // to server + + // var startFpsCounter: Boolean, + + // --no-power-on + var powerOn: Boolean = true, // to server + // --no-video + var video: Boolean = true, // to server + // --no-audio + var audio: Boolean = true, // to server + // --require-audio + var requireAudio: Boolean = false, + + // 结束 scrcpy 后断开 adb 连接 + // --kill-adb-on-close + var killAdbOnClose: Boolean = false, // to server but client side + // --camera-high-speed + var cameraHighSpeed: Boolean = false, // to server + + var list: ListOptions = ListOptions.NULL, // to server + + // --no-window + // var window: Boolean, + + // --no-mouse-hover + // var mouseHover: Boolean = true, + + // --audio-dup + var audioDup: Boolean = false, // to server + // --new-display=[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 fix(): ClientOptions { + when(videoSource) { + VideoSource.DISPLAY -> { + cameraId = "" + cameraFacing = CameraFacing.ANY + cameraSize = "" + cameraAr = "" + cameraFps = 0u + cameraHighSpeed = false + } + VideoSource.CAMERA -> { + displayId = 0 + maxSize = 0u + maxFps = "" + newDisplay = "" + crop = "" + } + } + return this + } + + fun validate(): ClientOptions { + if (!video) { + videoPlayback = false + powerOn = false + } + + if (!audio) { + audioPlayback = false + } + + if (video && !videoPlayback && recordFilename.isBlank()) { + video = false + } + + if (audio && !audioPlayback && recordFilename.isBlank()) { + audio = false + } + + if (!video && !audio && !control) { + throw IllegalArgumentException( + "nothing to do" + ) + } + + if (!video) { + requireAudio = true + } + + if (newDisplay.isNotBlank()) { + if (videoSource != VideoSource.DISPLAY) { + throw IllegalArgumentException( + "--new-display is only available with --video-source=display" + ) + } + + if (!video) { + throw IllegalArgumentException( + "--new-display is incompatible with --no-video" + ) + } + } + + if (videoSource == VideoSource.CAMERA) { + if (displayId > 0) { + throw IllegalArgumentException( + "--display-id is only available with --video-source=display" + ) + } + + if (displayImePolicy != DisplayImePolicy.UNDEFINED) { + throw IllegalArgumentException( + "--display-ime-policy is only available with --video-source=display" + ) + } + + if (cameraId.isNotBlank() && cameraFacing != CameraFacing.ANY) { + throw IllegalArgumentException( + "Cannot specify both --camera-id and --camera-facing" + ) + } + + if (cameraSize.isNotBlank()) { + if (maxSize > 0u) { + throw IllegalArgumentException( + "Cannot specify both --camera-size and -m/--max-size" + ) + } + + if (cameraAr.isNotBlank()) { + throw IllegalArgumentException( + "--camera-high-speed requires an explicit --camera-fps value" + ) + } + } + + if (cameraHighSpeed && cameraFps > 0u) { + throw IllegalArgumentException( + "--camera-high-speed requires an explicit --camera-fps value" + ) + } + + if (control) { + control = false + } + } else if (cameraId.isNotBlank() + || cameraAr.isNotBlank() + || cameraFacing != CameraFacing.ANY + || cameraFps > 0u + || cameraHighSpeed + || cameraSize.isNotBlank() + ) { + throw IllegalArgumentException( + "Camera options are only available with --video-source=camera" + ) + } + + if (displayId > 0 && newDisplay.isNotBlank()) { + throw IllegalArgumentException( + "Cannot specify both --display-id and --new-display" + ) + } + + if (displayImePolicy != DisplayImePolicy.UNDEFINED + && displayId == 0 && newDisplay.isBlank() + ) { + throw IllegalArgumentException( + "--display-ime-policy is only supported on a secondary display" + ) + } + + if (audio && audioSource == AudioSource.AUTO) { + // Select the audio source according to the video source + audioSource = if (videoSource == VideoSource.DISPLAY) { + if (audioDup) { + AudioSource.PLAYBACK + } else { + AudioSource.OUTPUT + } + } else { + AudioSource.MIC + } + } + + if (audioDup) { + if (!audio) { + throw IllegalArgumentException( + "--audio-dup not supported if audio is disabled" + ) + } + + if (audioSource != AudioSource.PLAYBACK) { + throw IllegalArgumentException( + "--audio-dup is specific to --audio-source=playback" + ) + } + } + + if (recordFormat != RecordFormat.AUTO && recordFilename.isNotBlank()) { + throw IllegalArgumentException( + "Record format specified without recording" + ) + } + + if (recordFilename.isNotBlank()) { + if (!video && !audio) { + throw IllegalArgumentException( + "Video and audio disabled, nothing to record" + ) + } + + if (recordFormat == RecordFormat.AUTO) { + // TODO: guess from recordFilename + recordFormat = RecordFormat.MP4 + } + + if (recordOrientation != Orientation.ORIENT_0) { + if (recordOrientation.isMirror()) { + throw IllegalArgumentException( + "Record orientation only supports rotation, " + + "not flipping: ${recordOrientation.string}" + ) + } + } + + if (video && recordFormat.isAudioOnly()) { + throw IllegalArgumentException( + "Audio container does not support video stream" + ) + } + + if (recordFormat == RecordFormat.OPUS && audioCodec != Codec.OPUS) { + throw IllegalArgumentException( + "Recording to OPUS file requires an OPUS audio stream " + + "(try with --audio-codec=opus)" + ) + } + + if (recordFormat == RecordFormat.AAC && audioCodec != Codec.AAC) { + throw IllegalArgumentException( + "Recording to AAC file requires an AAC audio stream " + + "(try with --audio-codec=aac)" + ) + } + + if (recordFormat == RecordFormat.FLAC && audioCodec != Codec.FLAC) { + throw IllegalArgumentException( + "Recording to FLAC file requires an FLAC audio stream " + + "(try with --audio-codec=flac)" + ) + } + + if (recordFormat == RecordFormat.WAV && audioCodec != Codec.RAW) { + throw IllegalArgumentException( + "Recording to WAV file requires an RAW audio stream " + + "(try with --audio-codec=raw)" + ) + } + + if ((recordFormat == RecordFormat.MP4 || recordFormat == RecordFormat.M4A) + && audioCodec == Codec.RAW + ) { + throw IllegalArgumentException( + "Recording to MP4 container does not support RAW audio" + ) + } + } + + /* + if (audioCodec == Codec.FLAC && audioBitRate > 0u) { + // "--audio-bit-rate is ignored for FLAC audio codec" + // audioBitRate + } + */ + + /* + if (audioCodec == Codec.RAW) { + if (audioBitRate > 0u) { + // "--audio-bit-rate is ignored for raw audio codec" + // audioBitRate + } + if (audioCodecOptions.isNotBlank()) { + // "--audio-codec-options is ignored for raw audio codec" + // audioCodecOptions + } + if (audioEncoder.isNotBlank()) { + // "--audio-encoder is ignored for raw audio codec" + // audioEncoder + } + } + */ + + if (!control) { + if (turnScreenOff) { + throw IllegalArgumentException( + "Cannot request to turn screen off if control is disabled" + ) + } + if (stayAwake) { + throw IllegalArgumentException( + "Cannot request to stay awake if control is disabled" + ) + } + if (showTouches) { + throw IllegalArgumentException( + "Cannot request to show touches if control is disabled" + ) + } + if (powerOffOnClose) { + throw IllegalArgumentException( + "Cannot request power off on close if control is disabled" + ) + } + if (startApp.isNotBlank()) { + throw IllegalArgumentException( + "Cannot start an Android app if control is disabled" + ) + } + } + + return this + } + + fun toServerParams(scid: UInt): ServerParams { + return ServerParams( + scid = scid, + + logLevel = logLevel, + videoCodec = videoCodec, + audioCodec = audioCodec, + videoSource = videoSource, + audioSource = audioSource, + cameraFacing = cameraFacing, + crop = crop, + + maxSize = maxSize, + videoBitRate = videoBitRate, + audioBitRate = audioBitRate, + maxFps = maxFps, + angle = angle, + screenOffTimeout = screenOffTimeout, + captureOrientation = captureOrientation, + captureOrientationLock = captureOrientationLock, + control = control, + displayId = displayId, + newDisplay = newDisplay, + displayImePolicy = displayImePolicy, + video = video, + audio = audio, + audioDup = audioDup, + showTouches = showTouches, + stayAwake = stayAwake, + videoCodecOptions = videoCodecOptions, + audioCodecOptions = audioCodecOptions, + videoEncoder = videoEncoder, + audioEncoder = audioEncoder, + cameraId = cameraId, + cameraSize = cameraSize, + cameraAr = cameraAr, + cameraFps = cameraFps, + + powerOffOnClose = powerOffOnClose, + + cleanUp = cleanUp, + powerOn = powerOn, + + // killAdbOnClose == killAdbOnClose, // client side + + cameraHighSpeed = cameraHighSpeed, + vdDestroyContent = vdDestroyContent, + vdSystemDecorations = vdSystemDecorations, + list = list, + ) + } +} \ 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..f8ec94b --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt @@ -0,0 +1,1265 @@ +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.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 +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 + +/** + * High-level scrcpy client API. + * + * Manages scrcpy sessions including: + * - Server jar extraction and deployment + * - Session lifecycle (start/stop) + * - Audio playback + * - Screen control + * + * @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 appContext: Context, + private val adbService: NativeAdbService, + private val serverAsset: String = DEFAULT_SERVER_ASSET, + private val customServerUri: String? = null, + private val serverVersion: String = DEFAULT_SERVER_VERSION, + private val serverRemotePath: String = DEFAULT_REMOTE_PATH, +) { + private val session = Session(adbService) + private val nativeCore: NativeCoreFacade = NativeCoreFacade.get(appContext) + + @Volatile + private var currentSession: Session.SessionInfo? = null + + @Volatile + private var isRunning: Boolean = false + + @Volatile + private var audioPlayer: ScrcpyAudioPlayer? = null + + val listings = Listings() + + companion object { + private const val TAG = "Scrcpy" + + 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_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 + return (Random.nextUInt() and 0x7FFFFFFFu) + } + } + + suspend fun start(options: ClientOptions): Session.SessionInfo = withContext(Dispatchers.IO) { + 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 { session.setDisplayPower(on = false) } + .onFailure { e -> Log.w(TAG, "start(): set display power failed", e) } + } + } + + // Create session info + currentSession = info + isRunning = true + + // Setup video consumer (notify NativeCoreFacade to setup decoders) + if (options.video) { + nativeCore.onScrcpySessionStarted(info, session) + } + + // 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 + session.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=${info.deviceName}, " + + "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}" + ) + + return@withContext info + + } catch (e: Exception) { + Log.e(TAG, "start(): Failed to start scrcpy session", e) + isRunning = false + currentSession = null + throw e + } + } + + suspend fun stop(): Boolean = withContext(Dispatchers.IO) { + if (!isRunning) { + Log.w(TAG, "stop(): No active session to stop") + return@withContext false + } + + Log.i(TAG, "stop(): Stopping scrcpy session") + + return@withContext try { + nativeCore.onScrcpySessionStopped() + session.clearVideoConsumer() + session.clearAudioConsumer() + session.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 && session.isStarted() + + fun getCurrentSession(): Session.SessionInfo? = currentSession + + 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 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() + } + + 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 + } + } + } + + } + + private suspend fun executeList(list: ListOptions): String = withContext(Dispatchers.IO) { + require(list != ListOptions.NULL) { "Nothing to do with ListOptions.NULL" } + + val serverJar = if (customServerUri.isNullOrBlank()) { + extractAssetToCache(serverAsset) + } else { + extractUriToCache(customServerUri.toUri()) + } + + adbService.push(serverJar.toPath(), serverRemotePath) + + val scid = generateScid() + val options = ClientOptions( + video = false, + audio = false, + control = false, + cleanUp = false, + list = list, + ) + val serverParams = options.toServerParams(scid) + val serverCommand = serverParams.build( + "CLASSPATH=$serverRemotePath", + "app_process", + "/", + "com.genymobile.scrcpy.Server", + serverVersion, + ) + + Log.i(TAG, "listOptions(): cmd=$serverCommand") + adbService.shell("$serverCommand 2>&1") + } + + 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 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]) + } + return sizes.toList() + } + + 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, + ) + + 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( + serverJar: File, + options: ClientOptions, + scid: UInt, + ): Session.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") + 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 = appContext.assets.open(clean) + val outputFile = File(appContext.cacheDir, File(clean).name) + source.use { input -> + outputFile.outputStream().use { output -> input.copyTo(output) } + } + return outputFile + } + + private fun extractUriToCache(uri: Uri): File { + val fileName = "custom-scrcpy-server.jar" + val outputFile = File(appContext.cacheDir, fileName) + appContext.contentResolver.openInputStream(uri).use { input -> + requireNotNull(input) { "Unable to open selected server URI" } + outputFile.outputStream().use { output -> input.copyTo(output) } + } + return outputFile + } + + /** + * 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 + + 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 { + 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) { + 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) + videoCodecId = vInput.readInt() + width = vInput.readInt() + height = vInput.readInt() + } else { + videoCodecId = 0 + width = 0 + height = 0 + } + + val sessionInfo = SessionInfo( + deviceName = deviceName, + codecId = videoCodecId, + codec = Codec.fromId(videoCodecId, Codec.Type.VIDEO), + 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 { + 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 pressBackOrTurnScreenOn(action: Int = KeyEvent.ACTION_DOWN) = mutex.withLock { + try { + requireControlWriter().pressBackOrTurnScreenOn(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 + + 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) + } + + data class SessionInfo( + val deviceName: String, + val codecId: Int, + val codec: Codec?, + 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 pressBackOrTurnScreenOn(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 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/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..0097b4b --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt @@ -0,0 +1,288 @@ +package io.github.miuzarte.scrcpyforandroid.scrcpy + +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.AudioSource +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.DisplayImePolicy +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.LogLevel +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Orientation +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.OrientationLock +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource + +// 启动 scrcpy 前直接继承自 [ClientOptions], +// 不需要默认值 +data class ServerParams( + var scid: UInt, // (random uint32) & 0x7FFFFFFF + + // var reqSerial: String, + + var logLevel: LogLevel, + + var videoCodec: Codec, + var audioCodec: Codec, + var videoSource: VideoSource, + var audioSource: AudioSource, + + var cameraFacing: CameraFacing, + + var crop: String, + + var videoCodecOptions: String, + var audioCodecOptions: String, + var videoEncoder: String, + var audioEncoder: String, + + var cameraId: String, + var cameraSize: String, + var cameraAr: String, // aspect ratio + var cameraFps: UShort, + + // var portRange: PortRange, // sc_port_range(first, last) + // var tunnelHost: UInt, + // var tunnelPort: UShort, + + var maxSize: UShort, + + var videoBitRate: Int, + var audioBitRate: Int, + + var maxFps: String, // float to be parsed by the server + var angle: String, // float to be parsed by the server + + var screenOffTimeout: Tick, + + var captureOrientation: Orientation, + var captureOrientationLock: OrientationLock, + + var control: Boolean, + + var displayId: Int, + var newDisplay: String, + + var displayImePolicy: DisplayImePolicy, + + var video: Boolean, + var audio: Boolean, + var audioDup: Boolean, + var showTouches: Boolean, + var stayAwake: Boolean, + + // var forceAdbForward: Boolean, + + var powerOffOnClose: Boolean, + + // var downsizeOnError: Boolean, + // var tcpip: Boolean, + // var tcpipDst: String, + // var selectUsb: Boolean, + // var selectTcpip: Boolean, + + var cleanUp: Boolean, + var powerOn: Boolean, + + // var killAdbOnClose: Boolean, + + var cameraHighSpeed: Boolean, + + var vdDestroyContent: Boolean, + var vdSystemDecorations: Boolean, + + var list: ListOptions, +) { + companion object { + const val SEPARATOR: String = " " + const val ILLEGAL_CHARACTER_SET: String = " ;'\"*$?&`#\\|<>[]{}()!~\r\n" + } + + private fun validate(str: String) { + // forbid special shell characters + if (str.any { it in ILLEGAL_CHARACTER_SET }) { + throw IllegalArgumentException("Invalid server param: [$str]") + } + } + + fun build(vararg extraArgs: String): String { + return (extraArgs.toList() + this.toList()).joinToString(SEPARATOR) + } + + fun toList(simplify: Boolean = false): MutableList { + val cmd = mutableListOf() + + if (!simplify) { + cmd.add("scid=${scid.toString(16)}") + cmd.add("log_level=${logLevel.string}") + } + + if (!video) { + cmd.add("video=false") + } + if (videoBitRate > 0) { + cmd.add("video_bit_rate=$videoBitRate") + } + if (!audio) { + cmd.add("audio=false") + } + if (audioBitRate > 0) { + if (!audioCodec.isLossless) { + // 比官方实现多个判断编解码器类型 + cmd.add("audio_bit_rate=$audioBitRate") + } + } + if (videoCodec != Codec.H264) { + cmd.add("video_codec=${videoCodec.string}") + } + if (audioCodec != Codec.OPUS) { + cmd.add("audio_codec=${audioCodec.string}") + } + if (videoSource != VideoSource.DISPLAY) { + cmd.add("video_source=${videoSource.string}") + } + // audio_source (default: output, only add if different and audio is enabled) + // Note: AUTO should have been resolved by ClientOptions.validate() + if (audioSource != AudioSource.OUTPUT && audio) { + cmd.add("audio_source=${audioSource.string}") + } + if (audioDup) { + cmd.add("audio_dup=true") + } + if (maxSize > 0u) { + cmd.add("max_size=$maxSize") + } + if (maxFps.isNotBlank()) { + validate(maxFps) + cmd.add("max_fps=${maxFps.trim()}") + } + if (angle.isNotBlank()) { + validate(angle) + cmd.add("angle=${angle.trim()}") + } + if (captureOrientationLock != OrientationLock.UNLOCKED + || captureOrientation != Orientation.ORIENT_0 + ) { + when (captureOrientationLock) { + OrientationLock.LOCKED_INITIAL -> + cmd.add("capture_orientation=@") + + OrientationLock.LOCKED_VALUE -> + cmd.add("capture_orientation=@${captureOrientation.string}") + + OrientationLock.UNLOCKED -> + cmd.add("capture_orientation=${captureOrientation.string}") + } + } + // always true cause we are using adb wireless + cmd.add("tunnel_forward=true") + if (crop.isNotBlank()) { + validate(crop) + cmd.add("crop=${crop.trim()}") + } + if (!control) { + // By default, control is true + cmd.add("control=false") + } + if (displayId >= 0) { + cmd.add("display_id=$displayId") + } + if (cameraId.isNotBlank()) { + validate(cameraId) + cmd.add("camera_id=${cameraId.trim()}") + } + if (cameraSize.isNotBlank()) { + validate(cameraSize) + cmd.add("camera_size=${cameraSize.trim()}") + } + if (cameraFacing != CameraFacing.ANY) { + cmd.add("camera_facing=${cameraFacing.string}") + } + if (cameraAr.isNotBlank()) { + validate(cameraAr) + cmd.add("camera_ar=${cameraAr.trim()}") + } + if (cameraFps > 0u) { + cmd.add("camera_fps=$cameraFps") + } + if (cameraHighSpeed) { + cmd.add("camera_high_speed=true") + } + if (showTouches) { + cmd.add("show_touches=true") + } + if (stayAwake) { + cmd.add("stay_awake=true") + } + if (screenOffTimeout.value != -1L) { + require(screenOffTimeout >= 0) { + "screen_off_timeout must be >= 0" + } + cmd.add("screen_off_timeout=${screenOffTimeout.toMs()}") + } + if (videoCodecOptions.isNotBlank()) { + validate(videoCodecOptions) + cmd.add("video_codec_options=${videoCodecOptions.trim()}") + } + if (audioCodecOptions.isNotBlank()) { + validate(audioCodecOptions) + cmd.add("audio_codec_options=${audioCodecOptions.trim()}") + } + if (videoEncoder.isNotBlank()) { + validate(videoEncoder) + cmd.add("video_encoder=${videoEncoder.trim()}") + } + if (audioEncoder.isNotBlank()) { + validate(audioEncoder) + cmd.add("audio_encoder=${audioEncoder.trim()}") + } + if (powerOffOnClose) { + cmd.add("power_off_on_close=true") + } + // not implemented + val clipBoardAutosync = false + if (!clipBoardAutosync) { + cmd.add("clipboard_autosync=false") + } + // not implemented + val downsizeOnError = false + if (!downsizeOnError) { + cmd.add("downsize_on_error=false") + } + if (!cleanUp) { + // By default, cleanup is true + cmd.add("cleanup=false") + } + if (!powerOn) { + // By default, power_on is true + cmd.add("power_on=false") + } + if (newDisplay.isNotBlank()) { + validate(newDisplay) + cmd.add("new_display=${newDisplay.trim()}") + } + if (displayImePolicy != DisplayImePolicy.UNDEFINED) { + cmd.add("display_ime_policy=${displayImePolicy.string}") + } + if (!vdDestroyContent) { + cmd.add("vd_destroy_content=false") + } + if (!vdSystemDecorations) { + cmd.add("vd_system_decorations=false") + } + if (list has ListOptions.ENCODERS) { + cmd.add("list_encoders=true") + } + if (list has ListOptions.DISPLAYS) { + cmd.add("list_displays=true") + } + if (list has ListOptions.CAMERAS) { + cmd.add("list_cameras=true") + } + if (list has ListOptions.CAMERA_SIZES) { + cmd.add("list_camera_sizes=true") + } + if (list has ListOptions.APPS) { + cmd.add("list_apps=true") + } + return cmd + } +} \ 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..07d4fcb --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt @@ -0,0 +1,188 @@ +package io.github.miuzarte.scrcpyforandroid.scrcpy + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.microseconds + +/* +参考官方实现尽量统一行为 + */ + +class Shared { + @JvmInline + value class Tick(val value: Long) { + fun toNs(): Long = value * 1000 + fun toUs(): Long = value + fun toMs(): Long = value / 1000 + fun toSec(): Long = value / 1_000_000 + fun toSecDouble(): Double = value / 1_000_000.0 + + operator fun plus(other: Tick): Tick = Tick(value + other.value) + operator fun minus(other: Tick): Tick = Tick(value - other.value) + operator fun times(scale: Long): Tick = Tick(value * scale) + operator fun div(scale: Long): Tick = Tick(value / scale) + operator fun compareTo(other: Tick): Int = value.compareTo(other.value) + operator fun compareTo(other: Long): Int = value.compareTo(other) + operator fun compareTo(other: Int): Int = value.compareTo(other.toLong()) + + companion object { + const val FREQ = 1_000_000 // 微秒/秒 + fun fromNs(ns: Long): Tick = Tick(ns / 1000) + fun fromUs(us: Long): Tick = Tick(us) + fun fromMs(ms: Long): Tick = Tick(ms * 1000) + fun fromSec(sec: Long): Tick = Tick(sec * 1_000_000) + fun fromSecDouble(sec: Double): Tick = Tick((sec * 1_000_000).toLong()) + fun fromDuration(duration: Duration): Tick = Tick(duration.inWholeMicroseconds) + } + + fun toDuration(): Duration = value.microseconds + fun Duration.toTick(): Tick = Tick(this.inWholeMicroseconds) + } + + enum class ListOptions(val value: Int) { + NULL(0x0), + ENCODERS(0x1), // --list-encoders + DISPLAYS(0x2), // --list-displays + CAMERAS(0x4), // --list-cameras + CAMERA_SIZES(0x8), // --list-camera-sizes + APPS(0x10); // --list-apps + + infix fun or(other: ListOptions) = value or other.value + infix fun and(other: ListOptions) = value and other.value + infix fun has(other: ListOptions) = this and other != 0 + } + + enum class EncoderType(val s: String) { + HARDWARE("hw"), + SOFTWARE("sw"), + } + + enum class LogLevel(val string: String) { + VERBOSE("verbose"), + DEBUG("debug"), + INFO("info"), + WARN("warn"), + ERROR("error"); + } + + enum class Codec( + val string: String, + val displayName: String, + val type: Type, + val id: Int, + val isLossless: Boolean, + ) { + H264("h264", "H.264", Type.VIDEO, 0x68323634, false), // default, ignore when passing + H265("h265", "H.265", Type.VIDEO, 0x68323635, false), + AV1("av1", "AV1", Type.VIDEO, 0x00617631, false), + + OPUS("opus", "Opus", Type.AUDIO, 0x6f707573, false), // default, ignore when passing + AAC("aac", "AAC", Type.AUDIO, 0x00616163, false), + FLAC("flac", "FLAC", Type.AUDIO, 0x666c6163, true), + RAW("raw", "RAW", Type.AUDIO, 0x00726177, true); // wav raw + + enum class Type { VIDEO, AUDIO } + + companion object { + val VIDEO = entries.filter { it.type == Type.VIDEO } + val AUDIO = entries.filter { it.type == Type.AUDIO } + + fun fromString(value: String, type: Type = Type.VIDEO) = + entries.find { it.string.equals(value, ignoreCase = true) } + ?: when (type) { + Type.VIDEO -> H264 + Type.AUDIO -> OPUS + } + + fun fromId(value: Int, type: Type? = null): Codec? = + entries.find { it.id == value && (type == null || it.type == type) } + } + } + + enum class VideoSource(val string: String) { + DISPLAY("display"), // default, ignore when passing + CAMERA("camera"); + + companion object { + fun fromString(value: String) = + entries.find { it.string.equals(value, ignoreCase = true) } + ?: DISPLAY + } + } + + enum class AudioSource(val string: String) { + AUTO("auto"), // OUTPUT for video DISPLAY, MIC for video CAMERA + OUTPUT("output"), + MIC("mic"), + PLAYBACK("playback"), + MIC_UNPROCESSED("mic-unprocessed"), + MIC_CAMCORDER("mic-camcorder"), + MIC_VOICE_RECOGNITION("mic-voice-recognition"), + MIC_VOICE_COMMUNICATION("mic-voice-communication"), + VOICE_CALL("voice-call"), + VOICE_CALL_UPLINK("voice-call-uplink"), + VOICE_CALL_DOWNLINK("voice-call-downlink"), + VOICE_PERFORMANCE("voice-performance"); + + companion object { + fun fromString(value: String) = + entries.find { it.string.equals(value, ignoreCase = true) } + ?: AUTO + } + } + + enum class CameraFacing(val string: String) { + ANY("any"), // default, ignore when passing + FRONT("front"), + BACK("back"), + EXTERNAL("external"); + + companion object { + fun fromString(value: String) = + entries.find { it.string.equals(value, ignoreCase = true) } + ?: ANY + } + } + + enum class Orientation(val string: String) { + ORIENT_0("0"), + ORIENT_90("90"), + ORIENT_180("180"), + ORIENT_270("270"), + FLIP_0("flip0"), + FLIP_90("flip90"), + FLIP_180("flip180"), + FLIP_270("flip270"); + + fun isMirror(): Boolean = ordinal and 4 != 0 + fun isSwap(): Boolean = ordinal and 1 != 0 + fun getRotation(): Orientation { + return when (ordinal and 3) { + 0 -> ORIENT_0 + 1 -> ORIENT_90 + 2 -> ORIENT_180 + 3 -> ORIENT_270 + else -> error("Invalid rotation value") + } + } + + companion object { + fun fromInt(value: Int): Orientation { + return entries.getOrNull(value) + ?: error("Invalid orientation value: $value") + } + } + } + + enum class OrientationLock(val string: String) { + UNLOCKED("unlocked"), // ignore + LOCKED_VALUE("locked_value"), // "@${orientation.string}" + LOCKED_INITIAL("locked_initial"); // "@" + } + + enum class DisplayImePolicy(val string: String) { + UNDEFINED("undefined"), // default, ignore when passing + LOCAL("local"), + FALLBACK("fallback"), + HIDE("hide"); + } +} 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/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/DeviceConnectionLogic.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt index 28efac5..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,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.Dispatchers +import kotlinx.coroutines.withContext internal data class ConnectedDeviceInfo( val model: String, @@ -16,34 +18,26 @@ 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( - nativeCore: NativeCoreFacade, +internal suspend fun fetchConnectedDeviceInfo( + adbService: NativeAdbService, host: String, port: Int -): ConnectedDeviceInfo { - fun prop(name: String): String = - runCatching { nativeCore.adbShell("getprop $name").trim() }.getOrDefault("") +): ConnectedDeviceInfo = withContext(Dispatchers.IO) { + 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..ae02188 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/EventLogger.kt @@ -0,0 +1,72 @@ +package io.github.miuzarte.scrcpyforandroid.services + +import android.util.Log +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import io.github.miuzarte.scrcpyforandroid.constants.Defaults +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Global singleton for event logging. + * + * Manages event logs with timestamp formatting and automatic log rotation. + * Logs are stored in a thread-safe SnapshotStateList for Compose integration. + */ +object EventLogger { + private const val LOG_TAG = "EventLogger" + + const val MAX_LINES = 512 + + private val _eventLog: SnapshotStateList = 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 > MAX_LINES) { + _eventLog.removeRange(MAX_LINES, _eventLog.size) + } + + // Log to Android logcat + when (level) { + Log.ERROR -> if (error != null) Log.e(LOG_TAG, message, error) + else Log.e(LOG_TAG, message) + + Log.WARN -> if (error != null) Log.w(LOG_TAG, message, error) + else Log.w(LOG_TAG, message) + + Log.DEBUG -> if (error != null) Log.d(LOG_TAG, message, error) + else Log.d(LOG_TAG, message) + + else -> if (error != null) Log.i(LOG_TAG, message, error) + else Log.i(LOG_TAG, message) + } + } + + /** + * Clear all event logs. + */ + fun clearLogs() { + _eventLog.clear() + } + + /** + * Check if there are any logs. + */ + fun hasLogs(): Boolean = _eventLog.isNotEmpty() +} 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 3697433..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt +++ /dev/null @@ -1,137 +0,0 @@ -package io.github.miuzarte.scrcpyforandroid.services - -import android.content.Context -import androidx.core.content.edit -import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults -import io.github.miuzarte.scrcpyforandroid.constants.AppPreferenceKeys -import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget -import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut - -internal fun loadQuickDevices(context: Context): List { - 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() ?: AppDefaults.ADB_PORT - if (host.isNotBlank()) { - result.add( - DeviceShortcut( - id = "$host:$port", - name = name, - host = host, - port = port, - online = false, - ), - ) - } - } - - 2 -> { - // Backward compatibility with old format: name|host:port - val name = parts[0].trim() - val host = parts[1].substringBefore(":").trim() - val port = parts[1].substringAfter(":", AppDefaults.ADB_PORT.toString()).trim() - .toIntOrNull() ?: AppDefaults.ADB_PORT - if (host.isNotBlank()) { - result.add( - DeviceShortcut( - id = "$host:$port", - name = name, - host = host, - port = port, - online = false, - ), - ) - } - } - } - } - return result -} - -internal fun saveQuickDevices(context: Context, quickDevices: List) { - val raw = quickDevices.joinToString("\n") { "${it.name}|${it.host}|${it.port}" } - context.getSharedPreferences(AppPreferenceKeys.PREFS_NAME, Context.MODE_PRIVATE) - .edit { - putString(AppPreferenceKeys.QUICK_DEVICES, raw) - } -} - -internal fun parseQuickTarget(raw: String): ConnectionTarget? { - val value = raw.trim() - if (value.isEmpty()) return null - val host = value.substringBefore(':').trim() - if (host.isEmpty()) return null - val port = value.substringAfter(':', AppDefaults.ADB_PORT.toString()).trim().toIntOrNull() - ?: AppDefaults.ADB_PORT - return ConnectionTarget(host, port) -} - -internal fun upsertQuickDevice( - context: Context, - quickDevices: MutableList, - host: String, - port: Int, - online: Boolean, -) { - val id = "$host:$port" - val idx = quickDevices.indexOfFirst { it.id == id } - val existingName = if (idx >= 0) quickDevices[idx].name else "" - val item = DeviceShortcut( - id = id, - name = existingName, - host = host, - port = port, - online = online, - ) - if (idx >= 0) quickDevices[idx] = item else quickDevices.add(0, item) - saveQuickDevices(context, quickDevices) -} - -internal fun updateQuickDeviceNameIfEmpty( - context: Context, - quickDevices: MutableList, - host: String, - port: Int, - fallbackName: String, -) { - val idx = quickDevices.indexOfFirst { it.host == host && it.port == port } - if (idx >= 0 && quickDevices[idx].name.isBlank()) { - quickDevices[idx] = quickDevices[idx].copy(name = fallbackName) - saveQuickDevices(context, quickDevices) - } -} - -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( - id = "$host:$newPort", - 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/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/AdbClientData.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AdbClientData.kt new file mode 100644 index 0000000..fbce077 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AdbClientData.kt @@ -0,0 +1,51 @@ +package io.github.miuzarte.scrcpyforandroid.storage + +import android.content.Context +import android.os.Parcelable +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.StateFlow +import kotlinx.parcelize.Parcelize + +class AdbClientData(context: Context) : Settings(context, "AdbClient") { + companion object { + val RSA_PRIVATE_KEY = Pair( + stringPreferencesKey("rsa_private_key"), + "", + ) + val RSA_PUBLIC_KEY_X509 = Pair( + stringPreferencesKey("rsa_public_key_x509"), + "", + ) + } + + val rsaPrivateKey by setting(RSA_PRIVATE_KEY) + val rsaPublicKeyX509 by setting(RSA_PUBLIC_KEY_X509) + + @Parcelize + data class Bundle( + val rsaPrivateKey: String, + val rsaPublicKeyX509: String, + ) : Parcelable { + } + + private val bundleFields = arrayOf( + bundleField(RSA_PRIVATE_KEY) { bundle: Bundle -> bundle.rsaPrivateKey }, + bundleField(RSA_PUBLIC_KEY_X509) { bundle: Bundle -> bundle.rsaPublicKeyX509 }, + ) + + val bundleState: StateFlow = 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 new file mode 100644 index 0000000..fff143f --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt @@ -0,0 +1,162 @@ +package io.github.miuzarte.scrcpyforandroid.storage + +import android.content.Context +import android.os.Parcelable +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy +import kotlinx.coroutines.flow.StateFlow +import kotlinx.parcelize.Parcelize + +class AppSettings(context: Context) : Settings(context, "AppSettings") { + companion object { + val THEME_BASE_INDEX = Pair( + intPreferencesKey("theme_base_index"), + 0 + ) + val MONET = Pair( + booleanPreferencesKey("monet"), + false + ) + val FULLSCREEN_DEBUG_INFO = Pair( + booleanPreferencesKey("fullscreen_debug_info"), + false + ) + val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = Pair( + booleanPreferencesKey("show_fullscreen_virtual_buttons"), + true + ) + val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair( + intPreferencesKey("device_preview_card_height_dp"), + 320 + ) + val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = Pair( + booleanPreferencesKey("preview_virtual_button_show_text"), + true + ) + val VIRTUAL_BUTTONS_LAYOUT = Pair( + stringPreferencesKey("virtual_buttons_layout"), + "more:1,app_switch:1,home:0,back:1,menu:0,notification:0,volume_up:0,volume_down:0,volume_mute:0,power:0,screenshot:0" + ) + val CUSTOM_SERVER_URI = Pair( + stringPreferencesKey("custom_server_uri"), + "" + ) + val CUSTOM_SERVER_VERSION = Pair( + stringPreferencesKey("custom_server_version"), + "" + ) + val SERVER_REMOTE_PATH = Pair( + stringPreferencesKey("server_remote_path"), + Scrcpy.DEFAULT_REMOTE_PATH, + ) + val ADB_KEY_NAME = Pair( + stringPreferencesKey("adb_key_name"), + "scrcpy" + ) + val ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN = Pair( + booleanPreferencesKey("adb_pairing_auto_discover_on_dialog_open"), + true + ) + val ADB_AUTO_RECONNECT_PAIRED_DEVICE = Pair( + booleanPreferencesKey("adb_auto_reconnect_paired_device"), + true + ) + val ADB_MDNS_LAN_DISCOVERY = Pair( + booleanPreferencesKey("adb_mdns_lan_discovery"), + true + ) + } + + // Theme Settings + val themeBaseIndex by setting(THEME_BASE_INDEX) + val monet by setting(MONET) + + // Scrcpy Settings + val fullscreenDebugInfo by setting(FULLSCREEN_DEBUG_INFO) + val showFullscreenVirtualButtons by setting(SHOW_FULLSCREEN_VIRTUAL_BUTTONS) + val devicePreviewCardHeightDp by setting(DEVICE_PREVIEW_CARD_HEIGHT_DP) + val previewVirtualButtonShowText by setting(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) + val virtualButtonsLayout by setting(VIRTUAL_BUTTONS_LAYOUT) + + // Scrcpy Server Settings + val customServerUri by setting(CUSTOM_SERVER_URI) + val customServerVersion by setting(CUSTOM_SERVER_VERSION) + val serverRemotePath by setting(SERVER_REMOTE_PATH) + + // ADB Settings + val adbKeyName by setting(ADB_KEY_NAME) + val adbPairingAutoDiscoverOnDialogOpen by setting(ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN) + val adbAutoReconnectPairedDevice by setting(ADB_AUTO_RECONNECT_PAIRED_DEVICE) + val adbMdnsLanDiscovery by setting(ADB_MDNS_LAN_DISCOVERY) + + @Parcelize + data class Bundle( + val themeBaseIndex: Int, + val monet: Boolean, + val fullscreenDebugInfo: Boolean, + val showFullscreenVirtualButtons: Boolean, + val devicePreviewCardHeightDp: Int, + val previewVirtualButtonShowText: Boolean, + val virtualButtonsLayout: String, + val customServerUri: String, + val customServerVersion: String, + val serverRemotePath: String, + val adbKeyName: String, + val adbPairingAutoDiscoverOnDialogOpen: Boolean, + val adbAutoReconnectPairedDevice: Boolean, + val adbMdnsLanDiscovery: Boolean, + ) : Parcelable { + } + + private val bundleFields = arrayOf( + bundleField(THEME_BASE_INDEX) { bundle: Bundle -> bundle.themeBaseIndex }, + bundleField(MONET) { bundle: Bundle -> bundle.monet }, + bundleField(FULLSCREEN_DEBUG_INFO) { bundle: Bundle -> bundle.fullscreenDebugInfo }, + bundleField(SHOW_FULLSCREEN_VIRTUAL_BUTTONS) { bundle: Bundle -> bundle.showFullscreenVirtualButtons }, + bundleField(DEVICE_PREVIEW_CARD_HEIGHT_DP) { bundle: Bundle -> bundle.devicePreviewCardHeightDp }, + bundleField(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) { bundle: Bundle -> bundle.previewVirtualButtonShowText }, + bundleField(VIRTUAL_BUTTONS_LAYOUT) { bundle: Bundle -> bundle.virtualButtonsLayout }, + bundleField(CUSTOM_SERVER_URI) { bundle: Bundle -> bundle.customServerUri }, + bundleField(CUSTOM_SERVER_VERSION) { bundle: Bundle -> bundle.customServerVersion }, + bundleField(SERVER_REMOTE_PATH) { bundle: Bundle -> bundle.serverRemotePath }, + bundleField(ADB_KEY_NAME) { bundle: Bundle -> bundle.adbKeyName }, + bundleField(ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN) { bundle: Bundle -> bundle.adbPairingAutoDiscoverOnDialogOpen }, + bundleField(ADB_AUTO_RECONNECT_PAIRED_DEVICE) { bundle: Bundle -> bundle.adbAutoReconnectPairedDevice }, + bundleField(ADB_MDNS_LAN_DISCOVERY) { bundle: Bundle -> bundle.adbMdnsLanDiscovery }, + ) + + val bundleState: StateFlow = createBundleState(::bundleFromPreferences) + + private fun bundleFromPreferences(preferences: Preferences) = Bundle( + themeBaseIndex = preferences.read(THEME_BASE_INDEX), + monet = preferences.read(MONET), + fullscreenDebugInfo = preferences.read(FULLSCREEN_DEBUG_INFO), + showFullscreenVirtualButtons = preferences.read(SHOW_FULLSCREEN_VIRTUAL_BUTTONS), + devicePreviewCardHeightDp = preferences.read(DEVICE_PREVIEW_CARD_HEIGHT_DP), + previewVirtualButtonShowText = preferences.read(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT), + virtualButtonsLayout = preferences.read(VIRTUAL_BUTTONS_LAYOUT), + customServerUri = preferences.read(CUSTOM_SERVER_URI), + customServerVersion = preferences.read(CUSTOM_SERVER_VERSION), + serverRemotePath = preferences.read(SERVER_REMOTE_PATH), + adbKeyName = preferences.read(ADB_KEY_NAME), + adbPairingAutoDiscoverOnDialogOpen = preferences.read( + ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN + ), + adbAutoReconnectPairedDevice = preferences.read(ADB_AUTO_RECONNECT_PAIRED_DEVICE), + adbMdnsLanDiscovery = preferences.read(ADB_MDNS_LAN_DISCOVERY), + ) + + suspend fun loadBundle() = loadBundle(::bundleFromPreferences) + + suspend fun saveBundle(new: Bundle) = saveBundle(bundleState.value, new, bundleFields) + + suspend fun updateBundle(transform: (Bundle) -> Bundle) { + saveBundle(transform(bundleState.value)) + } + + // TODO? + // fun validate(): Boolean = true +} 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..47455f7 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/PreferenceMigration.kt @@ -0,0 +1,564 @@ +package io.github.miuzarte.scrcpyforandroid.storage + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import androidx.core.content.edit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val TAG = "PreferenceMigration" + +/** + * 从旧的 SharedPreferences 迁移到新的 DataStore + */ +class PreferenceMigration(private val appContext: Context) { + private val appSharedPrefs: SharedPreferences by lazy { + appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + + private val sharedPrefs: SharedPreferences by lazy { + appContext.getSharedPreferences("nativecore_adb_rsa", Context.MODE_PRIVATE) + } + + /** + * 检查是否需要迁移(SharedPreferences 是否包含数据) + */ + suspend fun needsMigration(): Boolean = withContext(Dispatchers.IO) { + appSharedPrefs.all.isNotEmpty() || sharedPrefs.all.isNotEmpty() + } + + /** + * 执行完整迁移 + */ + suspend fun migrate( + clearSharedPrefs: Boolean = false, + ) = withContext(Dispatchers.IO) { + if (!needsMigration()) { + Log.i(TAG, "No data to migrate, skipping") + return@withContext + } else { + val appList = appSharedPrefs.all.entries.joinToString { (k, v) -> "\"$k\" to $v" } + val adbList = sharedPrefs.all.entries.joinToString { (k, v) -> "\"$k\" to $v" } + Log.d(TAG, "Migrating appSharedPrefs ($appList)") + } + + Log.i(TAG, "Starting migration from SharedPreferences to DataStore") + + // 迁移 AppSettings + migrateAppSettings() + + // 迁移 ScrcpyOptions + migrateScrcpyOptions() + + // 迁移 QuickDevices + migrateQuickDevices() + + // 迁移 ADB 密钥 + migrateAdbClientData() + + // 清空 SharedPreferences + if (clearSharedPrefs) { + appSharedPrefs.edit { clear() } + sharedPrefs.edit { clear() } + Log.d(TAG, "SharedPreferences cleared") + } + + Log.i( + TAG, "Migration completed successfully" + + " and SharedPreferences ${if (clearSharedPrefs) "" else "is not "}cleared" + ) + } + + /** + * 迁移应用设置 + */ + private suspend fun migrateAppSettings() { + val appSettings = Storage.appSettings + + // Theme Settings + migrateInt( + THEME_BASE_INDEX, + AppSettings.THEME_BASE_INDEX.defaultValue, + appSettings.themeBaseIndex, + ) + migrateBoolean( + MONET, + AppSettings.MONET.defaultValue, + appSettings.monet, + ) + + // Scrcpy Settings + migrateBoolean( + FULLSCREEN_DEBUG_INFO, + AppSettings.FULLSCREEN_DEBUG_INFO.defaultValue, + appSettings.fullscreenDebugInfo, + ) + migrateBoolean( + SHOW_FULLSCREEN_VIRTUAL_BUTTONS, + AppSettings.SHOW_FULLSCREEN_VIRTUAL_BUTTONS.defaultValue, + appSettings.showFullscreenVirtualButtons, + ) + migrateInt( + DEVICE_PREVIEW_CARD_HEIGHT_DP, + AppSettings.DEVICE_PREVIEW_CARD_HEIGHT_DP.defaultValue, + appSettings.devicePreviewCardHeightDp, + ) + migrateBoolean( + PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT, + AppSettings.PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT.defaultValue, + appSettings.previewVirtualButtonShowText, + ) + migrateString( + VIRTUAL_BUTTONS_LAYOUT, + AppSettings.VIRTUAL_BUTTONS_LAYOUT.defaultValue, + appSettings.virtualButtonsLayout, + ) + + // Scrcpy Server Settings + migrateString( + CUSTOM_SERVER_URI, + AppSettings.CUSTOM_SERVER_URI.defaultValue, + appSettings.customServerUri, + ) + migrateString( + SERVER_REMOTE_PATH, + AppSettings.SERVER_REMOTE_PATH.defaultValue, + appSettings.serverRemotePath, + ) + + // ADB Settings + migrateString( + ADB_KEY_NAME, + AppSettings.ADB_KEY_NAME.defaultValue, + appSettings.adbKeyName, + ) + migrateBoolean( + ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN, + AppSettings.ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN.defaultValue, + appSettings.adbPairingAutoDiscoverOnDialogOpen, + ) + migrateBoolean( + ADB_AUTO_RECONNECT_PAIRED_DEVICE, + AppSettings.ADB_AUTO_RECONNECT_PAIRED_DEVICE.defaultValue, + appSettings.adbAutoReconnectPairedDevice, + ) + migrateBoolean( + ADB_MDNS_LAN_DISCOVERY, + AppSettings.ADB_MDNS_LAN_DISCOVERY.defaultValue, + appSettings.adbMdnsLanDiscovery, + ) + + Log.d(TAG, "AppSettings migration completed") + } + + /** + * 迁移 Scrcpy 选项 + */ + private suspend fun migrateScrcpyOptions() { + val scrcpyOptions = Storage.scrcpyOptions + + // Audio & Video Codecs + migrateString( + AUDIO_CODEC, + ScrcpyOptions.AUDIO_CODEC.defaultValue, + scrcpyOptions.audioCodec, + ) + migrateString( + VIDEO_CODEC, + ScrcpyOptions.VIDEO_CODEC.defaultValue, + scrcpyOptions.videoCodec, + ) + + // Bit Rates + val audioBitRateKbps = sharedPrefs.getInt( + AUDIO_BIT_RATE_KBPS, + ScrcpyOptions.AUDIO_BIT_RATE.defaultValue / 1_000, + ) + scrcpyOptions.audioBitRate.set(audioBitRateKbps * 1_000) // Convert to bps + + val videoBitRateMbps = sharedPrefs.getFloat( + VIDEO_BIT_RATE_MBPS, + (ScrcpyOptions.VIDEO_BIT_RATE.defaultValue / 1_000_000).toFloat(), + ) + scrcpyOptions.videoBitRate.set((videoBitRateMbps * 1_000_000).toInt()) + + // Control Options + migrateBoolean( + TURN_SCREEN_OFF, + ScrcpyOptions.TURN_SCREEN_OFF.defaultValue, + scrcpyOptions.turnScreenOff, + ) + migrateBoolean( + NO_CONTROL, + !ScrcpyOptions.CONTROL.defaultValue, + ) { value -> + scrcpyOptions.control.set(!value) // Invert logic + } + migrateBoolean( + NO_VIDEO, + !ScrcpyOptions.VIDEO.defaultValue, + ) { value -> + scrcpyOptions.video.set(!value) // Invert logic + } + + // Video Source + val videoSourcePreset = sharedPrefs.getString( + VIDEO_SOURCE_PRESET, + ScrcpyOptions.VIDEO_SOURCE.defaultValue, + ).orEmpty().ifBlank { ScrcpyOptions.VIDEO_SOURCE.defaultValue } + scrcpyOptions.videoSource.set(videoSourcePreset) + + migrateString( + DISPLAY_ID, + ScrcpyOptions.DISPLAY_ID.defaultValue.toString(), + ) { value -> + value.toIntOrNull()?.let { scrcpyOptions.displayId.set(it) } + } + + // Camera Settings + migrateString( + CAMERA_ID, + ScrcpyOptions.CAMERA_ID.defaultValue, + scrcpyOptions.cameraId, + ) + migrateString( + CAMERA_FACING_PRESET, + ScrcpyOptions.CAMERA_FACING.defaultValue, + scrcpyOptions.cameraFacing, + ) + migrateString( + CAMERA_SIZE_PRESET, + ScrcpyOptions.CAMERA_SIZE.defaultValue, + ) { value -> + if (value == "custom") { + val customSize = sharedPrefs.getString( + CAMERA_SIZE_CUSTOM, + ScrcpyOptions.CAMERA_SIZE_CUSTOM.defaultValue, + ).orEmpty() + scrcpyOptions.cameraSizeCustom.set(customSize) + scrcpyOptions.cameraSizeUseCustom.set(true) + } else { + scrcpyOptions.cameraSize.set(value) + } + } + migrateString( + CAMERA_AR, + ScrcpyOptions.CAMERA_AR.defaultValue, + scrcpyOptions.cameraAr, + ) + migrateString( + CAMERA_FPS, + ScrcpyOptions.CAMERA_FPS.defaultValue.toString(), + ) { value -> + value.toIntOrNull()?.let { scrcpyOptions.cameraFps.set(it) } + } + migrateBoolean( + CAMERA_HIGH_SPEED, + ScrcpyOptions.CAMERA_HIGH_SPEED.defaultValue, + scrcpyOptions.cameraHighSpeed, + ) + + // Audio Source + val audioSourcePreset = sharedPrefs.getString( + AUDIO_SOURCE_PRESET, + "auto", + ).orEmpty().ifBlank { "auto" } + + if (audioSourcePreset == "custom") { + val customSource = sharedPrefs.getString( + AUDIO_SOURCE_CUSTOM, + ScrcpyOptions.AUDIO_SOURCE.defaultValue, + ).orEmpty() + scrcpyOptions.audioSource.set(customSource) + } else { + scrcpyOptions.audioSource.set(audioSourcePreset) + } + + migrateBoolean( + AUDIO_DUP, + ScrcpyOptions.AUDIO_DUP.defaultValue, + scrcpyOptions.audioDup, + ) + migrateBoolean( + NO_AUDIO_PLAYBACK, + !ScrcpyOptions.AUDIO_PLAYBACK.defaultValue, + ) { value -> + scrcpyOptions.audioPlayback.set(!value) // Invert logic + } + migrateBoolean( + REQUIRE_AUDIO, + ScrcpyOptions.REQUIRE_AUDIO.defaultValue, + scrcpyOptions.requireAudio, + ) + + // Max Size & FPS + migrateString( + MAX_SIZE_INPUT, + ScrcpyOptions.MAX_SIZE.defaultValue.toString(), + ) { value -> + value.toIntOrNull()?.let { scrcpyOptions.maxSize.set(it) } + } + migrateString( + MAX_FPS_INPUT, + ScrcpyOptions.MAX_FPS.defaultValue, + scrcpyOptions.maxFps, + ) + + // Encoders & Codec Options + migrateString( + VIDEO_ENCODER, + ScrcpyOptions.VIDEO_ENCODER.defaultValue, + scrcpyOptions.videoEncoder, + ) + migrateString( + VIDEO_CODEC_OPTION, + ScrcpyOptions.VIDEO_CODEC_OPTIONS.defaultValue, + scrcpyOptions.videoCodecOptions, + ) + migrateString( + AUDIO_ENCODER, + ScrcpyOptions.AUDIO_ENCODER.defaultValue, + scrcpyOptions.audioEncoder, + ) + migrateString( + AUDIO_CODEC_OPTION, + ScrcpyOptions.AUDIO_CODEC_OPTIONS.defaultValue, + scrcpyOptions.audioCodecOptions, + ) + + // New Display + val newDisplayWidth = sharedPrefs.getString( + NEW_DISPLAY_WIDTH, + "", + ).orEmpty() + val newDisplayHeight = sharedPrefs.getString( + NEW_DISPLAY_HEIGHT, + "", + ).orEmpty() + val newDisplayDpi = sharedPrefs.getString( + NEW_DISPLAY_DPI, + "", + ).orEmpty() + + if (newDisplayWidth.isNotBlank() && newDisplayHeight.isNotBlank()) { + val newDisplay = if (newDisplayDpi.isNotBlank()) { + "${newDisplayWidth}x${newDisplayHeight}/${newDisplayDpi}" + } else { + "${newDisplayWidth}x${newDisplayHeight}" + } + scrcpyOptions.newDisplay.set(newDisplay) + } + + // Crop + val cropWidth = sharedPrefs.getString( + CROP_WIDTH, + "", + ).orEmpty() + val cropHeight = sharedPrefs.getString( + CROP_HEIGHT, + "", + ).orEmpty() + val cropX = sharedPrefs.getString( + CROP_X, + "", + ).orEmpty() + val cropY = sharedPrefs.getString( + CROP_Y, + "", + ).orEmpty() + + if (cropWidth.isNotBlank() && cropHeight.isNotBlank() + && cropX.isNotBlank() && cropY.isNotBlank() + ) { + scrcpyOptions.crop.set("${cropWidth}:${cropHeight}:${cropX}:${cropY}") + } + + migrateBoolean( + AUDIO_ENABLED, + ScrcpyOptions.AUDIO.defaultValue, + scrcpyOptions.audio, + ) + + Log.d(TAG, "ScrcpyOptions migration completed") + } + + /** + * 迁移快速设备列表 + */ + private suspend fun migrateQuickDevices() { + val quickDevices = Storage.quickDevices + + // Migrate quick devices list + val quickDevicesRaw = appSharedPrefs.getString( + QUICK_DEVICES, + "" + ).orEmpty() + if (quickDevicesRaw.isNotBlank()) { + quickDevices.quickDevicesList.set(quickDevicesRaw) + } + + // Migrate quick connect input + migrateString( + QUICK_CONNECT_INPUT, + QuickDevices.QUICK_CONNECT_INPUT.defaultValue, + quickDevices.quickConnectInput, + ) + + Log.d(TAG, "QuickDevices migration completed") + } + + /** + * 迁移 ADB 客户端数据(RSA 密钥) + */ + private suspend fun migrateAdbClientData() { + val adbClientData = Storage.adbClientData + + // 迁移 RSA 私钥 + val privKey = sharedPrefs.getString("priv", null) + if (privKey != null) { + adbClientData.rsaPrivateKey.set(privKey) + Log.d(TAG, "ADB RSA private key migrated") + } + + Log.d(TAG, "AdbClientData migration completed") + } + + // Helper methods for different data types + + private suspend fun migrateString( + key: String, + defaultValue: String, + settingProperty: Settings.SettingProperty + ) { + val value = appSharedPrefs.getString(key, defaultValue) + .orEmpty() + .ifBlank { defaultValue } + settingProperty.set(value) + } + + private suspend fun migrateString( + key: String, + defaultValue: String, + action: suspend (String) -> Unit + ) { + val value = appSharedPrefs.getString(key, defaultValue) + .orEmpty() + .ifBlank { defaultValue } + action(value) + } + + private suspend fun migrateInt( + key: String, + defaultValue: Int, + settingProperty: Settings.SettingProperty + ) { + val value = appSharedPrefs.getInt(key, defaultValue) + settingProperty.set(value) + } + + private suspend fun migrateInt( + key: String, + defaultValue: Int, + action: suspend (Int) -> Unit + ) { + val value = appSharedPrefs.getInt(key, defaultValue) + action(value) + } + + private suspend fun migrateBoolean( + key: String, + defaultValue: Boolean, + settingProperty: Settings.SettingProperty + ) { + val value = appSharedPrefs.getBoolean(key, defaultValue) + settingProperty.set(value) + } + + private suspend fun migrateBoolean( + key: String, + defaultValue: Boolean, + action: suspend (Boolean) -> Unit + ) { + val value = appSharedPrefs.getBoolean(key, defaultValue) + action(value) + } + + companion object { + const val PREFS_NAME = "scrcpy_app_prefs" + + // Devices + const val QUICK_DEVICES = "quick_devices" + const val QUICK_CONNECT_INPUT = "quick_connect_input" + + const val PAIR_HOST = "pair_host" + const val PAIR_PORT = "pair_port" + const val PAIR_CODE = "pair_code" + + const val AUDIO_ENABLED = "audio_enabled" + const val AUDIO_CODEC = "audio_codec" + const val AUDIO_BIT_RATE_INPUT = "audio_bit_rate_input" + const val AUDIO_BIT_RATE_KBPS = "audio_bit_rate_kbps" + const val VIDEO_CODEC = "video_codec" + const val VIDEO_BIT_RATE_MBPS = "video_bit_rate_mbps" + const val VIDEO_BIT_RATE_INPUT = "video_bit_rate_input" + + const val TURN_SCREEN_OFF = "turn_screen_off" + const val NO_CONTROL = "no_control" + const val NO_VIDEO = "no_video" + + const val VIDEO_SOURCE_PRESET = "video_source_preset" + const val DISPLAY_ID = "display_id" + + const val CAMERA_ID = "camera_id" + const val CAMERA_FACING_PRESET = "camera_facing_preset" + const val CAMERA_SIZE_PRESET = "camera_size_preset" + const val CAMERA_SIZE_CUSTOM = "camera_size_custom" + const val CAMERA_AR = "camera_ar" + const val CAMERA_FPS = "camera_fps" + const val CAMERA_HIGH_SPEED = "camera_high_speed" + + const val AUDIO_SOURCE_PRESET = "audio_source_preset" + const val AUDIO_SOURCE_CUSTOM = "audio_source_custom" + const val AUDIO_DUP = "audio_dup" + const val NO_AUDIO_PLAYBACK = "no_audio_playback" + const val REQUIRE_AUDIO = "require_audio" + + const val MAX_SIZE_INPUT = "max_size_input" + const val MAX_FPS_INPUT = "max_fps_input" + + const val VIDEO_ENCODER = "video_encoder" + const val VIDEO_CODEC_OPTION = "video_codec_options" + const val AUDIO_ENCODER = "audio_encoder" + const val AUDIO_CODEC_OPTION = "audio_codec_options" + + const val NEW_DISPLAY_WIDTH = "new_display_width" + const val NEW_DISPLAY_HEIGHT = "new_display_height" + const val NEW_DISPLAY_DPI = "new_display_dpi" + + const val CROP_WIDTH = "crop_width" + const val CROP_HEIGHT = "crop_height" + const val CROP_X = "crop_x" + const val CROP_Y = "crop_y" + + // Settings + const val THEME_BASE_INDEX = "theme_base_index" + const val MONET = "monet" + + const val FULLSCREEN_DEBUG_INFO = "fullscreen_debug_info" + const val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = "show_fullscreen_virtual_buttons" + const val DEVICE_PREVIEW_CARD_HEIGHT_DP = "device_preview_card_height_dp" + const val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = "preview_virtual_button_show_text" + const val VIRTUAL_BUTTONS_LAYOUT = "virtual_buttons_layout" + + const val CUSTOM_SERVER_URI = "custom_server_uri" + + const val SERVER_REMOTE_PATH = "server_remote_path" + + const val ADB_KEY_NAME = "adb_key_name" + const val ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN = + "adb_pairing_auto_discover_on_dialog_open" + const val ADB_AUTO_RECONNECT_PAIRED_DEVICE = "adb_auto_reconnect_paired_device" + const val ADB_MDNS_LAN_DISCOVERY = "adb_mdns_lan_discovery" + } +} 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..bc0af4b --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt @@ -0,0 +1,51 @@ +package io.github.miuzarte.scrcpyforandroid.storage + +import android.content.Context +import android.os.Parcelable +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.StateFlow +import kotlinx.parcelize.Parcelize + +class QuickDevices(context: Context) : Settings(context, "QuickDevices") { + companion object { + val QUICK_DEVICES_LIST = Pair( + stringPreferencesKey("quick_devices_list"), + "", + ) + val QUICK_CONNECT_INPUT = Pair( + stringPreferencesKey("quick_connect_input"), + "", + ) + } + + val quickDevicesList by setting(QUICK_DEVICES_LIST) + val quickConnectInput by setting(QUICK_CONNECT_INPUT) + + @Parcelize + data class Bundle( + val quickDevicesList: String, + val quickConnectInput: String, + ) : Parcelable { + } + + private val bundleFields = arrayOf( + bundleField(QUICK_DEVICES_LIST) { bundle: Bundle -> bundle.quickDevicesList }, + bundleField(QUICK_CONNECT_INPUT) { bundle: Bundle -> bundle.quickConnectInput }, + ) + + val bundleState: StateFlow = 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 new file mode 100644 index 0000000..4075e9e --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt @@ -0,0 +1,540 @@ +package io.github.miuzarte.scrcpyforandroid.storage + +import android.content.Context +import android.os.Parcelable +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.AudioSource +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.DisplayImePolicy +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.LogLevel +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Orientation +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.OrientationLock +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.parcelize.Parcelize + +class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { + companion object { + val CROP = Pair( + stringPreferencesKey("crop"), + "", + ) + val RECORD_FILENAME = Pair( + stringPreferencesKey("record_filename"), + "", + ) + val VIDEO_CODEC_OPTIONS = Pair( + stringPreferencesKey("video_codec_options"), + "", + ) + val AUDIO_CODEC_OPTIONS = Pair( + stringPreferencesKey("audio_codec_options"), + "", + ) + val VIDEO_ENCODER = Pair( + stringPreferencesKey("video_encoder"), + "", + ) + val AUDIO_ENCODER = Pair( + stringPreferencesKey("audio_encoder"), + "", + ) + val CAMERA_ID = Pair( + stringPreferencesKey("camera_id"), + "", + ) + val CAMERA_SIZE = Pair( + stringPreferencesKey("camera_size"), + "", + ) + val CAMERA_SIZE_CUSTOM = Pair( + stringPreferencesKey("camera_size_custom"), + "", + ) + val CAMERA_SIZE_USE_CUSTOM = Pair( + booleanPreferencesKey("camera_size_use_custom"), + false, + ) + val CAMERA_AR = Pair( + stringPreferencesKey("camera_ar"), + "", + ) + val CAMERA_FPS = Pair( + intPreferencesKey("camera_fps"), + 0, + ) + val LOG_LEVEL = Pair( + stringPreferencesKey("log_level"), + "info", + ) + val VIDEO_CODEC = Pair( + stringPreferencesKey("video_codec"), + "h264", + ) + val AUDIO_CODEC = Pair( + stringPreferencesKey("audio_codec"), + "opus", + ) + val VIDEO_SOURCE = Pair( + stringPreferencesKey("video_source"), + "display", + ) + val AUDIO_SOURCE = Pair( + stringPreferencesKey("audio_source"), + "output", + ) + val RECORD_FORMAT = Pair( + stringPreferencesKey("record_format"), + "auto", + ) + val CAMERA_FACING = Pair( + stringPreferencesKey("camera_facing"), + "any", + ) + val MAX_SIZE = Pair( + intPreferencesKey("max_size"), + 0, + ) + val VIDEO_BIT_RATE = Pair( + intPreferencesKey("video_bit_rate"), + 0, + ) + val AUDIO_BIT_RATE = Pair( + intPreferencesKey("audio_bit_rate"), + 0, + ) + val MAX_FPS = Pair( + stringPreferencesKey("max_fps"), + "", + ) + val ANGLE = Pair( + stringPreferencesKey("angle"), + "", + ) + val CAPTURE_ORIENTATION = Pair( + intPreferencesKey("capture_orientation"), + 0, + ) + val CAPTURE_ORIENTATION_LOCK = Pair( + stringPreferencesKey("capture_orientation_lock"), + "unlocked", + ) + val DISPLAY_ORIENTATION = Pair( + intPreferencesKey("display_orientation"), + 0, + ) + val RECORD_ORIENTATION = Pair( + intPreferencesKey("record_orientation"), + 0, + ) + val DISPLAY_IME_POLICY = Pair( + stringPreferencesKey("display_ime_policy"), + "undefined", + ) + val DISPLAY_ID = Pair( + intPreferencesKey("display_id"), + -1, // undefined + ) + val SCREEN_OFF_TIMEOUT = Pair( + longPreferencesKey("screen_off_timeout"), + -1, + ) + val SHOW_TOUCHES = Pair( + booleanPreferencesKey("show_touches"), + false, + ) + val FULLSCREEN = Pair( + booleanPreferencesKey("fullscreen"), + false, + ) + val CONTROL = Pair( + booleanPreferencesKey("control"), + true, + ) + val VIDEO_PLAYBACK = Pair( + booleanPreferencesKey("video_playback"), + true, + ) + val AUDIO_PLAYBACK = Pair( + booleanPreferencesKey("audio_playback"), + true, + ) + val TURN_SCREEN_OFF = Pair( + booleanPreferencesKey("turn_screen_off"), + false, + ) + val STAY_AWAKE = Pair( + booleanPreferencesKey("stay_awake"), + false, + ) + val DISABLE_SCREENSAVER = Pair( + booleanPreferencesKey("disable_screensaver"), + false, + ) + val POWER_OFF_ON_CLOSE = Pair( + booleanPreferencesKey("power_off_on_close"), + false, + ) + val CLEANUP = Pair( + booleanPreferencesKey("cleanup"), + true, + ) + val POWER_ON = Pair( + booleanPreferencesKey("power_on"), + true, + ) + val VIDEO = Pair( + booleanPreferencesKey("video"), + true, + ) + val AUDIO = Pair( + booleanPreferencesKey("audio"), + true, + ) + val REQUIRE_AUDIO = Pair( + booleanPreferencesKey("require_audio"), + false, + ) + val KILL_ADB_ON_CLOSE = Pair( + booleanPreferencesKey("kill_adb_on_close"), + false, + ) + val CAMERA_HIGH_SPEED = Pair( + booleanPreferencesKey("camera_high_speed"), + false, + ) + val LIST = Pair( + stringPreferencesKey("list"), + "null", + ) + val AUDIO_DUP = Pair( + booleanPreferencesKey("audio_dup"), + false, + ) + val NEW_DISPLAY = Pair( + stringPreferencesKey("new_display"), + "", + ) + val START_APP = Pair( + stringPreferencesKey("start_app"), + "", + ) + val VD_DESTROY_CONTENT = Pair( + booleanPreferencesKey("vd_destroy_content"), + true, + ) + val VD_SYSTEM_DECORATIONS = Pair( + booleanPreferencesKey("vd_system_decorations"), + true, + ) + } + + val crop by setting(CROP) + val recordFilename by setting(RECORD_FILENAME) + val videoCodecOptions by setting(VIDEO_CODEC_OPTIONS) + val audioCodecOptions by setting(AUDIO_CODEC_OPTIONS) + val videoEncoder by setting(VIDEO_ENCODER) + val audioEncoder by setting(AUDIO_ENCODER) + val cameraId by setting(CAMERA_ID) + val cameraSize by setting(CAMERA_SIZE) + val cameraSizeCustom by setting(CAMERA_SIZE_CUSTOM) + val cameraSizeUseCustom by setting(CAMERA_SIZE_USE_CUSTOM) + val cameraAr by setting(CAMERA_AR) + val cameraFps by setting(CAMERA_FPS) + val logLevel by setting(LOG_LEVEL) + val videoCodec by setting(VIDEO_CODEC) + val audioCodec by setting(AUDIO_CODEC) + val videoSource by setting(VIDEO_SOURCE) + val audioSource by setting(AUDIO_SOURCE) + val recordFormat by setting(RECORD_FORMAT) + val cameraFacing by setting(CAMERA_FACING) + val maxSize by setting(MAX_SIZE) + val videoBitRate by setting(VIDEO_BIT_RATE) + val audioBitRate by setting(AUDIO_BIT_RATE) + val maxFps by setting(MAX_FPS) + val angle by setting(ANGLE) + val captureOrientation by setting(CAPTURE_ORIENTATION) + val captureOrientationLock by setting(CAPTURE_ORIENTATION_LOCK) + val displayOrientation by setting(DISPLAY_ORIENTATION) + val recordOrientation by setting(RECORD_ORIENTATION) + val displayImePolicy by setting(DISPLAY_IME_POLICY) + val displayId by setting(DISPLAY_ID) + val screenOffTimeout by setting(SCREEN_OFF_TIMEOUT) + val showTouches by setting(SHOW_TOUCHES) + val fullscreen by setting(FULLSCREEN) + val control by setting(CONTROL) + val videoPlayback by setting(VIDEO_PLAYBACK) + val audioPlayback by setting(AUDIO_PLAYBACK) + val turnScreenOff by setting(TURN_SCREEN_OFF) + val stayAwake by setting(STAY_AWAKE) + val disableScreensaver by setting(DISABLE_SCREENSAVER) + val powerOffOnClose by setting(POWER_OFF_ON_CLOSE) + val cleanup by setting(CLEANUP) + val powerOn by setting(POWER_ON) + val video by setting(VIDEO) + val audio by setting(AUDIO) + val requireAudio by setting(REQUIRE_AUDIO) + val killAdbOnClose by setting(KILL_ADB_ON_CLOSE) + val cameraHighSpeed by setting(CAMERA_HIGH_SPEED) + val list by setting(LIST) + val audioDup by setting(AUDIO_DUP) + val newDisplay by setting(NEW_DISPLAY) + val startApp by setting(START_APP) + val vdDestroyContent by setting(VD_DESTROY_CONTENT) + val vdSystemDecorations by setting(VD_SYSTEM_DECORATIONS) + + @Parcelize + data class Bundle( + val crop: String, + val recordFilename: String, + val videoCodecOptions: String, + val audioCodecOptions: String, + val videoEncoder: String, + val audioEncoder: String, + val cameraId: String, + val cameraSize: String, + val cameraSizeCustom: String, + val cameraSizeUseCustom: Boolean, + val cameraAr: String, + val cameraFps: Int, + val logLevel: String, + val videoCodec: String, + val audioCodec: String, + val videoSource: String, + val audioSource: String, + val recordFormat: String, + val cameraFacing: String, + val maxSize: Int, + val videoBitRate: Int, + val audioBitRate: Int, + val maxFps: String, + val angle: String, + val captureOrientation: Int, + val captureOrientationLock: String, + val displayOrientation: Int, + val recordOrientation: Int, + val displayImePolicy: String, + val displayId: Int, + val screenOffTimeout: Long, + val showTouches: Boolean, + val fullscreen: Boolean, + val control: Boolean, + val videoPlayback: Boolean, + val audioPlayback: Boolean, + val turnScreenOff: Boolean, + val stayAwake: Boolean, + val disableScreensaver: Boolean, + val powerOffOnClose: Boolean, + val cleanup: Boolean, + val powerOn: Boolean, + val video: Boolean, + val audio: Boolean, + val requireAudio: Boolean, + val killAdbOnClose: Boolean, + val cameraHighSpeed: Boolean, + val list: String, + val audioDup: Boolean, + val newDisplay: String, + val startApp: String, + val vdDestroyContent: Boolean, + val vdSystemDecorations: Boolean + ) : Parcelable { + } + + private val bundleFields = arrayOf( + bundleField(CROP) { bundle: Bundle -> bundle.crop }, + bundleField(RECORD_FILENAME) { bundle: Bundle -> bundle.recordFilename }, + bundleField(VIDEO_CODEC_OPTIONS) { bundle: Bundle -> bundle.videoCodecOptions }, + bundleField(AUDIO_CODEC_OPTIONS) { bundle: Bundle -> bundle.audioCodecOptions }, + bundleField(VIDEO_ENCODER) { bundle: Bundle -> bundle.videoEncoder }, + bundleField(AUDIO_ENCODER) { bundle: Bundle -> bundle.audioEncoder }, + bundleField(CAMERA_ID) { bundle: Bundle -> bundle.cameraId }, + bundleField(CAMERA_SIZE) { bundle: Bundle -> bundle.cameraSize }, + bundleField(CAMERA_SIZE_CUSTOM) { bundle: Bundle -> bundle.cameraSizeCustom }, + bundleField(CAMERA_SIZE_USE_CUSTOM) { bundle: Bundle -> bundle.cameraSizeUseCustom }, + bundleField(CAMERA_AR) { bundle: Bundle -> bundle.cameraAr }, + bundleField(CAMERA_FPS) { bundle: Bundle -> bundle.cameraFps }, + bundleField(LOG_LEVEL) { bundle: Bundle -> bundle.logLevel }, + bundleField(VIDEO_CODEC) { bundle: Bundle -> bundle.videoCodec }, + bundleField(AUDIO_CODEC) { bundle: Bundle -> bundle.audioCodec }, + bundleField(VIDEO_SOURCE) { bundle: Bundle -> bundle.videoSource }, + bundleField(AUDIO_SOURCE) { bundle: Bundle -> bundle.audioSource }, + bundleField(RECORD_FORMAT) { bundle: Bundle -> bundle.recordFormat }, + bundleField(CAMERA_FACING) { bundle: Bundle -> bundle.cameraFacing }, + bundleField(MAX_SIZE) { bundle: Bundle -> bundle.maxSize }, + bundleField(VIDEO_BIT_RATE) { bundle: Bundle -> bundle.videoBitRate }, + bundleField(AUDIO_BIT_RATE) { bundle: Bundle -> bundle.audioBitRate }, + bundleField(MAX_FPS) { bundle: Bundle -> bundle.maxFps }, + bundleField(ANGLE) { bundle: Bundle -> bundle.angle }, + bundleField(CAPTURE_ORIENTATION) { bundle: Bundle -> bundle.captureOrientation }, + bundleField(CAPTURE_ORIENTATION_LOCK) { bundle: Bundle -> bundle.captureOrientationLock }, + bundleField(DISPLAY_ORIENTATION) { bundle: Bundle -> bundle.displayOrientation }, + bundleField(RECORD_ORIENTATION) { bundle: Bundle -> bundle.recordOrientation }, + bundleField(DISPLAY_IME_POLICY) { bundle: Bundle -> bundle.displayImePolicy }, + bundleField(DISPLAY_ID) { bundle: Bundle -> bundle.displayId }, + bundleField(SCREEN_OFF_TIMEOUT) { bundle: Bundle -> bundle.screenOffTimeout }, + bundleField(SHOW_TOUCHES) { bundle: Bundle -> bundle.showTouches }, + bundleField(FULLSCREEN) { bundle: Bundle -> bundle.fullscreen }, + bundleField(CONTROL) { bundle: Bundle -> bundle.control }, + bundleField(VIDEO_PLAYBACK) { bundle: Bundle -> bundle.videoPlayback }, + bundleField(AUDIO_PLAYBACK) { bundle: Bundle -> bundle.audioPlayback }, + bundleField(TURN_SCREEN_OFF) { bundle: Bundle -> bundle.turnScreenOff }, + bundleField(STAY_AWAKE) { bundle: Bundle -> bundle.stayAwake }, + bundleField(DISABLE_SCREENSAVER) { bundle: Bundle -> bundle.disableScreensaver }, + bundleField(POWER_OFF_ON_CLOSE) { bundle: Bundle -> bundle.powerOffOnClose }, + bundleField(CLEANUP) { bundle: Bundle -> bundle.cleanup }, + bundleField(POWER_ON) { bundle: Bundle -> bundle.powerOn }, + bundleField(VIDEO) { bundle: Bundle -> bundle.video }, + bundleField(AUDIO) { bundle: Bundle -> bundle.audio }, + bundleField(REQUIRE_AUDIO) { bundle: Bundle -> bundle.requireAudio }, + bundleField(KILL_ADB_ON_CLOSE) { bundle: Bundle -> bundle.killAdbOnClose }, + bundleField(CAMERA_HIGH_SPEED) { bundle: Bundle -> bundle.cameraHighSpeed }, + bundleField(LIST) { bundle: Bundle -> bundle.list }, + bundleField(AUDIO_DUP) { bundle: Bundle -> bundle.audioDup }, + bundleField(NEW_DISPLAY) { bundle: Bundle -> bundle.newDisplay }, + bundleField(START_APP) { bundle: Bundle -> bundle.startApp }, + bundleField(VD_DESTROY_CONTENT) { bundle: Bundle -> bundle.vdDestroyContent }, + bundleField(VD_SYSTEM_DECORATIONS) { bundle: Bundle -> bundle.vdSystemDecorations }, + ) + + val bundleState: StateFlow = createBundleState(::bundleFromPreferences) + + private fun bundleFromPreferences(preferences: Preferences) = Bundle( + crop = preferences.read(CROP), + recordFilename = preferences.read(RECORD_FILENAME), + videoCodecOptions = preferences.read(VIDEO_CODEC_OPTIONS), + audioCodecOptions = preferences.read(AUDIO_CODEC_OPTIONS), + videoEncoder = preferences.read(VIDEO_ENCODER), + audioEncoder = preferences.read(AUDIO_ENCODER), + cameraId = preferences.read(CAMERA_ID), + cameraSize = preferences.read(CAMERA_SIZE), + cameraSizeCustom = preferences.read(CAMERA_SIZE_CUSTOM), + cameraSizeUseCustom = preferences.read(CAMERA_SIZE_USE_CUSTOM), + cameraAr = preferences.read(CAMERA_AR), + cameraFps = preferences.read(CAMERA_FPS), + logLevel = preferences.read(LOG_LEVEL), + videoCodec = preferences.read(VIDEO_CODEC), + audioCodec = preferences.read(AUDIO_CODEC), + videoSource = preferences.read(VIDEO_SOURCE), + audioSource = preferences.read(AUDIO_SOURCE), + recordFormat = preferences.read(RECORD_FORMAT), + cameraFacing = preferences.read(CAMERA_FACING), + maxSize = preferences.read(MAX_SIZE), + videoBitRate = preferences.read(VIDEO_BIT_RATE), + audioBitRate = preferences.read(AUDIO_BIT_RATE), + maxFps = preferences.read(MAX_FPS), + angle = preferences.read(ANGLE), + captureOrientation = preferences.read(CAPTURE_ORIENTATION), + captureOrientationLock = preferences.read(CAPTURE_ORIENTATION_LOCK), + displayOrientation = preferences.read(DISPLAY_ORIENTATION), + recordOrientation = preferences.read(RECORD_ORIENTATION), + displayImePolicy = preferences.read(DISPLAY_IME_POLICY), + displayId = preferences.read(DISPLAY_ID), + screenOffTimeout = preferences.read(SCREEN_OFF_TIMEOUT), + showTouches = preferences.read(SHOW_TOUCHES), + fullscreen = preferences.read(FULLSCREEN), + control = preferences.read(CONTROL), + videoPlayback = preferences.read(VIDEO_PLAYBACK), + audioPlayback = preferences.read(AUDIO_PLAYBACK), + turnScreenOff = preferences.read(TURN_SCREEN_OFF), + stayAwake = preferences.read(STAY_AWAKE), + disableScreensaver = preferences.read(DISABLE_SCREENSAVER), + powerOffOnClose = preferences.read(POWER_OFF_ON_CLOSE), + cleanup = preferences.read(CLEANUP), + powerOn = preferences.read(POWER_ON), + video = preferences.read(VIDEO), + audio = preferences.read(AUDIO), + requireAudio = preferences.read(REQUIRE_AUDIO), + killAdbOnClose = preferences.read(KILL_ADB_ON_CLOSE), + cameraHighSpeed = preferences.read(CAMERA_HIGH_SPEED), + list = preferences.read(LIST), + audioDup = preferences.read(AUDIO_DUP), + newDisplay = preferences.read(NEW_DISPLAY), + startApp = preferences.read(START_APP), + vdDestroyContent = preferences.read(VD_DESTROY_CONTENT), + vdSystemDecorations = preferences.read(VD_SYSTEM_DECORATIONS), + ) + + suspend fun loadBundle() = loadBundle(::bundleFromPreferences) + + suspend fun saveBundle(new: Bundle) = saveBundle(bundleState.value, new, bundleFields) + + suspend fun updateBundle(transform: (Bundle) -> Bundle) { + saveBundle(transform(bundleState.value)) + } + + fun validate(): Boolean = runBlocking { + runCatching { + toClientOptions().validate() + true + }.getOrDefault(false) + } + + // TODO: 处理空值 + fun toClientOptions() = toClientOptions(bundleState.value) + + fun toClientOptions(bundle: Bundle) = ClientOptions( + crop = bundle.crop, + recordFilename = bundle.recordFilename, + videoCodecOptions = bundle.videoCodecOptions, + audioCodecOptions = bundle.audioCodecOptions, + videoEncoder = bundle.videoEncoder, + audioEncoder = bundle.audioEncoder, + cameraId = bundle.cameraId, + cameraSize = if (!bundle.cameraSizeUseCustom) bundle.cameraSize else bundle.cameraSizeCustom, + cameraAr = bundle.cameraAr, + cameraFps = bundle.cameraFps.toUShort(), + logLevel = LogLevel.valueOf(bundle.logLevel.uppercase()), + videoCodec = Codec.fromString(bundle.videoCodec), + audioCodec = Codec.fromString(bundle.audioCodec), + videoSource = VideoSource.fromString(bundle.videoSource), + audioSource = AudioSource.fromString(bundle.audioSource), + recordFormat = ClientOptions.RecordFormat.valueOf(bundle.recordFormat.uppercase()), + cameraFacing = CameraFacing.fromString(bundle.cameraFacing), + maxSize = bundle.maxSize.toUShort(), + videoBitRate = bundle.videoBitRate, + audioBitRate = bundle.audioBitRate, + maxFps = bundle.maxFps, + angle = bundle.angle, + captureOrientation = Orientation.fromInt(bundle.captureOrientation), + captureOrientationLock = OrientationLock.valueOf( + bundle.captureOrientationLock.uppercase() + ), + displayOrientation = Orientation.fromInt(bundle.displayOrientation), + recordOrientation = Orientation.fromInt(bundle.recordOrientation), + displayImePolicy = DisplayImePolicy.valueOf(bundle.displayImePolicy.uppercase()), + displayId = bundle.displayId, + screenOffTimeout = Tick(bundle.screenOffTimeout), + showTouches = bundle.showTouches, + fullscreen = bundle.fullscreen, + control = bundle.control, + videoPlayback = bundle.videoPlayback, + audioPlayback = bundle.audioPlayback, + turnScreenOff = bundle.turnScreenOff, + stayAwake = bundle.stayAwake, + disableScreensaver = bundle.disableScreensaver, + powerOffOnClose = bundle.powerOffOnClose, + cleanUp = bundle.cleanup, + powerOn = bundle.powerOn, + video = bundle.video, + audio = bundle.audio, + requireAudio = bundle.requireAudio, + killAdbOnClose = bundle.killAdbOnClose, + cameraHighSpeed = bundle.cameraHighSpeed, + list = ListOptions.valueOf(bundle.list.uppercase()), + audioDup = bundle.audioDup, + newDisplay = bundle.newDisplay, + startApp = bundle.startApp, + vdDestroyContent = bundle.vdDestroyContent, + vdSystemDecorations = bundle.vdSystemDecorations + ) +} 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..fd030eb --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt @@ -0,0 +1,178 @@ +package io.github.miuzarte.scrcpyforandroid.storage + +import android.content.Context +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.datastore.core.DataStore +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlin.reflect.KProperty +import kotlin.time.Duration.Companion.milliseconds + +private const val TAG = "Settings" + +abstract class Settings( + context: Context, + private val name: String, + corruptionHandler: ReplaceFileCorruptionHandler? = + ReplaceFileCorruptionHandler { + Log.e(TAG, "Preferences corrupted, resetting.", it) + emptyPreferences() + } +) { + private val settingsScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + data class Pair( + val key: Preferences.Key, + val defaultValue: T, + ) { + 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 方法 + */ + inner class SettingProperty( + val pair: Pair + ) { + // 创建时注册自身 + init { + registerProperty(pair.name, this) + } + + 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) + + fun observe(): Flow = this@Settings.observe(pair) + + @Composable + fun asState(): State = this@Settings.asState(pair) + + @Composable + 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, + corruptionHandler = corruptionHandler, + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + ) + + // 对外暴露的 DataStore 实例 + protected val dataStore: DataStore = context.dataStore + + 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 + + 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 rememberSaveable(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 } + } + } + } + + companion object { + val BUNDLE_SAVE_DELAY = 100.milliseconds + } +} 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/widgets/DeviceWidgets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt index 0a28d54..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 @@ -2,10 +2,10 @@ package io.github.miuzarte.scrcpyforandroid.widgets import android.annotation.SuppressLint import android.graphics.SurfaceTexture -import android.view.MotionEvent import android.view.Surface import android.view.TextureView import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -38,16 +38,19 @@ 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 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 @@ -63,14 +66,24 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade -import io.github.miuzarte.scrcpyforandroid.ScrcpySessionInfo -import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults +import io.github.miuzarte.scrcpyforandroid.constants.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.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.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 import top.yukonga.miuix.kmp.basic.Button @@ -91,40 +104,19 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme.isDynamicColor import top.yukonga.miuix.kmp.utils.PressFeedbackType import kotlin.math.roundToInt -private val VIDEO_CODEC_OPTIONS = listOf( - "h264" to "H.264", - "h265" to "H.265", - "av1" to "AV1", -) - -private val AUDIO_CODEC_OPTIONS = listOf( - "opus" to "Opus", - "aac" to "AAC", - "flac" to "FLAC", - "raw" to "RAW", -) - -private object UiMotionActions { - const val DOWN = 0 - const val UP = 1 - const val MOVE = 2 - const val CANCEL = 3 - const val POINTER_DOWN = 5 - const val POINTER_UP = 6 -} - -private const val FULLSCREEN_TOUCH_LOG_TAG = "FullscreenTouch" - @Composable internal fun StatusCard( statusLine: String, adbConnected: Boolean, streaming: Boolean, - sessionInfo: ScrcpySessionInfo?, + sessionInfo: Scrcpy.Session.SessionInfo?, busyLabel: String?, connectedDeviceLabel: String, - themeBaseIndex: Int, ) { + val appSettings = Storage.appSettings + val appSettingsBundle by appSettings.bundleState.collectAsState() + val themeBaseIndex = appSettingsBundle.themeBaseIndex + val cleanStatusLine = normalizeStatusLine(statusLine) // 根据应用主题设置决定是否使用深色模式 @@ -168,7 +160,7 @@ internal fun StatusCard( ), secondSmall = StatusSmallCardSpec( "编解码器", - sessionInfo.codec, + sessionInfo.codec?.displayName ?: "null", ), ) } @@ -221,7 +213,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) } @@ -255,14 +247,14 @@ internal fun PairingCard( @Composable internal fun PreviewCard( - sessionInfo: ScrcpySessionInfo?, + sessionInfo: Scrcpy.Session.SessionInfo?, nativeCore: NativeCoreFacade, 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 { @@ -306,12 +298,12 @@ internal fun PreviewCard( Box( modifier = Modifier .align(Alignment.BottomEnd) - .padding(UiSpacing.CardContent), + .padding(UiSpacing.ContentVertical), ) { Button( onClick = { if (alpha > 0.1) { - onOpenFullscreenHaptic?.invoke() + haptics.contextClick() onOpenFullscreen() } }, @@ -352,7 +344,7 @@ internal fun VirtualButtonCard( onAction = onAction, modifier = Modifier .fillMaxWidth() - .padding(UiSpacing.CardContent), + .padding(UiSpacing.ContentVertical), ) } } @@ -360,94 +352,151 @@ 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, + snackbar: SnackbarController, audioForwardingSupported: Boolean, - audioCodec: String, - onAudioCodecChange: (String) -> Unit, + cameraMirroringSupported: Boolean, + adbConnecting: Boolean, + isQuickConnected: Boolean, onOpenAdvanced: () -> Unit, onStartStopHaptic: (() -> Unit)? = null, onStart: () -> Unit, onStop: () -> Unit, - sessionStarted: Boolean, + sessionInfo: Scrcpy.Session.SessionInfo?, + onDisconnect: () -> Unit = {}, ) { - 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 taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } + + val sessionStarted = sessionInfo != null + + 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 { + taskScope.launch { + scrcpyOptions.saveBundle(soBundleLatest) + } + } + } + + val audioBitRateVisibility = rememberSaveable(soBundle) { + soBundle.audio && (soBundle.audioCodec == "opus" || soBundle.audioCodec == "aac") + } + + 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) + } - SectionSmallTitle("Scrcpy") Card { SuperSwitch( title = "音频转发", summary = "转发设备音频到本机 (Android 11+)", - checked = audioEnabled, - onCheckedChange = onAudioEnabledChange, - enabled = !sessionStarted && audioForwardingSupported, + checked = soBundle.audio, + onCheckedChange = { soBundle = soBundle.copy(audio = it) }, + enabled = !sessionStarted + && audioForwardingSupported, ) + SuperDropdown( title = "音频编码", summary = "--audio-codec", items = audioCodecItems, selectedIndex = audioCodecIndex, - onSelectedIndexChange = { onAudioCodecChange(AUDIO_CODEC_OPTIONS[it].first) }, - enabled = !sessionStarted && audioEnabled, + onSelectedIndexChange = { + val codec = Codec.AUDIO[it] + soBundle = soBundle.copy(audioCodec = codec.string) + if (codec == Codec.FLAC) { + snackbar.show("注意:FLAC编解码会引入较大的延迟") + } + }, + enabled = !sessionStarted && soBundle.audio, ) - if (audioEnabled && (audioCodec == "opus" || audioCodec == "aac")) { - SuperSlide( - title = "音频码率", - summary = "--audio-bit-rate", - value = audioBitRatePresetIndex.toFloat(), - onValueChange = { value -> - val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex) - onAudioBitRateChange(ScrcpyPresets.AudioBitRate[idx]) - }, - valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(), - steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0), - enabled = !sessionStarted, - unit = "Kbps", - displayText = audioBitRateKbps.toString(), - inputInitialValue = audioBitRateKbps.toString(), + AnimatedVisibility(audioBitRateVisibility) { + 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 = 1f..Float.MAX_VALUE, + inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), onInputConfirm = { raw -> - raw.toIntOrNull()?.takeIf { it > 0 }?.let { onAudioBitRateChange(it) } + raw.toIntOrNull() + ?.takeIf { it >= 0 } + ?.let { soBundle = soBundle.copy(audioBitRate = it * 1000) } }, ) } + SuperDropdown( title = "视频编码", summary = "--video-codec", items = videoCodecItems, selectedIndex = videoCodecIndex, - onSelectedIndexChange = { onVideoCodecChange(VIDEO_CODEC_OPTIONS[it].first) }, + onSelectedIndexChange = { + val codec = Codec.VIDEO[it] + soBundle = soBundle.copy(videoCodec = codec.string) + if (codec == Codec.AV1) { + snackbar.show("注意:绝大部分设备不支持AV1硬件编码") + } + }, enabled = !sessionStarted, ) - SuperSlide( + @SuppressLint("DefaultLocale") + SuperSlider( title = "视频码率", summary = "--video-bit-rate", - value = bitRateMbps, - onValueChange = { - onBitRateSliderChange(it) - onBitRateInputChange(formatBitRate(it)) + value = soBundle.videoBitRate / 1_000_000f, + onValueChange = { mbps -> + 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(bitRateMbps), + 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 -> @@ -462,39 +511,62 @@ internal fun ConfigPanel( } } }, - inputValueRange = 0.1f..Float.MAX_VALUE, + inputValueRange = 0f..UInt.MAX_VALUE.toFloat(), onInputConfirm = { raw -> raw.toFloatOrNull()?.let { parsed -> - if (parsed >= 0.1f) { - onBitRateSliderChange(parsed) - onBitRateInputChange(formatBitRate(parsed)) + if (parsed >= 0f) { + soBundle = soBundle.copy( + videoBitRate = (parsed * 1_000_000f).roundToInt() + ) } } }, + enabled = !sessionStarted, ) + SuperArrow( - title = "高级参数", - summary = "更多 scrcpy 启动参数", + title = "更多参数", + summary = "所有 scrcpy 参数", onClick = onOpenAdvanced, 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() - }, - ) + .padding(horizontal = UiSpacing.ContentVertical) + .padding(bottom = UiSpacing.ContentVertical), + horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), + ) { + 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, + ) + } } } @@ -516,7 +588,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, @@ -526,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) { @@ -544,122 +617,114 @@ private fun PairingDialog( } } - fun clearInputs() { - host = "" - port = "" - code = "" - discoveringPort = false - } - SuperDialog( show = showDialog, title = "使用配对码配对设备", summary = "使用六位数的配对码配对新设备", onDismissRequest = { - clearInputs() onDismissRequest() }, - onDismissFinished = onDismissFinished, + onDismissFinished = { + onDismissFinished() + }, content = { - TextField( - value = host, - onValueChange = { host = it }, - label = "IP 地址", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = UiSpacing.CardContent), - ) - TextField( - value = port, - onValueChange = { port = it.filter(Char::isDigit) }, - label = "端口", - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier - .fillMaxWidth() - .padding(bottom = UiSpacing.CardContent), - ) - TextField( - value = code, - onValueChange = { code = it }, - label = "WLAN 配对码", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = UiSpacing.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 = { - clearInputs() - onDismissRequest() + if (enabled && onDiscoverTarget != null && !discoveringPort) + scope.launch { doDiscover() } }, - modifier = Modifier.weight(1f), - ) - TextButton( - text = "配对", - onClick = { - onConfirm(host.trim(), port.trim(), code.trim()) - clearInputs() - }, - 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(), + ) + } } }, ) } -private fun presetIndexFromInput(raw: String, presets: List): Int { - if (raw.isBlank()) return 0 - val value = raw.toIntOrNull() ?: return 0 - val exact = presets.indexOf(value) - if (exact >= 0) return exact - val nearest = presets.withIndex().minByOrNull { (_, preset) -> kotlin.math.abs(preset - value) } - return nearest?.index ?: 0 -} - -@SuppressLint("DefaultLocale") -private fun formatBitRate(value: Float): String = String.format("%.1f", value) - -@Composable -internal fun LogsPanel(lines: List) { - Card { - TextField( - value = lines.joinToString(separator = "\n"), - onValueChange = {}, - readOnly = true, - modifier = Modifier.fillMaxWidth(), - ) - } -} +/** + * TouchEventHandler + * + * Purpose: + * - Handles touch event processing for fullscreen control screen + * - Manages pointer tracking, coordinate mapping, and touch injection + */ /** * FullscreenControlScreen @@ -679,14 +744,16 @@ internal fun LogsPanel(lines: List) { */ @Composable fun FullscreenControlScreen( - session: ScrcpySessionInfo, + session: Scrcpy.Session.SessionInfo, nativeCore: NativeCoreFacade, onDismiss: () -> Unit, showDebugInfo: Boolean, currentFps: Float, enableBackHandler: Boolean = true, - onInjectTouch: (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit, + 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 +762,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 }, ) { @@ -897,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( @@ -954,27 +878,25 @@ 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() + val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } LaunchedEffect(session, currentSurface) { - if (session != null && currentSurface != null) { - nativeCore.registerVideoSurface(surfaceTag, currentSurface!!) + val surface = currentSurface + if (session != null && surface != null && surface.isValid) { + nativeCore.attachVideoSurface(surface) } - // Unregistration is handled directly in onSurfaceTextureDestroyed and DisposableEffect } DisposableEffect(Unit) { onDispose { - val released = currentSurface - if (released != null) { - nativeCore.unregisterVideoSurface(surfaceTag, released) - released.release() - currentSurface = null + val surface = currentSurface + if (surface != null) { + taskScope.launch { nativeCore.detachVideoSurface(surface, releaseDecoder = false) } } - // If currentSurface is null, onSurfaceTextureDestroyed already handled cleanup } } @@ -988,9 +910,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.attachVideoSurface(newSurface) + } + } } override fun onSurfaceTextureSizeChanged( @@ -1000,11 +928,13 @@ private fun ScrcpyVideoSurface( ) = Unit override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { - val released = currentSurface - currentSurface = null - if (released != null) { - nativeCore.unregisterVideoSurface(surfaceTag, released) - released.release() + val surface = currentSurface + if (surface != null) { + taskScope.launch { + nativeCore.detachVideoSurface(surface, releaseDecoder = false) + } + surface.release() + currentSurface = null } return true } @@ -1020,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, @@ -1079,27 +1031,129 @@ 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, + ) + } } } @Composable internal fun QuickConnectCard( input: String, - onInputChange: (String) -> Unit, + onValueChange: (String) -> Unit, + onFocusChange: (() -> Unit)? = null, onConnect: () -> Unit, onAddDevice: () -> Unit, enabled: Boolean = true, @@ -1108,158 +1162,54 @@ internal fun QuickConnectCard( 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, - ) - } - TextField( - value = input, - onValueChange = { - if (enabled) onInputChange(it) - }, - label = "IP:PORT", - enabled = enabled, - useLabelAsPlaceholder = true, - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.SectionTitleLeadingGap), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), - ) { - TextButton( - text = "添加设备", - onClick = onAddDevice, - modifier = Modifier.weight(1f), - enabled = enabled, - ) - TextButton( - text = "连接", - onClick = onConnect, - modifier = Modifier.weight(1f), - enabled = enabled, - colors = ButtonDefaults.textButtonColorsPrimary(), - ) - } - } -} - -@Composable -internal fun DeviceEditorScreen( - contentPadding: PaddingValues, - device: DeviceShortcut, - onSave: (DeviceShortcut) -> Unit, - onDelete: () -> Unit, - onBack: () -> Unit, -) { - var name by rememberSaveable(device.id) { mutableStateOf(device.name) } - var host by rememberSaveable(device.id) { mutableStateOf(device.host) } - var port by rememberSaveable(device.id) { mutableStateOf(device.port.toString()) } - - BackHandler(enabled = true, onBack = onBack) - - Column( - modifier = Modifier - .fillMaxSize() - .padding(contentPadding) - .padding(UiSpacing.PageHorizontal), - ) { - SectionSmallTitle("编辑设备") - 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() ?: AppDefaults.ADB_PORT - val h = host.trim() - if (h.isNotBlank()) { - onSave( - DeviceShortcut( - id = "$h:$p", - 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 6e0c1b8..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 @@ -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, @@ -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 9756d19..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 @@ -25,15 +25,17 @@ 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 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 import top.yukonga.miuix.kmp.basic.Icon @@ -118,7 +120,7 @@ enum class VirtualButtonAction( "截图", Icons.Rounded.Screenshot, UiAndroidKeycodes.SYSRQ - ), + ); } data class VirtualButtonItem( @@ -133,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(':') @@ -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 -> @@ -296,7 +302,7 @@ class VirtualButtonBar( action.icon, contentDescription = action.title, modifier = Modifier - .padding(end = UiSpacing.CardContent), + .padding(end = UiSpacing.ContentVertical), ) }, title = action.title, @@ -323,7 +329,9 @@ class VirtualButtonBar( dialogMode = false, onSelectedIndexChange = { selectedIdx -> haptics.confirm() - onAction(moreActions[selectedIdx]) + scope.launch { + onAction(moreActions[selectedIdx]) + } }, ) } 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" }