diff --git a/TODO.md b/TODO.md index 923de22..97448bf 100644 --- a/TODO.md +++ b/TODO.md @@ -4,10 +4,6 @@ - Refactoring -## BUGS - -- Figure out is virtual display destroyed at the end of scrcpy session - ## WIDGETS - `SuperTextField` Click to pop a dialog with custom notes/summary diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d448197..76676bc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose.compiler) + id("kotlin-parcelize") } val defaultAbiList = listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt index 48b2a58..5686bd8 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt @@ -4,7 +4,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import io.github.miuzarte.scrcpyforandroid.pages.MainPage +import io.github.miuzarte.scrcpyforandroid.pages.MainScreen import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration import io.github.miuzarte.scrcpyforandroid.storage.Storage import kotlinx.coroutines.runBlocking @@ -25,7 +25,7 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { - MainPage() + MainScreen() } } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt index 27d671b..14e5f21 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt @@ -6,11 +6,11 @@ import android.os.Looper import android.util.Log import android.view.Surface import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder +import io.github.miuzarte.scrcpyforandroid.nativecore.PersistentVideoRenderer import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.util.ArrayDeque -import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArraySet /** @@ -24,11 +24,11 @@ class NativeCoreFacade private constructor() { @Volatile var session: Scrcpy.Session? = null private set - + private val sessionLifecycleMutex = Mutex() - private val surfaceMap = ConcurrentHashMap() - private val surfaceIdentityMap = ConcurrentHashMap() - private val decoderMap = ConcurrentHashMap() + private val renderer = PersistentVideoRenderer() + private var activeSurfaceId: Int? = null + private var decoder: AnnexBDecoder? = null private val videoSizeListeners = CopyOnWriteArraySet<(Int, Int) -> Unit>() private val videoFpsListeners = CopyOnWriteArraySet<(Float) -> Unit>() private val mainHandler = Handler(Looper.getMainLooper()) @@ -45,71 +45,75 @@ class NativeCoreFacade private constructor() { suspend fun close() { sessionLifecycleMutex.withLock { releaseAllDecoders() + renderer.release() } } /** - * Register a rendering Surface for a given `tag`. + * Register the current rendering [surface]. * - * - If the surface is already known and the decoder is active, this is a no-op. + * - If the surface is already active and the decoder exists, this is a no-op. * - If a decoder exists but cannot switch output surface, a new decoder is created * and bound to the supplied surface. - * - This method must be called from the UI thread (it is called by the composable - * that owns the TextureView). Native decoder operations are performed synchronously - * on the UI thread only via the decoder API; heavy work happens inside the decoder. */ - suspend fun registerVideoSurface(tag: String, surface: Surface) { + suspend fun attachVideoSurface(surface: Surface) { sessionLifecycleMutex.withLock { if (!surface.isValid) { - Log.w(TAG, "registerVideoSurface(): skip invalid surface for tag=$tag") + Log.w(TAG, "attachVideoSurface(): skip invalid surface") return } val newId = System.identityHashCode(surface) - val oldId = surfaceIdentityMap[tag] - if (oldId != null && oldId == newId && decoderMap.containsKey(tag)) { + if (activeSurfaceId == newId && decoder != null) { return } - Log.i(TAG, "registerVideoSurface(): tag=$tag surfaceId=$newId oldSurfaceId=$oldId") - surfaceMap[tag] = surface - surfaceIdentityMap[tag] = newId + Log.i(TAG, "attachVideoSurface(): surfaceId=$newId oldSurfaceId=$activeSurfaceId") + activeSurfaceId = newId + renderer.attachDisplaySurface(surface) val session = currentSessionInfo ?: return - // ensureVideoConsumerAttached() - val decoder = decoderMap[tag] - if (decoder != null) { - val switched = decoder.switchOutputSurface(surface) - Log.i(TAG, "registerVideoSurface(): switchOutputSurface tag=$tag success=$switched") + val currentDecoder = decoder + if (currentDecoder != null) { + Log.i(TAG, "attachVideoSurface(): try switch decoder output to persistent surface") + val switched = currentDecoder.switchOutputSurface(renderer.getDecoderSurface()) + Log.i(TAG, "attachVideoSurface(): switchOutputSurface success=$switched") if (switched) { return } } - createOrReplaceDecoder(tag, surface, session) + createOrReplaceDecoder(session) } } /** - * Unregister the rendering Surface previously bound to `tag`. + * Unregister the active rendering [surface]. * * - If a stale surface reference is supplied (identity mismatch), the request is ignored. - * - When there is no active session, the decoder for the tag is released immediately. - * - This protects the native decoder from feeding into a released Surface. + * - When [releaseDecoder] is false, only the active display target is cleared so a future + * surface can attempt to rebind via `setOutputSurface()`. + * - When [releaseDecoder] is true, the current decoder is also released because the backing + * surface is being destroyed for real. */ - suspend fun unregisterVideoSurface(tag: String, surface: Surface? = null) { + suspend fun detachVideoSurface(surface: Surface? = null, releaseDecoder: Boolean = false) { sessionLifecycleMutex.withLock { - val currentId = surfaceIdentityMap[tag] + val currentId = activeSurfaceId val requestId = surface?.let { System.identityHashCode(it) } if (requestId != null && currentId != null && requestId != currentId) { Log.i( TAG, - "unregisterVideoSurface(): skip stale request tag=$tag requestSurfaceId=$requestId currentSurfaceId=$currentId" + "detachVideoSurface(): skip stale request requestSurfaceId=$requestId currentSurfaceId=$currentId" ) return } - Log.i(TAG, "unregisterVideoSurface(): tag=$tag surfaceId=$requestId, releasing decoder") - surfaceMap.remove(tag) - surfaceIdentityMap.remove(tag) - // Always release decoder when surface is unregistered - // This ensures clean state when surface is recreated - decoderMap.remove(tag)?.release() + Log.i( + TAG, + "detachVideoSurface(): surfaceId=$requestId releaseDecoder=$releaseDecoder" + ) + activeSurfaceId = null + renderer.detachDisplaySurface(surface, releaseSurface = false) + if (releaseDecoder) { + Log.i(TAG, "detachVideoSurface(): releasing decoder with destroyed surface") + decoder?.release() + decoder = null + } } } @@ -148,46 +152,29 @@ class NativeCoreFacade private constructor() { bootstrapPackets.clear() latestConfigPacket = null } - - surfaceMap.forEach { (tag, surface) -> - if (!surface.isValid) { - Log.w(TAG, "onScrcpySessionStarted(): skip invalid surface for tag=$tag") - return@forEach - } - Log.i(TAG, "onScrcpySessionStarted(): bind decoder to tag=$tag") - createOrReplaceDecoder(tag, surface, session) + if (activeSurfaceId != null) { + Log.i(TAG, "onScrcpySessionStarted(): bind decoder to persistent surface") + createOrReplaceDecoder(session) } packetCount = 0 - // if (!request.noVideo) { - // ensureVideoConsumerAttached() - // } sessionMgr.attachVideoConsumer { packet -> cacheBootstrapPacket(packet) packetCount += 1 if (packetCount == 1L || packetCount % 120L == 0L) { Log.i( TAG, - "videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoders=${decoderMap.size}" + "videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoder=${decoder != null}" ) } - // Snapshot decoders to avoid feeding released decoders during iteration - val decoders = decoderMap.toMap() - decoders.forEach { (tag, decoder) -> - if (!surfaceIdentityMap.containsKey(tag)) { - return@forEach - } - // Double-check decoder is still in map before feeding - if (decoderMap[tag] != decoder) { - return@forEach - } - runCatching { - decoder.feedAnnexB( - packet.data, - packet.ptsUs, - packet.isKeyFrame, - packet.isConfig - ) - } + val currentDecoder = decoder ?: return@attachVideoConsumer + if (activeSurfaceId == null) return@attachVideoConsumer + runCatching { + currentDecoder.feedAnnexB( + packet.data, + packet.ptsUs, + packet.isKeyFrame, + packet.isConfig + ) } } } @@ -251,7 +238,7 @@ class NativeCoreFacade private constructor() { } /** - * Create or replace a decoder bound to `surface` for `session`. + * Create or replace the active decoder bound to [surface] for [session]. * * - Chooses MIME type from `session.codec` and constructs an [AnnexBDecoder]. * - The decoder's `onOutputSizeChanged` callback publishes size changes to @@ -259,17 +246,15 @@ class NativeCoreFacade private constructor() { * - Newly created decoders are fed with any cached bootstrap packets to allow * faster playback startup. */ - private fun createOrReplaceDecoder(tag: String, surface: Surface, session: Scrcpy.Session.SessionInfo) { - if (!surface.isValid) { - Log.w(TAG, "createOrReplaceDecoder(): skip invalid surface for tag=$tag") - return - } - decoderMap.remove(tag)?.release() + private fun createOrReplaceDecoder(session: Scrcpy.Session.SessionInfo) { + val surface = renderer.getDecoderSurface() + decoder?.release() + decoder = null Log.i( TAG, - "createOrReplaceDecoder(): tag=$tag codec=${session.codecName} size=${session.width}x${session.height}" + "createOrReplaceDecoder(): codec=${session.codecName} size=${session.width}x${session.height} persistent=true" ) - val decoder = AnnexBDecoder( + val newDecoder = AnnexBDecoder( width = session.width, height = session.height, outputSurface = surface, @@ -303,8 +288,8 @@ class NativeCoreFacade private constructor() { } }, ) - decoderMap[tag] = decoder - replayBootstrapPackets(decoder) + decoder = newDecoder + replayBootstrapPackets(newDecoder) } private fun replayBootstrapPackets(decoder: AnnexBDecoder) { @@ -355,8 +340,7 @@ class NativeCoreFacade private constructor() { * * - Called when a session is active and at least one decoder exists. Packets are * cached into [bootstrapPackets] to allow late-attaching decoders to catch up. - * - The consumer iterates [decoderMap] and feeds each decoder. Errors are - * isolated with `runCatching` so one codec failure doesn't stop others. + * - Kept only for documentation parity with the old multi-decoder design. */ @Deprecated("TODO: Determine if this is really unnecessary") private suspend fun ensureVideoConsumerAttached(sessionMgr: Scrcpy.Session) { @@ -366,35 +350,24 @@ class NativeCoreFacade private constructor() { if (packetCount == 1L || packetCount % 120L == 0L) { Log.i( TAG, - "videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoders=${decoderMap.size}" + "videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoder=${decoder != null}" ) } - // Snapshot decoders to avoid feeding released decoders during iteration - val decoders = decoderMap.toMap() - decoders.forEach { (tag, decoder) -> - if (!surfaceIdentityMap.containsKey(tag)) { - return@forEach - } - // Double-check decoder is still in map before feeding - if (decoderMap[tag] != decoder) { - return@forEach - } - runCatching { - decoder.feedAnnexB( - packet.data, - packet.ptsUs, - packet.isKeyFrame, - packet.isConfig - ) - } + val currentDecoder = decoder ?: return@attachVideoConsumer + if (activeSurfaceId == null) return@attachVideoConsumer + runCatching { + currentDecoder.feedAnnexB( + packet.data, + packet.ptsUs, + packet.isKeyFrame, + packet.isConfig + ) } } } private fun releaseAllDecoders() { - decoderMap.values.forEach { decoder -> - runCatching { decoder.release() } - } - decoderMap.clear() + runCatching { decoder?.release() } + decoder = null } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/PersistentVideoRenderer.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/PersistentVideoRenderer.kt new file mode 100644 index 0000000..f6be3e2 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/PersistentVideoRenderer.kt @@ -0,0 +1,359 @@ +package io.github.miuzarte.scrcpyforandroid.nativecore + +import android.graphics.SurfaceTexture +import android.opengl.EGL14 +import android.opengl.EGLConfig +import android.opengl.EGLContext +import android.opengl.EGLDisplay +import android.opengl.EGLSurface +import android.opengl.GLES11Ext +import android.opengl.GLES20 +import android.opengl.Matrix +import android.os.Handler +import android.os.HandlerThread +import android.util.Log +import android.view.Surface +import java.util.concurrent.atomic.AtomicLong + +/** + * Decoder always renders into a persistent SurfaceTexture-backed Surface. + * UI surfaces are display-only targets fed from that persistent texture via EGL. + */ +class PersistentVideoRenderer { + private val tag = "PersistentVideoRenderer" + private val renderThread = HandlerThread("PersistentVideoRenderer").apply { start() } + private val handler = Handler(renderThread.looper) + + @Volatile + private var initialized = false + + @Volatile + private var released = false + + private var eglDisplay: EGLDisplay = EGL14.EGL_NO_DISPLAY + private var eglContext: EGLContext = EGL14.EGL_NO_CONTEXT + private var eglConfig: EGLConfig? = null + private var eglPbufferSurface: EGLSurface = EGL14.EGL_NO_SURFACE + private var displayEglSurface: EGLSurface = EGL14.EGL_NO_SURFACE + private var displaySurface: Surface? = null + private var displaySurfaceId: Int? = null + + private var oesTextureId = 0 + private var decoderSurfaceTexture: SurfaceTexture? = null + private var decoderSurface: Surface? = null + private val stMatrix = FloatArray(16) + private val mvpMatrix = FloatArray(16) + private val frameAvailableCount = AtomicLong(0) + private val frameConsumedCount = AtomicLong(0) + private val frameRenderedCount = AtomicLong(0) + + private var program = 0 + private var positionHandle = 0 + private var texCoordHandle = 0 + private var mvpMatrixHandle = 0 + private var stMatrixHandle = 0 + private var samplerHandle = 0 + + private val initLock = Object() + + fun getDecoderSurface(): Surface { + ensureInitialized() + synchronized(initLock) { + return requireNotNull(decoderSurface) { "decoderSurface not initialized" } + } + } + + fun attachDisplaySurface(surface: Surface) { + ensureInitialized() + val newId = System.identityHashCode(surface) + if (displaySurfaceId == newId) return + Log.i(tag, "attachDisplaySurface(): request surfaceId=$newId old=${displaySurfaceId}") + handler.post { + if (released || !surface.isValid) return@post + releaseDisplaySurfaceLocked() + displaySurface = surface + displaySurfaceId = newId + displayEglSurface = EGL14.eglCreateWindowSurface( + eglDisplay, + eglConfig, + surface, + intArrayOf(EGL14.EGL_NONE), + 0 + ) + Log.i(tag, "attachDisplaySurface(): attached surfaceId=$newId") + drawFrame() + } + } + + fun detachDisplaySurface(surface: Surface? = null, releaseSurface: Boolean = false) { + val requestId = surface?.let { System.identityHashCode(it) } + Log.i(tag, "detachDisplaySurface(): request surfaceId=$requestId releaseSurface=$releaseSurface current=${displaySurfaceId}") + handler.post { + if (released) return@post + if (requestId != null && requestId != displaySurfaceId) return@post + releaseDisplaySurfaceLocked() + if (releaseSurface) { + runCatching { surface?.release() } + } + } + } + + fun release() { + handler.post { + if (released) return@post + released = true + releaseDisplaySurfaceLocked() + runCatching { decoderSurface?.release() } + decoderSurface = null + runCatching { decoderSurfaceTexture?.release() } + decoderSurfaceTexture = null + if (program != 0) { + GLES20.glDeleteProgram(program) + program = 0 + } + if (oesTextureId != 0) { + GLES20.glDeleteTextures(1, intArrayOf(oesTextureId), 0) + oesTextureId = 0 + } + if (eglDisplay !== EGL14.EGL_NO_DISPLAY) { + EGL14.eglMakeCurrent( + eglDisplay, + EGL14.EGL_NO_SURFACE, + EGL14.EGL_NO_SURFACE, + EGL14.EGL_NO_CONTEXT + ) + if (eglPbufferSurface != EGL14.EGL_NO_SURFACE) { + EGL14.eglDestroySurface(eglDisplay, eglPbufferSurface) + } + EGL14.eglDestroyContext(eglDisplay, eglContext) + EGL14.eglTerminate(eglDisplay) + } + eglDisplay = EGL14.EGL_NO_DISPLAY + eglContext = EGL14.EGL_NO_CONTEXT + eglPbufferSurface = EGL14.EGL_NO_SURFACE + eglConfig = null + renderThread.quitSafely() + } + } + + private fun ensureInitialized() { + if (initialized) return + synchronized(initLock) { + if (initialized) return + val latch = java.util.concurrent.CountDownLatch(1) + handler.post { + initializeLocked() + initialized = true + latch.countDown() + } + latch.await() + } + } + + private fun initializeLocked() { + eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY) + check(eglDisplay != EGL14.EGL_NO_DISPLAY) + val version = IntArray(2) + check(EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) + + val configs = arrayOfNulls(1) + val numConfigs = IntArray(1) + val attribs = intArrayOf( + EGL14.EGL_RENDERABLE_TYPE, 4, + EGL14.EGL_RED_SIZE, 8, + EGL14.EGL_GREEN_SIZE, 8, + EGL14.EGL_BLUE_SIZE, 8, + EGL14.EGL_ALPHA_SIZE, 8, + EGL14.EGL_NONE + ) + check(EGL14.eglChooseConfig(eglDisplay, attribs, 0, configs, 0, 1, numConfigs, 0)) + eglConfig = configs[0] + eglContext = EGL14.eglCreateContext( + eglDisplay, + eglConfig, + EGL14.EGL_NO_CONTEXT, + intArrayOf(0x3098, 2, EGL14.EGL_NONE), + 0 + ) + check(eglContext != EGL14.EGL_NO_CONTEXT) + eglPbufferSurface = EGL14.eglCreatePbufferSurface( + eglDisplay, + eglConfig, + intArrayOf(EGL14.EGL_WIDTH, 1, EGL14.EGL_HEIGHT, 1, EGL14.EGL_NONE), + 0 + ) + check(eglPbufferSurface != EGL14.EGL_NO_SURFACE) + check(EGL14.eglMakeCurrent(eglDisplay, eglPbufferSurface, eglPbufferSurface, eglContext)) + + oesTextureId = createExternalTexture() + decoderSurfaceTexture = SurfaceTexture(oesTextureId).apply { + setOnFrameAvailableListener({ + val n = frameAvailableCount.incrementAndGet() + if (n == 1L || n % 120L == 0L) { + Log.i(tag, "onFrameAvailable(): available=$n consumed=${frameConsumedCount.get()} rendered=${frameRenderedCount.get()} display=${displaySurfaceId != null}") + } + drawFrame() + }, handler) + } + decoderSurface = Surface(decoderSurfaceTexture) + Log.i(tag, "initializeLocked(): decoder surface created") + + Matrix.setIdentityM(stMatrix, 0) + Matrix.setIdentityM(mvpMatrix, 0) + Matrix.rotateM(mvpMatrix, 0, 180f, 0f, 0f, 1f) + Matrix.scaleM(mvpMatrix, 0, -1f, 1f, 1f) + program = createProgram(VERTEX_SHADER, FRAGMENT_SHADER) + positionHandle = GLES20.glGetAttribLocation(program, "aPosition") + texCoordHandle = GLES20.glGetAttribLocation(program, "aTexCoord") + mvpMatrixHandle = GLES20.glGetUniformLocation(program, "uMvpMatrix") + stMatrixHandle = GLES20.glGetUniformLocation(program, "uStMatrix") + samplerHandle = GLES20.glGetUniformLocation(program, "sTexture") + } + + private fun drawFrame() { + if (released) return + val surfaceTexture = decoderSurfaceTexture ?: return + if (eglPbufferSurface == EGL14.EGL_NO_SURFACE) return + + // Always consume decoder frames on the persistent context, even if there is no + // visible output surface right now. Otherwise the producer side can stall. + EGL14.eglMakeCurrent(eglDisplay, eglPbufferSurface, eglPbufferSurface, eglContext) + runCatching { surfaceTexture.updateTexImage() } + .onSuccess { + val consumed = frameConsumedCount.incrementAndGet() + if (consumed == 1L || consumed % 120L == 0L) { + Log.i(tag, "drawFrame(): consumed=$consumed available=${frameAvailableCount.get()} rendered=${frameRenderedCount.get()} display=${displaySurfaceId != null}") + } + } + .onFailure { Log.w(tag, "updateTexImage failed", it) } + surfaceTexture.getTransformMatrix(stMatrix) + + if (displayEglSurface == EGL14.EGL_NO_SURFACE) { + return + } + + val width = IntArray(1) + val height = IntArray(1) + EGL14.eglQuerySurface(eglDisplay, displayEglSurface, EGL14.EGL_WIDTH, width, 0) + EGL14.eglQuerySurface(eglDisplay, displayEglSurface, EGL14.EGL_HEIGHT, height, 0) + EGL14.eglMakeCurrent(eglDisplay, displayEglSurface, displayEglSurface, eglContext) + GLES20.glViewport(0, 0, width[0].coerceAtLeast(1), height[0].coerceAtLeast(1)) + GLES20.glClearColor(0f, 0f, 0f, 1f) + GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT) + GLES20.glUseProgram(program) + GLES20.glUniformMatrix4fv(mvpMatrixHandle, 1, false, mvpMatrix, 0) + GLES20.glUniformMatrix4fv(stMatrixHandle, 1, false, stMatrix, 0) + GLES20.glUniform1i(samplerHandle, 0) + + VERTICES.position(0) + GLES20.glVertexAttribPointer(positionHandle, 2, GLES20.GL_FLOAT, false, 16, VERTICES) + GLES20.glEnableVertexAttribArray(positionHandle) + VERTICES.position(2) + GLES20.glVertexAttribPointer(texCoordHandle, 2, GLES20.GL_FLOAT, false, 16, VERTICES) + GLES20.glEnableVertexAttribArray(texCoordHandle) + + GLES20.glActiveTexture(GLES20.GL_TEXTURE0) + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, oesTextureId) + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4) + EGL14.eglSwapBuffers(eglDisplay, displayEglSurface) + val rendered = frameRenderedCount.incrementAndGet() + if (rendered == 1L || rendered % 120L == 0L) { + Log.i(tag, "drawFrame(): rendered=$rendered consumed=${frameConsumedCount.get()} available=${frameAvailableCount.get()} viewport=${width[0]}x${height[0]}") + } + } + + private fun releaseDisplaySurfaceLocked() { + if (displayEglSurface != EGL14.EGL_NO_SURFACE) { + EGL14.eglDestroySurface(eglDisplay, displayEglSurface) + displayEglSurface = EGL14.EGL_NO_SURFACE + } + if (displaySurfaceId != null) { + Log.i(tag, "releaseDisplaySurfaceLocked(): surfaceId=$displaySurfaceId") + } + displaySurface = null + displaySurfaceId = null + } + + private fun createExternalTexture(): Int { + val textures = IntArray(1) + GLES20.glGenTextures(1, textures, 0) + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textures[0]) + GLES20.glTexParameteri( + GLES11Ext.GL_TEXTURE_EXTERNAL_OES, + GLES20.GL_TEXTURE_MIN_FILTER, + GLES20.GL_LINEAR + ) + GLES20.glTexParameteri( + GLES11Ext.GL_TEXTURE_EXTERNAL_OES, + GLES20.GL_TEXTURE_MAG_FILTER, + GLES20.GL_LINEAR + ) + GLES20.glTexParameteri( + GLES11Ext.GL_TEXTURE_EXTERNAL_OES, + GLES20.GL_TEXTURE_WRAP_S, + GLES20.GL_CLAMP_TO_EDGE + ) + GLES20.glTexParameteri( + GLES11Ext.GL_TEXTURE_EXTERNAL_OES, + GLES20.GL_TEXTURE_WRAP_T, + GLES20.GL_CLAMP_TO_EDGE + ) + return textures[0] + } + + private fun createProgram(vertexShader: String, fragmentShader: String): Int { + val vertex = compileShader(GLES20.GL_VERTEX_SHADER, vertexShader) + val fragment = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentShader) + return GLES20.glCreateProgram().also { program -> + GLES20.glAttachShader(program, vertex) + GLES20.glAttachShader(program, fragment) + GLES20.glLinkProgram(program) + } + } + + private fun compileShader(type: Int, source: String): Int { + return GLES20.glCreateShader(type).also { shader -> + GLES20.glShaderSource(shader, source) + GLES20.glCompileShader(shader) + } + } + + companion object { + private val VERTICES = java.nio.ByteBuffer.allocateDirect(4 * 4 * 4) + .order(java.nio.ByteOrder.nativeOrder()) + .asFloatBuffer() + .apply { + put( + floatArrayOf( + -1f, -1f, 0f, 1f, + 1f, -1f, 1f, 1f, + -1f, 1f, 0f, 0f, + 1f, 1f, 1f, 0f, + ) + ) + position(0) + } + + private const val VERTEX_SHADER = """ + attribute vec4 aPosition; + attribute vec4 aTexCoord; + uniform mat4 uMvpMatrix; + uniform mat4 uStMatrix; + varying vec2 vTexCoord; + void main() { + gl_Position = uMvpMatrix * aPosition; + vTexCoord = (uStMatrix * aTexCoord).xy; + } + """ + + private const val FRAGMENT_SHADER = """ + #extension GL_OES_EGL_image_external : require + precision mediump float; + varying vec2 vTexCoord; + uniform samplerExternalOES sTexture; + void main() { + gl_FragColor = texture2D(sTexture, vTexCoord); + } + """ + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt index 0859957..4fd720f 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt @@ -1,5 +1,6 @@ package io.github.miuzarte.scrcpyforandroid.pages +import android.annotation.SuppressLint import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -11,13 +12,18 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -30,125 +36,215 @@ import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.Crop import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.NewDisplay -import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn +import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy -import io.github.miuzarte.scrcpyforandroid.scrcpy.ServerParams import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared -import io.github.miuzarte.scrcpyforandroid.storage.Storage +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec +import io.github.miuzarte.scrcpyforandroid.storage.Settings +import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions +import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.ScrollBehavior +import top.yukonga.miuix.kmp.basic.SnackbarHost import top.yukonga.miuix.kmp.basic.SnackbarHostState import top.yukonga.miuix.kmp.basic.SpinnerEntry import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.TextField +import top.yukonga.miuix.kmp.basic.TopAppBar import top.yukonga.miuix.kmp.extra.SuperArrow import top.yukonga.miuix.kmp.extra.SuperDropdown import top.yukonga.miuix.kmp.extra.SuperSpinner import top.yukonga.miuix.kmp.extra.SuperSwitch import kotlin.math.roundToInt +@Composable +internal fun AdvancedConfigScreen( + onBack: () -> Unit, + scrollBehavior: ScrollBehavior, + snack: SnackbarHostState, + scrcpy: Scrcpy, +) { + Scaffold( + topBar = { + TopAppBar( + title = "高级参数", + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "返回" + ) + } + }, + scrollBehavior = scrollBehavior, + ) + }, + snackbarHost = { SnackbarHost(snack) }, + ) { contentPadding -> + AdvancedConfigPage( + contentPadding = contentPadding, + scrollBehavior = scrollBehavior, + snack = snack, + scrcpy = scrcpy, + ) + } +} + @Composable internal fun AdvancedConfigPage( contentPadding: PaddingValues, scrollBehavior: ScrollBehavior, - snackbarHostState: SnackbarHostState, + snack: SnackbarHostState, scrcpy: Scrcpy, ) { - val scrcpyOptions = Storage.scrcpyOptions - val focusManager = LocalFocusManager.current - val scope = rememberCoroutineScope() - var refreshBusy by remember { mutableStateOf(false) } - // TODO: handle custom value - // TODO: handle empty input - var turnScreenOff by scrcpyOptions.turnScreenOff.asMutableState() - var control by scrcpyOptions.control.asMutableState() - var video by scrcpyOptions.video.asMutableState() + var refreshBusy by rememberSaveable { mutableStateOf(false) } - var videoSource by scrcpyOptions.videoSource.asMutableState() - val videoSourceItems = remember { Shared.VideoSource.entries.map { it.string } } - val videoSourceIndex = remember(videoSource) { - Shared.VideoSource.entries.indexOfFirst { it.string == videoSource }.coerceAtLeast(0) + val soBundleShared by scrcpyOptions.bundleState.collectAsState() + val soBundleSharedLatest by rememberUpdatedState(soBundleShared) + var soBundle by rememberSaveable(soBundleShared) { mutableStateOf(soBundleShared) } + val soBundleLatest by rememberUpdatedState(soBundle) + LaunchedEffect(soBundleShared) { + if (soBundle != soBundleShared) { + soBundle = soBundleShared + } } - var displayId by scrcpyOptions.displayId.asMutableState() - - var cameraId by scrcpyOptions.cameraId.asMutableState() - var cameraFacing by scrcpyOptions.cameraFacing.asMutableState() - val cameraFacingItems = remember { - listOf("默认") + Shared.CameraFacing.entries.drop(1).map { it.string } + LaunchedEffect(soBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (soBundle != soBundleSharedLatest) { + scrcpyOptions.saveBundle(soBundle) + } } - val cameraFacingIndex = remember(cameraFacing) { - if (cameraFacing.isEmpty()) { + DisposableEffect(Unit) { + onDispose { + scope.launch { + scrcpyOptions.saveBundle(soBundleLatest) + } + } + } + + val audioCodecItems = rememberSaveable { Codec.AUDIO.map { it.displayName } } + val audioCodecIndex = rememberSaveable(soBundle) { + Codec.AUDIO + .indexOfFirst { it.string == soBundle.audioCodec } + .coerceAtLeast(0) + } + + val videoCodecItems = rememberSaveable { Codec.VIDEO.map { it.displayName } } + val videoCodecIndex = rememberSaveable(soBundle) { + Codec.VIDEO + .indexOfFirst { it.string == soBundle.videoCodec } + .coerceAtLeast(0) + } + + val videoSourceItems = rememberSaveable { Shared.VideoSource.entries.map { it.string } } + val videoSourceIndex = rememberSaveable(soBundle) { + Shared.VideoSource.entries + .indexOfFirst { it.string == soBundle.videoSource } + .coerceAtLeast(0) + } + + var displayIdInput by rememberSaveable(soBundle) { + mutableStateOf( + if (soBundle.displayId == -1) "" + else soBundle.displayId.toString() + ) + } + + var cameraIdInput by rememberSaveable(soBundle) { + mutableStateOf(soBundle.cameraId) + } + + val cameraFacingItems = rememberSaveable { + listOf("默认") + Shared.CameraFacing.entries + .drop(1) + .map { it.string } + } + val cameraFacingIndex = rememberSaveable(soBundle) { + if (soBundle.cameraFacing.isEmpty()) { 0 } else { - val idx = Shared.CameraFacing.entries.indexOfFirst { it.string == cameraFacing } + val idx = Shared.CameraFacing.entries + .indexOfFirst { it.string == soBundle.cameraFacing } if (idx > 0) idx else 0 } } - var cameraSize by scrcpyOptions.cameraSize.asMutableState() - var cameraSizeCustom by scrcpyOptions.cameraSizeCustom.asMutableState() - var cameraSizeUseCustom by scrcpyOptions.cameraSizeUseCustom.asMutableState() + var cameraSizeCustomInput by rememberSaveable(soBundle) { + mutableStateOf(soBundle.cameraSizeCustom) + } - var cameraSizeCustomInput by rememberSaveable { mutableStateOf(cameraSizeCustom) } val cameraSizeDropdownItems = rememberSaveable(scrcpy.cameraSizes) { listOf("自动", "自定义") + scrcpy.cameraSizes } - var cameraSizeDropdownIndex by rememberSaveable { + var cameraSizeDropdownIndex by rememberSaveable(soBundle, scrcpy.cameraSizes) { mutableIntStateOf( when { - cameraSizeUseCustom -> 1 // "自定义" - cameraSize.isEmpty() -> 0 // "自动" - cameraSize in scrcpy.cameraSizes -> scrcpy.cameraSizes.indexOf(cameraSize) + 2 + soBundle.cameraSizeUseCustom -> 1 // "自定义" + soBundle.cameraSize.isEmpty() -> 0 // "自动" + soBundle.cameraSize in scrcpy.cameraSizes -> + scrcpy.cameraSizes.indexOf(soBundle.cameraSize) + 2 + else -> 0 // 默认自动 } ) } - var cameraAr by scrcpyOptions.cameraAr.asMutableState() - var cameraFps by scrcpyOptions.cameraFps.asMutableState() - val cameraFpsPresetIndex = ScrcpyPresets.CameraFps.indexOfOrNearest(cameraFps) - var cameraHighSpeed by scrcpyOptions.cameraHighSpeed.asMutableState() + var cameraArInput by rememberSaveable(soBundle) { + mutableStateOf(soBundle.cameraAr) + } - var audioSource by scrcpyOptions.audioSource.asMutableState() - val audioSourceItems = remember { + val cameraFpsPresetIndex = rememberSaveable(soBundle) { + ScrcpyPresets.CameraFps.indexOfOrNearest(soBundle.cameraFps) + } + + val audioSourceItems = rememberSaveable { Shared.AudioSource.entries.map { it.string } } - val audioSourceIndex = remember(audioSource) { - Shared.AudioSource.entries.indexOfFirst { it.string == audioSource }.coerceAtLeast(0) + val audioSourceIndex = rememberSaveable(soBundle) { + Shared.AudioSource.entries + .indexOfFirst { it.string == soBundle.audioSource } + .coerceAtLeast(0) } - var audioDup by scrcpyOptions.audioDup.asMutableState() - var audioPlayback by scrcpyOptions.audioPlayback.asMutableState() - var requireAudio by scrcpyOptions.requireAudio.asMutableState() - var maxSize by scrcpyOptions.maxSize.asMutableState() - val maxSizePresetIndex = ScrcpyPresets.MaxSize.indexOfOrNearest(maxSize) - var maxFps by scrcpyOptions.maxFps.asMutableState() - val maxFpsPresetIndex = ScrcpyPresets.MaxFPS.indexOfOrNearest(maxFps.toIntOrNull() ?: 0) + val maxSizePresetIndex = rememberSaveable(soBundle) { + ScrcpyPresets.MaxSize.indexOfOrNearest(soBundle.maxSize) + } - var videoEncoder by scrcpyOptions.videoEncoder.asMutableState() - var videoCodecOptions by scrcpyOptions.videoCodecOptions.asMutableState() - var audioEncoder by scrcpyOptions.audioEncoder.asMutableState() - var audioCodecOptions by scrcpyOptions.audioCodecOptions.asMutableState() + val maxFpsPresetIndex = rememberSaveable(soBundle) { + ScrcpyPresets.MaxFPS.indexOfOrNearest(soBundle.maxFps.toIntOrNull() ?: 0) + } - val videoEncoderDropdownItems = remember(scrcpy.videoEncoders) { + var videoCodecOptionsInput by rememberSaveable(soBundle) { + mutableStateOf(soBundle.videoCodecOptions) + } + + var audioCodecOptionsInput by rememberSaveable(soBundle) { + mutableStateOf(soBundle.audioCodecOptions) + } + + val videoEncoderDropdownItems = rememberSaveable(scrcpy.videoEncoders) { listOf("") + scrcpy.videoEncoders } - val videoEncoderIndex = remember(videoEncoder, scrcpy.videoEncoders) { - (scrcpy.videoEncoders.indexOf(videoEncoder) + 1).coerceAtLeast(0) + val videoEncoderIndex = rememberSaveable(soBundle, scrcpy.videoEncoders) { + (scrcpy.videoEncoders.indexOf(soBundle.videoEncoder) + 1).coerceAtLeast(0) } - val audioEncoderDropdownItems = remember(scrcpy.audioEncoders) { + + val audioEncoderDropdownItems = rememberSaveable(scrcpy.audioEncoders) { listOf("") + scrcpy.audioEncoders } - val audioEncoderIndex = remember(audioEncoder, scrcpy.audioEncoders) { - (scrcpy.audioEncoders.indexOf(audioEncoder) + 1).coerceAtLeast(0) + val audioEncoderIndex = rememberSaveable(soBundle, scrcpy.audioEncoders) { + (scrcpy.audioEncoders.indexOf(soBundle.audioEncoder) + 1).coerceAtLeast(0) } + val videoEncoderEntries = videoEncoderDropdownItems.map { encoderName -> if (encoderName == "") { SpinnerEntry(title = "自动") @@ -159,6 +255,7 @@ internal fun AdvancedConfigPage( ) } } + val audioEncoderEntries = audioEncoderDropdownItems.map { encoderName -> if (encoderName == "") { SpinnerEntry(title = "自动") @@ -171,68 +268,56 @@ internal fun AdvancedConfigPage( } // [x][/] - var newDisplay by scrcpyOptions.newDisplay.asMutableState() - val (width, height, dpi) = NewDisplay.parseFrom(newDisplay) - var newDisplayWidth by remember(newDisplay) { mutableStateOf(width?.toString() ?: "") } - var newDisplayHeight by remember(newDisplay) { mutableStateOf(height?.toString() ?: "") } - var newDisplayDpi by remember(newDisplay) { mutableStateOf(dpi?.toString() ?: "") } - fun updateNewDisplay() { - newDisplay = NewDisplay - .parseFrom(newDisplayWidth, newDisplayHeight, newDisplayDpi) - .toString() + val (ndWidth, ndHeight, ndDpi) = remember(soBundle) { + NewDisplay.parseFrom(soBundle.newDisplay) + } + var newDisplayWidthInput by rememberSaveable(soBundle) { + mutableStateOf(ndWidth?.toString() ?: "") + } + var newDisplayHeightInput by rememberSaveable(soBundle) { + mutableStateOf(ndHeight?.toString() ?: "") + } + var newDisplayDpiInput by rememberSaveable(soBundle) { + mutableStateOf(ndDpi?.toString() ?: "") } // width:height:x:y - var crop by scrcpyOptions.crop.asMutableState() - val (cWidth, cHeight, cX, cY) = Crop.parseFrom(crop) - var cropWidth by remember(crop) { mutableStateOf(cWidth?.toString() ?: "") } - var cropHeight by remember(crop) { mutableStateOf(cHeight?.toString() ?: "") } - var cropX by remember(crop) { mutableStateOf(cX?.toString() ?: "") } - var cropY by remember(crop) { mutableStateOf(cY?.toString() ?: "") } - fun updateCrop() { - crop = Crop - .parseFrom(cropWidth, cropHeight, cropX, cropY) - .toString() + val (cWidth, cHeight, cX, cY) = remember(soBundle) { + Crop.parseFrom(soBundle.crop) + } + var cropWidthInput by rememberSaveable(soBundle) { + mutableStateOf(cWidth?.toString() ?: "") + } + var cropHeightInput by rememberSaveable(soBundle) { + mutableStateOf(cHeight?.toString() ?: "") + } + var cropXInput by rememberSaveable(soBundle) { + mutableStateOf(cX?.toString() ?: "") + } + var cropYInput by rememberSaveable(soBundle) { + mutableStateOf(cY?.toString() ?: "") } - var serverParamsPreview by rememberSaveable { - mutableStateOf(runBlocking { - scrcpyOptions - .toClientOptions() - .toServerParams(0u) - .toList(simplify = true) - .joinToString(ServerParams.SEPARATOR) - }) - } - - // 监听所有选项变化,自动更新 serverParams 预览 - LaunchedEffect( - turnScreenOff, control, video, - videoSource, displayId, - cameraId, cameraFacing, cameraSize, cameraAr, cameraFps, cameraHighSpeed, - audioSource, audioDup, audioPlayback, requireAudio, - maxSize, maxFps, - videoEncoder, videoCodecOptions, - audioEncoder, audioCodecOptions, - newDisplay, crop, - ) { - val clientOptions = scrcpyOptions.toClientOptions() + var serverParamsPreview by rememberSaveable { mutableStateOf("") } + // 监听选项变化, 自动更新预览 + LaunchedEffect(soBundle) { + val clientOptions = scrcpyOptions.toClientOptions(soBundle) try { clientOptions.validate() } catch (e: IllegalArgumentException) { - snackbarHostState.showSnackbar("Invalid options: ${e.message}") + snack.showSnackbar("Invalid options: ${e.message}") return@LaunchedEffect } serverParamsPreview = clientOptions .toServerParams(0u) .toList(simplify = true) - .joinToString(ServerParams.SEPARATOR) + // improve readability using hard line breaks + .joinToString("\n") } - // 高级参数 - AppPageLazyColumn( + LazyColumn( contentPadding = contentPadding, scrollBehavior = scrollBehavior, ) { @@ -245,33 +330,143 @@ internal fun AdvancedConfigPage( modifier = Modifier.fillMaxWidth(), ) } + } + item { Card { SuperSwitch( title = "启动后关闭屏幕", summary = "--turn-screen-off", - checked = turnScreenOff, + checked = soBundle.turnScreenOff, onCheckedChange = { - turnScreenOff = it + soBundle = soBundle.copy(turnScreenOff = it) if (it) scope.launch { // github.com/Genymobile/scrcpy/issues/3376 // github.com/Genymobile/scrcpy/issues/4587 // github.com/Genymobile/scrcpy/issues/5676 - snackbarHostState.showSnackbar("注意:大部分设备在关闭屏幕后刷新率会降低/减半") + snack.showSnackbar("注意:大部分设备在关闭屏幕后刷新率会降低/减半") } }, ) SuperSwitch( title = "禁用控制", summary = "--no-control", - checked = !control, - onCheckedChange = { control = !it }, + checked = !soBundle.control, + onCheckedChange = { soBundle = soBundle.copy(control = !it) }, + // 拦不住同时点, 弃用 + // enabled = audio || video, ) SuperSwitch( title = "禁用视频", summary = "--no-video", - checked = !video, - onCheckedChange = { video = !it }, + checked = !soBundle.video, + onCheckedChange = { soBundle = soBundle.copy(video = !it) }, + // enabled = audio || control, + ) + SuperSwitch( + title = "禁用音频", + summary = "--no-audio", + checked = !soBundle.audio, + onCheckedChange = { soBundle = soBundle.copy(audio = !it) }, + // enabled = control || video, + ) + } + } + + item { + Card { + SuperDropdown( + title = "音频编码", + summary = "--audio-codec", + items = audioCodecItems, + selectedIndex = audioCodecIndex, + onSelectedIndexChange = { + soBundle = soBundle.copy(audioCodec = Codec.AUDIO[it].string) + }, + ) + SuperSlider( + title = "音频码率", + summary = "--audio-bit-rate", + value = if (soBundle.audioBitRate <= 0) 0f + else (ScrcpyPresets.AudioBitRate + .indexOfOrNearest(soBundle.audioBitRate / 1000) + 1 + ).toFloat(), + onValueChange = { value -> + val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.size) + soBundle = soBundle.copy( + audioBitRate = + if (idx == 0) 0 + else ScrcpyPresets.AudioBitRate[idx - 1] * 1000 + ) + }, + valueRange = 0f..ScrcpyPresets.AudioBitRate.size.toFloat(), + steps = (ScrcpyPresets.AudioBitRate.size - 1).coerceAtLeast(0), + unit = "Kbps", + zeroStateText = "默认", + displayText = (soBundle.audioBitRate / 1_000).toString(), + inputInitialValue = + if (soBundle.audioBitRate <= 0) "" + else (soBundle.audioBitRate / 1_000).toString(), + inputFilter = { it.filter(Char::isDigit) }, + inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), + onInputConfirm = { raw -> + raw.toIntOrNull() + ?.takeIf { it >= 0 } + ?.let { soBundle = soBundle.copy(audioBitRate = it * 1000) } + }, + ) + + SuperDropdown( + title = "视频编码", + summary = "--video-codec", + items = videoCodecItems, + selectedIndex = videoCodecIndex, + onSelectedIndexChange = { + soBundle = soBundle.copy(videoCodec = Codec.VIDEO[it].string) + }, + ) + @SuppressLint("DefaultLocale") + SuperSlider( + title = "视频码率", + summary = "--video-bit-rate", + value = soBundle.videoBitRate / 1_000_000f, + onValueChange = { mbps -> + soBundle = soBundle.copy( + videoBitRate = (mbps * 10).roundToInt() * (1_000_000 / 10) + ) + }, + valueRange = 0f..40f, + steps = 400 - 1, + unit = "Mbps", + zeroStateText = "默认", + displayFormatter = { String.format("%.1f", it) }, + inputInitialValue = + if (soBundle.videoBitRate <= 0) "" + else String.format("%.1f", soBundle.videoBitRate / 1_000_000f), + inputFilter = { text -> + var dotUsed = false + text.filter { ch -> + when { + ch.isDigit() -> true + ch == '.' && !dotUsed -> { + dotUsed = true + true + } + + else -> false + } + } + }, + inputValueRange = 0f..UInt.MAX_VALUE.toFloat(), + onInputConfirm = { raw -> + raw.toFloatOrNull()?.let { parsed -> + if (parsed >= 0f) { + soBundle = soBundle.copy( + videoBitRate = (parsed * 1_000_000f).roundToInt() + ) + } + } + }, ) } } @@ -284,13 +479,18 @@ internal fun AdvancedConfigPage( items = videoSourceItems, selectedIndex = videoSourceIndex, onSelectedIndexChange = { - videoSource = Shared.VideoSource.entries[it].string + soBundle = soBundle.copy( + videoSource = Shared.VideoSource.entries[it].string + ) }, ) - AnimatedVisibility(videoSource == "display") { - TextField( - value = if (displayId == -1) "" else displayId.toString(), - onValueChange = { displayId = it.toIntOrNull() ?: -1 }, + AnimatedVisibility(soBundle.videoSource == "display") { + SuperTextField( + value = displayIdInput, + onValueChange = { displayIdInput = it }, + onFocusLost = { + soBundle = soBundle.copy(displayId = displayIdInput.toIntOrNull() ?: -1) + }, label = "--display-id", singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), @@ -300,10 +500,13 @@ internal fun AdvancedConfigPage( .padding(bottom = UiSpacing.CardContent), ) } - AnimatedVisibility(videoSource == "camera") { - TextField( - value = cameraId, - onValueChange = { cameraId = it }, + AnimatedVisibility(soBundle.videoSource == "camera") { + SuperTextField( + value = cameraIdInput, + onValueChange = { cameraIdInput = it }, + onFocusLost = { + soBundle = soBundle.copy(cameraId = cameraIdInput) + }, label = "--camera-id", singleLine = true, modifier = Modifier @@ -312,7 +515,7 @@ internal fun AdvancedConfigPage( .padding(bottom = UiSpacing.CardContent), ) } - AnimatedVisibility(videoSource == "camera") { + AnimatedVisibility(soBundle.videoSource == "camera") { SuperArrow( title = "重新获取 Camera Sizes", summary = "--list-camera-sizes", @@ -322,9 +525,9 @@ internal fun AdvancedConfigPage( refreshBusy = true try { scrcpy.refreshCameraSizes() - snackbarHostState.showSnackbar("Camera Sizes 已刷新") + snack.showSnackbar("Camera Sizes 已刷新") } catch (e: Exception) { - snackbarHostState.showSnackbar("刷新失败: ${e.message}") + snack.showSnackbar("刷新失败: ${e.message}") } finally { refreshBusy = false } @@ -332,19 +535,22 @@ internal fun AdvancedConfigPage( }, ) } - AnimatedVisibility(videoSource == "camera") { + AnimatedVisibility(soBundle.videoSource == "camera") { SuperDropdown( title = "摄像头朝向", summary = "--camera-facing", items = cameraFacingItems, selectedIndex = cameraFacingIndex, onSelectedIndexChange = { - cameraFacing = - if (it == 0) "" else Shared.CameraFacing.entries[it].string + soBundle = soBundle.copy( + cameraFacing = + if (it == 0) "" + else Shared.CameraFacing.entries[it].string + ) }, ) } - AnimatedVisibility(videoSource == "camera") { + AnimatedVisibility(soBundle.videoSource == "camera") { SuperDropdown( title = "摄像头分辨率", summary = "--camera-size", @@ -352,24 +558,30 @@ internal fun AdvancedConfigPage( selectedIndex = cameraSizeDropdownIndex, onSelectedIndexChange = { cameraSizeDropdownIndex = it - cameraSizeUseCustom = it == 1 when (it) { 0 -> { // "自动" - cameraSize = "" + soBundle = soBundle.copy( + cameraSize = "", + cameraSizeUseCustom = false, + ) cameraSizeCustomInput = "" } 1 -> { // "自定义" - 进入自定义输入模式 - cameraSizeCustomInput = cameraSize.takeIf { size -> - size.isNotEmpty() && size !in scrcpy.cameraSizes - } ?: "" + soBundle = soBundle.copy( + cameraSizeUseCustom = true, + ) + cameraSizeCustomInput = soBundle.cameraSize } else -> { // 选择列表中的实际分辨率 - cameraSize = cameraSizeDropdownItems[it] + soBundle = soBundle.copy( + cameraSize = cameraSizeDropdownItems[it], + cameraSizeUseCustom = false, + ) cameraSizeCustomInput = "" } } @@ -377,17 +589,18 @@ internal fun AdvancedConfigPage( ) } // 只在选择"自定义"时显示输入框 - AnimatedVisibility(videoSource == "camera" && cameraSizeUseCustom) { + AnimatedVisibility(soBundle.videoSource == "camera" && soBundle.cameraSizeUseCustom) { SuperTextField( value = cameraSizeCustomInput, onValueChange = { cameraSizeCustomInput = it }, onFocusLost = { if (cameraSizeCustomInput in scrcpy.cameraSizes) { + // 输入的值存在于列表中, 取消自定义输入 cameraSizeDropdownIndex = scrcpy.cameraSizes.indexOf(cameraSizeCustomInput) + 2 - cameraSizeUseCustom = false + soBundle = soBundle.copy(cameraSizeUseCustom = false) } else { - cameraSizeCustom = cameraSizeCustomInput + soBundle = soBundle.copy(cameraSizeCustom = cameraSizeCustomInput) } }, label = "--camera-size", @@ -399,10 +612,11 @@ internal fun AdvancedConfigPage( .padding(bottom = UiSpacing.CardContent), ) } - AnimatedVisibility(videoSource == "camera") { - TextField( - value = cameraAr, - onValueChange = { cameraAr = it }, + AnimatedVisibility(soBundle.videoSource == "camera") { + SuperTextField( + value = cameraArInput, + onValueChange = { cameraArInput = it }, + onFocusLost = { soBundle = soBundle.copy(cameraAr = cameraArInput) }, label = "--camera-ar", singleLine = true, modifier = Modifier @@ -411,15 +625,15 @@ internal fun AdvancedConfigPage( .padding(bottom = UiSpacing.CardContent), ) } - AnimatedVisibility(videoSource == "camera") { + AnimatedVisibility(soBundle.videoSource == "camera") { SuperSlider( title = "摄像头帧率", summary = "--camera-fps", value = cameraFpsPresetIndex.toFloat(), onValueChange = { value -> - val idx = - value.roundToInt().coerceIn(0, ScrcpyPresets.CameraFps.lastIndex) - cameraFps = ScrcpyPresets.CameraFps[idx] + val idx = value.roundToInt() + .coerceIn(0, ScrcpyPresets.CameraFps.lastIndex) + soBundle = soBundle.copy(cameraFps = ScrcpyPresets.CameraFps[idx]) }, valueRange = 0f..ScrcpyPresets.CameraFps.lastIndex.toFloat(), steps = (ScrcpyPresets.CameraFps.size - 2).coerceAtLeast(0), @@ -428,24 +642,24 @@ internal fun AdvancedConfigPage( showUnitWhenZeroState = false, showKeyPoints = true, keyPoints = ScrcpyPresets.CameraFps.indices.map { it.toFloat() }, - displayText = cameraFps.toString(), - inputHint = "0 或留空表示默认", - inputInitialValue = cameraFps.toString(), + displayText = soBundle.cameraFps.toString(), + inputSummary = "0 或留空表示默认", + inputInitialValue = soBundle.cameraFps.toString(), inputFilter = { it.filter(Char::isDigit) }, inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), - onInputConfirm = { input -> - input.toIntOrNull() - ?.let { cameraFps = it } - ?: run { cameraFps = 0 } + onInputConfirm = { + soBundle = soBundle.copy( + cameraFps = it.toIntOrNull() ?: run { 0 } + ) }, ) } - AnimatedVisibility(videoSource == "camera") { + AnimatedVisibility(soBundle.videoSource == "camera") { SuperSwitch( title = "高帧率模式", summary = "--camera-high-speed", - checked = cameraHighSpeed, - onCheckedChange = { cameraHighSpeed = it }, + checked = soBundle.cameraHighSpeed, + onCheckedChange = { soBundle = soBundle.copy(cameraHighSpeed = it) }, ) } } @@ -459,27 +673,28 @@ internal fun AdvancedConfigPage( items = audioSourceItems, selectedIndex = audioSourceIndex, onSelectedIndexChange = { - audioSource = Shared.AudioSource.entries[it].string + soBundle = soBundle.copy( + audioSource = Shared.AudioSource.entries[it].string + ) }, ) SuperSwitch( title = "音频双路输出", summary = "--audio-dup", - checked = audioDup, - onCheckedChange = { audioDup = it }, + checked = soBundle.audioDup, + onCheckedChange = { soBundle = soBundle.copy(audioDup = it) }, ) SuperSwitch( title = "仅转发不播放", summary = "--no-audio-playback", - checked = !audioPlayback, - onCheckedChange = { audioPlayback = !it }, + checked = !soBundle.audioPlayback, + onCheckedChange = { soBundle = soBundle.copy(audioPlayback = !it) }, ) SuperSwitch( - title = "音频失败时终止 [TODO]", + title = "音频失败时终止", summary = "--require-audio", - checked = requireAudio, - onCheckedChange = { requireAudio = it }, - enabled = false, + checked = soBundle.requireAudio, + onCheckedChange = { soBundle = soBundle.copy(requireAudio = it) }, ) } } @@ -490,10 +705,10 @@ internal fun AdvancedConfigPage( title = "最大分辨率", summary = "--max-size", value = maxSizePresetIndex.toFloat(), - onValueChange = { value -> - val idx = - value.roundToInt().coerceIn(0, ScrcpyPresets.MaxSize.lastIndex) - maxSize = ScrcpyPresets.MaxSize[idx] + onValueChange = { + val idx = it.roundToInt() + .coerceIn(0, ScrcpyPresets.MaxSize.lastIndex) + soBundle = soBundle.copy(maxSize = ScrcpyPresets.MaxSize[idx]) }, valueRange = 0f..ScrcpyPresets.MaxSize.lastIndex.toFloat(), steps = (ScrcpyPresets.MaxSize.size - 2).coerceAtLeast(0), @@ -502,20 +717,30 @@ internal fun AdvancedConfigPage( showUnitWhenZeroState = false, showKeyPoints = true, keyPoints = ScrcpyPresets.MaxSize.indices.map { it.toFloat() }, - displayText = maxSize.toString(), - inputHint = "0 或留空表示关闭", - inputInitialValue = maxSize.toString(), + displayText = soBundle.maxSize.toString(), + inputTitle = "最大分辨率 (px)", + inputSummary = "0 或留空表示关闭", + inputInitialValue = soBundle.maxSize.takeIf { it != 0 }?.toString() ?: "", inputFilter = { it.filter(Char::isDigit) }, inputValueRange = 0f..UInt.MAX_VALUE.toFloat(), - onInputConfirm = { input -> input.toIntOrNull()?.let { maxSize = it } }, + onInputConfirm = { + soBundle = soBundle.copy( + maxSize = it.toIntOrNull() ?: run { 0 } + ) + }, ) SuperSlider( title = "最大帧率", summary = "--max-fps", value = maxFpsPresetIndex.toFloat(), onValueChange = { value -> - val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxFPS.lastIndex) - maxFps = if (idx == 0) "" else ScrcpyPresets.MaxFPS[idx].toString() + val idx = value.roundToInt() + .coerceIn(0, ScrcpyPresets.MaxFPS.lastIndex) + soBundle = soBundle.copy( + maxFps = + if (idx == 0) "" + else ScrcpyPresets.MaxFPS[idx].toString() + ) }, valueRange = 0f..ScrcpyPresets.MaxFPS.lastIndex.toFloat(), steps = (ScrcpyPresets.MaxFPS.size - 2).coerceAtLeast(0), @@ -524,12 +749,13 @@ internal fun AdvancedConfigPage( showUnitWhenZeroState = false, showKeyPoints = true, keyPoints = ScrcpyPresets.MaxFPS.indices.map { it.toFloat() }, - displayText = maxFps, - inputHint = "0 或留空表示关闭", - inputInitialValue = maxFps, + displayText = soBundle.maxFps, + inputTitle = "最大帧率 (FPS)", + inputSummary = "0 或留空表示关闭", + inputInitialValue = soBundle.maxFps, inputFilter = { it.filter(Char::isDigit) }, inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), - onInputConfirm = { maxFps = it }, + onInputConfirm = { soBundle = soBundle.copy(maxFps = it) }, ) } } @@ -545,9 +771,9 @@ internal fun AdvancedConfigPage( refreshBusy = true try { scrcpy.refreshEncoders() - snackbarHostState.showSnackbar("编码器列表已刷新") + snack.showSnackbar("编码器列表已刷新") } catch (e: Exception) { - snackbarHostState.showSnackbar("刷新失败: ${e.message}") + snack.showSnackbar("刷新失败: ${e.message}") } finally { refreshBusy = false } @@ -560,12 +786,15 @@ internal fun AdvancedConfigPage( items = videoEncoderEntries, selectedIndex = videoEncoderIndex, onSelectedIndexChange = { - videoEncoder = videoEncoderEntries[it].title ?: "" + soBundle = soBundle.copy(videoEncoder = videoEncoderEntries[it].title ?: "") }, ) - TextField( - value = videoCodecOptions, - onValueChange = { videoCodecOptions = it }, + SuperTextField( + value = videoCodecOptionsInput, + onValueChange = { videoCodecOptionsInput = it }, + onFocusLost = { + soBundle = soBundle.copy(videoCodecOptions = videoCodecOptionsInput) + }, label = "--video-codec-options", singleLine = true, modifier = Modifier @@ -579,12 +808,15 @@ internal fun AdvancedConfigPage( items = audioEncoderEntries, selectedIndex = audioEncoderIndex, onSelectedIndexChange = { - audioEncoder = audioEncoderEntries[it].title ?: "" + soBundle = soBundle.copy(audioEncoder = audioEncoderEntries[it].title ?: "") }, ) - TextField( - value = audioCodecOptions, - onValueChange = { audioCodecOptions = it }, + SuperTextField( + value = audioCodecOptionsInput, + onValueChange = { audioCodecOptionsInput = it }, + onFocusLost = { + soBundle = soBundle.copy(audioCodecOptions = audioCodecOptionsInput) + }, label = "--audio-codec-options", singleLine = true, modifier = Modifier @@ -614,10 +846,21 @@ internal fun AdvancedConfigPage( .padding(bottom = UiSpacing.CardContent), horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), ) { - TextField( + SuperTextField( label = "width", - value = newDisplayWidth, - onValueChange = { newDisplayWidth = it; updateNewDisplay() }, + value = newDisplayWidthInput, + onValueChange = { newDisplayWidthInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + newDisplay = NewDisplay + .parseFrom( + newDisplayWidthInput, + newDisplayHeightInput, + newDisplayDpiInput + ) + .toString() + ) + }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -628,10 +871,21 @@ internal fun AdvancedConfigPage( ), modifier = Modifier.weight(1f), ) - TextField( + SuperTextField( label = "height", - value = newDisplayHeight, - onValueChange = { newDisplayHeight = it; updateNewDisplay() }, + value = newDisplayHeightInput, + onValueChange = { newDisplayHeightInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + newDisplay = NewDisplay + .parseFrom( + newDisplayWidthInput, + newDisplayHeightInput, + newDisplayDpiInput + ) + .toString() + ) + }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -642,10 +896,21 @@ internal fun AdvancedConfigPage( ), modifier = Modifier.weight(1f), ) - TextField( + SuperTextField( label = "dpi", - value = newDisplayDpi, - onValueChange = { newDisplayDpi = it; updateNewDisplay() }, + value = newDisplayDpiInput, + onValueChange = { newDisplayDpiInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + newDisplay = NewDisplay + .parseFrom( + newDisplayWidthInput, + newDisplayHeightInput, + newDisplayDpiInput + ) + .toString() + ) + }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -683,10 +948,22 @@ internal fun AdvancedConfigPage( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), ) { - TextField( + SuperTextField( label = "width", - value = cropWidth, - onValueChange = { cropWidth = it; updateCrop() }, + value = cropWidthInput, + onValueChange = { cropWidthInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + crop = Crop + .parseFrom( + cropWidthInput, + cropHeightInput, + cropXInput, + cropYInput + ) + .toString() + ) + }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -697,10 +974,22 @@ internal fun AdvancedConfigPage( ), modifier = Modifier.weight(1f), ) - TextField( + SuperTextField( label = "height", - value = cropHeight, - onValueChange = { cropHeight = it; updateCrop() }, + value = cropHeightInput, + onValueChange = { cropHeightInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + crop = Crop + .parseFrom( + cropWidthInput, + cropHeightInput, + cropXInput, + cropYInput + ) + .toString() + ) + }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -716,10 +1005,22 @@ internal fun AdvancedConfigPage( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), ) { - TextField( + SuperTextField( label = "x", - value = cropX, - onValueChange = { cropX = it; updateCrop() }, + value = cropXInput, + onValueChange = { cropXInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + crop = Crop + .parseFrom( + cropWidthInput, + cropHeightInput, + cropXInput, + cropYInput + ) + .toString() + ) + }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -730,10 +1031,22 @@ internal fun AdvancedConfigPage( ), modifier = Modifier.weight(1f), ) - TextField( + SuperTextField( label = "y", - value = cropY, - onValueChange = { cropY = it; updateCrop() }, + value = cropYInput, + onValueChange = { cropYInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + crop = Crop + .parseFrom( + cropWidthInput, + cropHeightInput, + cropXInput, + cropYInput + ) + .toString() + ) + }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt index c5240e3..cf5c7ed 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt @@ -4,20 +4,26 @@ import android.annotation.SuppressLint import android.util.Log import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade import io.github.miuzarte.scrcpyforandroid.constants.Defaults import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing @@ -26,20 +32,21 @@ import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService -import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn +import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.services.EventLogger import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo -import io.github.miuzarte.scrcpyforandroid.storage.Storage +import io.github.miuzarte.scrcpyforandroid.storage.Settings +import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings +import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices +import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel import io.github.miuzarte.scrcpyforandroid.widgets.DeviceEditorScreen import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTile -import io.github.miuzarte.scrcpyforandroid.widgets.LogsPanel import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard import io.github.miuzarte.scrcpyforandroid.widgets.QuickConnectCard -import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle import io.github.miuzarte.scrcpyforandroid.widgets.StatusCard import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction @@ -51,9 +58,21 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.DropdownImpl +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.ListPopupColumn +import top.yukonga.miuix.kmp.basic.ListPopupDefaults +import top.yukonga.miuix.kmp.basic.PopupPositionProvider +import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.ScrollBehavior import top.yukonga.miuix.kmp.basic.SnackbarHostState -import top.yukonga.miuix.kmp.extra.SuperBottomSheet +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.basic.TextField +import top.yukonga.miuix.kmp.basic.TopAppBar +import top.yukonga.miuix.kmp.extra.SuperListPopup +import top.yukonga.miuix.kmp.theme.MiuixTheme import java.net.InetSocketAddress import java.net.Socket @@ -66,6 +85,71 @@ private const val ADB_TCP_PROBE_TIMEOUT_MS = 500 @Composable fun DeviceTabScreen( + nativeCore: NativeCoreFacade, + adbService: NativeAdbService, + scrcpy: Scrcpy, + snack: SnackbarHostState, + scrollBehavior: ScrollBehavior, + onOpenVirtualButtonOrder: () -> Unit, + onSessionStartedChange: (Boolean) -> Unit, + onOpenReorderDevices: () -> Unit, + onOpenAdvancedPage: () -> Unit, + onOpenFullscreenPage: (Scrcpy.Session.SessionInfo) -> Unit, +) { + var showThreePointMenu by rememberSaveable { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = "设备", + actions = { + IconButton( + onClick = { showThreePointMenu = true }, + holdDownState = showThreePointMenu, + ) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = "更多" + ) + } + DeviceMenuPopup( + show = showThreePointMenu, + onDismissRequest = { showThreePointMenu = false }, + onReorderDevices = { + onOpenReorderDevices() + showThreePointMenu = false + }, + onOpenVirtualButtonOrder = { + onOpenVirtualButtonOrder() + showThreePointMenu = false + }, + canClearLogs = EventLogger.hasLogs(), + onClearLogs = { + EventLogger.clearLogs() + showThreePointMenu = false + }, + ) + }, + scrollBehavior = scrollBehavior, + ) + }, + ) { pagePadding -> + DeviceTabPage( + contentPadding = pagePadding, + nativeCore = nativeCore, + adbService = adbService, + scrcpy = scrcpy, + snack = snack, + scrollBehavior = scrollBehavior, + onSessionStartedChange = onSessionStartedChange, + onOpenAdvancedPage = onOpenAdvancedPage, + onOpenFullscreenPage = onOpenFullscreenPage, + ) + } +} + +@Composable +fun DeviceTabPage( contentPadding: PaddingValues, nativeCore: NativeCoreFacade, adbService: NativeAdbService, @@ -73,26 +157,60 @@ fun DeviceTabScreen( snack: SnackbarHostState, scrollBehavior: ScrollBehavior, onSessionStartedChange: (Boolean) -> Unit, - onClearLogsActionChange: ((() -> Unit)) -> Unit, - onCanClearLogsChange: (Boolean) -> Unit, - onOpenReorderDevicesActionChange: ((() -> Unit)) -> Unit, onOpenAdvancedPage: () -> Unit, onOpenFullscreenPage: (Scrcpy.Session.SessionInfo) -> Unit, ) { - val appSettings = Storage.appSettings - val quickDevices = Storage.quickDevices - val scrcpyOptions = Storage.scrcpyOptions - - val context = LocalContext.current - + val scope = rememberCoroutineScope() val haptics = rememberAppHaptics() - val virtualButtonsLayout by appSettings.virtualButtonsLayout.asState() - val virtualButtonLayout = remember(virtualButtonsLayout) { - VirtualButtonActions.splitLayout(VirtualButtonActions.parseStoredLayout(virtualButtonsLayout)) + val asBundleShared by appSettings.bundleState.collectAsState() + val asBundleSharedLatest by rememberUpdatedState(asBundleShared) + var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) } + val asBundleLatest by rememberUpdatedState(asBundle) + LaunchedEffect(asBundleShared) { + if (asBundle != asBundleShared) { + asBundle = asBundleShared + } } - // val initialSettings = remember(context) { loadDevicePageSettings(context) } - val scope = rememberCoroutineScope() + LaunchedEffect(asBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (asBundle != asBundleSharedLatest) { + appSettings.saveBundle(asBundle) + } + } + DisposableEffect(Unit) { + onDispose { + scope.launch { + appSettings.saveBundle(asBundleLatest) + } + } + } + + val qdBundleShared by quickDevices.bundleState.collectAsState() + val qdBundleSharedLatest by rememberUpdatedState(qdBundleShared) + var qdBundle by rememberSaveable(qdBundleShared) { mutableStateOf(qdBundleShared) } + val qdBundleLatest by rememberUpdatedState(qdBundle) + LaunchedEffect(qdBundleShared) { + if (qdBundle != qdBundleShared) { + qdBundle = qdBundleShared + } + } + LaunchedEffect(qdBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (qdBundle != qdBundleSharedLatest) { + quickDevices.saveBundle(qdBundle) + } + } + DisposableEffect(Unit) { + onDispose { + scope.launch { + quickDevices.saveBundle(qdBundleLatest) + } + } + } + + // read only + val soBundleShared by scrcpyOptions.bundleState.collectAsState() // Run adb operations on a dedicated single thread. // Try to avoid blocking UI/recomposition and keeps adb call ordering deterministic. @@ -138,7 +256,6 @@ fun DeviceTabScreen( var previewControlsVisible by rememberSaveable { mutableStateOf(false) } var editingDevice by rememberSaveable { mutableStateOf(null) } var activeDeviceActionId by rememberSaveable { mutableStateOf(null) } - var showReorderSheet by rememberSaveable { mutableStateOf(false) } var adbConnecting by rememberSaveable { mutableStateOf(false) } var audioForwardingSupported by rememberSaveable { mutableStateOf(true) } @@ -146,34 +263,32 @@ fun DeviceTabScreen( val currentTarget = if (currentTargetHost.isNotBlank()) - ConnectionTarget( - currentTargetHost, - currentTargetPort, - ) else null + ConnectionTarget(currentTargetHost, currentTargetPort) + else null val sessionReconnectBlacklistHosts = remember { mutableSetOf() } - LaunchedEffect(EventLogger.eventLog.size) { - onCanClearLogsChange(EventLogger.hasLogs()) + val virtualButtonLayout = remember(asBundle.virtualButtonsLayout) { + VirtualButtonActions.splitLayout( + VirtualButtonActions.parseStoredLayout(asBundle.virtualButtonsLayout) + ) } - var quickDevicesList by quickDevices.quickDevicesList.asMutableState() - var savedShortcuts by remember { mutableStateOf(DeviceShortcuts.unmarshalFrom(quickDevicesList)) } + var savedShortcuts by remember { + mutableStateOf(DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList)) + } - LaunchedEffect(quickDevicesList) { - savedShortcuts = DeviceShortcuts.unmarshalFrom(quickDevicesList) + LaunchedEffect(qdBundle.quickDevicesList) { + savedShortcuts = DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList) } // save changes when [savedShortcuts] was modified LaunchedEffect(savedShortcuts) { val serialized = savedShortcuts.marshalToString() - if (serialized != quickDevicesList) { - quickDevicesList = serialized + if (serialized != qdBundle.quickDevicesList) { + qdBundle = qdBundle.copy(quickDevicesList = serialized) } } - var quickConnectInput by quickDevices.quickConnectInput.asMutableState() - var quickConnectInputTemp by remember(quickConnectInput) { mutableStateOf(quickConnectInput) } - /** * Disconnect the current ADB connection and stop any running scrcpy session. * @@ -236,14 +351,13 @@ fun DeviceTabScreen( disconnectAdbConnection(clearQuickOnlineForTarget = current) } - var audio by scrcpyOptions.audio.asMutableState() - var videoSource by scrcpyOptions.videoSource.asMutableState() - fun applyConnectedDeviceCapabilities(sdkInt: Int, release: String) { val audioSupported = sdkInt !in 0..<30 audioForwardingSupported = audioSupported - if (!audioSupported && audio) { - audio = false + if (!audioSupported && soBundleShared.audio) { + scope.launch { + scrcpyOptions.updateBundle { it.copy(audio = false) } + } logEvent( "设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持音频转发,已自动关闭", Log.WARN @@ -251,8 +365,10 @@ fun DeviceTabScreen( } val cameraSupported = sdkInt !in 0..<31 cameraMirroringSupported = cameraSupported - if (!cameraSupported && videoSource == "camera") { - videoSource = "display" + if (!cameraSupported && soBundleShared.videoSource == "camera") { + scope.launch { + scrcpyOptions.updateBundle { it.copy(videoSource = "display") } + } logEvent( "设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持 camera mirroring,已切换为 display", Log.WARN @@ -405,20 +521,17 @@ fun DeviceTabScreen( } } - var videoEncoder by scrcpyOptions.videoEncoder.asMutableState() - var audioEncoder by scrcpyOptions.audioEncoder.asMutableState() - suspend fun refreshEncoderLists() { if (!adbConnected) return runCatching { scrcpy.refreshEncoders() }.onSuccess { // Validate current selections - if (videoEncoder.isNotBlank() && videoEncoder !in scrcpy.videoEncoders) { - videoEncoder = "" + if (soBundleShared.videoEncoder.isNotBlank() && soBundleShared.videoEncoder !in scrcpy.videoEncoders) { + scrcpyOptions.updateBundle { it.copy(videoEncoder = "") } } - if (audioEncoder.isNotBlank() && audioEncoder !in scrcpy.audioEncoders) { - audioEncoder = "" + if (soBundleShared.audioEncoder.isNotBlank() && soBundleShared.audioEncoder !in scrcpy.audioEncoders) { + scrcpyOptions.updateBundle { it.copy(audioEncoder = "") } } logEvent("编码器列表已刷新: video=${scrcpy.videoEncoders.size} audio=${scrcpy.audioEncoders.size}") if (scrcpy.videoEncoders.isEmpty() && scrcpy.audioEncoders.isEmpty()) { @@ -436,16 +549,18 @@ fun DeviceTabScreen( } } - var cameraSize by scrcpyOptions.cameraSize.asMutableState() - suspend fun refreshCameraSizeLists() { if (!adbConnected) return runCatching { scrcpy.refreshCameraSizes() }.onSuccess { // Validate current selection - if (cameraSize.isNotBlank() && cameraSize != "custom" && cameraSize !in scrcpy.cameraSizes) { - cameraSize = "" + if ( + soBundleShared.cameraSize.isNotBlank() + && soBundleShared.cameraSize != "custom" + && soBundleShared.cameraSize !in scrcpy.cameraSizes + ) { + scrcpyOptions.updateBundle { it.copy(cameraSize = "") } } logEvent("camera sizes 已刷新: count=${scrcpy.cameraSizes.size}") }.onFailure { e -> @@ -543,9 +658,9 @@ fun DeviceTabScreen( } } - val adbPairingAutoDiscoverOnDialogOpen by appSettings.adbPairingAutoDiscoverOnDialogOpen.asState() - val adbAutoReconnectPairedDevice by appSettings.adbAutoReconnectPairedDevice.asState() - val adbMdnsLanDiscovery by appSettings.adbMdnsLanDiscovery.asState() + val adbPairingAutoDiscoverOnDialogOpen = asBundle.adbPairingAutoDiscoverOnDialogOpen + val adbAutoReconnectPairedDevice = asBundle.adbAutoReconnectPairedDevice + val adbMdnsLanDiscovery = asBundle.adbMdnsLanDiscovery LaunchedEffect(adbConnected, adbAutoReconnectPairedDevice, adbMdnsLanDiscovery) { if (adbConnected || !adbAutoReconnectPairedDevice) return@LaunchedEffect @@ -676,44 +791,6 @@ fun DeviceTabScreen( onSessionStartedChange(sessionInfo != null) } - DisposableEffect(Unit) { - onClearLogsActionChange { - EventLogger.clearLogs() - } - onOpenReorderDevicesActionChange { - showReorderSheet = true - } - onDispose { - // canClearLogs 是 DevicePage 特有的状态,需要重置 - onCanClearLogsChange(false) - } - } - - SuperBottomSheet( - show = showReorderSheet, - title = "快速设备排序", - onDismissRequest = { showReorderSheet = false }, - ) { - val list = remember { - ReorderableList( - itemsProvider = { - savedShortcuts.map { device -> - ReorderableList.Item( - id = device.id, - title = device.name.ifBlank { device.host }, - subtitle = "${device.host}:${device.port}", - ) - } - }, - onSettle = { fromIndex, toIndex -> - savedShortcuts = savedShortcuts.move(fromIndex, toIndex) - }, - ) - } - list() - Spacer(Modifier.height(UiSpacing.BottomSheetBottom)) - } - fun sendVirtualButtonAction(action: VirtualButtonAction) { val keycode = action.keycode ?: return runBusy("发送 ${action.title}") { @@ -744,14 +821,14 @@ fun DeviceTabScreen( return } - val devicePreviewCardHeightDp by appSettings.devicePreviewCardHeightDp.asState() - val previewVirtualButtonShowText by appSettings.previewVirtualButtonShowText.asState() + val devicePreviewCardHeightDp = asBundle.devicePreviewCardHeightDp + val previewVirtualButtonShowText = asBundle.previewVirtualButtonShowText - val audioBitRate by scrcpyOptions.audioBitRate.asState() - val videoBitRate by scrcpyOptions.videoBitRate.asState() + val audioBitRate = soBundleShared.audioBitRate + val videoBitRate = soBundleShared.videoBitRate // 设备 - AppPageLazyColumn( + LazyColumn( contentPadding = contentPadding, scrollBehavior = scrollBehavior, ) { @@ -831,26 +908,27 @@ fun DeviceTabScreen( if (!adbConnected) item { // "快速连接" QuickConnectCard( - input = quickConnectInputTemp, + input = qdBundle.quickConnectInput, onValueChange = { - quickConnectInputTemp = it - quickConnectInput = quickConnectInputTemp + qdBundle = qdBundle.copy(quickConnectInput = it) }, - // onFocusChange = { quickConnectInput = quickConnectInputTemp }, enabled = !adbConnecting, onAddDevice = { - val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp) + val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput) ?: return@QuickConnectCard savedShortcuts = savedShortcuts.upsert( DeviceShortcut(host = target.host, port = target.port) ) - Log.d("SavedShortcuts", "size: ${savedShortcuts.size}, list: $quickDevicesList") + Log.d( + "SavedShortcuts", + "size: ${savedShortcuts.size}, list: ${qdBundle.quickDevicesList}" + ) scope.launch { snack.showSnackbar("已添加设备: ${target.host}:${target.port}") } }, onConnect = { - val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp) + val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput) ?: return@QuickConnectCard runAdbConnect( "连接 ADB", @@ -914,13 +992,15 @@ fun DeviceTabScreen( item { ConfigPanel( busy = busy, + snack = snack, audioForwardingSupported = audioForwardingSupported, cameraMirroringSupported = cameraMirroringSupported, + isQuickConnected = isQuickConnected, onOpenAdvanced = onOpenAdvancedPage, onStartStopHaptic = { haptics.contextClick() }, onStart = { runBusy("启动 scrcpy") { - val options = scrcpyOptions.toClientOptions() + val options = scrcpyOptions.toClientOptions(soBundleShared) val session = scrcpy.start(options) sessionInfo = session.copy( host = currentTargetHost, @@ -928,18 +1008,17 @@ fun DeviceTabScreen( ) statusLine = "scrcpy 运行中" @SuppressLint("DefaultLocale") - val videoDetail = if (!options.video) { - "off" - } else { - "${session.codecName} ${session.width}x${session.height} " + + val videoDetail = + if (!options.video) "off" + else if (videoBitRate <= 0) "${session.codecName} ${session.width}x${session.height} @default" + else "${session.codecName} ${session.width}x${session.height} " + "@${String.format("%.1f", videoBitRate / 1_000_000f)}Mbps" - } - val audioDetail = if (!audio) { - "off" - } else { - val playback = if (!options.audioPlayback) "(no-playback)" else "" - "${options.audioCodec} ${videoBitRate / 1_000f}Kbps source=${options.audioSource}$playback" - } + + val audioDetail = + if (!soBundleShared.audio) "off" + else if (audioBitRate <= 0) "${options.audioCodec} default source=${options.audioSource}" + else "${options.audioCodec} ${audioBitRate / 1_000f}Kbps source=${options.audioSource}${if (!options.audioPlayback) "(no-playback)" else ""}" + logEvent( "scrcpy 已启动: device=${session.deviceName}" + ", video=$videoDetail, audio=$audioDetail" + @@ -1017,9 +1096,91 @@ fun DeviceTabScreen( if (EventLogger.hasLogs()) item { Spacer(Modifier.height(UiSpacing.PageItem)) - LogsPanel(lines = EventLogger.eventLog) + Card { + TextField( + value = EventLogger.eventLog.joinToString(separator = "\n"), + onValueChange = {}, + readOnly = true, + modifier = Modifier.fillMaxWidth(), + ) + } } item { Spacer(Modifier.height(UiSpacing.BottomContent)) } } } + +@Composable +private fun DeviceMenuPopup( + show: Boolean, + onDismissRequest: () -> Unit, + onReorderDevices: () -> Unit, + onOpenVirtualButtonOrder: () -> Unit, + canClearLogs: Boolean, + onClearLogs: () -> Unit, +) { + SuperListPopup( + show = show, + popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider, + alignment = PopupPositionProvider.Align.TopEnd, + onDismissRequest = onDismissRequest, + enableWindowDim = false, + ) { + ListPopupColumn { + DeviceMenuPopupItem( + text = "快速设备排序", + optionSize = 3, + index = 0, + onClick = onReorderDevices, + ) + DeviceMenuPopupItem( + text = "虚拟按钮排序", + optionSize = 3, + index = 1, + onClick = onOpenVirtualButtonOrder, + ) + DeviceMenuPopupItem( + text = "清空日志", + optionSize = 3, + index = 2, + enabled = canClearLogs, + onClick = onClearLogs, + ) + } + } +} + +@Composable +private fun DeviceMenuPopupItem( + text: String, + optionSize: Int, + index: Int, + enabled: Boolean = true, + // TODO: (Int) -> Unit + onClick: () -> Unit, +) { + if (enabled) { + DropdownImpl( + text = text, + optionSize = optionSize, + isSelected = false, + index = index, + onSelectedIndexChange = { onClick() }, + ) + return + } + + val additionalTopPadding = if (index == 0) UiSpacing.PopupHorizontal else UiSpacing.PageItem + val additionalBottomPadding = + if (index == optionSize - 1) UiSpacing.PopupHorizontal else UiSpacing.PageItem + Text( + text = text, + fontSize = MiuixTheme.textStyles.body1.fontSize, + fontWeight = FontWeight.Medium, + color = MiuixTheme.colorScheme.disabledOnSecondaryVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = UiSpacing.PopupHorizontal) + .padding(top = additionalTopPadding, bottom = additionalBottomPadding), + ) +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt similarity index 63% rename from app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt rename to app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt index 4b38956..2c62473 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt @@ -8,10 +8,15 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -22,43 +27,65 @@ import androidx.core.view.WindowInsetsControllerCompat import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy -import io.github.miuzarte.scrcpyforandroid.storage.Storage +import io.github.miuzarte.scrcpyforandroid.storage.Settings +import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import top.yukonga.miuix.kmp.basic.Scaffold -data class FullscreenControlLaunch( - val deviceName: String, - val width: Int, - val height: Int, - val codec: String, -) - @Composable -fun FullscreenControlPage( - launch: FullscreenControlLaunch, +fun FullscreenControlScreen( + onBack: () -> Unit, + session: Scrcpy.Session.SessionInfo, nativeCore: NativeCoreFacade, onVideoSizeChanged: (width: Int, height: Int) -> Unit, - onDismiss: () -> Unit, ) { // Disable predictive back handler temporarily to avoid decoding issues. - BackHandler(enabled = true, onBack = onDismiss) + BackHandler(enabled = true, onBack = onBack) - val appSettings = Storage.appSettings val context = LocalContext.current val haptics = rememberAppHaptics() + val scope = rememberCoroutineScope() val activity = remember(context) { context as? Activity } - var virtualButtonsLayout by appSettings.virtualButtonsLayout.asMutableState() - val buttonItems = remember(virtualButtonsLayout) { - VirtualButtonActions.splitLayout(VirtualButtonActions.parseStoredLayout(virtualButtonsLayout)) + val asBundleShared by appSettings.bundleState.collectAsState() + val asBundleSharedLatest by rememberUpdatedState(asBundleShared) + var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) } + val asBundleLatest by rememberUpdatedState(asBundle) + LaunchedEffect(asBundleShared) { + if (asBundle != asBundleShared) { + asBundle = asBundleShared + } } - val fullscreenDebugInfo by appSettings.fullscreenDebugInfo.asState() - val showFullscreenVirtualButtons by appSettings.showFullscreenVirtualButtons.asState() + LaunchedEffect(asBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (asBundle != asBundleSharedLatest) { + appSettings.saveBundle(asBundle) + } + } + DisposableEffect(Unit) { + onDispose { + scope.launch { + appSettings.saveBundle(asBundleLatest) + } + } + } + + val buttonItems = remember(asBundle.virtualButtonsLayout) { + VirtualButtonActions.splitLayout( + VirtualButtonActions.parseStoredLayout(asBundle.virtualButtonsLayout) + ) + } + val fullscreenDebugInfo = asBundle.fullscreenDebugInfo + val showFullscreenVirtualButtons = asBundle.showFullscreenVirtualButtons val bar = remember(buttonItems) { VirtualButtonBar( @@ -66,19 +93,7 @@ fun FullscreenControlPage( moreActions = buttonItems.second, ) } - var session by remember(launch) { - mutableStateOf( - Scrcpy.Session.SessionInfo( - width = launch.width, - height = launch.height, - deviceName = launch.deviceName.ifBlank { "设备" }, - codecId = 0, - codecName = launch.codec.ifBlank { "unknown" }, - audioCodecId = 0, - controlEnabled = true, - ), - ) - } + var currentFps by remember { mutableFloatStateOf(0f) } DisposableEffect(activity) { @@ -103,7 +118,6 @@ fun FullscreenControlPage( DisposableEffect(nativeCore) { val listener: (Int, Int) -> Unit = { w, h -> - session = session.copy(width = w, height = h) onVideoSizeChanged(w, h) } nativeCore.addVideoSizeListener(listener) @@ -124,8 +138,10 @@ fun FullscreenControlPage( suspend fun sendKeycode(keycode: Int) { runCatching { - nativeCore.session?.injectKeycode(0, keycode) - nativeCore.session?.injectKeycode(1, keycode) + withContext(Dispatchers.IO) { + nativeCore.session?.injectKeycode(0, keycode) + nativeCore.session?.injectKeycode(1, keycode) + } }.onFailure { e -> android.util.Log.w("FullscreenControlPage", "sendKeycode failed for keycode=$keycode", e) } @@ -140,22 +156,24 @@ fun FullscreenControlPage( FullscreenControlScreen( session = session, nativeCore = nativeCore, - onDismiss = onDismiss, + onDismiss = onBack, showDebugInfo = fullscreenDebugInfo, currentFps = currentFps, enableBackHandler = false, onInjectTouch = { action, pointerId, x, y, pressure, buttons -> - nativeCore.session?.injectTouch( - action = action, - pointerId = pointerId, - x = x, - y = y, - screenWidth = session.width, - screenHeight = session.height, - pressure = pressure, - actionButton = 0, - buttons = buttons, - ) + withContext(Dispatchers.IO) { + nativeCore.session?.injectTouch( + action = action, + pointerId = pointerId, + x = x, + y = y, + screenWidth = session.width, + screenHeight = session.height, + pressure = pressure, + actionButton = 0, + buttons = buttons, + ) + } }, ) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt deleted file mode 100644 index 3f38849..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt +++ /dev/null @@ -1,550 +0,0 @@ -package io.github.miuzarte.scrcpyforandroid.pages - -import android.app.Activity -import android.content.Intent -import android.content.pm.ActivityInfo -import android.net.Uri -import android.os.SystemClock -import android.view.WindowManager -import android.widget.Toast -import androidx.activity.compose.BackHandler -import androidx.activity.compose.PredictiveBackHandler -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.core.spring -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.rounded.Devices -import androidx.compose.material.icons.rounded.MoreVert -import androidx.compose.material.icons.rounded.Settings -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.saveable.rememberSaveableStateHolder -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.navigation3.runtime.NavKey -import androidx.navigation3.runtime.entryProvider -import androidx.navigation3.runtime.rememberDecoratedNavEntries -import androidx.navigation3.ui.NavDisplay -import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade -import io.github.miuzarte.scrcpyforandroid.constants.UiMotion -import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing -import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService -import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy -import io.github.miuzarte.scrcpyforandroid.storage.AppSettings -import io.github.miuzarte.scrcpyforandroid.storage.Storage -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import top.yukonga.miuix.kmp.basic.DropdownImpl -import top.yukonga.miuix.kmp.basic.Icon -import top.yukonga.miuix.kmp.basic.IconButton -import top.yukonga.miuix.kmp.basic.ListPopupColumn -import top.yukonga.miuix.kmp.basic.ListPopupDefaults -import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior -import top.yukonga.miuix.kmp.basic.NavigationBar -import top.yukonga.miuix.kmp.basic.NavigationBarItem -import top.yukonga.miuix.kmp.basic.PopupPositionProvider -import top.yukonga.miuix.kmp.basic.Scaffold -import top.yukonga.miuix.kmp.basic.SnackbarHost -import top.yukonga.miuix.kmp.basic.SnackbarHostState -import top.yukonga.miuix.kmp.basic.Text -import top.yukonga.miuix.kmp.basic.TopAppBar -import top.yukonga.miuix.kmp.extra.SuperListPopup -import top.yukonga.miuix.kmp.theme.MiuixTheme -import top.yukonga.miuix.kmp.theme.ThemeController - -private enum class MainTabDestination( - val title: String, - val label: String, - val icon: ImageVector, -) { - Device(title = "设备", label = "设备", icon = Icons.Rounded.Devices), - Settings(title = "设置", label = "设置", icon = Icons.Rounded.Settings); -} - -private sealed interface RootScreen : NavKey { - data object Home : RootScreen - data object Advanced : RootScreen - data object VirtualButtonOrder : RootScreen - data class Fullscreen(val launch: FullscreenControlLaunch) : RootScreen -} - -@Composable -fun MainPage() { - val context = LocalContext.current - val appContext = context.applicationContext - val scope = rememberCoroutineScope() - - val activity = remember(context) { context as? Activity } - val initialOrientation = remember(activity) { - activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED - } - DisposableEffect(activity) { - onDispose { - activity?.requestedOrientation = initialOrientation - } - } - - val nativeCore = remember(appContext) { NativeCoreFacade.get(appContext) } - val adbService = remember(appContext) { NativeAdbService(appContext) } - val scrcpy = remember(appContext, adbService) { Scrcpy(appContext, adbService) } - - val snackHostState = remember { SnackbarHostState() } - val saveableStateHolder = rememberSaveableStateHolder() - val tabs = remember { MainTabDestination.entries } - val pagerState = rememberPagerState( - initialPage = MainTabDestination.Device.ordinal, - pageCount = { tabs.size }) - val currentTab = tabs[pagerState.currentPage] - val rootBackStack = remember { mutableStateListOf(RootScreen.Home) } - val currentRootScreen = rootBackStack.lastOrNull() as? RootScreen ?: RootScreen.Home - val devicesPageScrollBehavior = MiuixScrollBehavior( - canScroll = { currentTab == MainTabDestination.Device }) - val settingsPageScrollBehavior = MiuixScrollBehavior( - canScroll = { currentTab == MainTabDestination.Settings }) - val advancedPageScrollBehavior = MiuixScrollBehavior( - canScroll = { - when (currentRootScreen) { - is RootScreen.Advanced -> true - is RootScreen.VirtualButtonOrder -> true - else -> false - } - }) - - fun popRoot() { - if (rootBackStack.size > 1) { - rootBackStack.removeAt(rootBackStack.lastIndex) - } - } - - // Unified back behavior: - // 1) pop inner route - // 2) switch tab back to Device - // 3) double-back to exit and disconnect adb/scrcpy - var lastExitBackPressAtMs by rememberSaveable { mutableLongStateOf(0L) } - fun handleBackNavigation() { - if (rootBackStack.size > 1) { - popRoot() - } else if (pagerState.currentPage != MainTabDestination.Device.ordinal) { - scope.launch { - pagerState.animateScrollToPage( - page = MainTabDestination.Device.ordinal, - animationSpec = spring( - dampingRatio = UiMotion.PAGE_SWITCH_DAMPING_RATIO, - stiffness = UiMotion.PAGE_SWITCH_STIFFNESS, - ), - ) - } - } else { - val now = SystemClock.elapsedRealtime() - if (now - lastExitBackPressAtMs > 2_000L) { - lastExitBackPressAtMs = now - Toast.makeText(context, "再按一次返回键退出", Toast.LENGTH_SHORT).show() - return - } - lastExitBackPressAtMs = 0L - scope.launch { - withContext(Dispatchers.IO) { - runCatching { scrcpy.stop() } - runCatching { adbService.disconnect() } - } - activity?.finish() - } - } - } - - val canNavigateBack = rootBackStack.size > 1 || - pagerState.currentPage != MainTabDestination.Device.ordinal - - BackHandler(enabled = currentRootScreen !is RootScreen.Fullscreen) { - handleBackNavigation() - } - - PredictiveBackHandler( - enabled = canNavigateBack && currentRootScreen !is RootScreen.Fullscreen - ) { progress -> - try { - progress.collect { } - handleBackNavigation() - } catch (_: CancellationException) { - // Gesture was cancelled by the system/user. - } - } - - val appSettings = Storage.appSettings - val scrcpyOptions = Storage.scrcpyOptions - - var sessionStarted by remember { mutableStateOf(false) } - var clearLogsAction by remember { mutableStateOf({}) } - var openReorderDevicesAction by remember { mutableStateOf({}) } - var canClearLogs by remember { mutableStateOf(false) } - var showDeviceMenu by rememberSaveable { mutableStateOf(false) } - var fullscreenOrientation by rememberSaveable { - mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) - } - - var themeBaseIndex by appSettings.themeBaseIndex.asMutableState() - var monet by appSettings.monet.asMutableState() - val themeMode = resolveThemeMode(themeBaseIndex, monet) - val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) } - - val keepScreenOnWhenStreamingEnabled by appSettings.keepScreenOnWhenStreaming.asMutableState() - // Keep-screen-on is controlled globally, so fullscreen and preview share the same behavior. - DisposableEffect(activity, keepScreenOnWhenStreamingEnabled, sessionStarted) { - val window = activity?.window - val shouldKeepScreenOn = keepScreenOnWhenStreamingEnabled && sessionStarted - if (window != null && shouldKeepScreenOn) { - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } - onDispose { - if (window != null && shouldKeepScreenOn) { - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } - } - } - - // Fullscreen route can force orientation based on stream ratio; all other routes are portrait. - LaunchedEffect(activity, currentRootScreen, fullscreenOrientation) { - val targetOrientation = when (currentRootScreen) { - is RootScreen.Fullscreen -> fullscreenOrientation - else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } - activity?.requestedOrientation = targetOrientation - } - - val adbKeyName by appSettings.adbKeyName.asState() - LaunchedEffect(adbKeyName) { - adbService.keyName = adbKeyName.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue } - } - - var customServerUri by appSettings.customServerUri.asMutableState() - val picker = rememberLauncherForActivityResult( - ActivityResultContracts.OpenDocument() - ) { uri: Uri? -> - if (uri == null) return@rememberLauncherForActivityResult - runCatching { - context.contentResolver.takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION - ) - } - customServerUri = uri.toString() - } - - val rootEntryProvider = entryProvider { - entry(RootScreen.Home) { - Scaffold( - bottomBar = { - NavigationBar { - tabs.forEach { tab -> - NavigationBarItem( - selected = currentTab == tab, - onClick = { - scope.launch { - pagerState.animateScrollToPage( - page = tab.ordinal, - animationSpec = spring( - dampingRatio = UiMotion.PAGE_SWITCH_DAMPING_RATIO, - stiffness = UiMotion.PAGE_SWITCH_STIFFNESS, - ), - ) - } - }, - icon = tab.icon, - label = tab.label, - ) - } - } - }, - snackbarHost = { SnackbarHost(snackHostState) }, - ) { contentPadding -> - HorizontalPager( - modifier = Modifier - .fillMaxSize() - .padding(bottom = contentPadding.calculateBottomPadding()), - state = pagerState, - beyondViewportPageCount = 1, - ) { page -> - val tab = tabs[page] - saveableStateHolder.SaveableStateProvider(tab.name) { - when (tab) { - MainTabDestination.Device -> Scaffold( - topBar = { - TopAppBar( - title = tab.title, - actions = { - IconButton( - onClick = { showDeviceMenu = true }, - holdDownState = showDeviceMenu, - ) { - Icon( - Icons.Rounded.MoreVert, - contentDescription = "更多" - ) - } - DeviceMenuPopup( - show = showDeviceMenu, - canClearLogs = canClearLogs, - onDismissRequest = { showDeviceMenu = false }, - onReorderDevices = { - openReorderDevicesAction() - showDeviceMenu = false - }, - onOpenVirtualButtonOrder = { - rootBackStack.add(RootScreen.VirtualButtonOrder) - showDeviceMenu = false - }, - onClearLogs = { - clearLogsAction() - showDeviceMenu = false - }, - ) - }, - scrollBehavior = devicesPageScrollBehavior, - ) - }, - ) { pagePadding -> - DeviceTabScreen( - contentPadding = pagePadding, - nativeCore = nativeCore, - adbService = adbService, - scrcpy = scrcpy, - snack = snackHostState, - scrollBehavior = devicesPageScrollBehavior, - onSessionStartedChange = { sessionStarted = it }, - onClearLogsActionChange = { clearLogsAction = it }, - onCanClearLogsChange = { canClearLogs = it }, - onOpenReorderDevicesActionChange = { - openReorderDevicesAction = it - }, - onOpenAdvancedPage = { rootBackStack.add(RootScreen.Advanced) }, - onOpenFullscreenPage = { session -> - fullscreenOrientation = - if (session.width >= session.height) { - ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - } else { - ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } - rootBackStack.add( - RootScreen.Fullscreen( - launch = FullscreenControlLaunch( - deviceName = session.deviceName, - width = session.width, - height = session.height, - codec = session.codecName, - ), - ), - ) - }, - ) - } - - MainTabDestination.Settings -> Scaffold( - topBar = { - TopAppBar( - title = tab.title, - scrollBehavior = settingsPageScrollBehavior, - ) - }, - ) { pagePadding -> - SettingsScreen( - contentPadding = pagePadding, - onOpenReorderDevices = { - openReorderDevicesAction() - }, - onOpenVirtualButtonOrder = { - rootBackStack.add(RootScreen.VirtualButtonOrder) - }, - onPickServer = { - picker.launch( - arrayOf( - "application/java-archive", - "application/octet-stream", - "*/*" - ) - ) - }, - scrollBehavior = settingsPageScrollBehavior, - ) - } - } - } - } - } - } - - entry(RootScreen.Advanced) { - Scaffold( - topBar = { - TopAppBar( - title = "高级参数", - navigationIcon = { - IconButton(onClick = { popRoot() }) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "返回" - ) - } - }, - scrollBehavior = advancedPageScrollBehavior, - ) - }, - snackbarHost = { SnackbarHost(snackHostState) }, - ) { pagePadding -> - AdvancedConfigPage( - contentPadding = pagePadding, - scrollBehavior = advancedPageScrollBehavior, - snackbarHostState = snackHostState, - scrcpy = scrcpy, - ) - } - } - - entry(RootScreen.VirtualButtonOrder) { - Scaffold( - modifier = Modifier.nestedScroll(advancedPageScrollBehavior.nestedScrollConnection), - topBar = { - TopAppBar( - title = "虚拟按钮排序", - navigationIcon = { - IconButton(onClick = { popRoot() }) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "返回" - ) - } - }, - scrollBehavior = advancedPageScrollBehavior, - ) - }, - ) { pagePadding -> - VirtualButtonOrderPage( - contentPadding = pagePadding, - scrollBehavior = advancedPageScrollBehavior, - ) - } - } - - entry { screen -> - FullscreenControlPage( - launch = screen.launch, - nativeCore = nativeCore, - onVideoSizeChanged = { width, height -> - fullscreenOrientation = if (width >= height) { - ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - } else { - ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } - }, - onDismiss = { popRoot() }, - ) - } - } - - val rootEntries = rememberDecoratedNavEntries( - backStack = rootBackStack, - entryProvider = rootEntryProvider, - ) - - MiuixTheme(controller = themeController) { - NavDisplay( - entries = rootEntries, - onBack = { popRoot() }, - ) - } -} - -@Composable -private fun DeviceMenuPopup( - show: Boolean, - onDismissRequest: () -> Unit, - onReorderDevices: () -> Unit, - onOpenVirtualButtonOrder: () -> Unit, - onClearLogs: () -> Unit, - canClearLogs: Boolean, -) { - SuperListPopup( - show = show, - popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider, - alignment = PopupPositionProvider.Align.TopEnd, - onDismissRequest = onDismissRequest, - enableWindowDim = false, - ) { - ListPopupColumn { - DeviceMenuPopupItem( - text = "快速设备排序", - optionSize = 3, - index = 0, - onClick = onReorderDevices, - ) - DeviceMenuPopupItem( - text = "虚拟按钮排序", - optionSize = 3, - index = 1, - onClick = onOpenVirtualButtonOrder, - ) - DeviceMenuPopupItem( - text = "清空日志", - optionSize = 3, - index = 2, - enabled = canClearLogs, - onClick = onClearLogs, - ) - } - } -} - -@Composable -private fun DeviceMenuPopupItem( - text: String, - optionSize: Int, - index: Int, - enabled: Boolean = true, - // TODO: (Int) -> Unit - onClick: () -> Unit, -) { - if (enabled) { - DropdownImpl( - text = text, - optionSize = optionSize, - isSelected = false, - index = index, - onSelectedIndexChange = { onClick() }, - ) - return - } - - val additionalTopPadding = if (index == 0) UiSpacing.PopupHorizontal else UiSpacing.PageItem - val additionalBottomPadding = - if (index == optionSize - 1) UiSpacing.PopupHorizontal else UiSpacing.PageItem - Text( - text = text, - fontSize = MiuixTheme.textStyles.body1.fontSize, - fontWeight = FontWeight.Medium, - color = MiuixTheme.colorScheme.disabledOnSecondaryVariant, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.PopupHorizontal) - .padding(top = additionalTopPadding, bottom = additionalBottomPadding), - ) -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt new file mode 100644 index 0000000..6125900 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt @@ -0,0 +1,425 @@ +package io.github.miuzarte.scrcpyforandroid.pages + +import android.app.Activity +import android.content.Intent +import android.content.pm.ActivityInfo +import android.net.Uri +import android.os.SystemClock +import android.view.WindowManager +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.activity.compose.PredictiveBackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.spring +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Devices +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberDecoratedNavEntries +import androidx.navigation3.ui.NavDisplay +import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade +import io.github.miuzarte.scrcpyforandroid.constants.UiMotion +import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts +import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService +import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy +import io.github.miuzarte.scrcpyforandroid.storage.AppSettings +import io.github.miuzarte.scrcpyforandroid.storage.Settings +import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings +import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior +import top.yukonga.miuix.kmp.basic.NavigationBar +import top.yukonga.miuix.kmp.basic.NavigationBarItem +import top.yukonga.miuix.kmp.basic.Scaffold +import top.yukonga.miuix.kmp.basic.SnackbarHost +import top.yukonga.miuix.kmp.basic.SnackbarHostState +import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.theme.ThemeController + +private enum class MainBottomTabDestination( + val label: String, + val icon: ImageVector, +) { + Device(label = "设备", icon = Icons.Rounded.Devices), + Settings(label = "设置", icon = Icons.Rounded.Settings); +} + +sealed interface RootScreen : NavKey { + data object Home : RootScreen + data object Advanced : RootScreen + data object VirtualButtonOrder : RootScreen + data class Fullscreen(val session: Scrcpy.Session.SessionInfo) : RootScreen +} + +@Composable +fun MainScreen() { + val context = LocalContext.current + val appContext = context.applicationContext + val scope = rememberCoroutineScope() + + val activity = remember(context) { context as? Activity } + val initialOrientation = remember(activity) { + activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + DisposableEffect(activity) { + onDispose { + activity?.requestedOrientation = initialOrientation + } + } + + val nativeCore = remember(appContext) { + NativeCoreFacade.get(appContext) + } + val adbService = remember(appContext) { + NativeAdbService(appContext) + } + val scrcpy = remember(appContext, adbService) { + Scrcpy(appContext, adbService) + } + + val snackHostState = remember { SnackbarHostState() } + val saveableStateHolder = rememberSaveableStateHolder() + val tabs = remember { MainBottomTabDestination.entries } + val pagerState = rememberPagerState( + initialPage = MainBottomTabDestination.Device.ordinal, + pageCount = { tabs.size }) + val currentTab = tabs[pagerState.currentPage] + val rootBackStack = remember { mutableStateListOf(RootScreen.Home) } + val currentRootScreen = rootBackStack.lastOrNull() as? RootScreen ?: RootScreen.Home + val devicesPageScrollBehavior = MiuixScrollBehavior( + canScroll = { currentTab == MainBottomTabDestination.Device }) + val settingsPageScrollBehavior = MiuixScrollBehavior( + canScroll = { currentTab == MainBottomTabDestination.Settings }) + val advancedPageScrollBehavior = MiuixScrollBehavior( + canScroll = { + when (currentRootScreen) { + is RootScreen.Advanced -> true + is RootScreen.VirtualButtonOrder -> true + else -> false + } + }) + + fun popRoot() { + if (rootBackStack.size > 1) + rootBackStack.removeAt(rootBackStack.lastIndex) + } + + // Unified back behavior: + // 1) pop inner route + // 2) switch tab back to Device + // 3) double-back to exit and disconnect adb/scrcpy + var lastExitBackPressAtMs by rememberSaveable { mutableLongStateOf(0L) } + fun handleBackNavigation() { + if (rootBackStack.size > 1) { + popRoot() + } else if (pagerState.currentPage != MainBottomTabDestination.Device.ordinal) { + scope.launch { + pagerState.animateScrollToPage( + page = MainBottomTabDestination.Device.ordinal, + animationSpec = spring( + dampingRatio = UiMotion.PAGE_SWITCH_DAMPING_RATIO, + stiffness = UiMotion.PAGE_SWITCH_STIFFNESS, + ), + ) + } + } else { + val now = SystemClock.elapsedRealtime() + if (now - lastExitBackPressAtMs > 2_000L) { + lastExitBackPressAtMs = now + Toast.makeText(context, "再按一次返回键退出", Toast.LENGTH_SHORT).show() + return + } + lastExitBackPressAtMs = 0L + scope.launch { + withContext(Dispatchers.IO) { + runCatching { scrcpy.stop() } + runCatching { adbService.disconnect() } + } + activity?.finish() + } + } + } + + val canNavigateBack = rootBackStack.size > 1 + || pagerState.currentPage != MainBottomTabDestination.Device.ordinal + + BackHandler(enabled = currentRootScreen !is RootScreen.Fullscreen) { + handleBackNavigation() + } + + PredictiveBackHandler( + enabled = canNavigateBack && currentRootScreen !is RootScreen.Fullscreen + ) { progress -> + try { + progress.collect { } + handleBackNavigation() + } catch (_: CancellationException) { + // Gesture was cancelled by the system/user. + } + } + + val asBundleShared by appSettings.bundleState.collectAsState() + val asBundleSharedLatest by rememberUpdatedState(asBundleShared) + var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) } + val asBundleLatest by rememberUpdatedState(asBundle) + LaunchedEffect(asBundleShared) { + if (asBundle != asBundleShared) { + asBundle = asBundleShared + } + } + LaunchedEffect(asBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (asBundle != asBundleSharedLatest) { + appSettings.saveBundle(asBundle) + } + } + DisposableEffect(Unit) { + onDispose { + scope.launch { + appSettings.saveBundle(asBundleLatest) + } + } + } + + val qdBundleShared by quickDevices.bundleState.collectAsState() + val qdBundleSharedLatest by rememberUpdatedState(qdBundleShared) + var qdBundle by rememberSaveable(qdBundleShared) { mutableStateOf(qdBundleShared) } + val qdBundleLatest by rememberUpdatedState(qdBundle) + LaunchedEffect(qdBundleShared) { + if (qdBundle != qdBundleShared) { + qdBundle = qdBundleShared + } + } + LaunchedEffect(qdBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (qdBundle != qdBundleSharedLatest) { + quickDevices.saveBundle(qdBundle) + } + } + DisposableEffect(Unit) { + onDispose { + scope.launch { + quickDevices.saveBundle(qdBundleLatest) + } + } + } + + var sessionStarted by remember { mutableStateOf(false) } + var showReorderDevices by rememberSaveable { mutableStateOf(false) } + var fullscreenOrientation by rememberSaveable { + mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) + } + + var savedShortcuts by remember { + mutableStateOf(DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList)) + } + + LaunchedEffect(qdBundle.quickDevicesList) { + savedShortcuts = DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList) + } + LaunchedEffect(savedShortcuts) { + val serialized = savedShortcuts.marshalToString() + if (serialized != qdBundle.quickDevicesList) { + qdBundle = qdBundle.copy(quickDevicesList = serialized) + } + } + + val themeMode = resolveThemeMode(asBundle.themeBaseIndex, asBundle.monet) + val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) } + + val keepScreenOnWhenStreamingEnabled = asBundle.keepScreenOnWhenStreaming + // Keep-screen-on is controlled globally, so fullscreen and preview share the same behavior. + DisposableEffect(activity, keepScreenOnWhenStreamingEnabled, sessionStarted) { + val window = activity?.window + val shouldKeepScreenOn = keepScreenOnWhenStreamingEnabled && sessionStarted + if (window != null && shouldKeepScreenOn) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + onDispose { + if (window != null && shouldKeepScreenOn) { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + } + + // Fullscreen route can force orientation based on stream ratio; all other routes are portrait. + LaunchedEffect(activity, currentRootScreen, fullscreenOrientation) { + val targetOrientation = when (currentRootScreen) { + is RootScreen.Fullscreen -> fullscreenOrientation + else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + activity?.requestedOrientation = targetOrientation + } + + LaunchedEffect(asBundle.adbKeyName) { + adbService.keyName = asBundle.adbKeyName.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue } + } + val picker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + if (uri == null) return@rememberLauncherForActivityResult + runCatching { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + asBundle = asBundle.copy(customServerUri = uri.toString()) + } + + val rootEntryProvider = entryProvider { + entry(RootScreen.Home) { + Scaffold( + bottomBar = { + NavigationBar { + tabs.forEach { tab -> + NavigationBarItem( + selected = currentTab == tab, + onClick = { + scope.launch { + pagerState.animateScrollToPage( + page = tab.ordinal, + animationSpec = spring( + dampingRatio = UiMotion.PAGE_SWITCH_DAMPING_RATIO, + stiffness = UiMotion.PAGE_SWITCH_STIFFNESS, + ), + ) + } + }, + icon = tab.icon, + label = tab.label, + ) + } + } + }, + snackbarHost = { SnackbarHost(snackHostState) }, + ) { contentPadding -> + HorizontalPager( + modifier = Modifier + .fillMaxSize() + .padding(bottom = contentPadding.calculateBottomPadding()), + state = pagerState, + beyondViewportPageCount = 1, + ) { page -> + val tab = tabs[page] + saveableStateHolder.SaveableStateProvider(tab.name) { + when (tab) { + MainBottomTabDestination.Device -> DeviceTabScreen( + nativeCore = nativeCore, + adbService = adbService, + scrcpy = scrcpy, + snack = snackHostState, + scrollBehavior = devicesPageScrollBehavior, + onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) }, + onSessionStartedChange = { sessionStarted = it }, + onOpenReorderDevices = { showReorderDevices = true }, + onOpenAdvancedPage = { rootBackStack.add(RootScreen.Advanced) }, + onOpenFullscreenPage = { session -> + fullscreenOrientation = + if (session.width >= session.height) { + ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } else { + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + rootBackStack.add( + RootScreen.Fullscreen(session), + ) + }, + ) + + MainBottomTabDestination.Settings -> SettingsScreen( + scrollBehavior = settingsPageScrollBehavior, + snack = snackHostState, + onOpenReorderDevices = { showReorderDevices = true }, + onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) }, + onPickServer = { + picker.launch( + arrayOf( + "application/java-archive", + "application/octet-stream", + "*/*" + ) + ) + }, + ) + } + } + } + + ReorderDevicesScreen( + show = showReorderDevices, + onDismissRequest = { showReorderDevices = false }, + ) + } + } + + entry(RootScreen.Advanced) { + AdvancedConfigScreen( + onBack = ::popRoot, + scrollBehavior = advancedPageScrollBehavior, + snack = snackHostState, + scrcpy = scrcpy, + ) + } + + entry(RootScreen.VirtualButtonOrder) { + VirtualButtonOrderScreen( + onBack = ::popRoot, + scrollBehavior = advancedPageScrollBehavior, + ) + } + + entry { screen -> + FullscreenControlScreen( + onBack = ::popRoot, + session = screen.session, + nativeCore = nativeCore, + onVideoSizeChanged = { width, height -> + fullscreenOrientation = + if (width >= height) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + else ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + }, + ) + } + } + + val rootEntries = rememberDecoratedNavEntries( + backStack = rootBackStack, + entryProvider = rootEntryProvider, + ) + + MiuixTheme(controller = themeController) { + NavDisplay( + entries = rootEntries, + onBack = ::popRoot, + ) + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ReorderDevicesScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ReorderDevicesScreen.kt new file mode 100644 index 0000000..2006027 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ReorderDevicesScreen.kt @@ -0,0 +1,90 @@ +package io.github.miuzarte.scrcpyforandroid.pages + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing +import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts +import io.github.miuzarte.scrcpyforandroid.storage.Settings +import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices +import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import top.yukonga.miuix.kmp.extra.SuperBottomSheet + +@Composable +fun ReorderDevicesScreen( + show: Boolean, + onDismissRequest: () -> Unit, +) { + val scope = rememberCoroutineScope() + + val qdBundleShared by quickDevices.bundleState.collectAsState() + val qdBundleSharedLatest by rememberUpdatedState(qdBundleShared) + var qdBundle by rememberSaveable(qdBundleShared) { mutableStateOf(qdBundleShared) } + val qdBundleLatest by rememberUpdatedState(qdBundle) + LaunchedEffect(qdBundleShared) { + if (qdBundle != qdBundleShared) { + qdBundle = qdBundleShared + } + } + LaunchedEffect(qdBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (qdBundle != qdBundleSharedLatest) { + quickDevices.saveBundle(qdBundle) + } + } + DisposableEffect(Unit) { + onDispose { + scope.launch { + quickDevices.saveBundle(qdBundleLatest) + } + } + } + + var savedShortcuts by remember { + mutableStateOf(DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList)) + } + LaunchedEffect(qdBundle.quickDevicesList) { + savedShortcuts = DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList) + } + LaunchedEffect(savedShortcuts) { + val serialized = savedShortcuts.marshalToString() + if (serialized != qdBundle.quickDevicesList) { + qdBundle = qdBundle.copy(quickDevicesList = serialized) + } + } + + SuperBottomSheet( + show = show, + title = "快速设备排序", + onDismissRequest = onDismissRequest, + ) { + ReorderableList( + itemsProvider = { + savedShortcuts.map { device -> + ReorderableList.Item( + id = device.id, + title = device.name.ifBlank { device.host }, + subtitle = "${device.host}:${device.port}", + ) + } + }, + onSettle = { fromIndex, toIndex -> + savedShortcuts = savedShortcuts.move(fromIndex, toIndex) + }, + ).invoke() + Spacer(Modifier.height(UiSpacing.BottomSheetBottom)) + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt index f500843..417d151 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt @@ -13,11 +13,14 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Clear import androidx.compose.material.icons.rounded.FileOpen import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -25,23 +28,25 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.core.net.toUri import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing -import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn +import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField import io.github.miuzarte.scrcpyforandroid.storage.AppSettings import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration +import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.ScrollBehavior import top.yukonga.miuix.kmp.basic.SnackbarHostState import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.TextField +import top.yukonga.miuix.kmp.basic.TopAppBar import top.yukonga.miuix.kmp.extra.SuperArrow import top.yukonga.miuix.kmp.extra.SuperDropdown import top.yukonga.miuix.kmp.extra.SuperSwitch @@ -75,11 +80,39 @@ private fun resolveThemeLabel(baseIndex: Int, monetEnabled: Boolean): String { @Composable fun SettingsScreen( - contentPadding: PaddingValues, + scrollBehavior: ScrollBehavior, + snack: SnackbarHostState, onOpenReorderDevices: () -> Unit, onOpenVirtualButtonOrder: () -> Unit, onPickServer: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = "设置", + scrollBehavior = scrollBehavior, + ) + }, + ) { pagePadding -> + SettingsPage( + contentPadding = pagePadding, + scrollBehavior = scrollBehavior, + snack = snack, + onOpenReorderDevices = onOpenReorderDevices, + onOpenVirtualButtonOrder = onOpenVirtualButtonOrder, + onPickServer = onPickServer, + ) + } +} + +@Composable +fun SettingsPage( + contentPadding: PaddingValues, scrollBehavior: ScrollBehavior, + snack: SnackbarHostState, + onOpenReorderDevices: () -> Unit, + onOpenVirtualButtonOrder: () -> Unit, + onPickServer: () -> Unit, ) { val appContext = LocalContext.current.applicationContext val appSettings = Storage.appSettings @@ -91,44 +124,57 @@ fun SettingsScreen( val context = LocalContext.current val scope = rememberCoroutineScope() - val snackHostState = remember { SnackbarHostState() } + + val asBundleShared by appSettings.bundleState.collectAsState() + val asBundleSharedLatest by rememberUpdatedState(asBundleShared) + var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) } + val asBundleLatest by rememberUpdatedState(asBundle) + LaunchedEffect(asBundleShared) { + if (asBundle != asBundleShared) { + asBundle = asBundleShared + } + } + LaunchedEffect(asBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (asBundle != asBundleSharedLatest) { + appSettings.saveBundle(asBundle) + } + } + DisposableEffect(Unit) { + onDispose { + scope.launch { + appSettings.saveBundle(asBundleLatest) + } + } + } val baseModeItems = THEME_BASE_OPTIONS.map { it.label } - - // 主题 - var themeBaseIndex by appSettings.themeBaseIndex.asMutableState() - var monet by appSettings.monet.asMutableState() - - // 投屏 - var fullscreenDebugInfo by appSettings.fullscreenDebugInfo.asMutableState() - var keepScreenOnWhenStreaming by appSettings.keepScreenOnWhenStreaming.asMutableState() - var devicePreviewCardHeightDp by appSettings.devicePreviewCardHeightDp.asMutableState() - var showFullscreenVirtualButtons by appSettings.showFullscreenVirtualButtons.asMutableState() - - // scrcpy-server - var customServerUri by appSettings.customServerUri.asMutableState() - var serverRemotePath by appSettings.serverRemotePath.asMutableState() var serverRemotePathInput by rememberSaveable { mutableStateOf( - // 默认值留空显示为 hint - if (runBlocking { appSettings.serverRemotePath.isDefaultValue() }) "" - else serverRemotePath + if (asBundleShared.serverRemotePath == AppSettings.SERVER_REMOTE_PATH.defaultValue) "" + else asBundleShared.serverRemotePath ) } + LaunchedEffect(asBundleShared.serverRemotePath) { + serverRemotePathInput = + if (asBundleShared.serverRemotePath == AppSettings.SERVER_REMOTE_PATH.defaultValue) "" + else asBundleShared.serverRemotePath + } - // ADB - var adbKeyName by appSettings.adbKeyName.asMutableState() var adbKeyNameInput by rememberSaveable { mutableStateOf( - if (runBlocking { appSettings.adbKeyName.isDefaultValue() }) "" - else adbKeyName + if (asBundleShared.adbKeyName == AppSettings.ADB_KEY_NAME.defaultValue) "" + else asBundleShared.adbKeyName ) } - var adbPairingAutoDiscoverOnDialogOpen by appSettings.adbPairingAutoDiscoverOnDialogOpen.asMutableState() - var adbAutoReconnectPairedDevice by appSettings.adbAutoReconnectPairedDevice.asMutableState() + LaunchedEffect(asBundleShared.adbKeyName) { + adbKeyNameInput = + if (asBundleShared.adbKeyName == AppSettings.ADB_KEY_NAME.defaultValue) "" + else asBundleShared.adbKeyName + } // 设置 - AppPageLazyColumn( + LazyColumn( contentPadding = contentPadding, scrollBehavior = scrollBehavior, ) { @@ -137,16 +183,20 @@ fun SettingsScreen( Card { SuperDropdown( title = "外观模式", - summary = resolveThemeLabel(themeBaseIndex, monet), + summary = resolveThemeLabel(asBundle.themeBaseIndex, asBundle.monet), items = baseModeItems, - selectedIndex = themeBaseIndex.coerceIn(0, baseModeItems.lastIndex), - onSelectedIndexChange = { themeBaseIndex = it }, + selectedIndex = asBundle.themeBaseIndex.coerceIn(0, baseModeItems.lastIndex), + onSelectedIndexChange = { + asBundle = asBundle.copy(themeBaseIndex = it) + }, ) SuperSwitch( title = "Monet", summary = "开启后使用 Monet 动态配色", - checked = monet, - onCheckedChange = { monet = it }, + checked = asBundle.monet, + onCheckedChange = { + asBundle = asBundle.copy(monet = it) + }, ) } @@ -155,32 +205,40 @@ fun SettingsScreen( SuperSwitch( title = "启用调试信息", summary = "在全屏界面显示触点数量、设备分辨率和实时 FPS", - checked = fullscreenDebugInfo, - onCheckedChange = { fullscreenDebugInfo = it }, + checked = asBundle.fullscreenDebugInfo, + onCheckedChange = { + asBundle = asBundle.copy(fullscreenDebugInfo = it) + }, ) SuperSwitch( title = "投屏时保持屏幕常亮", summary = "Scrcpy 启动后保持本机屏幕常亮,避免锁屏导致 ADB 断开", - checked = keepScreenOnWhenStreaming, - onCheckedChange = { keepScreenOnWhenStreaming = it }, + checked = asBundle.keepScreenOnWhenStreaming, + onCheckedChange = { + asBundle = asBundle.copy(keepScreenOnWhenStreaming = it) + }, ) SuperSlider( title = "预览卡高度", summary = "设备页预览卡高度", - value = devicePreviewCardHeightDp.toFloat(), + value = asBundle.devicePreviewCardHeightDp.toFloat(), onValueChange = { - devicePreviewCardHeightDp = it.roundToInt().coerceAtLeast(120) + asBundle = asBundle.copy( + devicePreviewCardHeightDp = it.roundToInt().coerceAtLeast(120) + ) }, valueRange = 160f..600f, - steps = 600-160-1, + steps = 600-160-2, unit = "dp", displayFormatter = { it.roundToInt().toString() }, - inputInitialValue = devicePreviewCardHeightDp.toString(), + inputInitialValue = asBundle.devicePreviewCardHeightDp.toString(), inputFilter = { it.filter(Char::isDigit) }, inputValueRange = 120f..UShort.MAX_VALUE.toFloat(), onInputConfirm = { input -> input.toIntOrNull()?.let { - devicePreviewCardHeightDp = it.coerceAtLeast(120) + asBundle = asBundle.copy( + devicePreviewCardHeightDp = it.coerceAtLeast(120) + ) } }, ) @@ -197,8 +255,10 @@ fun SettingsScreen( SuperSwitch( title = "全屏显示虚拟按钮", summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮", - checked = showFullscreenVirtualButtons, - onCheckedChange = { showFullscreenVirtualButtons = it }, + checked = asBundle.showFullscreenVirtualButtons, + onCheckedChange = { + asBundle = asBundle.copy(showFullscreenVirtualButtons = it) + }, ) } @@ -213,19 +273,21 @@ fun SettingsScreen( fontWeight = FontWeight.Medium, ) TextField( - value = customServerUri, + value = asBundle.customServerUri, onValueChange = {}, readOnly = true, label = "scrcpy-server-v3.3.4", - useLabelAsPlaceholder = customServerUri.isBlank(), + useLabelAsPlaceholder = asBundle.customServerUri.isBlank(), modifier = Modifier .fillMaxWidth() .padding(horizontal = UiSpacing.CardContent) .padding(bottom = UiSpacing.CardContent), trailingIcon = { Row(modifier = Modifier.padding(end = UiSpacing.SectionTitleLeadingGap)) { - if (customServerUri.isNotBlank()) - IconButton(onClick = { customServerUri = "" }) { + if (asBundle.customServerUri.isNotBlank()) + IconButton(onClick = { + asBundle = asBundle.copy(customServerUri = "") + }) { Icon(Icons.Rounded.Clear, contentDescription = "清空") } IconButton(onClick = onPickServer) { @@ -247,6 +309,11 @@ fun SettingsScreen( onFocusLost = { if (serverRemotePathInput == AppSettings.SERVER_REMOTE_PATH.defaultValue) serverRemotePathInput = "" + asBundle = asBundle.copy( + serverRemotePath = serverRemotePathInput.ifBlank { + AppSettings.SERVER_REMOTE_PATH.defaultValue + } + ) }, label = AppSettings.SERVER_REMOTE_PATH.defaultValue, useLabelAsPlaceholder = true, @@ -273,6 +340,9 @@ fun SettingsScreen( onFocusLost = { if (adbKeyNameInput == AppSettings.ADB_KEY_NAME.defaultValue) adbKeyNameInput = "" + asBundle = asBundle.copy( + adbKeyName = adbKeyNameInput.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue } + ) }, label = AppSettings.ADB_KEY_NAME.defaultValue, useLabelAsPlaceholder = true, @@ -285,14 +355,22 @@ fun SettingsScreen( SuperSwitch( title = "配对时自动启用发现服务", summary = "打开配对弹窗后自动搜索可用配对端口", - checked = adbPairingAutoDiscoverOnDialogOpen, - onCheckedChange = { adbPairingAutoDiscoverOnDialogOpen = it }, + checked = asBundle.adbPairingAutoDiscoverOnDialogOpen, + onCheckedChange = { + asBundle = asBundle.copy( + adbPairingAutoDiscoverOnDialogOpen = it + ) + }, ) SuperSwitch( title = "自动重连已配对设备", summary = "自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘)", - checked = adbAutoReconnectPairedDevice, - onCheckedChange = { adbAutoReconnectPairedDevice = it }, + checked = asBundle.adbAutoReconnectPairedDevice, + onCheckedChange = { + asBundle = asBundle.copy( + adbAutoReconnectPairedDevice = it + ) + }, ) } @@ -310,7 +388,7 @@ fun SettingsScreen( scope.launch { val migration = PreferenceMigration(appContext) migration.migrate(clearSharedPrefs = false) - snackHostState.showSnackbar("迁移完成,应用将重启") + snack.showSnackbar("迁移完成,应用将重启") delay(1000) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderPage.kt deleted file mode 100644 index f30d70e..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderPage.kt +++ /dev/null @@ -1,86 +0,0 @@ -package io.github.miuzarte.scrcpyforandroid.pages - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext -import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn -import io.github.miuzarte.scrcpyforandroid.storage.Storage -import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList -import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction -import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions -import top.yukonga.miuix.kmp.basic.Card -import top.yukonga.miuix.kmp.basic.ScrollBehavior -import top.yukonga.miuix.kmp.extra.SuperSwitch - -@Composable -internal fun VirtualButtonOrderPage( - contentPadding: PaddingValues, - scrollBehavior: ScrollBehavior, -) { - val appSettings = Storage.appSettings - - val context = LocalContext.current - - var previewVirtualButtonShowText by appSettings.previewVirtualButtonShowText.asMutableState() - var virtualButtonsLayout by appSettings.virtualButtonsLayout.asMutableState() - var buttonItems by remember(virtualButtonsLayout) { - mutableStateOf(VirtualButtonActions.parseStoredLayout(virtualButtonsLayout)) - } - - AppPageLazyColumn( - contentPadding = contentPadding, - scrollBehavior = scrollBehavior, - ) { - // 按钮显示文本开关 - item { - Card { - SuperSwitch( - title = "按钮显示文本", - summary = "超过3个建议关闭,只对预览卡下方的虚拟按钮生效", - checked = previewVirtualButtonShowText, - onCheckedChange = { previewVirtualButtonShowText = it }, - ) - } - } - - item { - ReorderableList( - itemsProvider = { - buttonItems.map { item -> - val action = item.action - ReorderableList.Item( - id = action.id, - icon = action.icon, - title = if (action.keycode == null) action.title else "${action.title} (${action.keycode})", - subtitle = if (item.showOutside) "显示在外部" else "显示在更多菜单内", - checked = item.showOutside, - checkboxEnabled = action != VirtualButtonAction.MORE, - ) - } - }, - orientation = ReorderableList.Orientation.Column, - onSettle = { fromIndex, toIndex -> - buttonItems = buttonItems.toMutableList().apply { - add(toIndex, removeAt(fromIndex)) - } - virtualButtonsLayout = VirtualButtonActions.encodeStoredLayout(buttonItems) - }, - showCheckbox = true, - onCheckboxChange = { id, checked -> - buttonItems = buttonItems.map { item -> - if (item.action.id == id) { - item.copy(showOutside = checked) - } else { - item - } - } - virtualButtonsLayout = VirtualButtonActions.encodeStoredLayout(buttonItems) - }, - )() - } - } -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt new file mode 100644 index 0000000..ba3a8ba --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt @@ -0,0 +1,156 @@ +package io.github.miuzarte.scrcpyforandroid.pages + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn +import io.github.miuzarte.scrcpyforandroid.storage.Settings +import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings +import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList +import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction +import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.Scaffold +import top.yukonga.miuix.kmp.basic.ScrollBehavior +import top.yukonga.miuix.kmp.basic.TopAppBar +import top.yukonga.miuix.kmp.extra.SuperSwitch + +@Composable +internal fun VirtualButtonOrderScreen( + onBack: () -> Unit, + scrollBehavior: ScrollBehavior, +) { + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + TopAppBar( + title = "虚拟按钮排序", + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "返回" + ) + } + }, + scrollBehavior = scrollBehavior, + ) + }, + ) { pagePadding -> + VirtualButtonOrderPage( + contentPadding = pagePadding, + scrollBehavior = scrollBehavior, + ) + } +} + +@Composable +internal fun VirtualButtonOrderPage( + contentPadding: PaddingValues, + scrollBehavior: ScrollBehavior, +) { + val scope = rememberCoroutineScope() + + val asBundleShared by appSettings.bundleState.collectAsState() + val asBundleSharedLatest by rememberUpdatedState(asBundleShared) + var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) } + val asBundleLatest by rememberUpdatedState(asBundle) + LaunchedEffect(asBundleShared) { + if (asBundle != asBundleShared) { + asBundle = asBundleShared + } + } + LaunchedEffect(asBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (asBundle != asBundleSharedLatest) { + appSettings.saveBundle(asBundle) + } + } + DisposableEffect(Unit) { + onDispose { + scope.launch { + appSettings.saveBundle(asBundleLatest) + } + } + } + + var buttonItems by remember(asBundle.virtualButtonsLayout) { + mutableStateOf(VirtualButtonActions.parseStoredLayout(asBundle.virtualButtonsLayout)) + } + + LazyColumn( + contentPadding = contentPadding, + scrollBehavior = scrollBehavior, + ) { + // 按钮显示文本开关 + item { + Card { + SuperSwitch( + title = "按钮显示文本", + summary = "超过3个建议关闭,只对预览卡下方的虚拟按钮生效", + checked = asBundle.previewVirtualButtonShowText, + onCheckedChange = { + asBundle = asBundle.copy(previewVirtualButtonShowText = it) + }, + ) + } + } + + item { + ReorderableList( + itemsProvider = { + buttonItems.map { item -> + val action = item.action + ReorderableList.Item( + id = action.id, + icon = action.icon, + title = if (action.keycode == null) action.title else "${action.title} (${action.keycode})", + subtitle = if (item.showOutside) "显示在外部" else "显示在更多菜单内", + checked = item.showOutside, + checkboxEnabled = action != VirtualButtonAction.MORE, + ) + } + }, + orientation = ReorderableList.Orientation.Column, + onSettle = { fromIndex, toIndex -> + buttonItems = buttonItems.toMutableList().apply { + add(toIndex, removeAt(fromIndex)) + } + asBundle = asBundle.copy( + virtualButtonsLayout = VirtualButtonActions.encodeStoredLayout(buttonItems) + ) + }, + showCheckbox = true, + onCheckboxChange = { id, checked -> + buttonItems = buttonItems.map { item -> + if (item.action.id == id) { + item.copy(showOutside = checked) + } else { + item + } + } + asBundle = asBundle.copy( + virtualButtonsLayout = VirtualButtonActions.encodeStoredLayout(buttonItems) + ) + }, + )() + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/PageLayouts.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/LazyColumn.kt similarity index 98% rename from app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/PageLayouts.kt rename to app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/LazyColumn.kt index f884751..4e781a0 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/PageLayouts.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/LazyColumn.kt @@ -20,7 +20,7 @@ import top.yukonga.miuix.kmp.basic.ScrollBehavior import top.yukonga.miuix.kmp.utils.overScrollVertical @Composable -fun AppPageLazyColumn( +fun LazyColumn( contentPadding: PaddingValues, scrollBehavior: ScrollBehavior, modifier: Modifier = Modifier, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt index f202b08..04f7a7d 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt @@ -3,7 +3,6 @@ package io.github.miuzarte.scrcpyforandroid.scaffolds import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable @@ -13,12 +12,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import top.yukonga.miuix.kmp.basic.ButtonDefaults import top.yukonga.miuix.kmp.basic.Slider import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.TextButton -import top.yukonga.miuix.kmp.basic.TextField import top.yukonga.miuix.kmp.extra.SuperArrow import top.yukonga.miuix.kmp.extra.SuperDialog import top.yukonga.miuix.kmp.theme.MiuixTheme @@ -40,7 +37,9 @@ fun SuperSlider( displayFormatter: (Float) -> String = { it.toInt().toString() }, displayText: String? = null, inputTitle: String = title, - inputHint: String = unit, + inputSummary: String = summary, + inputLabel: String = unit, + useLabelAsPlaceholder: Boolean = false, inputInitialValue: String = displayFormatter(value), inputFilter: (String) -> String = { text -> text.filter { it.isDigit() || it == '.' } }, inputValueRange: ClosedFloatingPointRange? = null, @@ -60,7 +59,8 @@ fun SuperSlider( endActions = { val isZeroState = value == 0f && zeroStateText != null val valueText = - if (isZeroState) zeroStateText else (displayText ?: displayFormatter(value)) + if (isZeroState) zeroStateText + else (displayText ?: displayFormatter(value)) val shouldShowUnit = unit.isNotBlank() && (!isZeroState || showUnitWhenZeroState) val text = if (shouldShowUnit) "$valueText $unit" else valueText Text( @@ -86,7 +86,9 @@ fun SuperSlider( SliderInputDialog( showDialog = showInputDialog, title = inputTitle, - summary = inputHint, + summary = inputSummary, + label = inputLabel, + useLabelAsPlaceholder = useLabelAsPlaceholder, initialValue = inputInitialValue, inputFilter = inputFilter, inputValueRange = inputValueRange ?: valueRange, @@ -104,6 +106,8 @@ private fun SliderInputDialog( showDialog: Boolean, title: String, summary: String, + label: String = "", + useLabelAsPlaceholder: Boolean = false, initialValue: String, inputFilter: (String) -> String, inputValueRange: ClosedFloatingPointRange, @@ -119,16 +123,18 @@ private fun SliderInputDialog( onDismissFinished = onDismissFinished, content = { var text by remember(initialValue) { mutableStateOf(initialValue) } - - TextField( + + SuperTextField( modifier = Modifier.padding(bottom = 16.dp), value = text, + label = label, + useLabelAsPlaceholder = useLabelAsPlaceholder, maxLines = 1, onValueChange = { newValue -> text = inputFilter(newValue) }, ) - + Row(horizontalArrangement = Arrangement.SpaceBetween) { TextButton( text = "取消", diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/TouchEventHandler.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/TouchEventHandler.kt new file mode 100644 index 0000000..fc3cfad --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/TouchEventHandler.kt @@ -0,0 +1,273 @@ +package io.github.miuzarte.scrcpyforandroid.scrcpy + +import android.util.Log +import android.view.MotionEvent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntSize +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +class TouchEventHandler( + private val coroutineScope: CoroutineScope, + private val session: Scrcpy.Session.SessionInfo, + private val touchAreaSize: IntSize, + private val activePointerIds: LinkedHashSet, + private val activePointerPositions: LinkedHashMap, + private val activePointerDevicePositions: LinkedHashMap>, + private val pointerLabels: LinkedHashMap, + private var nextPointerLabel: Int, + private val onInjectTouch: suspend (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit, + private val onActiveTouchCountChanged: (Int) -> Unit, + private val onActiveTouchDebugChanged: (String) -> Unit, + private val onNextPointerLabelChanged: (Int) -> Unit, +) { + companion object { + private const val FULLSCREEN_TOUCH_LOG_TAG = "FullscreenTouch" + } + + private object UiMotionActions { + const val DOWN = 0 + const val UP = 1 + const val MOVE = 2 + const val CANCEL = 3 + const val POINTER_DOWN = 5 + const val POINTER_UP = 6 + } + + private val eventPointerIds = HashSet(10) + private val eventPositions = HashMap(10) + private val eventPressures = HashMap(10) + private val justPressedPointerIds = HashSet(10) + + fun handleMotionEvent(event: MotionEvent): Boolean { + if (touchAreaSize.width == 0 || touchAreaSize.height == 0) { + return true + } + + val bounds = calculateContentBounds() + + if (event.actionMasked == MotionEvent.ACTION_CANCEL) { + return handleCancelAction(bounds) + } + + extractEventData(event) + handleDisappearedPointers(eventPointerIds, bounds) + + val endedPointerId = getEndedPointerId(event) + handlePointerDown(event, endedPointerId, bounds) + handlePointerMove(event, endedPointerId, bounds) + handlePointerUp(endedPointerId, bounds) + + onActiveTouchCountChanged(activePointerIds.size) + refreshTouchDebug() + return true + } + + private data class ContentBounds( + val width: Float, + val height: Float, + val left: Float, + val top: Float, + ) + + private fun calculateContentBounds(): ContentBounds { + val sessionAspect = if (session.height == 0) { + 16f / 9f + } else { + session.width.toFloat() / session.height.toFloat() + } + val containerWidth = touchAreaSize.width.toFloat() + val containerHeight = touchAreaSize.height.toFloat() + val containerAspect = containerWidth / containerHeight + + val contentWidth: Float + val contentHeight: Float + if (sessionAspect > containerAspect) { + contentWidth = containerWidth + contentHeight = containerWidth / sessionAspect + } else { + contentHeight = containerHeight + contentWidth = containerHeight * sessionAspect + } + val contentLeft = (containerWidth - contentWidth) / 2f + val contentTop = (containerHeight - contentHeight) / 2f + + return ContentBounds(contentWidth, contentHeight, contentLeft, contentTop) + } + + private fun isInsideContent(rawX: Float, rawY: Float, bounds: ContentBounds): Boolean { + return rawX in bounds.left..(bounds.left + bounds.width) && + rawY in bounds.top..(bounds.top + bounds.height) + } + + private fun mapToDevice(rawX: Float, rawY: Float, bounds: ContentBounds): Pair { + val normalizedX = ((rawX - bounds.left) / bounds.width).coerceIn(0f, 1f) + val normalizedY = ((rawY - bounds.top) / bounds.height).coerceIn(0f, 1f) + val x = (normalizedX * (session.width - 1).coerceAtLeast(0)).roundToInt() + .coerceIn(0, (session.width - 1).coerceAtLeast(0)) + val y = (normalizedY * (session.height - 1).coerceAtLeast(0)).roundToInt() + .coerceIn(0, (session.height - 1).coerceAtLeast(0)) + return x to y + } + + private fun getPointerLabel(pointerId: Int): Int { + val existing = pointerLabels[pointerId] + if (existing != null) { + return existing + } + val assigned = nextPointerLabel + nextPointerLabel += 1 + onNextPointerLabelChanged(nextPointerLabel) + pointerLabels[pointerId] = assigned + return assigned + } + + private fun refreshTouchDebug() { + if (activePointerIds.isEmpty()) { + onActiveTouchDebugChanged("") + return + } + val debug = activePointerIds + .sortedBy { getPointerLabel(it) } + .joinToString(separator = "\n") { pointerId -> + val label = getPointerLabel(pointerId) + val pos = activePointerDevicePositions[pointerId] + if (pos == null) { + "#$label(id=$pointerId):?" + } else { + "#$label(id=$pointerId):${pos.first},${pos.second}" + } + } + onActiveTouchDebugChanged(debug) + } + + private fun releasePointer(pointerId: Int, bounds: ContentBounds) { + if (!activePointerIds.contains(pointerId)) return + val pos = activePointerPositions[pointerId] ?: Offset.Zero + val (x, y) = mapToDevice(pos.x, pos.y, bounds) + coroutineScope.launch { + runCatching { + onInjectTouch(UiMotionActions.UP, pointerId.toLong(), x, y, 0f, 0) + }.onFailure { e -> + Log.w(FULLSCREEN_TOUCH_LOG_TAG, "releasePointer failed for pointerId=$pointerId", e) + } + } + activePointerIds -= pointerId + activePointerPositions.remove(pointerId) + activePointerDevicePositions.remove(pointerId) + pointerLabels.remove(pointerId) + } + + private fun handleCancelAction(bounds: ContentBounds): Boolean { + val toCancel = activePointerIds.toList() + for (pointerId in toCancel) { + releasePointer(pointerId, bounds) + } + onActiveTouchCountChanged(activePointerIds.size) + refreshTouchDebug() + return true + } + + private fun extractEventData(event: MotionEvent) { + eventPointerIds.clear() + eventPositions.clear() + eventPressures.clear() + for (i in 0 until event.pointerCount) { + val pointerId = event.getPointerId(i) + eventPointerIds += pointerId + eventPositions[pointerId] = Offset(event.getX(i), event.getY(i)) + eventPressures[pointerId] = event.getPressure(i).coerceIn(0f, 1f) + } + } + + private fun handleDisappearedPointers(eventPointerIds: Set, bounds: ContentBounds) { + val disappearedPointers = activePointerIds.filter { it !in eventPointerIds } + for (pointerId in disappearedPointers) { + releasePointer(pointerId, bounds) + } + } + + private fun getEndedPointerId(event: MotionEvent): Int? { + return when (event.actionMasked) { + MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> event.getPointerId(event.actionIndex) + else -> null + } + } + + private fun handlePointerDown( + event: MotionEvent, + endedPointerId: Int?, + bounds: ContentBounds, + ) { + justPressedPointerIds.clear() + for (i in 0 until event.pointerCount) { + val pointerId = event.getPointerId(i) + if (pointerId == endedPointerId) continue + val raw = eventPositions[pointerId] ?: continue + val pressure = eventPressures[pointerId] ?: 0f + if (!activePointerIds.contains(pointerId)) { + if (!isInsideContent(raw.x, raw.y, bounds)) continue + val (x, y) = mapToDevice(raw.x, raw.y, bounds) + activePointerIds += pointerId + activePointerPositions[pointerId] = raw + activePointerDevicePositions[pointerId] = x to y + justPressedPointerIds += pointerId + coroutineScope.launch { + runCatching { + onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, pressure, 0) + }.onFailure { e -> + Log.w( + FULLSCREEN_TOUCH_LOG_TAG, + "handlePointerDown failed for pointerId=$pointerId", + e + ) + } + } + } + } + } + + private fun handlePointerMove( + event: MotionEvent, + endedPointerId: Int?, + bounds: ContentBounds, + ) { + for (i in 0 until event.pointerCount) { + val pointerId = event.getPointerId(i) + if (!activePointerIds.contains(pointerId)) continue + if (pointerId == endedPointerId) continue + if (pointerId in justPressedPointerIds) continue + val raw = eventPositions[pointerId] ?: continue + val pressure = eventPressures[pointerId] ?: 0f + activePointerPositions[pointerId] = raw + val (x, y) = mapToDevice(raw.x, raw.y, bounds) + activePointerDevicePositions[pointerId] = x to y + coroutineScope.launch { + runCatching { + onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, pressure, 0) + }.onFailure { e -> + Log.w( + FULLSCREEN_TOUCH_LOG_TAG, + "handlePointerMove failed for pointerId=$pointerId", + e + ) + } + } + } + } + + private fun handlePointerUp( + endedPointerId: Int?, + bounds: ContentBounds, + ) { + if (endedPointerId != null) { + val endPos = eventPositions[endedPointerId] + if (endPos != null) { + activePointerPositions[endedPointerId] = endPos + } + releasePointer(endedPointerId, bounds) + } + } + +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AdbClientData.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AdbClientData.kt index 5b92a29..fbce077 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AdbClientData.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AdbClientData.kt @@ -1,7 +1,11 @@ package io.github.miuzarte.scrcpyforandroid.storage import android.content.Context +import android.os.Parcelable +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.StateFlow +import kotlinx.parcelize.Parcelize class AdbClientData(context: Context) : Settings(context, "AdbClient") { companion object { @@ -17,4 +21,31 @@ class AdbClientData(context: Context) : Settings(context, "AdbClient") { val rsaPrivateKey by setting(RSA_PRIVATE_KEY) val rsaPublicKeyX509 by setting(RSA_PUBLIC_KEY_X509) + + @Parcelize + data class Bundle( + val rsaPrivateKey: String, + val rsaPublicKeyX509: String, + ) : Parcelable { + } + + private val bundleFields = arrayOf( + bundleField(RSA_PRIVATE_KEY) { bundle: Bundle -> bundle.rsaPrivateKey }, + bundleField(RSA_PUBLIC_KEY_X509) { bundle: Bundle -> bundle.rsaPublicKeyX509 }, + ) + + val bundleState: StateFlow = createBundleState(::bundleFromPreferences) + + private fun bundleFromPreferences(preferences: Preferences) = Bundle( + rsaPrivateKey = preferences.read(RSA_PRIVATE_KEY), + rsaPublicKeyX509 = preferences.read(RSA_PUBLIC_KEY_X509), + ) + + suspend fun loadBundle() = loadBundle(::bundleFromPreferences) + + suspend fun saveBundle(new: Bundle) = saveBundle(bundleState.value, new, bundleFields) + + suspend fun updateBundle(transform: (Bundle) -> Bundle) { + saveBundle(transform(bundleState.value)) + } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt index e0f4f8b..e0ee263 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt @@ -1,9 +1,13 @@ package io.github.miuzarte.scrcpyforandroid.storage import android.content.Context +import android.os.Parcelable +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.StateFlow +import kotlinx.parcelize.Parcelize class AppSettings(context: Context) : Settings(context, "AppSettings") { companion object { @@ -87,6 +91,71 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { val adbAutoReconnectPairedDevice by setting(ADB_AUTO_RECONNECT_PAIRED_DEVICE) val adbMdnsLanDiscovery by setting(ADB_MDNS_LAN_DISCOVERY) + @Parcelize + data class Bundle( + val themeBaseIndex: Int, + val monet: Boolean, + val fullscreenDebugInfo: Boolean, + val showFullscreenVirtualButtons: Boolean, + val keepScreenOnWhenStreaming: Boolean, + val devicePreviewCardHeightDp: Int, + val previewVirtualButtonShowText: Boolean, + val virtualButtonsLayout: String, + val customServerUri: String, + val serverRemotePath: String, + val adbKeyName: String, + val adbPairingAutoDiscoverOnDialogOpen: Boolean, + val adbAutoReconnectPairedDevice: Boolean, + val adbMdnsLanDiscovery: Boolean, + ) : Parcelable { + } + + private val bundleFields = arrayOf( + bundleField(THEME_BASE_INDEX) { bundle: Bundle -> bundle.themeBaseIndex }, + bundleField(MONET) { bundle: Bundle -> bundle.monet }, + bundleField(FULLSCREEN_DEBUG_INFO) { bundle: Bundle -> bundle.fullscreenDebugInfo }, + bundleField(SHOW_FULLSCREEN_VIRTUAL_BUTTONS) { bundle: Bundle -> bundle.showFullscreenVirtualButtons }, + bundleField(KEEP_SCREEN_ON_WHEN_STREAMING) { bundle: Bundle -> bundle.keepScreenOnWhenStreaming }, + bundleField(DEVICE_PREVIEW_CARD_HEIGHT_DP) { bundle: Bundle -> bundle.devicePreviewCardHeightDp }, + bundleField(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) { bundle: Bundle -> bundle.previewVirtualButtonShowText }, + bundleField(VIRTUAL_BUTTONS_LAYOUT) { bundle: Bundle -> bundle.virtualButtonsLayout }, + bundleField(CUSTOM_SERVER_URI) { bundle: Bundle -> bundle.customServerUri }, + bundleField(SERVER_REMOTE_PATH) { bundle: Bundle -> bundle.serverRemotePath }, + bundleField(ADB_KEY_NAME) { bundle: Bundle -> bundle.adbKeyName }, + bundleField(ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN) { bundle: Bundle -> bundle.adbPairingAutoDiscoverOnDialogOpen }, + bundleField(ADB_AUTO_RECONNECT_PAIRED_DEVICE) { bundle: Bundle -> bundle.adbAutoReconnectPairedDevice }, + bundleField(ADB_MDNS_LAN_DISCOVERY) { bundle: Bundle -> bundle.adbMdnsLanDiscovery }, + ) + + val bundleState: StateFlow = createBundleState(::bundleFromPreferences) + + private fun bundleFromPreferences(preferences: Preferences) = Bundle( + themeBaseIndex = preferences.read(THEME_BASE_INDEX), + monet = preferences.read(MONET), + fullscreenDebugInfo = preferences.read(FULLSCREEN_DEBUG_INFO), + showFullscreenVirtualButtons = preferences.read(SHOW_FULLSCREEN_VIRTUAL_BUTTONS), + keepScreenOnWhenStreaming = preferences.read(KEEP_SCREEN_ON_WHEN_STREAMING), + devicePreviewCardHeightDp = preferences.read(DEVICE_PREVIEW_CARD_HEIGHT_DP), + previewVirtualButtonShowText = preferences.read(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT), + virtualButtonsLayout = preferences.read(VIRTUAL_BUTTONS_LAYOUT), + customServerUri = preferences.read(CUSTOM_SERVER_URI), + serverRemotePath = preferences.read(SERVER_REMOTE_PATH), + adbKeyName = preferences.read(ADB_KEY_NAME), + adbPairingAutoDiscoverOnDialogOpen = preferences.read( + ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN + ), + adbAutoReconnectPairedDevice = preferences.read(ADB_AUTO_RECONNECT_PAIRED_DEVICE), + adbMdnsLanDiscovery = preferences.read(ADB_MDNS_LAN_DISCOVERY), + ) + + suspend fun loadBundle() = loadBundle(::bundleFromPreferences) + + suspend fun saveBundle(new: Bundle) = saveBundle(bundleState.value, new, bundleFields) + + suspend fun updateBundle(transform: (Bundle) -> Bundle) { + saveBundle(transform(bundleState.value)) + } + // TODO? // fun validate(): Boolean = true } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt index ebba540..bc0af4b 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt @@ -1,7 +1,11 @@ package io.github.miuzarte.scrcpyforandroid.storage import android.content.Context +import android.os.Parcelable +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.StateFlow +import kotlinx.parcelize.Parcelize class QuickDevices(context: Context) : Settings(context, "QuickDevices") { companion object { @@ -17,4 +21,31 @@ class QuickDevices(context: Context) : Settings(context, "QuickDevices") { val quickDevicesList by setting(QUICK_DEVICES_LIST) val quickConnectInput by setting(QUICK_CONNECT_INPUT) + + @Parcelize + data class Bundle( + val quickDevicesList: String, + val quickConnectInput: String, + ) : Parcelable { + } + + private val bundleFields = arrayOf( + bundleField(QUICK_DEVICES_LIST) { bundle: Bundle -> bundle.quickDevicesList }, + bundleField(QUICK_CONNECT_INPUT) { bundle: Bundle -> bundle.quickConnectInput }, + ) + + val bundleState: StateFlow = createBundleState(::bundleFromPreferences) + + private fun bundleFromPreferences(preferences: Preferences) = Bundle( + quickDevicesList = preferences.read(QUICK_DEVICES_LIST), + quickConnectInput = preferences.read(QUICK_CONNECT_INPUT), + ) + + suspend fun loadBundle() = loadBundle(::bundleFromPreferences) + + suspend fun saveBundle(new: Bundle) = saveBundle(bundleState.value, new, bundleFields) + + suspend fun updateBundle(transform: (Bundle) -> Bundle) { + saveBundle(transform(bundleState.value)) + } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt index cdc896a..4075e9e 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt @@ -1,6 +1,8 @@ package io.github.miuzarte.scrcpyforandroid.storage import android.content.Context +import android.os.Parcelable +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey @@ -16,7 +18,9 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Orientation import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.OrientationLock import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.runBlocking +import kotlinx.parcelize.Parcelize class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { companion object { @@ -102,11 +106,11 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { ) val VIDEO_BIT_RATE = Pair( intPreferencesKey("video_bit_rate"), - 8000000, + 0, ) val AUDIO_BIT_RATE = Pair( intPreferencesKey("audio_bit_rate"), - 128000, + 0, ) val MAX_FPS = Pair( stringPreferencesKey("max_fps"), @@ -288,6 +292,186 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { val vdDestroyContent by setting(VD_DESTROY_CONTENT) val vdSystemDecorations by setting(VD_SYSTEM_DECORATIONS) + @Parcelize + data class Bundle( + val crop: String, + val recordFilename: String, + val videoCodecOptions: String, + val audioCodecOptions: String, + val videoEncoder: String, + val audioEncoder: String, + val cameraId: String, + val cameraSize: String, + val cameraSizeCustom: String, + val cameraSizeUseCustom: Boolean, + val cameraAr: String, + val cameraFps: Int, + val logLevel: String, + val videoCodec: String, + val audioCodec: String, + val videoSource: String, + val audioSource: String, + val recordFormat: String, + val cameraFacing: String, + val maxSize: Int, + val videoBitRate: Int, + val audioBitRate: Int, + val maxFps: String, + val angle: String, + val captureOrientation: Int, + val captureOrientationLock: String, + val displayOrientation: Int, + val recordOrientation: Int, + val displayImePolicy: String, + val displayId: Int, + val screenOffTimeout: Long, + val showTouches: Boolean, + val fullscreen: Boolean, + val control: Boolean, + val videoPlayback: Boolean, + val audioPlayback: Boolean, + val turnScreenOff: Boolean, + val stayAwake: Boolean, + val disableScreensaver: Boolean, + val powerOffOnClose: Boolean, + val cleanup: Boolean, + val powerOn: Boolean, + val video: Boolean, + val audio: Boolean, + val requireAudio: Boolean, + val killAdbOnClose: Boolean, + val cameraHighSpeed: Boolean, + val list: String, + val audioDup: Boolean, + val newDisplay: String, + val startApp: String, + val vdDestroyContent: Boolean, + val vdSystemDecorations: Boolean + ) : Parcelable { + } + + private val bundleFields = arrayOf( + bundleField(CROP) { bundle: Bundle -> bundle.crop }, + bundleField(RECORD_FILENAME) { bundle: Bundle -> bundle.recordFilename }, + bundleField(VIDEO_CODEC_OPTIONS) { bundle: Bundle -> bundle.videoCodecOptions }, + bundleField(AUDIO_CODEC_OPTIONS) { bundle: Bundle -> bundle.audioCodecOptions }, + bundleField(VIDEO_ENCODER) { bundle: Bundle -> bundle.videoEncoder }, + bundleField(AUDIO_ENCODER) { bundle: Bundle -> bundle.audioEncoder }, + bundleField(CAMERA_ID) { bundle: Bundle -> bundle.cameraId }, + bundleField(CAMERA_SIZE) { bundle: Bundle -> bundle.cameraSize }, + bundleField(CAMERA_SIZE_CUSTOM) { bundle: Bundle -> bundle.cameraSizeCustom }, + bundleField(CAMERA_SIZE_USE_CUSTOM) { bundle: Bundle -> bundle.cameraSizeUseCustom }, + bundleField(CAMERA_AR) { bundle: Bundle -> bundle.cameraAr }, + bundleField(CAMERA_FPS) { bundle: Bundle -> bundle.cameraFps }, + bundleField(LOG_LEVEL) { bundle: Bundle -> bundle.logLevel }, + bundleField(VIDEO_CODEC) { bundle: Bundle -> bundle.videoCodec }, + bundleField(AUDIO_CODEC) { bundle: Bundle -> bundle.audioCodec }, + bundleField(VIDEO_SOURCE) { bundle: Bundle -> bundle.videoSource }, + bundleField(AUDIO_SOURCE) { bundle: Bundle -> bundle.audioSource }, + bundleField(RECORD_FORMAT) { bundle: Bundle -> bundle.recordFormat }, + bundleField(CAMERA_FACING) { bundle: Bundle -> bundle.cameraFacing }, + bundleField(MAX_SIZE) { bundle: Bundle -> bundle.maxSize }, + bundleField(VIDEO_BIT_RATE) { bundle: Bundle -> bundle.videoBitRate }, + bundleField(AUDIO_BIT_RATE) { bundle: Bundle -> bundle.audioBitRate }, + bundleField(MAX_FPS) { bundle: Bundle -> bundle.maxFps }, + bundleField(ANGLE) { bundle: Bundle -> bundle.angle }, + bundleField(CAPTURE_ORIENTATION) { bundle: Bundle -> bundle.captureOrientation }, + bundleField(CAPTURE_ORIENTATION_LOCK) { bundle: Bundle -> bundle.captureOrientationLock }, + bundleField(DISPLAY_ORIENTATION) { bundle: Bundle -> bundle.displayOrientation }, + bundleField(RECORD_ORIENTATION) { bundle: Bundle -> bundle.recordOrientation }, + bundleField(DISPLAY_IME_POLICY) { bundle: Bundle -> bundle.displayImePolicy }, + bundleField(DISPLAY_ID) { bundle: Bundle -> bundle.displayId }, + bundleField(SCREEN_OFF_TIMEOUT) { bundle: Bundle -> bundle.screenOffTimeout }, + bundleField(SHOW_TOUCHES) { bundle: Bundle -> bundle.showTouches }, + bundleField(FULLSCREEN) { bundle: Bundle -> bundle.fullscreen }, + bundleField(CONTROL) { bundle: Bundle -> bundle.control }, + bundleField(VIDEO_PLAYBACK) { bundle: Bundle -> bundle.videoPlayback }, + bundleField(AUDIO_PLAYBACK) { bundle: Bundle -> bundle.audioPlayback }, + bundleField(TURN_SCREEN_OFF) { bundle: Bundle -> bundle.turnScreenOff }, + bundleField(STAY_AWAKE) { bundle: Bundle -> bundle.stayAwake }, + bundleField(DISABLE_SCREENSAVER) { bundle: Bundle -> bundle.disableScreensaver }, + bundleField(POWER_OFF_ON_CLOSE) { bundle: Bundle -> bundle.powerOffOnClose }, + bundleField(CLEANUP) { bundle: Bundle -> bundle.cleanup }, + bundleField(POWER_ON) { bundle: Bundle -> bundle.powerOn }, + bundleField(VIDEO) { bundle: Bundle -> bundle.video }, + bundleField(AUDIO) { bundle: Bundle -> bundle.audio }, + bundleField(REQUIRE_AUDIO) { bundle: Bundle -> bundle.requireAudio }, + bundleField(KILL_ADB_ON_CLOSE) { bundle: Bundle -> bundle.killAdbOnClose }, + bundleField(CAMERA_HIGH_SPEED) { bundle: Bundle -> bundle.cameraHighSpeed }, + bundleField(LIST) { bundle: Bundle -> bundle.list }, + bundleField(AUDIO_DUP) { bundle: Bundle -> bundle.audioDup }, + bundleField(NEW_DISPLAY) { bundle: Bundle -> bundle.newDisplay }, + bundleField(START_APP) { bundle: Bundle -> bundle.startApp }, + bundleField(VD_DESTROY_CONTENT) { bundle: Bundle -> bundle.vdDestroyContent }, + bundleField(VD_SYSTEM_DECORATIONS) { bundle: Bundle -> bundle.vdSystemDecorations }, + ) + + val bundleState: StateFlow = createBundleState(::bundleFromPreferences) + + private fun bundleFromPreferences(preferences: Preferences) = Bundle( + crop = preferences.read(CROP), + recordFilename = preferences.read(RECORD_FILENAME), + videoCodecOptions = preferences.read(VIDEO_CODEC_OPTIONS), + audioCodecOptions = preferences.read(AUDIO_CODEC_OPTIONS), + videoEncoder = preferences.read(VIDEO_ENCODER), + audioEncoder = preferences.read(AUDIO_ENCODER), + cameraId = preferences.read(CAMERA_ID), + cameraSize = preferences.read(CAMERA_SIZE), + cameraSizeCustom = preferences.read(CAMERA_SIZE_CUSTOM), + cameraSizeUseCustom = preferences.read(CAMERA_SIZE_USE_CUSTOM), + cameraAr = preferences.read(CAMERA_AR), + cameraFps = preferences.read(CAMERA_FPS), + logLevel = preferences.read(LOG_LEVEL), + videoCodec = preferences.read(VIDEO_CODEC), + audioCodec = preferences.read(AUDIO_CODEC), + videoSource = preferences.read(VIDEO_SOURCE), + audioSource = preferences.read(AUDIO_SOURCE), + recordFormat = preferences.read(RECORD_FORMAT), + cameraFacing = preferences.read(CAMERA_FACING), + maxSize = preferences.read(MAX_SIZE), + videoBitRate = preferences.read(VIDEO_BIT_RATE), + audioBitRate = preferences.read(AUDIO_BIT_RATE), + maxFps = preferences.read(MAX_FPS), + angle = preferences.read(ANGLE), + captureOrientation = preferences.read(CAPTURE_ORIENTATION), + captureOrientationLock = preferences.read(CAPTURE_ORIENTATION_LOCK), + displayOrientation = preferences.read(DISPLAY_ORIENTATION), + recordOrientation = preferences.read(RECORD_ORIENTATION), + displayImePolicy = preferences.read(DISPLAY_IME_POLICY), + displayId = preferences.read(DISPLAY_ID), + screenOffTimeout = preferences.read(SCREEN_OFF_TIMEOUT), + showTouches = preferences.read(SHOW_TOUCHES), + fullscreen = preferences.read(FULLSCREEN), + control = preferences.read(CONTROL), + videoPlayback = preferences.read(VIDEO_PLAYBACK), + audioPlayback = preferences.read(AUDIO_PLAYBACK), + turnScreenOff = preferences.read(TURN_SCREEN_OFF), + stayAwake = preferences.read(STAY_AWAKE), + disableScreensaver = preferences.read(DISABLE_SCREENSAVER), + powerOffOnClose = preferences.read(POWER_OFF_ON_CLOSE), + cleanup = preferences.read(CLEANUP), + powerOn = preferences.read(POWER_ON), + video = preferences.read(VIDEO), + audio = preferences.read(AUDIO), + requireAudio = preferences.read(REQUIRE_AUDIO), + killAdbOnClose = preferences.read(KILL_ADB_ON_CLOSE), + cameraHighSpeed = preferences.read(CAMERA_HIGH_SPEED), + list = preferences.read(LIST), + audioDup = preferences.read(AUDIO_DUP), + newDisplay = preferences.read(NEW_DISPLAY), + startApp = preferences.read(START_APP), + vdDestroyContent = preferences.read(VD_DESTROY_CONTENT), + vdSystemDecorations = preferences.read(VD_SYSTEM_DECORATIONS), + ) + + suspend fun loadBundle() = loadBundle(::bundleFromPreferences) + + suspend fun saveBundle(new: Bundle) = saveBundle(bundleState.value, new, bundleFields) + + suspend fun updateBundle(transform: (Bundle) -> Bundle) { + saveBundle(transform(bundleState.value)) + } + fun validate(): Boolean = runBlocking { runCatching { toClientOptions().validate() @@ -296,59 +480,61 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { } // TODO: 处理空值 - suspend fun toClientOptions() = ClientOptions( - crop = crop.get(), - recordFilename = recordFilename.get(), - videoCodecOptions = videoCodecOptions.get(), - audioCodecOptions = audioCodecOptions.get(), - videoEncoder = videoEncoder.get(), - audioEncoder = audioEncoder.get(), - cameraId = cameraId.get(), - cameraSize = if (!cameraSizeUseCustom.get()) cameraSize.get() else cameraSizeCustom.get(), - cameraAr = cameraAr.get(), - cameraFps = cameraFps.get().toUShort(), - logLevel = LogLevel.valueOf(logLevel.get().uppercase()), - videoCodec = Codec.fromString(videoCodec.get()), - audioCodec = Codec.fromString(audioCodec.get()), - videoSource = VideoSource.fromString(videoSource.get()), - audioSource = AudioSource.fromString(audioSource.get()), - recordFormat = ClientOptions.RecordFormat.valueOf(recordFormat.get().uppercase()), - cameraFacing = CameraFacing.fromString(cameraFacing.get()), - maxSize = maxSize.get().toUShort(), - videoBitRate = videoBitRate.get(), - audioBitRate = audioBitRate.get(), - maxFps = maxFps.get(), - angle = angle.get(), - captureOrientation = Orientation.fromInt(captureOrientation.get()), + fun toClientOptions() = toClientOptions(bundleState.value) + + fun toClientOptions(bundle: Bundle) = ClientOptions( + crop = bundle.crop, + recordFilename = bundle.recordFilename, + videoCodecOptions = bundle.videoCodecOptions, + audioCodecOptions = bundle.audioCodecOptions, + videoEncoder = bundle.videoEncoder, + audioEncoder = bundle.audioEncoder, + cameraId = bundle.cameraId, + cameraSize = if (!bundle.cameraSizeUseCustom) bundle.cameraSize else bundle.cameraSizeCustom, + cameraAr = bundle.cameraAr, + cameraFps = bundle.cameraFps.toUShort(), + logLevel = LogLevel.valueOf(bundle.logLevel.uppercase()), + videoCodec = Codec.fromString(bundle.videoCodec), + audioCodec = Codec.fromString(bundle.audioCodec), + videoSource = VideoSource.fromString(bundle.videoSource), + audioSource = AudioSource.fromString(bundle.audioSource), + recordFormat = ClientOptions.RecordFormat.valueOf(bundle.recordFormat.uppercase()), + cameraFacing = CameraFacing.fromString(bundle.cameraFacing), + maxSize = bundle.maxSize.toUShort(), + videoBitRate = bundle.videoBitRate, + audioBitRate = bundle.audioBitRate, + maxFps = bundle.maxFps, + angle = bundle.angle, + captureOrientation = Orientation.fromInt(bundle.captureOrientation), captureOrientationLock = OrientationLock.valueOf( - captureOrientationLock.get().uppercase() + bundle.captureOrientationLock.uppercase() ), - displayOrientation = Orientation.fromInt(displayOrientation.get()), - recordOrientation = Orientation.fromInt(recordOrientation.get()), - displayImePolicy = DisplayImePolicy.valueOf(displayImePolicy.get().uppercase()), - displayId = displayId.get(), - screenOffTimeout = Tick(screenOffTimeout.get()), - showTouches = showTouches.get(), - fullscreen = fullscreen.get(), - control = control.get(), - videoPlayback = videoPlayback.get(), - audioPlayback = audioPlayback.get(), - turnScreenOff = turnScreenOff.get(), - stayAwake = stayAwake.get(), - disableScreensaver = disableScreensaver.get(), - powerOffOnClose = powerOffOnClose.get(), - cleanUp = cleanup.get(), - powerOn = powerOn.get(), - video = video.get(), - audio = audio.get(), - requireAudio = requireAudio.get(), - killAdbOnClose = killAdbOnClose.get(), - cameraHighSpeed = cameraHighSpeed.get(), - list = ListOptions.valueOf(list.get().uppercase()), - audioDup = audioDup.get(), - newDisplay = newDisplay.get(), - startApp = startApp.get(), - vdDestroyContent = vdDestroyContent.get(), - vdSystemDecorations = vdSystemDecorations.get() + displayOrientation = Orientation.fromInt(bundle.displayOrientation), + recordOrientation = Orientation.fromInt(bundle.recordOrientation), + displayImePolicy = DisplayImePolicy.valueOf(bundle.displayImePolicy.uppercase()), + displayId = bundle.displayId, + screenOffTimeout = Tick(bundle.screenOffTimeout), + showTouches = bundle.showTouches, + fullscreen = bundle.fullscreen, + control = bundle.control, + videoPlayback = bundle.videoPlayback, + audioPlayback = bundle.audioPlayback, + turnScreenOff = bundle.turnScreenOff, + stayAwake = bundle.stayAwake, + disableScreensaver = bundle.disableScreensaver, + powerOffOnClose = bundle.powerOffOnClose, + cleanUp = bundle.cleanup, + powerOn = bundle.powerOn, + video = bundle.video, + audio = bundle.audio, + requireAudio = bundle.requireAudio, + killAdbOnClose = bundle.killAdbOnClose, + cameraHighSpeed = bundle.cameraHighSpeed, + list = ListOptions.valueOf(bundle.list.uppercase()), + audioDup = bundle.audioDup, + newDisplay = bundle.newDisplay, + startApp = bundle.startApp, + vdDestroyContent = bundle.vdDestroyContent, + vdSystemDecorations = bundle.vdSystemDecorations ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt index faf3e5d..4485978 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt @@ -18,10 +18,15 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlin.reflect.KProperty +import kotlin.time.Duration.Companion.milliseconds private const val TAG = "Settings" @@ -34,6 +39,8 @@ abstract class Settings( emptyPreferences() } ) { + private val settingsScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + data class Pair( val key: Preferences.Key, val defaultValue: T, @@ -41,6 +48,24 @@ abstract class Settings( val name: String get() = key.name } + protected fun Preferences.read(pair: Pair): T = + this[pair.key] ?: pair.defaultValue + + protected interface BundleField { + suspend fun persist(settings: Settings, current: B, new: B) + } + + protected fun bundleField(pair: Pair, selector: (B) -> T): BundleField = + object : BundleField { + override suspend fun persist(settings: Settings, current: B, new: B) { + val currentValue = selector(current) + val newValue = selector(new) + if (currentValue != newValue) { + settings.setValue(pair, newValue) + } + } + } + /** * 设置项委托类,自动提供 get/set/observe/asState/asMutableState 方法 */ @@ -90,6 +115,28 @@ abstract class Settings( protected fun setting(pair: Pair) = SettingProperty(pair) + protected fun createBundleState(reader: (Preferences) -> B): StateFlow = + dataStore.data + .map(reader) + .stateIn( + scope = settingsScope, + started = SharingStarted.Eagerly, + initialValue = runBlocking { loadBundle(reader) } + ) + + protected suspend fun loadBundle(reader: (Preferences) -> B): B = + reader(dataStore.data.first()) + + protected suspend fun saveBundle( + current: B, + new: B, + fields: Array>, + ) { + for (field in fields) { + field.persist(this, current, new) + } + } + protected suspend fun getValue(pair: Pair): T = dataStore.data.first()[pair.key] ?: pair.defaultValue @@ -123,4 +170,8 @@ abstract class Settings( } } } + + companion object { + val BUNDLE_SAVE_DELAY = 100.milliseconds + } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt index 95a9881..7bd35bc 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt @@ -2,8 +2,6 @@ package io.github.miuzarte.scrcpyforandroid.widgets import android.annotation.SuppressLint import android.graphics.SurfaceTexture -import android.util.Log -import android.view.MotionEvent import android.view.Surface import android.view.TextureView import androidx.activity.compose.BackHandler @@ -38,12 +36,15 @@ import androidx.compose.material.icons.rounded.Fullscreen import androidx.compose.material.icons.rounded.LinkOff import androidx.compose.material.icons.rounded.Wifi import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -54,7 +55,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInteropFilter import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -70,14 +70,16 @@ import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut -import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec +import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler +import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Storage -import kotlinx.coroutines.CoroutineScope +import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import top.yukonga.miuix.kmp.basic.Button @@ -86,6 +88,7 @@ import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.CardDefaults import top.yukonga.miuix.kmp.basic.CircularProgressIndicator import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.SnackbarHostState import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.TextButton import top.yukonga.miuix.kmp.basic.TextField @@ -98,17 +101,6 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme.isDynamicColor import top.yukonga.miuix.kmp.utils.PressFeedbackType import kotlin.math.roundToInt -private object UiMotionActions { - const val DOWN = 0 - const val UP = 1 - const val MOVE = 2 - const val CANCEL = 3 - const val POINTER_DOWN = 5 - const val POINTER_UP = 6 -} - -private const val FULLSCREEN_TOUCH_LOG_TAG = "FullscreenTouch" - @Composable internal fun StatusCard( statusLine: String, @@ -119,10 +111,8 @@ internal fun StatusCard( connectedDeviceLabel: String, ) { val appSettings = Storage.appSettings - - val context = LocalContext.current - - val themeBaseIndex by appSettings.themeBaseIndex.asState() + val appSettingsBundle by appSettings.bundleState.collectAsState() + val themeBaseIndex = appSettingsBundle.themeBaseIndex val cleanStatusLine = normalizeStatusLine(statusLine) @@ -359,8 +349,10 @@ internal fun VirtualButtonCard( @Composable internal fun ConfigPanel( busy: Boolean, + snack: SnackbarHostState, audioForwardingSupported: Boolean, cameraMirroringSupported: Boolean, + isQuickConnected: Boolean, onOpenAdvanced: () -> Unit, onStartStopHaptic: (() -> Unit)? = null, onStart: () -> Unit, @@ -368,49 +360,60 @@ internal fun ConfigPanel( sessionInfo: Scrcpy.Session.SessionInfo?, onDisconnect: () -> Unit = {}, ) { - val scrcpyOptions = Storage.scrcpyOptions - val quickDevices = Storage.quickDevices - - val context = LocalContext.current + val scope = rememberCoroutineScope() val sessionStarted = sessionInfo != null - - // Check if device exists in shortcuts - var quickDevicesList by quickDevices.quickDevicesList.asMutableState() - val savedShortcuts = remember(quickDevicesList) { - DeviceShortcuts.unmarshalFrom(quickDevicesList) + + val soBundleShared by scrcpyOptions.bundleState.collectAsState() + val soBundleSharedLatest by rememberUpdatedState(soBundleShared) + var soBundle by rememberSaveable(soBundleShared) { mutableStateOf(soBundleShared) } + val soBundleLatest by rememberUpdatedState(soBundle) + LaunchedEffect(soBundleShared) { + if (soBundle != soBundleShared) { + soBundle = soBundleShared + } } - val isQuickConnected = remember(sessionInfo, savedShortcuts) { - sessionInfo?.let { info -> - savedShortcuts.get(info.host, info.port) == null - } ?: false + LaunchedEffect(soBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (soBundle != soBundleSharedLatest) { + scrcpyOptions.saveBundle(soBundle) + } + } + DisposableEffect(Unit) { + onDispose { + scope.launch { + scrcpyOptions.saveBundle(soBundleLatest) + } + } } - var audio by scrcpyOptions.audio.asMutableState() + val audioBitRateVisibility = rememberSaveable(soBundle) { + soBundle.audio && (soBundle.audioCodec == "opus" || soBundle.audioCodec == "aac") + } - var audioCodec by scrcpyOptions.audioCodec.asMutableState() - val audioCodecItems = remember { Codec.AUDIO.map { it.displayName } } - val audioCodecIndex = Codec.AUDIO.indexOfFirst { - it.string == audioCodec - }.coerceAtLeast(0) - var audioBitRate by scrcpyOptions.audioBitRate.asMutableState() + val audioCodecItems = rememberSaveable { Codec.AUDIO.map { it.displayName } } + val audioCodecIndex = rememberSaveable(soBundle) { + Codec.AUDIO + .indexOfFirst { it.string == soBundle.audioCodec } + .coerceAtLeast(0) + } - var videoCodec by scrcpyOptions.videoCodec.asMutableState() - val videoCodecItems = remember { Codec.VIDEO.map { it.displayName } } - val videoCodecIndex = Codec.VIDEO.indexOfFirst { - it.string == videoCodec - }.coerceAtLeast(0) - var videoBitRate by scrcpyOptions.videoBitRate.asMutableState() - val videoBitRateMbps = videoBitRate / 1_000_000f + val videoCodecItems = rememberSaveable { Codec.VIDEO.map { it.displayName } } + val videoCodecIndex = rememberSaveable(soBundle) { + Codec.VIDEO + .indexOfFirst { it.string == soBundle.videoCodec } + .coerceAtLeast(0) + } SectionSmallTitle("Scrcpy") Card { SuperSwitch( title = "音频转发", summary = "转发设备音频到本机 (Android 11+)", - checked = audio, - onCheckedChange = { audio = it }, - enabled = !sessionStarted && audioForwardingSupported, + checked = soBundle.audio, + onCheckedChange = { soBundle = soBundle.copy(audio = it) }, + enabled = !sessionStarted + && audioForwardingSupported, ) SuperDropdown( @@ -418,28 +421,47 @@ internal fun ConfigPanel( summary = "--audio-codec", items = audioCodecItems, selectedIndex = audioCodecIndex, - onSelectedIndexChange = { audioCodec = Codec.AUDIO[it].string }, - enabled = !sessionStarted && audio, + onSelectedIndexChange = { + val codec = Codec.AUDIO[it] + soBundle = soBundle.copy(audioCodec = codec.string) + if (codec == Codec.FLAC) { + scope.launch { + snack.showSnackbar("注意:FLAC编解码会引入较大的延迟") + } + } + }, + enabled = !sessionStarted && soBundle.audio, ) - AnimatedVisibility(audio && (audioCodec == "opus" || audioCodec == "aac")) { + AnimatedVisibility(audioBitRateVisibility) { SuperSlider( title = "音频码率", summary = "--audio-bit-rate", - value = ScrcpyPresets.AudioBitRate.indexOfOrNearest(audioBitRate / 1000).toFloat(), + value = if (soBundle.audioBitRate <= 0) 0f + else (ScrcpyPresets.AudioBitRate + .indexOfOrNearest(soBundle.audioBitRate / 1000) + 1 + ).toFloat(), onValueChange = { value -> - val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex) - audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1000 + val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.size) + soBundle = soBundle.copy( + audioBitRate = + if (idx == 0) 0 + else ScrcpyPresets.AudioBitRate[idx - 1] * 1000 + ) }, - valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(), - steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0), - enabled = !sessionStarted, + valueRange = 0f..ScrcpyPresets.AudioBitRate.size.toFloat(), + steps = (ScrcpyPresets.AudioBitRate.size - 1).coerceAtLeast(0), unit = "Kbps", - displayText = (audioBitRate / 1000).toString(), - inputInitialValue = (audioBitRate / 1000).toString(), + zeroStateText = "默认", + displayText = (soBundle.audioBitRate / 1_000).toString(), + inputInitialValue = + if (soBundle.audioBitRate <= 0) "" + else (soBundle.audioBitRate / 1_000).toString(), inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 1f..UShort.MAX_VALUE.toFloat(), + inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), onInputConfirm = { raw -> - raw.toIntOrNull()?.takeIf { it > 0 }?.let { audioBitRate = it * 1000 } + raw.toIntOrNull() + ?.takeIf { it >= 0 } + ?.let { soBundle = soBundle.copy(audioBitRate = it * 1000) } }, ) } @@ -449,22 +471,35 @@ internal fun ConfigPanel( summary = "--video-codec", items = videoCodecItems, selectedIndex = videoCodecIndex, - onSelectedIndexChange = { videoCodec = Codec.VIDEO[it].string }, + onSelectedIndexChange = { + val codec = Codec.VIDEO[it] + soBundle = soBundle.copy(videoCodec = codec.string) + if (codec == Codec.AV1) { + scope.launch { + snack.showSnackbar("注意:绝大部分设备不支持AV1硬件编码") + } + } + }, enabled = !sessionStarted, ) + @SuppressLint("DefaultLocale") SuperSlider( title = "视频码率", summary = "--video-bit-rate", - value = videoBitRateMbps, + value = soBundle.videoBitRate / 1_000_000f, onValueChange = { mbps -> - videoBitRate = (mbps * 1_000_000).toInt() + soBundle = soBundle.copy( + videoBitRate = (mbps * 10).roundToInt() * (1_000_000 / 10) + ) }, - valueRange = 0.1f..40f, - steps = 399, - enabled = !sessionStarted, + valueRange = 0f..40f, + steps = 400 - 1, unit = "Mbps", - displayFormatter = { formatBitRate(it) }, - inputInitialValue = formatBitRate(videoBitRateMbps), + zeroStateText = "默认", + displayFormatter = { String.format("%.1f", it) }, + inputInitialValue = + if (soBundle.videoBitRate <= 0) "" + else String.format("%.1f", soBundle.videoBitRate / 1_000_000f), inputFilter = { text -> var dotUsed = false text.filter { ch -> @@ -479,14 +514,17 @@ internal fun ConfigPanel( } } }, - inputValueRange = 0.1f..UInt.MAX_VALUE.toFloat(), + inputValueRange = 0f..UInt.MAX_VALUE.toFloat(), onInputConfirm = { raw -> raw.toFloatOrNull()?.let { parsed -> - if (parsed >= 0.1f) { - videoBitRate = (parsed * 1_000_000).toInt() + if (parsed >= 0f) { + soBundle = soBundle.copy( + videoBitRate = (parsed * 1_000_000f).roundToInt() + ) } } }, + enabled = !sessionStarted, ) SuperArrow( @@ -503,53 +541,34 @@ internal fun ConfigPanel( .padding(bottom = UiSpacing.CardContent), horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), ) { - AnimatedVisibility(!isQuickConnected) { - AnimatedVisibility(!sessionStarted) { - TextButton( - text = "启动", - onClick = { - onStartStopHaptic?.invoke() - onStart() - }, - modifier = Modifier.fillMaxWidth(), - enabled = !busy, - colors = ButtonDefaults.textButtonColorsPrimary(), - ) - } - AnimatedVisibility(sessionStarted) { - TextButton( - text = "停止", - onClick = { - onStartStopHaptic?.invoke() - onStop() - }, - modifier = Modifier.fillMaxWidth(), - enabled = !busy, - ) - } - } - // display them at the same time in quick connection - AnimatedVisibility(isQuickConnected) { - TextButton( - text = "断开", - onClick = { - onStartStopHaptic?.invoke() - onDisconnect() - }, - modifier = Modifier.weight(1f/4f), - enabled = !busy, - ) - TextButton( - text = "启动", - onClick = { - onStartStopHaptic?.invoke() - onStart() - }, - modifier = Modifier.weight(3f/4f), - enabled = !busy, - colors = ButtonDefaults.textButtonColorsPrimary(), - ) - } + if (isQuickConnected) TextButton( + text = "断开", + onClick = { + onStartStopHaptic?.invoke() + onDisconnect() + }, + modifier = Modifier.weight(1f / 4f), + enabled = !busy, + ) + if (!sessionStarted) TextButton( + text = "启动", + onClick = { + onStartStopHaptic?.invoke() + onStart() + }, + modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f), + enabled = !busy, + colors = ButtonDefaults.textButtonColorsPrimary(), + ) + if (sessionStarted) TextButton( + text = "停止", + onClick = { + onStartStopHaptic?.invoke() + onStop() + }, + modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f), + enabled = !busy, + ) } } } @@ -694,21 +713,6 @@ private fun PairingDialog( ) } -@SuppressLint("DefaultLocale") -private fun formatBitRate(value: Float): String = String.format("%.1f", value) - -@Composable -internal fun LogsPanel(lines: List) { - Card { - TextField( - value = lines.joinToString(separator = "\n"), - onValueChange = {}, - readOnly = true, - modifier = Modifier.fillMaxWidth(), - ) - } -} - /** * TouchEventHandler * @@ -716,256 +720,6 @@ internal fun LogsPanel(lines: List) { * - Handles touch event processing for fullscreen control screen * - Manages pointer tracking, coordinate mapping, and touch injection */ -class TouchEventHandler( - private val coroutineScope: CoroutineScope, - private val session: Scrcpy.Session.SessionInfo, - private val touchAreaSize: IntSize, - private val activePointerIds: LinkedHashSet, - private val activePointerPositions: LinkedHashMap, - private val activePointerDevicePositions: LinkedHashMap>, - private val pointerLabels: LinkedHashMap, - private var nextPointerLabel: Int, - private val onInjectTouch: suspend (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit, - private val onActiveTouchCountChanged: (Int) -> Unit, - private val onActiveTouchDebugChanged: (String) -> Unit, - private val onNextPointerLabelChanged: (Int) -> Unit, -) { - private val eventPointerIds = HashSet(10) - private val eventPositions = HashMap(10) - private val eventPressures = HashMap(10) - private val justPressedPointerIds = HashSet(10) - - fun handleMotionEvent(event: MotionEvent): Boolean { - if (touchAreaSize.width == 0 || touchAreaSize.height == 0) { - return true - } - - val bounds = calculateContentBounds() - - if (event.actionMasked == MotionEvent.ACTION_CANCEL) { - return handleCancelAction(bounds) - } - - extractEventData(event) - handleDisappearedPointers(eventPointerIds, bounds) - - val endedPointerId = getEndedPointerId(event) - handlePointerDown(event, endedPointerId, bounds) - handlePointerMove(event, endedPointerId, bounds) - handlePointerUp(endedPointerId, bounds) - - onActiveTouchCountChanged(activePointerIds.size) - refreshTouchDebug() - return true - } - - private data class ContentBounds( - val width: Float, - val height: Float, - val left: Float, - val top: Float, - ) - - private fun calculateContentBounds(): ContentBounds { - val sessionAspect = if (session.height == 0) { - 16f / 9f - } else { - session.width.toFloat() / session.height.toFloat() - } - val containerWidth = touchAreaSize.width.toFloat() - val containerHeight = touchAreaSize.height.toFloat() - val containerAspect = containerWidth / containerHeight - - val contentWidth: Float - val contentHeight: Float - if (sessionAspect > containerAspect) { - contentWidth = containerWidth - contentHeight = containerWidth / sessionAspect - } else { - contentHeight = containerHeight - contentWidth = containerHeight * sessionAspect - } - val contentLeft = (containerWidth - contentWidth) / 2f - val contentTop = (containerHeight - contentHeight) / 2f - - return ContentBounds(contentWidth, contentHeight, contentLeft, contentTop) - } - - private fun isInsideContent(rawX: Float, rawY: Float, bounds: ContentBounds): Boolean { - return rawX in bounds.left..(bounds.left + bounds.width) && - rawY in bounds.top..(bounds.top + bounds.height) - } - - private fun mapToDevice(rawX: Float, rawY: Float, bounds: ContentBounds): Pair { - val normalizedX = ((rawX - bounds.left) / bounds.width).coerceIn(0f, 1f) - val normalizedY = ((rawY - bounds.top) / bounds.height).coerceIn(0f, 1f) - val x = (normalizedX * (session.width - 1).coerceAtLeast(0)).roundToInt() - .coerceIn(0, (session.width - 1).coerceAtLeast(0)) - val y = (normalizedY * (session.height - 1).coerceAtLeast(0)).roundToInt() - .coerceIn(0, (session.height - 1).coerceAtLeast(0)) - return x to y - } - - private fun getPointerLabel(pointerId: Int): Int { - val existing = pointerLabels[pointerId] - if (existing != null) { - return existing - } - val assigned = nextPointerLabel - nextPointerLabel += 1 - onNextPointerLabelChanged(nextPointerLabel) - pointerLabels[pointerId] = assigned - return assigned - } - - private fun refreshTouchDebug() { - if (activePointerIds.isEmpty()) { - onActiveTouchDebugChanged("") - return - } - val debug = activePointerIds - .sortedBy { getPointerLabel(it) } - .joinToString(separator = "\n") { pointerId -> - val label = getPointerLabel(pointerId) - val pos = activePointerDevicePositions[pointerId] - if (pos == null) { - "#$label(id=$pointerId):?" - } else { - "#$label(id=$pointerId):${pos.first},${pos.second}" - } - } - onActiveTouchDebugChanged(debug) - } - - private fun releasePointer(pointerId: Int, bounds: ContentBounds) { - if (!activePointerIds.contains(pointerId)) return - val pos = activePointerPositions[pointerId] ?: Offset.Zero - val (x, y) = mapToDevice(pos.x, pos.y, bounds) - coroutineScope.launch { - runCatching { - onInjectTouch(UiMotionActions.UP, pointerId.toLong(), x, y, 0f, 0) - }.onFailure { e -> - Log.w(FULLSCREEN_TOUCH_LOG_TAG, "releasePointer failed for pointerId=$pointerId", e) - } - } - activePointerIds -= pointerId - activePointerPositions.remove(pointerId) - activePointerDevicePositions.remove(pointerId) - pointerLabels.remove(pointerId) - } - - private fun handleCancelAction(bounds: ContentBounds): Boolean { - val toCancel = activePointerIds.toList() - for (pointerId in toCancel) { - releasePointer(pointerId, bounds) - } - onActiveTouchCountChanged(activePointerIds.size) - refreshTouchDebug() - return true - } - - private fun extractEventData(event: MotionEvent) { - eventPointerIds.clear() - eventPositions.clear() - eventPressures.clear() - for (i in 0 until event.pointerCount) { - val pointerId = event.getPointerId(i) - eventPointerIds += pointerId - eventPositions[pointerId] = Offset(event.getX(i), event.getY(i)) - eventPressures[pointerId] = event.getPressure(i).coerceIn(0f, 1f) - } - } - - private fun handleDisappearedPointers(eventPointerIds: Set, bounds: ContentBounds) { - val disappearedPointers = activePointerIds.filter { it !in eventPointerIds } - for (pointerId in disappearedPointers) { - releasePointer(pointerId, bounds) - } - } - - private fun getEndedPointerId(event: MotionEvent): Int? { - return when (event.actionMasked) { - MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> event.getPointerId(event.actionIndex) - else -> null - } - } - - private fun handlePointerDown( - event: MotionEvent, - endedPointerId: Int?, - bounds: ContentBounds, - ) { - justPressedPointerIds.clear() - for (i in 0 until event.pointerCount) { - val pointerId = event.getPointerId(i) - if (pointerId == endedPointerId) continue - val raw = eventPositions[pointerId] ?: continue - val pressure = eventPressures[pointerId] ?: 0f - if (!activePointerIds.contains(pointerId)) { - if (!isInsideContent(raw.x, raw.y, bounds)) continue - val (x, y) = mapToDevice(raw.x, raw.y, bounds) - activePointerIds += pointerId - activePointerPositions[pointerId] = raw - activePointerDevicePositions[pointerId] = x to y - justPressedPointerIds += pointerId - coroutineScope.launch { - runCatching { - onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, pressure, 0) - }.onFailure { e -> - Log.w( - FULLSCREEN_TOUCH_LOG_TAG, - "handlePointerDown failed for pointerId=$pointerId", - e - ) - } - } - } - } - } - - private fun handlePointerMove( - event: MotionEvent, - endedPointerId: Int?, - bounds: ContentBounds, - ) { - for (i in 0 until event.pointerCount) { - val pointerId = event.getPointerId(i) - if (!activePointerIds.contains(pointerId)) continue - if (pointerId == endedPointerId) continue - if (pointerId in justPressedPointerIds) continue - val raw = eventPositions[pointerId] ?: continue - val pressure = eventPressures[pointerId] ?: 0f - activePointerPositions[pointerId] = raw - val (x, y) = mapToDevice(raw.x, raw.y, bounds) - activePointerDevicePositions[pointerId] = x to y - coroutineScope.launch { - runCatching { - onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, pressure, 0) - }.onFailure { e -> - Log.w( - FULLSCREEN_TOUCH_LOG_TAG, - "handlePointerMove failed for pointerId=$pointerId", - e - ) - } - } - } - } - - private fun handlePointerUp( - endedPointerId: Int?, - bounds: ContentBounds, - ) { - if (endedPointerId != null) { - val endPos = eventPositions[endedPointerId] - if (endPos != null) { - activePointerPositions[endedPointerId] = endPos - } - releasePointer(endedPointerId, bounds) - } - } - -} /** * FullscreenControlScreen @@ -1121,14 +875,22 @@ private fun ScrcpyVideoSurface( nativeCore: NativeCoreFacade, session: Scrcpy.Session.SessionInfo?, ) { - val surfaceTag = "video-main" var currentSurface by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() LaunchedEffect(session, currentSurface) { val surface = currentSurface if (session != null && surface != null && surface.isValid) { - nativeCore.registerVideoSurface(surfaceTag, surface) + nativeCore.attachVideoSurface(surface) + } + } + + DisposableEffect(Unit) { + onDispose { + val surface = currentSurface + if (surface != null) { + scope.launch { nativeCore.detachVideoSurface(surface, releaseDecoder = false) } + } } } @@ -1148,7 +910,7 @@ private fun ScrcpyVideoSurface( // Register immediately when surface becomes available if (session != null) { scope.launch { - nativeCore.registerVideoSurface(surfaceTag, newSurface) + nativeCore.attachVideoSurface(newSurface) } } } @@ -1160,9 +922,15 @@ private fun ScrcpyVideoSurface( ) = Unit override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { - // Return false to keep the SurfaceTexture alive - // This prevents the surface from being destroyed when the view is detached - return false + val surface = currentSurface + if (surface != null) { + scope.launch { + nativeCore.detachVideoSurface(surface, releaseDecoder = false) + } + surface.release() + currentSurface = null + } + return true } override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) = Unit