refactor: use Parcelable Bundle for state management

- 在 ScrcpyOptions 中引入 Parcelable 数据类 `Bundle`,用于封装各项配置
- 更新了使用新 Bundle 结构读写配置的方法
- 使用 StateFlow 增强状态管理,实现响应式 UI 更新
- 重构 toClientOptions 方法,接受 Bundle 参数以提高清晰度和可维护性
- 调整音频和视频编解码器设置,使其使用新的 Bundle 结构
- 更新 ConfigPanel 以反映 ScrcpyOptions 的变化,并更有效地管理状态
- 移除 DeviceWidgets 中的未使用代码并清理导入
- 重构解码流程,驻留一个中转 Surface,解决先前 Surface 销毁后解码无法恢复的问题
This commit is contained in:
Miuzarte
2026-04-08 23:47:53 +08:00
parent e970c89e8f
commit 95dc24e676
23 changed files with 2976 additions and 1627 deletions

View File

@@ -4,10 +4,6 @@
- Refactoring - Refactoring
## BUGS
- Figure out is virtual display destroyed at the end of scrcpy session
## WIDGETS ## WIDGETS
- `SuperTextField` Click to pop a dialog with custom notes/summary - `SuperTextField` Click to pop a dialog with custom notes/summary

View File

@@ -1,6 +1,7 @@
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.compiler)
id("kotlin-parcelize")
} }
val defaultAbiList = listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") val defaultAbiList = listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@@ -4,7 +4,7 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import io.github.miuzarte.scrcpyforandroid.pages.MainPage import io.github.miuzarte.scrcpyforandroid.pages.MainScreen
import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration
import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.storage.Storage
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -25,7 +25,7 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
MainPage() MainScreen()
} }
} }
} }

View File

@@ -6,11 +6,11 @@ import android.os.Looper
import android.util.Log import android.util.Log
import android.view.Surface import android.view.Surface
import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder
import io.github.miuzarte.scrcpyforandroid.nativecore.PersistentVideoRenderer
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import java.util.ArrayDeque import java.util.ArrayDeque
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArraySet import java.util.concurrent.CopyOnWriteArraySet
/** /**
@@ -26,9 +26,9 @@ class NativeCoreFacade private constructor() {
private set private set
private val sessionLifecycleMutex = Mutex() private val sessionLifecycleMutex = Mutex()
private val surfaceMap = ConcurrentHashMap<String, Surface>() private val renderer = PersistentVideoRenderer()
private val surfaceIdentityMap = ConcurrentHashMap<String, Int>() private var activeSurfaceId: Int? = null
private val decoderMap = ConcurrentHashMap<String, AnnexBDecoder>() private var decoder: AnnexBDecoder? = null
private val videoSizeListeners = CopyOnWriteArraySet<(Int, Int) -> Unit>() private val videoSizeListeners = CopyOnWriteArraySet<(Int, Int) -> Unit>()
private val videoFpsListeners = CopyOnWriteArraySet<(Float) -> Unit>() private val videoFpsListeners = CopyOnWriteArraySet<(Float) -> Unit>()
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
@@ -45,71 +45,75 @@ class NativeCoreFacade private constructor() {
suspend fun close() { suspend fun close() {
sessionLifecycleMutex.withLock { sessionLifecycleMutex.withLock {
releaseAllDecoders() 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 * - If a decoder exists but cannot switch output surface, a new decoder is created
* and bound to the supplied surface. * and bound to the supplied surface.
* - This method must be called from the UI thread (it is called by the composable
* that owns the TextureView). Native decoder operations are performed synchronously
* on the UI thread only via the decoder API; heavy work happens inside the decoder.
*/ */
suspend fun registerVideoSurface(tag: String, surface: Surface) { suspend fun attachVideoSurface(surface: Surface) {
sessionLifecycleMutex.withLock { sessionLifecycleMutex.withLock {
if (!surface.isValid) { if (!surface.isValid) {
Log.w(TAG, "registerVideoSurface(): skip invalid surface for tag=$tag") Log.w(TAG, "attachVideoSurface(): skip invalid surface")
return return
} }
val newId = System.identityHashCode(surface) val newId = System.identityHashCode(surface)
val oldId = surfaceIdentityMap[tag] if (activeSurfaceId == newId && decoder != null) {
if (oldId != null && oldId == newId && decoderMap.containsKey(tag)) {
return return
} }
Log.i(TAG, "registerVideoSurface(): tag=$tag surfaceId=$newId oldSurfaceId=$oldId") Log.i(TAG, "attachVideoSurface(): surfaceId=$newId oldSurfaceId=$activeSurfaceId")
surfaceMap[tag] = surface activeSurfaceId = newId
surfaceIdentityMap[tag] = newId renderer.attachDisplaySurface(surface)
val session = currentSessionInfo ?: return val session = currentSessionInfo ?: return
// ensureVideoConsumerAttached() val currentDecoder = decoder
val decoder = decoderMap[tag] if (currentDecoder != null) {
if (decoder != null) { Log.i(TAG, "attachVideoSurface(): try switch decoder output to persistent surface")
val switched = decoder.switchOutputSurface(surface) val switched = currentDecoder.switchOutputSurface(renderer.getDecoderSurface())
Log.i(TAG, "registerVideoSurface(): switchOutputSurface tag=$tag success=$switched") Log.i(TAG, "attachVideoSurface(): switchOutputSurface success=$switched")
if (switched) { if (switched) {
return 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. * - If a stale surface reference is supplied (identity mismatch), the request is ignored.
* - When there is no active session, the decoder for the tag is released immediately. * - When [releaseDecoder] is false, only the active display target is cleared so a future
* - This protects the native decoder from feeding into a released Surface. * surface can attempt to rebind via `setOutputSurface()`.
* - When [releaseDecoder] is true, the current decoder is also released because the backing
* surface is being destroyed for real.
*/ */
suspend fun unregisterVideoSurface(tag: String, surface: Surface? = null) { suspend fun detachVideoSurface(surface: Surface? = null, releaseDecoder: Boolean = false) {
sessionLifecycleMutex.withLock { sessionLifecycleMutex.withLock {
val currentId = surfaceIdentityMap[tag] val currentId = activeSurfaceId
val requestId = surface?.let { System.identityHashCode(it) } val requestId = surface?.let { System.identityHashCode(it) }
if (requestId != null && currentId != null && requestId != currentId) { if (requestId != null && currentId != null && requestId != currentId) {
Log.i( Log.i(
TAG, TAG,
"unregisterVideoSurface(): skip stale request tag=$tag requestSurfaceId=$requestId currentSurfaceId=$currentId" "detachVideoSurface(): skip stale request requestSurfaceId=$requestId currentSurfaceId=$currentId"
) )
return return
} }
Log.i(TAG, "unregisterVideoSurface(): tag=$tag surfaceId=$requestId, releasing decoder") Log.i(
surfaceMap.remove(tag) TAG,
surfaceIdentityMap.remove(tag) "detachVideoSurface(): surfaceId=$requestId releaseDecoder=$releaseDecoder"
// Always release decoder when surface is unregistered )
// This ensures clean state when surface is recreated activeSurfaceId = null
decoderMap.remove(tag)?.release() 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() bootstrapPackets.clear()
latestConfigPacket = null latestConfigPacket = null
} }
if (activeSurfaceId != null) {
surfaceMap.forEach { (tag, surface) -> Log.i(TAG, "onScrcpySessionStarted(): bind decoder to persistent surface")
if (!surface.isValid) { createOrReplaceDecoder(session)
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)
} }
packetCount = 0 packetCount = 0
// if (!request.noVideo) {
// ensureVideoConsumerAttached()
// }
sessionMgr.attachVideoConsumer { packet -> sessionMgr.attachVideoConsumer { packet ->
cacheBootstrapPacket(packet) cacheBootstrapPacket(packet)
packetCount += 1 packetCount += 1
if (packetCount == 1L || packetCount % 120L == 0L) { if (packetCount == 1L || packetCount % 120L == 0L) {
Log.i( Log.i(
TAG, 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 currentDecoder = decoder ?: return@attachVideoConsumer
val decoders = decoderMap.toMap() if (activeSurfaceId == null) return@attachVideoConsumer
decoders.forEach { (tag, decoder) -> runCatching {
if (!surfaceIdentityMap.containsKey(tag)) { currentDecoder.feedAnnexB(
return@forEach packet.data,
} packet.ptsUs,
// Double-check decoder is still in map before feeding packet.isKeyFrame,
if (decoderMap[tag] != decoder) { packet.isConfig
return@forEach )
}
runCatching {
decoder.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]. * - Chooses MIME type from `session.codec` and constructs an [AnnexBDecoder].
* - The decoder's `onOutputSizeChanged` callback publishes size changes to * - The decoder's `onOutputSizeChanged` callback publishes size changes to
@@ -259,17 +246,15 @@ class NativeCoreFacade private constructor() {
* - Newly created decoders are fed with any cached bootstrap packets to allow * - Newly created decoders are fed with any cached bootstrap packets to allow
* faster playback startup. * faster playback startup.
*/ */
private fun createOrReplaceDecoder(tag: String, surface: Surface, session: Scrcpy.Session.SessionInfo) { private fun createOrReplaceDecoder(session: Scrcpy.Session.SessionInfo) {
if (!surface.isValid) { val surface = renderer.getDecoderSurface()
Log.w(TAG, "createOrReplaceDecoder(): skip invalid surface for tag=$tag") decoder?.release()
return decoder = null
}
decoderMap.remove(tag)?.release()
Log.i( Log.i(
TAG, 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, width = session.width,
height = session.height, height = session.height,
outputSurface = surface, outputSurface = surface,
@@ -303,8 +288,8 @@ class NativeCoreFacade private constructor() {
} }
}, },
) )
decoderMap[tag] = decoder decoder = newDecoder
replayBootstrapPackets(decoder) replayBootstrapPackets(newDecoder)
} }
private fun replayBootstrapPackets(decoder: AnnexBDecoder) { 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 * - 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. * cached into [bootstrapPackets] to allow late-attaching decoders to catch up.
* - The consumer iterates [decoderMap] and feeds each decoder. Errors are * - Kept only for documentation parity with the old multi-decoder design.
* isolated with `runCatching` so one codec failure doesn't stop others.
*/ */
@Deprecated("TODO: Determine if this is really unnecessary") @Deprecated("TODO: Determine if this is really unnecessary")
private suspend fun ensureVideoConsumerAttached(sessionMgr: Scrcpy.Session) { private suspend fun ensureVideoConsumerAttached(sessionMgr: Scrcpy.Session) {
@@ -366,35 +350,24 @@ class NativeCoreFacade private constructor() {
if (packetCount == 1L || packetCount % 120L == 0L) { if (packetCount == 1L || packetCount % 120L == 0L) {
Log.i( Log.i(
TAG, 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 currentDecoder = decoder ?: return@attachVideoConsumer
val decoders = decoderMap.toMap() if (activeSurfaceId == null) return@attachVideoConsumer
decoders.forEach { (tag, decoder) -> runCatching {
if (!surfaceIdentityMap.containsKey(tag)) { currentDecoder.feedAnnexB(
return@forEach packet.data,
} packet.ptsUs,
// Double-check decoder is still in map before feeding packet.isKeyFrame,
if (decoderMap[tag] != decoder) { packet.isConfig
return@forEach )
}
runCatching {
decoder.feedAnnexB(
packet.data,
packet.ptsUs,
packet.isKeyFrame,
packet.isConfig
)
}
} }
} }
} }
private fun releaseAllDecoders() { private fun releaseAllDecoders() {
decoderMap.values.forEach { decoder -> runCatching { decoder?.release() }
runCatching { decoder.release() } decoder = null
}
decoderMap.clear()
} }
} }

View File

@@ -0,0 +1,359 @@
package io.github.miuzarte.scrcpyforandroid.nativecore
import android.graphics.SurfaceTexture
import android.opengl.EGL14
import android.opengl.EGLConfig
import android.opengl.EGLContext
import android.opengl.EGLDisplay
import android.opengl.EGLSurface
import android.opengl.GLES11Ext
import android.opengl.GLES20
import android.opengl.Matrix
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import android.view.Surface
import java.util.concurrent.atomic.AtomicLong
/**
* Decoder always renders into a persistent SurfaceTexture-backed Surface.
* UI surfaces are display-only targets fed from that persistent texture via EGL.
*/
class PersistentVideoRenderer {
private val tag = "PersistentVideoRenderer"
private val renderThread = HandlerThread("PersistentVideoRenderer").apply { start() }
private val handler = Handler(renderThread.looper)
@Volatile
private var initialized = false
@Volatile
private var released = false
private var eglDisplay: EGLDisplay = EGL14.EGL_NO_DISPLAY
private var eglContext: EGLContext = EGL14.EGL_NO_CONTEXT
private var eglConfig: EGLConfig? = null
private var eglPbufferSurface: EGLSurface = EGL14.EGL_NO_SURFACE
private var displayEglSurface: EGLSurface = EGL14.EGL_NO_SURFACE
private var displaySurface: Surface? = null
private var displaySurfaceId: Int? = null
private var oesTextureId = 0
private var decoderSurfaceTexture: SurfaceTexture? = null
private var decoderSurface: Surface? = null
private val stMatrix = FloatArray(16)
private val mvpMatrix = FloatArray(16)
private val frameAvailableCount = AtomicLong(0)
private val frameConsumedCount = AtomicLong(0)
private val frameRenderedCount = AtomicLong(0)
private var program = 0
private var positionHandle = 0
private var texCoordHandle = 0
private var mvpMatrixHandle = 0
private var stMatrixHandle = 0
private var samplerHandle = 0
private val initLock = Object()
fun getDecoderSurface(): Surface {
ensureInitialized()
synchronized(initLock) {
return requireNotNull(decoderSurface) { "decoderSurface not initialized" }
}
}
fun attachDisplaySurface(surface: Surface) {
ensureInitialized()
val newId = System.identityHashCode(surface)
if (displaySurfaceId == newId) return
Log.i(tag, "attachDisplaySurface(): request surfaceId=$newId old=${displaySurfaceId}")
handler.post {
if (released || !surface.isValid) return@post
releaseDisplaySurfaceLocked()
displaySurface = surface
displaySurfaceId = newId
displayEglSurface = EGL14.eglCreateWindowSurface(
eglDisplay,
eglConfig,
surface,
intArrayOf(EGL14.EGL_NONE),
0
)
Log.i(tag, "attachDisplaySurface(): attached surfaceId=$newId")
drawFrame()
}
}
fun detachDisplaySurface(surface: Surface? = null, releaseSurface: Boolean = false) {
val requestId = surface?.let { System.identityHashCode(it) }
Log.i(tag, "detachDisplaySurface(): request surfaceId=$requestId releaseSurface=$releaseSurface current=${displaySurfaceId}")
handler.post {
if (released) return@post
if (requestId != null && requestId != displaySurfaceId) return@post
releaseDisplaySurfaceLocked()
if (releaseSurface) {
runCatching { surface?.release() }
}
}
}
fun release() {
handler.post {
if (released) return@post
released = true
releaseDisplaySurfaceLocked()
runCatching { decoderSurface?.release() }
decoderSurface = null
runCatching { decoderSurfaceTexture?.release() }
decoderSurfaceTexture = null
if (program != 0) {
GLES20.glDeleteProgram(program)
program = 0
}
if (oesTextureId != 0) {
GLES20.glDeleteTextures(1, intArrayOf(oesTextureId), 0)
oesTextureId = 0
}
if (eglDisplay !== EGL14.EGL_NO_DISPLAY) {
EGL14.eglMakeCurrent(
eglDisplay,
EGL14.EGL_NO_SURFACE,
EGL14.EGL_NO_SURFACE,
EGL14.EGL_NO_CONTEXT
)
if (eglPbufferSurface != EGL14.EGL_NO_SURFACE) {
EGL14.eglDestroySurface(eglDisplay, eglPbufferSurface)
}
EGL14.eglDestroyContext(eglDisplay, eglContext)
EGL14.eglTerminate(eglDisplay)
}
eglDisplay = EGL14.EGL_NO_DISPLAY
eglContext = EGL14.EGL_NO_CONTEXT
eglPbufferSurface = EGL14.EGL_NO_SURFACE
eglConfig = null
renderThread.quitSafely()
}
}
private fun ensureInitialized() {
if (initialized) return
synchronized(initLock) {
if (initialized) return
val latch = java.util.concurrent.CountDownLatch(1)
handler.post {
initializeLocked()
initialized = true
latch.countDown()
}
latch.await()
}
}
private fun initializeLocked() {
eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
check(eglDisplay != EGL14.EGL_NO_DISPLAY)
val version = IntArray(2)
check(EGL14.eglInitialize(eglDisplay, version, 0, version, 1))
val configs = arrayOfNulls<EGLConfig>(1)
val numConfigs = IntArray(1)
val attribs = intArrayOf(
EGL14.EGL_RENDERABLE_TYPE, 4,
EGL14.EGL_RED_SIZE, 8,
EGL14.EGL_GREEN_SIZE, 8,
EGL14.EGL_BLUE_SIZE, 8,
EGL14.EGL_ALPHA_SIZE, 8,
EGL14.EGL_NONE
)
check(EGL14.eglChooseConfig(eglDisplay, attribs, 0, configs, 0, 1, numConfigs, 0))
eglConfig = configs[0]
eglContext = EGL14.eglCreateContext(
eglDisplay,
eglConfig,
EGL14.EGL_NO_CONTEXT,
intArrayOf(0x3098, 2, EGL14.EGL_NONE),
0
)
check(eglContext != EGL14.EGL_NO_CONTEXT)
eglPbufferSurface = EGL14.eglCreatePbufferSurface(
eglDisplay,
eglConfig,
intArrayOf(EGL14.EGL_WIDTH, 1, EGL14.EGL_HEIGHT, 1, EGL14.EGL_NONE),
0
)
check(eglPbufferSurface != EGL14.EGL_NO_SURFACE)
check(EGL14.eglMakeCurrent(eglDisplay, eglPbufferSurface, eglPbufferSurface, eglContext))
oesTextureId = createExternalTexture()
decoderSurfaceTexture = SurfaceTexture(oesTextureId).apply {
setOnFrameAvailableListener({
val n = frameAvailableCount.incrementAndGet()
if (n == 1L || n % 120L == 0L) {
Log.i(tag, "onFrameAvailable(): available=$n consumed=${frameConsumedCount.get()} rendered=${frameRenderedCount.get()} display=${displaySurfaceId != null}")
}
drawFrame()
}, handler)
}
decoderSurface = Surface(decoderSurfaceTexture)
Log.i(tag, "initializeLocked(): decoder surface created")
Matrix.setIdentityM(stMatrix, 0)
Matrix.setIdentityM(mvpMatrix, 0)
Matrix.rotateM(mvpMatrix, 0, 180f, 0f, 0f, 1f)
Matrix.scaleM(mvpMatrix, 0, -1f, 1f, 1f)
program = createProgram(VERTEX_SHADER, FRAGMENT_SHADER)
positionHandle = GLES20.glGetAttribLocation(program, "aPosition")
texCoordHandle = GLES20.glGetAttribLocation(program, "aTexCoord")
mvpMatrixHandle = GLES20.glGetUniformLocation(program, "uMvpMatrix")
stMatrixHandle = GLES20.glGetUniformLocation(program, "uStMatrix")
samplerHandle = GLES20.glGetUniformLocation(program, "sTexture")
}
private fun drawFrame() {
if (released) return
val surfaceTexture = decoderSurfaceTexture ?: return
if (eglPbufferSurface == EGL14.EGL_NO_SURFACE) return
// Always consume decoder frames on the persistent context, even if there is no
// visible output surface right now. Otherwise the producer side can stall.
EGL14.eglMakeCurrent(eglDisplay, eglPbufferSurface, eglPbufferSurface, eglContext)
runCatching { surfaceTexture.updateTexImage() }
.onSuccess {
val consumed = frameConsumedCount.incrementAndGet()
if (consumed == 1L || consumed % 120L == 0L) {
Log.i(tag, "drawFrame(): consumed=$consumed available=${frameAvailableCount.get()} rendered=${frameRenderedCount.get()} display=${displaySurfaceId != null}")
}
}
.onFailure { Log.w(tag, "updateTexImage failed", it) }
surfaceTexture.getTransformMatrix(stMatrix)
if (displayEglSurface == EGL14.EGL_NO_SURFACE) {
return
}
val width = IntArray(1)
val height = IntArray(1)
EGL14.eglQuerySurface(eglDisplay, displayEglSurface, EGL14.EGL_WIDTH, width, 0)
EGL14.eglQuerySurface(eglDisplay, displayEglSurface, EGL14.EGL_HEIGHT, height, 0)
EGL14.eglMakeCurrent(eglDisplay, displayEglSurface, displayEglSurface, eglContext)
GLES20.glViewport(0, 0, width[0].coerceAtLeast(1), height[0].coerceAtLeast(1))
GLES20.glClearColor(0f, 0f, 0f, 1f)
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
GLES20.glUseProgram(program)
GLES20.glUniformMatrix4fv(mvpMatrixHandle, 1, false, mvpMatrix, 0)
GLES20.glUniformMatrix4fv(stMatrixHandle, 1, false, stMatrix, 0)
GLES20.glUniform1i(samplerHandle, 0)
VERTICES.position(0)
GLES20.glVertexAttribPointer(positionHandle, 2, GLES20.GL_FLOAT, false, 16, VERTICES)
GLES20.glEnableVertexAttribArray(positionHandle)
VERTICES.position(2)
GLES20.glVertexAttribPointer(texCoordHandle, 2, GLES20.GL_FLOAT, false, 16, VERTICES)
GLES20.glEnableVertexAttribArray(texCoordHandle)
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, oesTextureId)
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
EGL14.eglSwapBuffers(eglDisplay, displayEglSurface)
val rendered = frameRenderedCount.incrementAndGet()
if (rendered == 1L || rendered % 120L == 0L) {
Log.i(tag, "drawFrame(): rendered=$rendered consumed=${frameConsumedCount.get()} available=${frameAvailableCount.get()} viewport=${width[0]}x${height[0]}")
}
}
private fun releaseDisplaySurfaceLocked() {
if (displayEglSurface != EGL14.EGL_NO_SURFACE) {
EGL14.eglDestroySurface(eglDisplay, displayEglSurface)
displayEglSurface = EGL14.EGL_NO_SURFACE
}
if (displaySurfaceId != null) {
Log.i(tag, "releaseDisplaySurfaceLocked(): surfaceId=$displaySurfaceId")
}
displaySurface = null
displaySurfaceId = null
}
private fun createExternalTexture(): Int {
val textures = IntArray(1)
GLES20.glGenTextures(1, textures, 0)
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textures[0])
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_MIN_FILTER,
GLES20.GL_LINEAR
)
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_MAG_FILTER,
GLES20.GL_LINEAR
)
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_WRAP_S,
GLES20.GL_CLAMP_TO_EDGE
)
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_WRAP_T,
GLES20.GL_CLAMP_TO_EDGE
)
return textures[0]
}
private fun createProgram(vertexShader: String, fragmentShader: String): Int {
val vertex = compileShader(GLES20.GL_VERTEX_SHADER, vertexShader)
val fragment = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentShader)
return GLES20.glCreateProgram().also { program ->
GLES20.glAttachShader(program, vertex)
GLES20.glAttachShader(program, fragment)
GLES20.glLinkProgram(program)
}
}
private fun compileShader(type: Int, source: String): Int {
return GLES20.glCreateShader(type).also { shader ->
GLES20.glShaderSource(shader, source)
GLES20.glCompileShader(shader)
}
}
companion object {
private val VERTICES = java.nio.ByteBuffer.allocateDirect(4 * 4 * 4)
.order(java.nio.ByteOrder.nativeOrder())
.asFloatBuffer()
.apply {
put(
floatArrayOf(
-1f, -1f, 0f, 1f,
1f, -1f, 1f, 1f,
-1f, 1f, 0f, 0f,
1f, 1f, 1f, 0f,
)
)
position(0)
}
private const val VERTEX_SHADER = """
attribute vec4 aPosition;
attribute vec4 aTexCoord;
uniform mat4 uMvpMatrix;
uniform mat4 uStMatrix;
varying vec2 vTexCoord;
void main() {
gl_Position = uMvpMatrix * aPosition;
vTexCoord = (uStMatrix * aTexCoord).xy;
}
"""
private const val FRAGMENT_SHADER = """
#extension GL_OES_EGL_image_external : require
precision mediump float;
varying vec2 vTexCoord;
uniform samplerExternalOES sTexture;
void main() {
gl_FragColor = texture2D(sTexture, vTexCoord);
}
"""
}
}

View File

@@ -4,20 +4,26 @@ import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.itemsIndexed 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.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.constants.Defaults import io.github.miuzarte.scrcpyforandroid.constants.Defaults
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing 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.DeviceShortcut
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService 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.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.services.EventLogger import io.github.miuzarte.scrcpyforandroid.services.EventLogger
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo 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.ConfigPanel
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceEditorScreen import io.github.miuzarte.scrcpyforandroid.widgets.DeviceEditorScreen
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTile 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.PairingCard
import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard
import io.github.miuzarte.scrcpyforandroid.widgets.QuickConnectCard 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.SectionSmallTitle
import io.github.miuzarte.scrcpyforandroid.widgets.StatusCard import io.github.miuzarte.scrcpyforandroid.widgets.StatusCard
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
@@ -51,9 +58,21 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout 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.ScrollBehavior
import top.yukonga.miuix.kmp.basic.SnackbarHostState 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.InetSocketAddress
import java.net.Socket import java.net.Socket
@@ -66,6 +85,71 @@ private const val ADB_TCP_PROBE_TIMEOUT_MS = 500
@Composable @Composable
fun DeviceTabScreen( 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, contentPadding: PaddingValues,
nativeCore: NativeCoreFacade, nativeCore: NativeCoreFacade,
adbService: NativeAdbService, adbService: NativeAdbService,
@@ -73,26 +157,60 @@ fun DeviceTabScreen(
snack: SnackbarHostState, snack: SnackbarHostState,
scrollBehavior: ScrollBehavior, scrollBehavior: ScrollBehavior,
onSessionStartedChange: (Boolean) -> Unit, onSessionStartedChange: (Boolean) -> Unit,
onClearLogsActionChange: ((() -> Unit)) -> Unit,
onCanClearLogsChange: (Boolean) -> Unit,
onOpenReorderDevicesActionChange: ((() -> Unit)) -> Unit,
onOpenAdvancedPage: () -> Unit, onOpenAdvancedPage: () -> Unit,
onOpenFullscreenPage: (Scrcpy.Session.SessionInfo) -> Unit, onOpenFullscreenPage: (Scrcpy.Session.SessionInfo) -> Unit,
) { ) {
val appSettings = Storage.appSettings val scope = rememberCoroutineScope()
val quickDevices = Storage.quickDevices
val scrcpyOptions = Storage.scrcpyOptions
val context = LocalContext.current
val haptics = rememberAppHaptics() val haptics = rememberAppHaptics()
val virtualButtonsLayout by appSettings.virtualButtonsLayout.asState() val asBundleShared by appSettings.bundleState.collectAsState()
val virtualButtonLayout = remember(virtualButtonsLayout) { val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
VirtualButtonActions.splitLayout(VirtualButtonActions.parseStoredLayout(virtualButtonsLayout)) 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) } LaunchedEffect(asBundle) {
val scope = rememberCoroutineScope() 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. // Run adb operations on a dedicated single thread.
// Try to avoid blocking UI/recomposition and keeps adb call ordering deterministic. // 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 previewControlsVisible by rememberSaveable { mutableStateOf(false) }
var editingDevice by rememberSaveable { mutableStateOf<DeviceShortcut?>(null) } var editingDevice by rememberSaveable { mutableStateOf<DeviceShortcut?>(null) }
var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(null) } var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(null) }
var showReorderSheet by rememberSaveable { mutableStateOf(false) }
var adbConnecting by rememberSaveable { mutableStateOf(false) } var adbConnecting by rememberSaveable { mutableStateOf(false) }
var audioForwardingSupported by rememberSaveable { mutableStateOf(true) } var audioForwardingSupported by rememberSaveable { mutableStateOf(true) }
@@ -146,34 +263,32 @@ fun DeviceTabScreen(
val currentTarget = val currentTarget =
if (currentTargetHost.isNotBlank()) if (currentTargetHost.isNotBlank())
ConnectionTarget( ConnectionTarget(currentTargetHost, currentTargetPort)
currentTargetHost, else null
currentTargetPort,
) else null
val sessionReconnectBlacklistHosts = remember { mutableSetOf<String>() } val sessionReconnectBlacklistHosts = remember { mutableSetOf<String>() }
LaunchedEffect(EventLogger.eventLog.size) { val virtualButtonLayout = remember(asBundle.virtualButtonsLayout) {
onCanClearLogsChange(EventLogger.hasLogs()) VirtualButtonActions.splitLayout(
VirtualButtonActions.parseStoredLayout(asBundle.virtualButtonsLayout)
)
} }
var quickDevicesList by quickDevices.quickDevicesList.asMutableState() var savedShortcuts by remember {
var savedShortcuts by remember { mutableStateOf(DeviceShortcuts.unmarshalFrom(quickDevicesList)) } mutableStateOf(DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList))
}
LaunchedEffect(quickDevicesList) { LaunchedEffect(qdBundle.quickDevicesList) {
savedShortcuts = DeviceShortcuts.unmarshalFrom(quickDevicesList) savedShortcuts = DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList)
} }
// save changes when [savedShortcuts] was modified // save changes when [savedShortcuts] was modified
LaunchedEffect(savedShortcuts) { LaunchedEffect(savedShortcuts) {
val serialized = savedShortcuts.marshalToString() val serialized = savedShortcuts.marshalToString()
if (serialized != quickDevicesList) { if (serialized != qdBundle.quickDevicesList) {
quickDevicesList = serialized 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. * Disconnect the current ADB connection and stop any running scrcpy session.
* *
@@ -236,14 +351,13 @@ fun DeviceTabScreen(
disconnectAdbConnection(clearQuickOnlineForTarget = current) disconnectAdbConnection(clearQuickOnlineForTarget = current)
} }
var audio by scrcpyOptions.audio.asMutableState()
var videoSource by scrcpyOptions.videoSource.asMutableState()
fun applyConnectedDeviceCapabilities(sdkInt: Int, release: String) { fun applyConnectedDeviceCapabilities(sdkInt: Int, release: String) {
val audioSupported = sdkInt !in 0..<30 val audioSupported = sdkInt !in 0..<30
audioForwardingSupported = audioSupported audioForwardingSupported = audioSupported
if (!audioSupported && audio) { if (!audioSupported && soBundleShared.audio) {
audio = false scope.launch {
scrcpyOptions.updateBundle { it.copy(audio = false) }
}
logEvent( logEvent(
"设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持音频转发,已自动关闭", "设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持音频转发,已自动关闭",
Log.WARN Log.WARN
@@ -251,8 +365,10 @@ fun DeviceTabScreen(
} }
val cameraSupported = sdkInt !in 0..<31 val cameraSupported = sdkInt !in 0..<31
cameraMirroringSupported = cameraSupported cameraMirroringSupported = cameraSupported
if (!cameraSupported && videoSource == "camera") { if (!cameraSupported && soBundleShared.videoSource == "camera") {
videoSource = "display" scope.launch {
scrcpyOptions.updateBundle { it.copy(videoSource = "display") }
}
logEvent( logEvent(
"设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持 camera mirroring已切换为 display", "设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持 camera mirroring已切换为 display",
Log.WARN Log.WARN
@@ -405,20 +521,17 @@ fun DeviceTabScreen(
} }
} }
var videoEncoder by scrcpyOptions.videoEncoder.asMutableState()
var audioEncoder by scrcpyOptions.audioEncoder.asMutableState()
suspend fun refreshEncoderLists() { suspend fun refreshEncoderLists() {
if (!adbConnected) return if (!adbConnected) return
runCatching { runCatching {
scrcpy.refreshEncoders() scrcpy.refreshEncoders()
}.onSuccess { }.onSuccess {
// Validate current selections // Validate current selections
if (videoEncoder.isNotBlank() && videoEncoder !in scrcpy.videoEncoders) { if (soBundleShared.videoEncoder.isNotBlank() && soBundleShared.videoEncoder !in scrcpy.videoEncoders) {
videoEncoder = "" scrcpyOptions.updateBundle { it.copy(videoEncoder = "") }
} }
if (audioEncoder.isNotBlank() && audioEncoder !in scrcpy.audioEncoders) { if (soBundleShared.audioEncoder.isNotBlank() && soBundleShared.audioEncoder !in scrcpy.audioEncoders) {
audioEncoder = "" scrcpyOptions.updateBundle { it.copy(audioEncoder = "") }
} }
logEvent("编码器列表已刷新: video=${scrcpy.videoEncoders.size} audio=${scrcpy.audioEncoders.size}") logEvent("编码器列表已刷新: video=${scrcpy.videoEncoders.size} audio=${scrcpy.audioEncoders.size}")
if (scrcpy.videoEncoders.isEmpty() && scrcpy.audioEncoders.isEmpty()) { if (scrcpy.videoEncoders.isEmpty() && scrcpy.audioEncoders.isEmpty()) {
@@ -436,16 +549,18 @@ fun DeviceTabScreen(
} }
} }
var cameraSize by scrcpyOptions.cameraSize.asMutableState()
suspend fun refreshCameraSizeLists() { suspend fun refreshCameraSizeLists() {
if (!adbConnected) return if (!adbConnected) return
runCatching { runCatching {
scrcpy.refreshCameraSizes() scrcpy.refreshCameraSizes()
}.onSuccess { }.onSuccess {
// Validate current selection // Validate current selection
if (cameraSize.isNotBlank() && cameraSize != "custom" && cameraSize !in scrcpy.cameraSizes) { if (
cameraSize = "" soBundleShared.cameraSize.isNotBlank()
&& soBundleShared.cameraSize != "custom"
&& soBundleShared.cameraSize !in scrcpy.cameraSizes
) {
scrcpyOptions.updateBundle { it.copy(cameraSize = "") }
} }
logEvent("camera sizes 已刷新: count=${scrcpy.cameraSizes.size}") logEvent("camera sizes 已刷新: count=${scrcpy.cameraSizes.size}")
}.onFailure { e -> }.onFailure { e ->
@@ -543,9 +658,9 @@ fun DeviceTabScreen(
} }
} }
val adbPairingAutoDiscoverOnDialogOpen by appSettings.adbPairingAutoDiscoverOnDialogOpen.asState() val adbPairingAutoDiscoverOnDialogOpen = asBundle.adbPairingAutoDiscoverOnDialogOpen
val adbAutoReconnectPairedDevice by appSettings.adbAutoReconnectPairedDevice.asState() val adbAutoReconnectPairedDevice = asBundle.adbAutoReconnectPairedDevice
val adbMdnsLanDiscovery by appSettings.adbMdnsLanDiscovery.asState() val adbMdnsLanDiscovery = asBundle.adbMdnsLanDiscovery
LaunchedEffect(adbConnected, adbAutoReconnectPairedDevice, adbMdnsLanDiscovery) { LaunchedEffect(adbConnected, adbAutoReconnectPairedDevice, adbMdnsLanDiscovery) {
if (adbConnected || !adbAutoReconnectPairedDevice) return@LaunchedEffect if (adbConnected || !adbAutoReconnectPairedDevice) return@LaunchedEffect
@@ -676,44 +791,6 @@ fun DeviceTabScreen(
onSessionStartedChange(sessionInfo != null) 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) { fun sendVirtualButtonAction(action: VirtualButtonAction) {
val keycode = action.keycode ?: return val keycode = action.keycode ?: return
runBusy("发送 ${action.title}") { runBusy("发送 ${action.title}") {
@@ -744,14 +821,14 @@ fun DeviceTabScreen(
return return
} }
val devicePreviewCardHeightDp by appSettings.devicePreviewCardHeightDp.asState() val devicePreviewCardHeightDp = asBundle.devicePreviewCardHeightDp
val previewVirtualButtonShowText by appSettings.previewVirtualButtonShowText.asState() val previewVirtualButtonShowText = asBundle.previewVirtualButtonShowText
val audioBitRate by scrcpyOptions.audioBitRate.asState() val audioBitRate = soBundleShared.audioBitRate
val videoBitRate by scrcpyOptions.videoBitRate.asState() val videoBitRate = soBundleShared.videoBitRate
// 设备 // 设备
AppPageLazyColumn( LazyColumn(
contentPadding = contentPadding, contentPadding = contentPadding,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
) { ) {
@@ -831,26 +908,27 @@ fun DeviceTabScreen(
if (!adbConnected) item { if (!adbConnected) item {
// "快速连接" // "快速连接"
QuickConnectCard( QuickConnectCard(
input = quickConnectInputTemp, input = qdBundle.quickConnectInput,
onValueChange = { onValueChange = {
quickConnectInputTemp = it qdBundle = qdBundle.copy(quickConnectInput = it)
quickConnectInput = quickConnectInputTemp
}, },
// onFocusChange = { quickConnectInput = quickConnectInputTemp },
enabled = !adbConnecting, enabled = !adbConnecting,
onAddDevice = { onAddDevice = {
val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp) val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput)
?: return@QuickConnectCard ?: return@QuickConnectCard
savedShortcuts = savedShortcuts.upsert( savedShortcuts = savedShortcuts.upsert(
DeviceShortcut(host = target.host, port = target.port) 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 { scope.launch {
snack.showSnackbar("已添加设备: ${target.host}:${target.port}") snack.showSnackbar("已添加设备: ${target.host}:${target.port}")
} }
}, },
onConnect = { onConnect = {
val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp) val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput)
?: return@QuickConnectCard ?: return@QuickConnectCard
runAdbConnect( runAdbConnect(
"连接 ADB", "连接 ADB",
@@ -914,13 +992,15 @@ fun DeviceTabScreen(
item { item {
ConfigPanel( ConfigPanel(
busy = busy, busy = busy,
snack = snack,
audioForwardingSupported = audioForwardingSupported, audioForwardingSupported = audioForwardingSupported,
cameraMirroringSupported = cameraMirroringSupported, cameraMirroringSupported = cameraMirroringSupported,
isQuickConnected = isQuickConnected,
onOpenAdvanced = onOpenAdvancedPage, onOpenAdvanced = onOpenAdvancedPage,
onStartStopHaptic = { haptics.contextClick() }, onStartStopHaptic = { haptics.contextClick() },
onStart = { onStart = {
runBusy("启动 scrcpy") { runBusy("启动 scrcpy") {
val options = scrcpyOptions.toClientOptions() val options = scrcpyOptions.toClientOptions(soBundleShared)
val session = scrcpy.start(options) val session = scrcpy.start(options)
sessionInfo = session.copy( sessionInfo = session.copy(
host = currentTargetHost, host = currentTargetHost,
@@ -928,18 +1008,17 @@ fun DeviceTabScreen(
) )
statusLine = "scrcpy 运行中" statusLine = "scrcpy 运行中"
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
val videoDetail = if (!options.video) { val videoDetail =
"off" if (!options.video) "off"
} else { else if (videoBitRate <= 0) "${session.codecName} ${session.width}x${session.height} @default"
"${session.codecName} ${session.width}x${session.height} " + else "${session.codecName} ${session.width}x${session.height} " +
"@${String.format("%.1f", videoBitRate / 1_000_000f)}Mbps" "@${String.format("%.1f", videoBitRate / 1_000_000f)}Mbps"
}
val audioDetail = if (!audio) { val audioDetail =
"off" if (!soBundleShared.audio) "off"
} else { else if (audioBitRate <= 0) "${options.audioCodec} default source=${options.audioSource}"
val playback = if (!options.audioPlayback) "(no-playback)" else "" else "${options.audioCodec} ${audioBitRate / 1_000f}Kbps source=${options.audioSource}${if (!options.audioPlayback) "(no-playback)" else ""}"
"${options.audioCodec} ${videoBitRate / 1_000f}Kbps source=${options.audioSource}$playback"
}
logEvent( logEvent(
"scrcpy 已启动: device=${session.deviceName}" + "scrcpy 已启动: device=${session.deviceName}" +
", video=$videoDetail, audio=$audioDetail" + ", video=$videoDetail, audio=$audioDetail" +
@@ -1017,9 +1096,91 @@ fun DeviceTabScreen(
if (EventLogger.hasLogs()) item { if (EventLogger.hasLogs()) item {
Spacer(Modifier.height(UiSpacing.PageItem)) 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)) } 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),
)
}

View File

@@ -8,10 +8,15 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -22,43 +27,65 @@ import androidx.core.view.WindowInsetsControllerCompat
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.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.FullscreenControlScreen
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.Scaffold
data class FullscreenControlLaunch(
val deviceName: String,
val width: Int,
val height: Int,
val codec: String,
)
@Composable @Composable
fun FullscreenControlPage( fun FullscreenControlScreen(
launch: FullscreenControlLaunch, onBack: () -> Unit,
session: Scrcpy.Session.SessionInfo,
nativeCore: NativeCoreFacade, nativeCore: NativeCoreFacade,
onVideoSizeChanged: (width: Int, height: Int) -> Unit, onVideoSizeChanged: (width: Int, height: Int) -> Unit,
onDismiss: () -> Unit,
) { ) {
// Disable predictive back handler temporarily to avoid decoding issues. // Disable predictive back handler temporarily to avoid decoding issues.
BackHandler(enabled = true, onBack = onDismiss) BackHandler(enabled = true, onBack = onBack)
val appSettings = Storage.appSettings
val context = LocalContext.current val context = LocalContext.current
val haptics = rememberAppHaptics() val haptics = rememberAppHaptics()
val scope = rememberCoroutineScope()
val activity = remember(context) { context as? Activity } val activity = remember(context) { context as? Activity }
var virtualButtonsLayout by appSettings.virtualButtonsLayout.asMutableState() val asBundleShared by appSettings.bundleState.collectAsState()
val buttonItems = remember(virtualButtonsLayout) { val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
VirtualButtonActions.splitLayout(VirtualButtonActions.parseStoredLayout(virtualButtonsLayout)) 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() LaunchedEffect(asBundle) {
val showFullscreenVirtualButtons by appSettings.showFullscreenVirtualButtons.asState() 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) { val bar = remember(buttonItems) {
VirtualButtonBar( VirtualButtonBar(
@@ -66,19 +93,7 @@ fun FullscreenControlPage(
moreActions = buttonItems.second, 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) } var currentFps by remember { mutableFloatStateOf(0f) }
DisposableEffect(activity) { DisposableEffect(activity) {
@@ -103,7 +118,6 @@ fun FullscreenControlPage(
DisposableEffect(nativeCore) { DisposableEffect(nativeCore) {
val listener: (Int, Int) -> Unit = { w, h -> val listener: (Int, Int) -> Unit = { w, h ->
session = session.copy(width = w, height = h)
onVideoSizeChanged(w, h) onVideoSizeChanged(w, h)
} }
nativeCore.addVideoSizeListener(listener) nativeCore.addVideoSizeListener(listener)
@@ -124,8 +138,10 @@ fun FullscreenControlPage(
suspend fun sendKeycode(keycode: Int) { suspend fun sendKeycode(keycode: Int) {
runCatching { runCatching {
nativeCore.session?.injectKeycode(0, keycode) withContext(Dispatchers.IO) {
nativeCore.session?.injectKeycode(1, keycode) nativeCore.session?.injectKeycode(0, keycode)
nativeCore.session?.injectKeycode(1, keycode)
}
}.onFailure { e -> }.onFailure { e ->
android.util.Log.w("FullscreenControlPage", "sendKeycode failed for keycode=$keycode", e) android.util.Log.w("FullscreenControlPage", "sendKeycode failed for keycode=$keycode", e)
} }
@@ -140,22 +156,24 @@ fun FullscreenControlPage(
FullscreenControlScreen( FullscreenControlScreen(
session = session, session = session,
nativeCore = nativeCore, nativeCore = nativeCore,
onDismiss = onDismiss, onDismiss = onBack,
showDebugInfo = fullscreenDebugInfo, showDebugInfo = fullscreenDebugInfo,
currentFps = currentFps, currentFps = currentFps,
enableBackHandler = false, enableBackHandler = false,
onInjectTouch = { action, pointerId, x, y, pressure, buttons -> onInjectTouch = { action, pointerId, x, y, pressure, buttons ->
nativeCore.session?.injectTouch( withContext(Dispatchers.IO) {
action = action, nativeCore.session?.injectTouch(
pointerId = pointerId, action = action,
x = x, pointerId = pointerId,
y = y, x = x,
screenWidth = session.width, y = y,
screenHeight = session.height, screenWidth = session.width,
pressure = pressure, screenHeight = session.height,
actionButton = 0, pressure = pressure,
buttons = buttons, actionButton = 0,
) buttons = buttons,
)
}
}, },
) )

View File

@@ -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<NavKey>(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<NavKey> {
entry(RootScreen.Home) {
Scaffold(
bottomBar = {
NavigationBar {
tabs.forEach { tab ->
NavigationBarItem(
selected = currentTab == tab,
onClick = {
scope.launch {
pagerState.animateScrollToPage(
page = tab.ordinal,
animationSpec = spring(
dampingRatio = UiMotion.PAGE_SWITCH_DAMPING_RATIO,
stiffness = UiMotion.PAGE_SWITCH_STIFFNESS,
),
)
}
},
icon = tab.icon,
label = tab.label,
)
}
}
},
snackbarHost = { SnackbarHost(snackHostState) },
) { contentPadding ->
HorizontalPager(
modifier = Modifier
.fillMaxSize()
.padding(bottom = contentPadding.calculateBottomPadding()),
state = pagerState,
beyondViewportPageCount = 1,
) { page ->
val tab = tabs[page]
saveableStateHolder.SaveableStateProvider(tab.name) {
when (tab) {
MainTabDestination.Device -> Scaffold(
topBar = {
TopAppBar(
title = tab.title,
actions = {
IconButton(
onClick = { showDeviceMenu = true },
holdDownState = showDeviceMenu,
) {
Icon(
Icons.Rounded.MoreVert,
contentDescription = "更多"
)
}
DeviceMenuPopup(
show = showDeviceMenu,
canClearLogs = canClearLogs,
onDismissRequest = { showDeviceMenu = false },
onReorderDevices = {
openReorderDevicesAction()
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<RootScreen.Fullscreen> { 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),
)
}

View File

@@ -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<NavKey>(RootScreen.Home) }
val currentRootScreen = rootBackStack.lastOrNull() as? RootScreen ?: RootScreen.Home
val devicesPageScrollBehavior = MiuixScrollBehavior(
canScroll = { currentTab == MainBottomTabDestination.Device })
val settingsPageScrollBehavior = MiuixScrollBehavior(
canScroll = { currentTab == MainBottomTabDestination.Settings })
val advancedPageScrollBehavior = MiuixScrollBehavior(
canScroll = {
when (currentRootScreen) {
is RootScreen.Advanced -> true
is RootScreen.VirtualButtonOrder -> true
else -> false
}
})
fun popRoot() {
if (rootBackStack.size > 1)
rootBackStack.removeAt(rootBackStack.lastIndex)
}
// Unified back behavior:
// 1) pop inner route
// 2) switch tab back to Device
// 3) double-back to exit and disconnect adb/scrcpy
var lastExitBackPressAtMs by rememberSaveable { mutableLongStateOf(0L) }
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<NavKey> {
entry(RootScreen.Home) {
Scaffold(
bottomBar = {
NavigationBar {
tabs.forEach { tab ->
NavigationBarItem(
selected = currentTab == tab,
onClick = {
scope.launch {
pagerState.animateScrollToPage(
page = tab.ordinal,
animationSpec = spring(
dampingRatio = UiMotion.PAGE_SWITCH_DAMPING_RATIO,
stiffness = UiMotion.PAGE_SWITCH_STIFFNESS,
),
)
}
},
icon = tab.icon,
label = tab.label,
)
}
}
},
snackbarHost = { SnackbarHost(snackHostState) },
) { contentPadding ->
HorizontalPager(
modifier = Modifier
.fillMaxSize()
.padding(bottom = contentPadding.calculateBottomPadding()),
state = pagerState,
beyondViewportPageCount = 1,
) { page ->
val tab = tabs[page]
saveableStateHolder.SaveableStateProvider(tab.name) {
when (tab) {
MainBottomTabDestination.Device -> DeviceTabScreen(
nativeCore = nativeCore,
adbService = adbService,
scrcpy = scrcpy,
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<RootScreen.Fullscreen> { screen ->
FullscreenControlScreen(
onBack = ::popRoot,
session = screen.session,
nativeCore = nativeCore,
onVideoSizeChanged = { width, height ->
fullscreenOrientation =
if (width >= height) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
else ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
},
)
}
}
val rootEntries = rememberDecoratedNavEntries(
backStack = rootBackStack,
entryProvider = rootEntryProvider,
)
MiuixTheme(controller = themeController) {
NavDisplay(
entries = rootEntries,
onBack = ::popRoot,
)
}
}

View File

@@ -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))
}
}

View File

@@ -13,11 +13,14 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Clear import androidx.compose.material.icons.rounded.Clear
import androidx.compose.material.icons.rounded.FileOpen import androidx.compose.material.icons.rounded.FileOpen
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -25,23 +28,25 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.core.net.toUri import androidx.core.net.toUri
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing 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.SuperSlider
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration 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.storage.Storage
import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton 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.ScrollBehavior
import top.yukonga.miuix.kmp.basic.SnackbarHostState import top.yukonga.miuix.kmp.basic.SnackbarHostState
import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField 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.SuperArrow
import top.yukonga.miuix.kmp.extra.SuperDropdown import top.yukonga.miuix.kmp.extra.SuperDropdown
import top.yukonga.miuix.kmp.extra.SuperSwitch import top.yukonga.miuix.kmp.extra.SuperSwitch
@@ -75,11 +80,39 @@ private fun resolveThemeLabel(baseIndex: Int, monetEnabled: Boolean): String {
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
contentPadding: PaddingValues, scrollBehavior: ScrollBehavior,
snack: SnackbarHostState,
onOpenReorderDevices: () -> Unit, onOpenReorderDevices: () -> Unit,
onOpenVirtualButtonOrder: () -> Unit, onOpenVirtualButtonOrder: () -> Unit,
onPickServer: () -> 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, scrollBehavior: ScrollBehavior,
snack: SnackbarHostState,
onOpenReorderDevices: () -> Unit,
onOpenVirtualButtonOrder: () -> Unit,
onPickServer: () -> Unit,
) { ) {
val appContext = LocalContext.current.applicationContext val appContext = LocalContext.current.applicationContext
val appSettings = Storage.appSettings val appSettings = Storage.appSettings
@@ -91,44 +124,57 @@ fun SettingsScreen(
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() 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 } 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 { var serverRemotePathInput by rememberSaveable {
mutableStateOf( mutableStateOf(
// 默认值留空显示为 hint if (asBundleShared.serverRemotePath == AppSettings.SERVER_REMOTE_PATH.defaultValue) ""
if (runBlocking { appSettings.serverRemotePath.isDefaultValue() }) "" else asBundleShared.serverRemotePath
else 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 { var adbKeyNameInput by rememberSaveable {
mutableStateOf( mutableStateOf(
if (runBlocking { appSettings.adbKeyName.isDefaultValue() }) "" if (asBundleShared.adbKeyName == AppSettings.ADB_KEY_NAME.defaultValue) ""
else adbKeyName else asBundleShared.adbKeyName
) )
} }
var adbPairingAutoDiscoverOnDialogOpen by appSettings.adbPairingAutoDiscoverOnDialogOpen.asMutableState() LaunchedEffect(asBundleShared.adbKeyName) {
var adbAutoReconnectPairedDevice by appSettings.adbAutoReconnectPairedDevice.asMutableState() adbKeyNameInput =
if (asBundleShared.adbKeyName == AppSettings.ADB_KEY_NAME.defaultValue) ""
else asBundleShared.adbKeyName
}
// 设置 // 设置
AppPageLazyColumn( LazyColumn(
contentPadding = contentPadding, contentPadding = contentPadding,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
) { ) {
@@ -137,16 +183,20 @@ fun SettingsScreen(
Card { Card {
SuperDropdown( SuperDropdown(
title = "外观模式", title = "外观模式",
summary = resolveThemeLabel(themeBaseIndex, monet), summary = resolveThemeLabel(asBundle.themeBaseIndex, asBundle.monet),
items = baseModeItems, items = baseModeItems,
selectedIndex = themeBaseIndex.coerceIn(0, baseModeItems.lastIndex), selectedIndex = asBundle.themeBaseIndex.coerceIn(0, baseModeItems.lastIndex),
onSelectedIndexChange = { themeBaseIndex = it }, onSelectedIndexChange = {
asBundle = asBundle.copy(themeBaseIndex = it)
},
) )
SuperSwitch( SuperSwitch(
title = "Monet", title = "Monet",
summary = "开启后使用 Monet 动态配色", summary = "开启后使用 Monet 动态配色",
checked = monet, checked = asBundle.monet,
onCheckedChange = { monet = it }, onCheckedChange = {
asBundle = asBundle.copy(monet = it)
},
) )
} }
@@ -155,32 +205,40 @@ fun SettingsScreen(
SuperSwitch( SuperSwitch(
title = "启用调试信息", title = "启用调试信息",
summary = "在全屏界面显示触点数量、设备分辨率和实时 FPS", summary = "在全屏界面显示触点数量、设备分辨率和实时 FPS",
checked = fullscreenDebugInfo, checked = asBundle.fullscreenDebugInfo,
onCheckedChange = { fullscreenDebugInfo = it }, onCheckedChange = {
asBundle = asBundle.copy(fullscreenDebugInfo = it)
},
) )
SuperSwitch( SuperSwitch(
title = "投屏时保持屏幕常亮", title = "投屏时保持屏幕常亮",
summary = "Scrcpy 启动后保持本机屏幕常亮,避免锁屏导致 ADB 断开", summary = "Scrcpy 启动后保持本机屏幕常亮,避免锁屏导致 ADB 断开",
checked = keepScreenOnWhenStreaming, checked = asBundle.keepScreenOnWhenStreaming,
onCheckedChange = { keepScreenOnWhenStreaming = it }, onCheckedChange = {
asBundle = asBundle.copy(keepScreenOnWhenStreaming = it)
},
) )
SuperSlider( SuperSlider(
title = "预览卡高度", title = "预览卡高度",
summary = "设备页预览卡高度", summary = "设备页预览卡高度",
value = devicePreviewCardHeightDp.toFloat(), value = asBundle.devicePreviewCardHeightDp.toFloat(),
onValueChange = { onValueChange = {
devicePreviewCardHeightDp = it.roundToInt().coerceAtLeast(120) asBundle = asBundle.copy(
devicePreviewCardHeightDp = it.roundToInt().coerceAtLeast(120)
)
}, },
valueRange = 160f..600f, valueRange = 160f..600f,
steps = 600-160-1, steps = 600-160-2,
unit = "dp", unit = "dp",
displayFormatter = { it.roundToInt().toString() }, displayFormatter = { it.roundToInt().toString() },
inputInitialValue = devicePreviewCardHeightDp.toString(), inputInitialValue = asBundle.devicePreviewCardHeightDp.toString(),
inputFilter = { it.filter(Char::isDigit) }, inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 120f..UShort.MAX_VALUE.toFloat(), inputValueRange = 120f..UShort.MAX_VALUE.toFloat(),
onInputConfirm = { input -> onInputConfirm = { input ->
input.toIntOrNull()?.let { input.toIntOrNull()?.let {
devicePreviewCardHeightDp = it.coerceAtLeast(120) asBundle = asBundle.copy(
devicePreviewCardHeightDp = it.coerceAtLeast(120)
)
} }
}, },
) )
@@ -197,8 +255,10 @@ fun SettingsScreen(
SuperSwitch( SuperSwitch(
title = "全屏显示虚拟按钮", title = "全屏显示虚拟按钮",
summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮", summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮",
checked = showFullscreenVirtualButtons, checked = asBundle.showFullscreenVirtualButtons,
onCheckedChange = { showFullscreenVirtualButtons = it }, onCheckedChange = {
asBundle = asBundle.copy(showFullscreenVirtualButtons = it)
},
) )
} }
@@ -213,19 +273,21 @@ fun SettingsScreen(
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
) )
TextField( TextField(
value = customServerUri, value = asBundle.customServerUri,
onValueChange = {}, onValueChange = {},
readOnly = true, readOnly = true,
label = "scrcpy-server-v3.3.4", label = "scrcpy-server-v3.3.4",
useLabelAsPlaceholder = customServerUri.isBlank(), useLabelAsPlaceholder = asBundle.customServerUri.isBlank(),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent) .padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent), .padding(bottom = UiSpacing.CardContent),
trailingIcon = { trailingIcon = {
Row(modifier = Modifier.padding(end = UiSpacing.SectionTitleLeadingGap)) { Row(modifier = Modifier.padding(end = UiSpacing.SectionTitleLeadingGap)) {
if (customServerUri.isNotBlank()) if (asBundle.customServerUri.isNotBlank())
IconButton(onClick = { customServerUri = "" }) { IconButton(onClick = {
asBundle = asBundle.copy(customServerUri = "")
}) {
Icon(Icons.Rounded.Clear, contentDescription = "清空") Icon(Icons.Rounded.Clear, contentDescription = "清空")
} }
IconButton(onClick = onPickServer) { IconButton(onClick = onPickServer) {
@@ -247,6 +309,11 @@ fun SettingsScreen(
onFocusLost = { onFocusLost = {
if (serverRemotePathInput == AppSettings.SERVER_REMOTE_PATH.defaultValue) if (serverRemotePathInput == AppSettings.SERVER_REMOTE_PATH.defaultValue)
serverRemotePathInput = "" serverRemotePathInput = ""
asBundle = asBundle.copy(
serverRemotePath = serverRemotePathInput.ifBlank {
AppSettings.SERVER_REMOTE_PATH.defaultValue
}
)
}, },
label = AppSettings.SERVER_REMOTE_PATH.defaultValue, label = AppSettings.SERVER_REMOTE_PATH.defaultValue,
useLabelAsPlaceholder = true, useLabelAsPlaceholder = true,
@@ -273,6 +340,9 @@ fun SettingsScreen(
onFocusLost = { onFocusLost = {
if (adbKeyNameInput == AppSettings.ADB_KEY_NAME.defaultValue) if (adbKeyNameInput == AppSettings.ADB_KEY_NAME.defaultValue)
adbKeyNameInput = "" adbKeyNameInput = ""
asBundle = asBundle.copy(
adbKeyName = adbKeyNameInput.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue }
)
}, },
label = AppSettings.ADB_KEY_NAME.defaultValue, label = AppSettings.ADB_KEY_NAME.defaultValue,
useLabelAsPlaceholder = true, useLabelAsPlaceholder = true,
@@ -285,14 +355,22 @@ fun SettingsScreen(
SuperSwitch( SuperSwitch(
title = "配对时自动启用发现服务", title = "配对时自动启用发现服务",
summary = "打开配对弹窗后自动搜索可用配对端口", summary = "打开配对弹窗后自动搜索可用配对端口",
checked = adbPairingAutoDiscoverOnDialogOpen, checked = asBundle.adbPairingAutoDiscoverOnDialogOpen,
onCheckedChange = { adbPairingAutoDiscoverOnDialogOpen = it }, onCheckedChange = {
asBundle = asBundle.copy(
adbPairingAutoDiscoverOnDialogOpen = it
)
},
) )
SuperSwitch( SuperSwitch(
title = "自动重连已配对设备", title = "自动重连已配对设备",
summary = "自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘)", summary = "自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘)",
checked = adbAutoReconnectPairedDevice, checked = asBundle.adbAutoReconnectPairedDevice,
onCheckedChange = { adbAutoReconnectPairedDevice = it }, onCheckedChange = {
asBundle = asBundle.copy(
adbAutoReconnectPairedDevice = it
)
},
) )
} }
@@ -310,7 +388,7 @@ fun SettingsScreen(
scope.launch { scope.launch {
val migration = PreferenceMigration(appContext) val migration = PreferenceMigration(appContext)
migration.migrate(clearSharedPrefs = false) migration.migrate(clearSharedPrefs = false)
snackHostState.showSnackbar("迁移完成,应用将重启") snack.showSnackbar("迁移完成,应用将重启")
delay(1000) delay(1000)

View File

@@ -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)
},
)()
}
}
}

View File

@@ -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)
)
},
)()
}
}
}

View File

@@ -20,7 +20,7 @@ import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.utils.overScrollVertical import top.yukonga.miuix.kmp.utils.overScrollVertical
@Composable @Composable
fun AppPageLazyColumn( fun LazyColumn(
contentPadding: PaddingValues, contentPadding: PaddingValues,
scrollBehavior: ScrollBehavior, scrollBehavior: ScrollBehavior,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,

View File

@@ -3,7 +3,6 @@ package io.github.miuzarte.scrcpyforandroid.scaffolds
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -13,12 +12,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp 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.ButtonDefaults
import top.yukonga.miuix.kmp.basic.Slider import top.yukonga.miuix.kmp.basic.Slider
import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.extra.SuperArrow import top.yukonga.miuix.kmp.extra.SuperArrow
import top.yukonga.miuix.kmp.extra.SuperDialog import top.yukonga.miuix.kmp.extra.SuperDialog
import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.MiuixTheme
@@ -40,7 +37,9 @@ fun SuperSlider(
displayFormatter: (Float) -> String = { it.toInt().toString() }, displayFormatter: (Float) -> String = { it.toInt().toString() },
displayText: String? = null, displayText: String? = null,
inputTitle: String = title, inputTitle: String = title,
inputHint: String = unit, inputSummary: String = summary,
inputLabel: String = unit,
useLabelAsPlaceholder: Boolean = false,
inputInitialValue: String = displayFormatter(value), inputInitialValue: String = displayFormatter(value),
inputFilter: (String) -> String = { text -> text.filter { it.isDigit() || it == '.' } }, inputFilter: (String) -> String = { text -> text.filter { it.isDigit() || it == '.' } },
inputValueRange: ClosedFloatingPointRange<Float>? = null, inputValueRange: ClosedFloatingPointRange<Float>? = null,
@@ -60,7 +59,8 @@ fun SuperSlider(
endActions = { endActions = {
val isZeroState = value == 0f && zeroStateText != null val isZeroState = value == 0f && zeroStateText != null
val valueText = val valueText =
if (isZeroState) zeroStateText else (displayText ?: displayFormatter(value)) if (isZeroState) zeroStateText
else (displayText ?: displayFormatter(value))
val shouldShowUnit = unit.isNotBlank() && (!isZeroState || showUnitWhenZeroState) val shouldShowUnit = unit.isNotBlank() && (!isZeroState || showUnitWhenZeroState)
val text = if (shouldShowUnit) "$valueText $unit" else valueText val text = if (shouldShowUnit) "$valueText $unit" else valueText
Text( Text(
@@ -86,7 +86,9 @@ fun SuperSlider(
SliderInputDialog( SliderInputDialog(
showDialog = showInputDialog, showDialog = showInputDialog,
title = inputTitle, title = inputTitle,
summary = inputHint, summary = inputSummary,
label = inputLabel,
useLabelAsPlaceholder = useLabelAsPlaceholder,
initialValue = inputInitialValue, initialValue = inputInitialValue,
inputFilter = inputFilter, inputFilter = inputFilter,
inputValueRange = inputValueRange ?: valueRange, inputValueRange = inputValueRange ?: valueRange,
@@ -104,6 +106,8 @@ private fun SliderInputDialog(
showDialog: Boolean, showDialog: Boolean,
title: String, title: String,
summary: String, summary: String,
label: String = "",
useLabelAsPlaceholder: Boolean = false,
initialValue: String, initialValue: String,
inputFilter: (String) -> String, inputFilter: (String) -> String,
inputValueRange: ClosedFloatingPointRange<Float>, inputValueRange: ClosedFloatingPointRange<Float>,
@@ -120,9 +124,11 @@ private fun SliderInputDialog(
content = { content = {
var text by remember(initialValue) { mutableStateOf(initialValue) } var text by remember(initialValue) { mutableStateOf(initialValue) }
TextField( SuperTextField(
modifier = Modifier.padding(bottom = 16.dp), modifier = Modifier.padding(bottom = 16.dp),
value = text, value = text,
label = label,
useLabelAsPlaceholder = useLabelAsPlaceholder,
maxLines = 1, maxLines = 1,
onValueChange = { newValue -> onValueChange = { newValue ->
text = inputFilter(newValue) text = inputFilter(newValue)

View File

@@ -0,0 +1,273 @@
package io.github.miuzarte.scrcpyforandroid.scrcpy
import android.util.Log
import android.view.MotionEvent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntSize
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
class TouchEventHandler(
private val coroutineScope: CoroutineScope,
private val session: Scrcpy.Session.SessionInfo,
private val touchAreaSize: IntSize,
private val activePointerIds: LinkedHashSet<Int>,
private val activePointerPositions: LinkedHashMap<Int, Offset>,
private val activePointerDevicePositions: LinkedHashMap<Int, Pair<Int, Int>>,
private val pointerLabels: LinkedHashMap<Int, Int>,
private var nextPointerLabel: Int,
private val onInjectTouch: suspend (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit,
private val onActiveTouchCountChanged: (Int) -> Unit,
private val onActiveTouchDebugChanged: (String) -> Unit,
private val onNextPointerLabelChanged: (Int) -> Unit,
) {
companion object {
private const val FULLSCREEN_TOUCH_LOG_TAG = "FullscreenTouch"
}
private object UiMotionActions {
const val DOWN = 0
const val UP = 1
const val MOVE = 2
const val CANCEL = 3
const val POINTER_DOWN = 5
const val POINTER_UP = 6
}
private val eventPointerIds = HashSet<Int>(10)
private val eventPositions = HashMap<Int, Offset>(10)
private val eventPressures = HashMap<Int, Float>(10)
private val justPressedPointerIds = HashSet<Int>(10)
fun handleMotionEvent(event: MotionEvent): Boolean {
if (touchAreaSize.width == 0 || touchAreaSize.height == 0) {
return true
}
val bounds = calculateContentBounds()
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
return handleCancelAction(bounds)
}
extractEventData(event)
handleDisappearedPointers(eventPointerIds, bounds)
val endedPointerId = getEndedPointerId(event)
handlePointerDown(event, endedPointerId, bounds)
handlePointerMove(event, endedPointerId, bounds)
handlePointerUp(endedPointerId, bounds)
onActiveTouchCountChanged(activePointerIds.size)
refreshTouchDebug()
return true
}
private data class ContentBounds(
val width: Float,
val height: Float,
val left: Float,
val top: Float,
)
private fun calculateContentBounds(): ContentBounds {
val sessionAspect = if (session.height == 0) {
16f / 9f
} else {
session.width.toFloat() / session.height.toFloat()
}
val containerWidth = touchAreaSize.width.toFloat()
val containerHeight = touchAreaSize.height.toFloat()
val containerAspect = containerWidth / containerHeight
val contentWidth: Float
val contentHeight: Float
if (sessionAspect > containerAspect) {
contentWidth = containerWidth
contentHeight = containerWidth / sessionAspect
} else {
contentHeight = containerHeight
contentWidth = containerHeight * sessionAspect
}
val contentLeft = (containerWidth - contentWidth) / 2f
val contentTop = (containerHeight - contentHeight) / 2f
return ContentBounds(contentWidth, contentHeight, contentLeft, contentTop)
}
private fun isInsideContent(rawX: Float, rawY: Float, bounds: ContentBounds): Boolean {
return rawX in bounds.left..(bounds.left + bounds.width) &&
rawY in bounds.top..(bounds.top + bounds.height)
}
private fun mapToDevice(rawX: Float, rawY: Float, bounds: ContentBounds): Pair<Int, Int> {
val normalizedX = ((rawX - bounds.left) / bounds.width).coerceIn(0f, 1f)
val normalizedY = ((rawY - bounds.top) / bounds.height).coerceIn(0f, 1f)
val x = (normalizedX * (session.width - 1).coerceAtLeast(0)).roundToInt()
.coerceIn(0, (session.width - 1).coerceAtLeast(0))
val y = (normalizedY * (session.height - 1).coerceAtLeast(0)).roundToInt()
.coerceIn(0, (session.height - 1).coerceAtLeast(0))
return x to y
}
private fun getPointerLabel(pointerId: Int): Int {
val existing = pointerLabels[pointerId]
if (existing != null) {
return existing
}
val assigned = nextPointerLabel
nextPointerLabel += 1
onNextPointerLabelChanged(nextPointerLabel)
pointerLabels[pointerId] = assigned
return assigned
}
private fun refreshTouchDebug() {
if (activePointerIds.isEmpty()) {
onActiveTouchDebugChanged("")
return
}
val debug = activePointerIds
.sortedBy { getPointerLabel(it) }
.joinToString(separator = "\n") { pointerId ->
val label = getPointerLabel(pointerId)
val pos = activePointerDevicePositions[pointerId]
if (pos == null) {
"#$label(id=$pointerId):?"
} else {
"#$label(id=$pointerId):${pos.first},${pos.second}"
}
}
onActiveTouchDebugChanged(debug)
}
private fun releasePointer(pointerId: Int, bounds: ContentBounds) {
if (!activePointerIds.contains(pointerId)) return
val pos = activePointerPositions[pointerId] ?: Offset.Zero
val (x, y) = mapToDevice(pos.x, pos.y, bounds)
coroutineScope.launch {
runCatching {
onInjectTouch(UiMotionActions.UP, pointerId.toLong(), x, y, 0f, 0)
}.onFailure { e ->
Log.w(FULLSCREEN_TOUCH_LOG_TAG, "releasePointer failed for pointerId=$pointerId", e)
}
}
activePointerIds -= pointerId
activePointerPositions.remove(pointerId)
activePointerDevicePositions.remove(pointerId)
pointerLabels.remove(pointerId)
}
private fun handleCancelAction(bounds: ContentBounds): Boolean {
val toCancel = activePointerIds.toList()
for (pointerId in toCancel) {
releasePointer(pointerId, bounds)
}
onActiveTouchCountChanged(activePointerIds.size)
refreshTouchDebug()
return true
}
private fun extractEventData(event: MotionEvent) {
eventPointerIds.clear()
eventPositions.clear()
eventPressures.clear()
for (i in 0 until event.pointerCount) {
val pointerId = event.getPointerId(i)
eventPointerIds += pointerId
eventPositions[pointerId] = Offset(event.getX(i), event.getY(i))
eventPressures[pointerId] = event.getPressure(i).coerceIn(0f, 1f)
}
}
private fun handleDisappearedPointers(eventPointerIds: Set<Int>, bounds: ContentBounds) {
val disappearedPointers = activePointerIds.filter { it !in eventPointerIds }
for (pointerId in disappearedPointers) {
releasePointer(pointerId, bounds)
}
}
private fun getEndedPointerId(event: MotionEvent): Int? {
return when (event.actionMasked) {
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> event.getPointerId(event.actionIndex)
else -> null
}
}
private fun handlePointerDown(
event: MotionEvent,
endedPointerId: Int?,
bounds: ContentBounds,
) {
justPressedPointerIds.clear()
for (i in 0 until event.pointerCount) {
val pointerId = event.getPointerId(i)
if (pointerId == endedPointerId) continue
val raw = eventPositions[pointerId] ?: continue
val pressure = eventPressures[pointerId] ?: 0f
if (!activePointerIds.contains(pointerId)) {
if (!isInsideContent(raw.x, raw.y, bounds)) continue
val (x, y) = mapToDevice(raw.x, raw.y, bounds)
activePointerIds += pointerId
activePointerPositions[pointerId] = raw
activePointerDevicePositions[pointerId] = x to y
justPressedPointerIds += pointerId
coroutineScope.launch {
runCatching {
onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, pressure, 0)
}.onFailure { e ->
Log.w(
FULLSCREEN_TOUCH_LOG_TAG,
"handlePointerDown failed for pointerId=$pointerId",
e
)
}
}
}
}
}
private fun handlePointerMove(
event: MotionEvent,
endedPointerId: Int?,
bounds: ContentBounds,
) {
for (i in 0 until event.pointerCount) {
val pointerId = event.getPointerId(i)
if (!activePointerIds.contains(pointerId)) continue
if (pointerId == endedPointerId) continue
if (pointerId in justPressedPointerIds) continue
val raw = eventPositions[pointerId] ?: continue
val pressure = eventPressures[pointerId] ?: 0f
activePointerPositions[pointerId] = raw
val (x, y) = mapToDevice(raw.x, raw.y, bounds)
activePointerDevicePositions[pointerId] = x to y
coroutineScope.launch {
runCatching {
onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, pressure, 0)
}.onFailure { e ->
Log.w(
FULLSCREEN_TOUCH_LOG_TAG,
"handlePointerMove failed for pointerId=$pointerId",
e
)
}
}
}
}
private fun handlePointerUp(
endedPointerId: Int?,
bounds: ContentBounds,
) {
if (endedPointerId != null) {
val endPos = eventPositions[endedPointerId]
if (endPos != null) {
activePointerPositions[endedPointerId] = endPos
}
releasePointer(endedPointerId, bounds)
}
}
}

View File

@@ -1,7 +1,11 @@
package io.github.miuzarte.scrcpyforandroid.storage package io.github.miuzarte.scrcpyforandroid.storage
import android.content.Context import android.content.Context
import android.os.Parcelable
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.StateFlow
import kotlinx.parcelize.Parcelize
class AdbClientData(context: Context) : Settings(context, "AdbClient") { class AdbClientData(context: Context) : Settings(context, "AdbClient") {
companion object { companion object {
@@ -17,4 +21,31 @@ class AdbClientData(context: Context) : Settings(context, "AdbClient") {
val rsaPrivateKey by setting(RSA_PRIVATE_KEY) val rsaPrivateKey by setting(RSA_PRIVATE_KEY)
val rsaPublicKeyX509 by setting(RSA_PUBLIC_KEY_X509) val rsaPublicKeyX509 by setting(RSA_PUBLIC_KEY_X509)
@Parcelize
data class Bundle(
val rsaPrivateKey: String,
val rsaPublicKeyX509: String,
) : Parcelable {
}
private val bundleFields = arrayOf(
bundleField(RSA_PRIVATE_KEY) { bundle: Bundle -> bundle.rsaPrivateKey },
bundleField(RSA_PUBLIC_KEY_X509) { bundle: Bundle -> bundle.rsaPublicKeyX509 },
)
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
private fun bundleFromPreferences(preferences: Preferences) = Bundle(
rsaPrivateKey = preferences.read(RSA_PRIVATE_KEY),
rsaPublicKeyX509 = preferences.read(RSA_PUBLIC_KEY_X509),
)
suspend fun loadBundle() = loadBundle(::bundleFromPreferences)
suspend fun saveBundle(new: Bundle) = saveBundle(bundleState.value, new, bundleFields)
suspend fun updateBundle(transform: (Bundle) -> Bundle) {
saveBundle(transform(bundleState.value))
}
} }

View File

@@ -1,9 +1,13 @@
package io.github.miuzarte.scrcpyforandroid.storage package io.github.miuzarte.scrcpyforandroid.storage
import android.content.Context 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.booleanPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.StateFlow
import kotlinx.parcelize.Parcelize
class AppSettings(context: Context) : Settings(context, "AppSettings") { class AppSettings(context: Context) : Settings(context, "AppSettings") {
companion object { companion object {
@@ -87,6 +91,71 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
val adbAutoReconnectPairedDevice by setting(ADB_AUTO_RECONNECT_PAIRED_DEVICE) val adbAutoReconnectPairedDevice by setting(ADB_AUTO_RECONNECT_PAIRED_DEVICE)
val adbMdnsLanDiscovery by setting(ADB_MDNS_LAN_DISCOVERY) 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<Bundle> = createBundleState(::bundleFromPreferences)
private fun bundleFromPreferences(preferences: Preferences) = Bundle(
themeBaseIndex = preferences.read(THEME_BASE_INDEX),
monet = preferences.read(MONET),
fullscreenDebugInfo = preferences.read(FULLSCREEN_DEBUG_INFO),
showFullscreenVirtualButtons = preferences.read(SHOW_FULLSCREEN_VIRTUAL_BUTTONS),
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? // TODO?
// fun validate(): Boolean = true // fun validate(): Boolean = true
} }

View File

@@ -1,7 +1,11 @@
package io.github.miuzarte.scrcpyforandroid.storage package io.github.miuzarte.scrcpyforandroid.storage
import android.content.Context import android.content.Context
import android.os.Parcelable
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.StateFlow
import kotlinx.parcelize.Parcelize
class QuickDevices(context: Context) : Settings(context, "QuickDevices") { class QuickDevices(context: Context) : Settings(context, "QuickDevices") {
companion object { companion object {
@@ -17,4 +21,31 @@ class QuickDevices(context: Context) : Settings(context, "QuickDevices") {
val quickDevicesList by setting(QUICK_DEVICES_LIST) val quickDevicesList by setting(QUICK_DEVICES_LIST)
val quickConnectInput by setting(QUICK_CONNECT_INPUT) val quickConnectInput by setting(QUICK_CONNECT_INPUT)
@Parcelize
data class Bundle(
val quickDevicesList: String,
val quickConnectInput: String,
) : Parcelable {
}
private val bundleFields = arrayOf(
bundleField(QUICK_DEVICES_LIST) { bundle: Bundle -> bundle.quickDevicesList },
bundleField(QUICK_CONNECT_INPUT) { bundle: Bundle -> bundle.quickConnectInput },
)
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
private fun bundleFromPreferences(preferences: Preferences) = Bundle(
quickDevicesList = preferences.read(QUICK_DEVICES_LIST),
quickConnectInput = preferences.read(QUICK_CONNECT_INPUT),
)
suspend fun loadBundle() = loadBundle(::bundleFromPreferences)
suspend fun saveBundle(new: Bundle) = saveBundle(bundleState.value, new, bundleFields)
suspend fun updateBundle(transform: (Bundle) -> Bundle) {
saveBundle(transform(bundleState.value))
}
} }

View File

@@ -1,6 +1,8 @@
package io.github.miuzarte.scrcpyforandroid.storage package io.github.miuzarte.scrcpyforandroid.storage
import android.content.Context 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.booleanPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey 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.OrientationLock
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.parcelize.Parcelize
class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
companion object { companion object {
@@ -102,11 +106,11 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
) )
val VIDEO_BIT_RATE = Pair( val VIDEO_BIT_RATE = Pair(
intPreferencesKey("video_bit_rate"), intPreferencesKey("video_bit_rate"),
8000000, 0,
) )
val AUDIO_BIT_RATE = Pair( val AUDIO_BIT_RATE = Pair(
intPreferencesKey("audio_bit_rate"), intPreferencesKey("audio_bit_rate"),
128000, 0,
) )
val MAX_FPS = Pair( val MAX_FPS = Pair(
stringPreferencesKey("max_fps"), stringPreferencesKey("max_fps"),
@@ -288,6 +292,186 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
val vdDestroyContent by setting(VD_DESTROY_CONTENT) val vdDestroyContent by setting(VD_DESTROY_CONTENT)
val vdSystemDecorations by setting(VD_SYSTEM_DECORATIONS) val vdSystemDecorations by setting(VD_SYSTEM_DECORATIONS)
@Parcelize
data class Bundle(
val crop: String,
val recordFilename: String,
val videoCodecOptions: String,
val audioCodecOptions: String,
val videoEncoder: String,
val audioEncoder: String,
val cameraId: String,
val cameraSize: String,
val cameraSizeCustom: String,
val cameraSizeUseCustom: Boolean,
val cameraAr: String,
val cameraFps: Int,
val logLevel: String,
val videoCodec: String,
val audioCodec: String,
val videoSource: String,
val audioSource: String,
val recordFormat: String,
val cameraFacing: String,
val maxSize: Int,
val videoBitRate: Int,
val audioBitRate: Int,
val maxFps: String,
val angle: String,
val captureOrientation: Int,
val captureOrientationLock: String,
val displayOrientation: Int,
val recordOrientation: Int,
val displayImePolicy: String,
val displayId: Int,
val screenOffTimeout: Long,
val showTouches: Boolean,
val fullscreen: Boolean,
val control: Boolean,
val videoPlayback: Boolean,
val audioPlayback: Boolean,
val turnScreenOff: Boolean,
val stayAwake: Boolean,
val disableScreensaver: Boolean,
val powerOffOnClose: Boolean,
val cleanup: Boolean,
val powerOn: Boolean,
val video: Boolean,
val audio: Boolean,
val requireAudio: Boolean,
val killAdbOnClose: Boolean,
val cameraHighSpeed: Boolean,
val list: String,
val audioDup: Boolean,
val newDisplay: String,
val startApp: String,
val vdDestroyContent: Boolean,
val vdSystemDecorations: Boolean
) : Parcelable {
}
private val bundleFields = arrayOf(
bundleField(CROP) { bundle: Bundle -> bundle.crop },
bundleField(RECORD_FILENAME) { bundle: Bundle -> bundle.recordFilename },
bundleField(VIDEO_CODEC_OPTIONS) { bundle: Bundle -> bundle.videoCodecOptions },
bundleField(AUDIO_CODEC_OPTIONS) { bundle: Bundle -> bundle.audioCodecOptions },
bundleField(VIDEO_ENCODER) { bundle: Bundle -> bundle.videoEncoder },
bundleField(AUDIO_ENCODER) { bundle: Bundle -> bundle.audioEncoder },
bundleField(CAMERA_ID) { bundle: Bundle -> bundle.cameraId },
bundleField(CAMERA_SIZE) { bundle: Bundle -> bundle.cameraSize },
bundleField(CAMERA_SIZE_CUSTOM) { bundle: Bundle -> bundle.cameraSizeCustom },
bundleField(CAMERA_SIZE_USE_CUSTOM) { bundle: Bundle -> bundle.cameraSizeUseCustom },
bundleField(CAMERA_AR) { bundle: Bundle -> bundle.cameraAr },
bundleField(CAMERA_FPS) { bundle: Bundle -> bundle.cameraFps },
bundleField(LOG_LEVEL) { bundle: Bundle -> bundle.logLevel },
bundleField(VIDEO_CODEC) { bundle: Bundle -> bundle.videoCodec },
bundleField(AUDIO_CODEC) { bundle: Bundle -> bundle.audioCodec },
bundleField(VIDEO_SOURCE) { bundle: Bundle -> bundle.videoSource },
bundleField(AUDIO_SOURCE) { bundle: Bundle -> bundle.audioSource },
bundleField(RECORD_FORMAT) { bundle: Bundle -> bundle.recordFormat },
bundleField(CAMERA_FACING) { bundle: Bundle -> bundle.cameraFacing },
bundleField(MAX_SIZE) { bundle: Bundle -> bundle.maxSize },
bundleField(VIDEO_BIT_RATE) { bundle: Bundle -> bundle.videoBitRate },
bundleField(AUDIO_BIT_RATE) { bundle: Bundle -> bundle.audioBitRate },
bundleField(MAX_FPS) { bundle: Bundle -> bundle.maxFps },
bundleField(ANGLE) { bundle: Bundle -> bundle.angle },
bundleField(CAPTURE_ORIENTATION) { bundle: Bundle -> bundle.captureOrientation },
bundleField(CAPTURE_ORIENTATION_LOCK) { bundle: Bundle -> bundle.captureOrientationLock },
bundleField(DISPLAY_ORIENTATION) { bundle: Bundle -> bundle.displayOrientation },
bundleField(RECORD_ORIENTATION) { bundle: Bundle -> bundle.recordOrientation },
bundleField(DISPLAY_IME_POLICY) { bundle: Bundle -> bundle.displayImePolicy },
bundleField(DISPLAY_ID) { bundle: Bundle -> bundle.displayId },
bundleField(SCREEN_OFF_TIMEOUT) { bundle: Bundle -> bundle.screenOffTimeout },
bundleField(SHOW_TOUCHES) { bundle: Bundle -> bundle.showTouches },
bundleField(FULLSCREEN) { bundle: Bundle -> bundle.fullscreen },
bundleField(CONTROL) { bundle: Bundle -> bundle.control },
bundleField(VIDEO_PLAYBACK) { bundle: Bundle -> bundle.videoPlayback },
bundleField(AUDIO_PLAYBACK) { bundle: Bundle -> bundle.audioPlayback },
bundleField(TURN_SCREEN_OFF) { bundle: Bundle -> bundle.turnScreenOff },
bundleField(STAY_AWAKE) { bundle: Bundle -> bundle.stayAwake },
bundleField(DISABLE_SCREENSAVER) { bundle: Bundle -> bundle.disableScreensaver },
bundleField(POWER_OFF_ON_CLOSE) { bundle: Bundle -> bundle.powerOffOnClose },
bundleField(CLEANUP) { bundle: Bundle -> bundle.cleanup },
bundleField(POWER_ON) { bundle: Bundle -> bundle.powerOn },
bundleField(VIDEO) { bundle: Bundle -> bundle.video },
bundleField(AUDIO) { bundle: Bundle -> bundle.audio },
bundleField(REQUIRE_AUDIO) { bundle: Bundle -> bundle.requireAudio },
bundleField(KILL_ADB_ON_CLOSE) { bundle: Bundle -> bundle.killAdbOnClose },
bundleField(CAMERA_HIGH_SPEED) { bundle: Bundle -> bundle.cameraHighSpeed },
bundleField(LIST) { bundle: Bundle -> bundle.list },
bundleField(AUDIO_DUP) { bundle: Bundle -> bundle.audioDup },
bundleField(NEW_DISPLAY) { bundle: Bundle -> bundle.newDisplay },
bundleField(START_APP) { bundle: Bundle -> bundle.startApp },
bundleField(VD_DESTROY_CONTENT) { bundle: Bundle -> bundle.vdDestroyContent },
bundleField(VD_SYSTEM_DECORATIONS) { bundle: Bundle -> bundle.vdSystemDecorations },
)
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
private fun bundleFromPreferences(preferences: Preferences) = Bundle(
crop = preferences.read(CROP),
recordFilename = preferences.read(RECORD_FILENAME),
videoCodecOptions = preferences.read(VIDEO_CODEC_OPTIONS),
audioCodecOptions = preferences.read(AUDIO_CODEC_OPTIONS),
videoEncoder = preferences.read(VIDEO_ENCODER),
audioEncoder = preferences.read(AUDIO_ENCODER),
cameraId = preferences.read(CAMERA_ID),
cameraSize = preferences.read(CAMERA_SIZE),
cameraSizeCustom = preferences.read(CAMERA_SIZE_CUSTOM),
cameraSizeUseCustom = preferences.read(CAMERA_SIZE_USE_CUSTOM),
cameraAr = preferences.read(CAMERA_AR),
cameraFps = preferences.read(CAMERA_FPS),
logLevel = preferences.read(LOG_LEVEL),
videoCodec = preferences.read(VIDEO_CODEC),
audioCodec = preferences.read(AUDIO_CODEC),
videoSource = preferences.read(VIDEO_SOURCE),
audioSource = preferences.read(AUDIO_SOURCE),
recordFormat = preferences.read(RECORD_FORMAT),
cameraFacing = preferences.read(CAMERA_FACING),
maxSize = preferences.read(MAX_SIZE),
videoBitRate = preferences.read(VIDEO_BIT_RATE),
audioBitRate = preferences.read(AUDIO_BIT_RATE),
maxFps = preferences.read(MAX_FPS),
angle = preferences.read(ANGLE),
captureOrientation = preferences.read(CAPTURE_ORIENTATION),
captureOrientationLock = preferences.read(CAPTURE_ORIENTATION_LOCK),
displayOrientation = preferences.read(DISPLAY_ORIENTATION),
recordOrientation = preferences.read(RECORD_ORIENTATION),
displayImePolicy = preferences.read(DISPLAY_IME_POLICY),
displayId = preferences.read(DISPLAY_ID),
screenOffTimeout = preferences.read(SCREEN_OFF_TIMEOUT),
showTouches = preferences.read(SHOW_TOUCHES),
fullscreen = preferences.read(FULLSCREEN),
control = preferences.read(CONTROL),
videoPlayback = preferences.read(VIDEO_PLAYBACK),
audioPlayback = preferences.read(AUDIO_PLAYBACK),
turnScreenOff = preferences.read(TURN_SCREEN_OFF),
stayAwake = preferences.read(STAY_AWAKE),
disableScreensaver = preferences.read(DISABLE_SCREENSAVER),
powerOffOnClose = preferences.read(POWER_OFF_ON_CLOSE),
cleanup = preferences.read(CLEANUP),
powerOn = preferences.read(POWER_ON),
video = preferences.read(VIDEO),
audio = preferences.read(AUDIO),
requireAudio = preferences.read(REQUIRE_AUDIO),
killAdbOnClose = preferences.read(KILL_ADB_ON_CLOSE),
cameraHighSpeed = preferences.read(CAMERA_HIGH_SPEED),
list = preferences.read(LIST),
audioDup = preferences.read(AUDIO_DUP),
newDisplay = preferences.read(NEW_DISPLAY),
startApp = preferences.read(START_APP),
vdDestroyContent = preferences.read(VD_DESTROY_CONTENT),
vdSystemDecorations = preferences.read(VD_SYSTEM_DECORATIONS),
)
suspend fun loadBundle() = loadBundle(::bundleFromPreferences)
suspend fun saveBundle(new: Bundle) = saveBundle(bundleState.value, new, bundleFields)
suspend fun updateBundle(transform: (Bundle) -> Bundle) {
saveBundle(transform(bundleState.value))
}
fun validate(): Boolean = runBlocking { fun validate(): Boolean = runBlocking {
runCatching { runCatching {
toClientOptions().validate() toClientOptions().validate()
@@ -296,59 +480,61 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
} }
// TODO: 处理空值 // TODO: 处理空值
suspend fun toClientOptions() = ClientOptions( fun toClientOptions() = toClientOptions(bundleState.value)
crop = crop.get(),
recordFilename = recordFilename.get(), fun toClientOptions(bundle: Bundle) = ClientOptions(
videoCodecOptions = videoCodecOptions.get(), crop = bundle.crop,
audioCodecOptions = audioCodecOptions.get(), recordFilename = bundle.recordFilename,
videoEncoder = videoEncoder.get(), videoCodecOptions = bundle.videoCodecOptions,
audioEncoder = audioEncoder.get(), audioCodecOptions = bundle.audioCodecOptions,
cameraId = cameraId.get(), videoEncoder = bundle.videoEncoder,
cameraSize = if (!cameraSizeUseCustom.get()) cameraSize.get() else cameraSizeCustom.get(), audioEncoder = bundle.audioEncoder,
cameraAr = cameraAr.get(), cameraId = bundle.cameraId,
cameraFps = cameraFps.get().toUShort(), cameraSize = if (!bundle.cameraSizeUseCustom) bundle.cameraSize else bundle.cameraSizeCustom,
logLevel = LogLevel.valueOf(logLevel.get().uppercase()), cameraAr = bundle.cameraAr,
videoCodec = Codec.fromString(videoCodec.get()), cameraFps = bundle.cameraFps.toUShort(),
audioCodec = Codec.fromString(audioCodec.get()), logLevel = LogLevel.valueOf(bundle.logLevel.uppercase()),
videoSource = VideoSource.fromString(videoSource.get()), videoCodec = Codec.fromString(bundle.videoCodec),
audioSource = AudioSource.fromString(audioSource.get()), audioCodec = Codec.fromString(bundle.audioCodec),
recordFormat = ClientOptions.RecordFormat.valueOf(recordFormat.get().uppercase()), videoSource = VideoSource.fromString(bundle.videoSource),
cameraFacing = CameraFacing.fromString(cameraFacing.get()), audioSource = AudioSource.fromString(bundle.audioSource),
maxSize = maxSize.get().toUShort(), recordFormat = ClientOptions.RecordFormat.valueOf(bundle.recordFormat.uppercase()),
videoBitRate = videoBitRate.get(), cameraFacing = CameraFacing.fromString(bundle.cameraFacing),
audioBitRate = audioBitRate.get(), maxSize = bundle.maxSize.toUShort(),
maxFps = maxFps.get(), videoBitRate = bundle.videoBitRate,
angle = angle.get(), audioBitRate = bundle.audioBitRate,
captureOrientation = Orientation.fromInt(captureOrientation.get()), maxFps = bundle.maxFps,
angle = bundle.angle,
captureOrientation = Orientation.fromInt(bundle.captureOrientation),
captureOrientationLock = OrientationLock.valueOf( captureOrientationLock = OrientationLock.valueOf(
captureOrientationLock.get().uppercase() bundle.captureOrientationLock.uppercase()
), ),
displayOrientation = Orientation.fromInt(displayOrientation.get()), displayOrientation = Orientation.fromInt(bundle.displayOrientation),
recordOrientation = Orientation.fromInt(recordOrientation.get()), recordOrientation = Orientation.fromInt(bundle.recordOrientation),
displayImePolicy = DisplayImePolicy.valueOf(displayImePolicy.get().uppercase()), displayImePolicy = DisplayImePolicy.valueOf(bundle.displayImePolicy.uppercase()),
displayId = displayId.get(), displayId = bundle.displayId,
screenOffTimeout = Tick(screenOffTimeout.get()), screenOffTimeout = Tick(bundle.screenOffTimeout),
showTouches = showTouches.get(), showTouches = bundle.showTouches,
fullscreen = fullscreen.get(), fullscreen = bundle.fullscreen,
control = control.get(), control = bundle.control,
videoPlayback = videoPlayback.get(), videoPlayback = bundle.videoPlayback,
audioPlayback = audioPlayback.get(), audioPlayback = bundle.audioPlayback,
turnScreenOff = turnScreenOff.get(), turnScreenOff = bundle.turnScreenOff,
stayAwake = stayAwake.get(), stayAwake = bundle.stayAwake,
disableScreensaver = disableScreensaver.get(), disableScreensaver = bundle.disableScreensaver,
powerOffOnClose = powerOffOnClose.get(), powerOffOnClose = bundle.powerOffOnClose,
cleanUp = cleanup.get(), cleanUp = bundle.cleanup,
powerOn = powerOn.get(), powerOn = bundle.powerOn,
video = video.get(), video = bundle.video,
audio = audio.get(), audio = bundle.audio,
requireAudio = requireAudio.get(), requireAudio = bundle.requireAudio,
killAdbOnClose = killAdbOnClose.get(), killAdbOnClose = bundle.killAdbOnClose,
cameraHighSpeed = cameraHighSpeed.get(), cameraHighSpeed = bundle.cameraHighSpeed,
list = ListOptions.valueOf(list.get().uppercase()), list = ListOptions.valueOf(bundle.list.uppercase()),
audioDup = audioDup.get(), audioDup = bundle.audioDup,
newDisplay = newDisplay.get(), newDisplay = bundle.newDisplay,
startApp = startApp.get(), startApp = bundle.startApp,
vdDestroyContent = vdDestroyContent.get(), vdDestroyContent = bundle.vdDestroyContent,
vdSystemDecorations = vdSystemDecorations.get() vdSystemDecorations = bundle.vdSystemDecorations
) )
} }

View File

@@ -18,10 +18,15 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlin.reflect.KProperty import kotlin.reflect.KProperty
import kotlin.time.Duration.Companion.milliseconds
private const val TAG = "Settings" private const val TAG = "Settings"
@@ -34,6 +39,8 @@ abstract class Settings(
emptyPreferences() emptyPreferences()
} }
) { ) {
private val settingsScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
data class Pair<T>( data class Pair<T>(
val key: Preferences.Key<T>, val key: Preferences.Key<T>,
val defaultValue: T, val defaultValue: T,
@@ -41,6 +48,24 @@ abstract class Settings(
val name: String get() = key.name val name: String get() = key.name
} }
protected fun <T> Preferences.read(pair: Pair<T>): T =
this[pair.key] ?: pair.defaultValue
protected interface BundleField<B> {
suspend fun persist(settings: Settings, current: B, new: B)
}
protected fun <B, T> bundleField(pair: Pair<T>, selector: (B) -> T): BundleField<B> =
object : BundleField<B> {
override suspend fun persist(settings: Settings, current: B, new: B) {
val currentValue = selector(current)
val newValue = selector(new)
if (currentValue != newValue) {
settings.setValue(pair, newValue)
}
}
}
/** /**
* 设置项委托类,自动提供 get/set/observe/asState/asMutableState 方法 * 设置项委托类,自动提供 get/set/observe/asState/asMutableState 方法
*/ */
@@ -90,6 +115,28 @@ abstract class Settings(
protected fun <T> setting(pair: Pair<T>) = SettingProperty(pair) protected fun <T> setting(pair: Pair<T>) = SettingProperty(pair)
protected fun <B> createBundleState(reader: (Preferences) -> B): StateFlow<B> =
dataStore.data
.map(reader)
.stateIn(
scope = settingsScope,
started = SharingStarted.Eagerly,
initialValue = runBlocking { loadBundle(reader) }
)
protected suspend fun <B> loadBundle(reader: (Preferences) -> B): B =
reader(dataStore.data.first())
protected suspend fun <B> saveBundle(
current: B,
new: B,
fields: Array<out BundleField<B>>,
) {
for (field in fields) {
field.persist(this, current, new)
}
}
protected suspend fun <T> getValue(pair: Pair<T>): T = protected suspend fun <T> getValue(pair: Pair<T>): T =
dataStore.data.first()[pair.key] ?: pair.defaultValue dataStore.data.first()[pair.key] ?: pair.defaultValue
@@ -123,4 +170,8 @@ abstract class Settings(
} }
} }
} }
companion object {
val BUNDLE_SAVE_DELAY = 100.milliseconds
}
} }

View File

@@ -2,8 +2,6 @@ package io.github.miuzarte.scrcpyforandroid.widgets
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.graphics.SurfaceTexture import android.graphics.SurfaceTexture
import android.util.Log
import android.view.MotionEvent
import android.view.Surface import android.view.Surface
import android.view.TextureView import android.view.TextureView
import androidx.activity.compose.BackHandler 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.LinkOff
import androidx.compose.material.icons.rounded.Wifi import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -54,7 +55,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.pointerInteropFilter import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction 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.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut 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.SuperSlider
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec 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 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.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.basic.Button 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.CardDefaults
import top.yukonga.miuix.kmp.basic.CircularProgressIndicator import top.yukonga.miuix.kmp.basic.CircularProgressIndicator
import top.yukonga.miuix.kmp.basic.Icon 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.Text
import top.yukonga.miuix.kmp.basic.TextButton import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.basic.TextField import top.yukonga.miuix.kmp.basic.TextField
@@ -98,17 +101,6 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme.isDynamicColor
import top.yukonga.miuix.kmp.utils.PressFeedbackType import top.yukonga.miuix.kmp.utils.PressFeedbackType
import kotlin.math.roundToInt 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 @Composable
internal fun StatusCard( internal fun StatusCard(
statusLine: String, statusLine: String,
@@ -119,10 +111,8 @@ internal fun StatusCard(
connectedDeviceLabel: String, connectedDeviceLabel: String,
) { ) {
val appSettings = Storage.appSettings val appSettings = Storage.appSettings
val appSettingsBundle by appSettings.bundleState.collectAsState()
val context = LocalContext.current val themeBaseIndex = appSettingsBundle.themeBaseIndex
val themeBaseIndex by appSettings.themeBaseIndex.asState()
val cleanStatusLine = normalizeStatusLine(statusLine) val cleanStatusLine = normalizeStatusLine(statusLine)
@@ -359,8 +349,10 @@ internal fun VirtualButtonCard(
@Composable @Composable
internal fun ConfigPanel( internal fun ConfigPanel(
busy: Boolean, busy: Boolean,
snack: SnackbarHostState,
audioForwardingSupported: Boolean, audioForwardingSupported: Boolean,
cameraMirroringSupported: Boolean, cameraMirroringSupported: Boolean,
isQuickConnected: Boolean,
onOpenAdvanced: () -> Unit, onOpenAdvanced: () -> Unit,
onStartStopHaptic: (() -> Unit)? = null, onStartStopHaptic: (() -> Unit)? = null,
onStart: () -> Unit, onStart: () -> Unit,
@@ -368,49 +360,60 @@ internal fun ConfigPanel(
sessionInfo: Scrcpy.Session.SessionInfo?, sessionInfo: Scrcpy.Session.SessionInfo?,
onDisconnect: () -> Unit = {}, onDisconnect: () -> Unit = {},
) { ) {
val scrcpyOptions = Storage.scrcpyOptions val scope = rememberCoroutineScope()
val quickDevices = Storage.quickDevices
val context = LocalContext.current
val sessionStarted = sessionInfo != null val sessionStarted = sessionInfo != null
// Check if device exists in shortcuts val soBundleShared by scrcpyOptions.bundleState.collectAsState()
var quickDevicesList by quickDevices.quickDevicesList.asMutableState() val soBundleSharedLatest by rememberUpdatedState(soBundleShared)
val savedShortcuts = remember(quickDevicesList) { var soBundle by rememberSaveable(soBundleShared) { mutableStateOf(soBundleShared) }
DeviceShortcuts.unmarshalFrom(quickDevicesList) val soBundleLatest by rememberUpdatedState(soBundle)
LaunchedEffect(soBundleShared) {
if (soBundle != soBundleShared) {
soBundle = soBundleShared
}
} }
val isQuickConnected = remember(sessionInfo, savedShortcuts) { LaunchedEffect(soBundle) {
sessionInfo?.let { info -> delay(Settings.BUNDLE_SAVE_DELAY)
savedShortcuts.get(info.host, info.port) == null if (soBundle != soBundleSharedLatest) {
} ?: false 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 = rememberSaveable { Codec.AUDIO.map { it.displayName } }
val audioCodecItems = remember { Codec.AUDIO.map { it.displayName } } val audioCodecIndex = rememberSaveable(soBundle) {
val audioCodecIndex = Codec.AUDIO.indexOfFirst { Codec.AUDIO
it.string == audioCodec .indexOfFirst { it.string == soBundle.audioCodec }
}.coerceAtLeast(0) .coerceAtLeast(0)
var audioBitRate by scrcpyOptions.audioBitRate.asMutableState() }
var videoCodec by scrcpyOptions.videoCodec.asMutableState() val videoCodecItems = rememberSaveable { Codec.VIDEO.map { it.displayName } }
val videoCodecItems = remember { Codec.VIDEO.map { it.displayName } } val videoCodecIndex = rememberSaveable(soBundle) {
val videoCodecIndex = Codec.VIDEO.indexOfFirst { Codec.VIDEO
it.string == videoCodec .indexOfFirst { it.string == soBundle.videoCodec }
}.coerceAtLeast(0) .coerceAtLeast(0)
var videoBitRate by scrcpyOptions.videoBitRate.asMutableState() }
val videoBitRateMbps = videoBitRate / 1_000_000f
SectionSmallTitle("Scrcpy") SectionSmallTitle("Scrcpy")
Card { Card {
SuperSwitch( SuperSwitch(
title = "音频转发", title = "音频转发",
summary = "转发设备音频到本机 (Android 11+)", summary = "转发设备音频到本机 (Android 11+)",
checked = audio, checked = soBundle.audio,
onCheckedChange = { audio = it }, onCheckedChange = { soBundle = soBundle.copy(audio = it) },
enabled = !sessionStarted && audioForwardingSupported, enabled = !sessionStarted
&& audioForwardingSupported,
) )
SuperDropdown( SuperDropdown(
@@ -418,28 +421,47 @@ internal fun ConfigPanel(
summary = "--audio-codec", summary = "--audio-codec",
items = audioCodecItems, items = audioCodecItems,
selectedIndex = audioCodecIndex, selectedIndex = audioCodecIndex,
onSelectedIndexChange = { audioCodec = Codec.AUDIO[it].string }, onSelectedIndexChange = {
enabled = !sessionStarted && audio, 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( SuperSlider(
title = "音频码率", title = "音频码率",
summary = "--audio-bit-rate", 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 -> onValueChange = { value ->
val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex) val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.size)
audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1000 soBundle = soBundle.copy(
audioBitRate =
if (idx == 0) 0
else ScrcpyPresets.AudioBitRate[idx - 1] * 1000
)
}, },
valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(), valueRange = 0f..ScrcpyPresets.AudioBitRate.size.toFloat(),
steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0), steps = (ScrcpyPresets.AudioBitRate.size - 1).coerceAtLeast(0),
enabled = !sessionStarted,
unit = "Kbps", unit = "Kbps",
displayText = (audioBitRate / 1000).toString(), zeroStateText = "默认",
inputInitialValue = (audioBitRate / 1000).toString(), displayText = (soBundle.audioBitRate / 1_000).toString(),
inputInitialValue =
if (soBundle.audioBitRate <= 0) ""
else (soBundle.audioBitRate / 1_000).toString(),
inputFilter = { it.filter(Char::isDigit) }, inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 1f..UShort.MAX_VALUE.toFloat(), inputValueRange = 0f..UShort.MAX_VALUE.toFloat(),
onInputConfirm = { raw -> 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", summary = "--video-codec",
items = videoCodecItems, items = videoCodecItems,
selectedIndex = videoCodecIndex, 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, enabled = !sessionStarted,
) )
@SuppressLint("DefaultLocale")
SuperSlider( SuperSlider(
title = "视频码率", title = "视频码率",
summary = "--video-bit-rate", summary = "--video-bit-rate",
value = videoBitRateMbps, value = soBundle.videoBitRate / 1_000_000f,
onValueChange = { mbps -> onValueChange = { mbps ->
videoBitRate = (mbps * 1_000_000).toInt() soBundle = soBundle.copy(
videoBitRate = (mbps * 10).roundToInt() * (1_000_000 / 10)
)
}, },
valueRange = 0.1f..40f, valueRange = 0f..40f,
steps = 399, steps = 400 - 1,
enabled = !sessionStarted,
unit = "Mbps", unit = "Mbps",
displayFormatter = { formatBitRate(it) }, zeroStateText = "默认",
inputInitialValue = formatBitRate(videoBitRateMbps), displayFormatter = { String.format("%.1f", it) },
inputInitialValue =
if (soBundle.videoBitRate <= 0) ""
else String.format("%.1f", soBundle.videoBitRate / 1_000_000f),
inputFilter = { text -> inputFilter = { text ->
var dotUsed = false var dotUsed = false
text.filter { ch -> 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 -> onInputConfirm = { raw ->
raw.toFloatOrNull()?.let { parsed -> raw.toFloatOrNull()?.let { parsed ->
if (parsed >= 0.1f) { if (parsed >= 0f) {
videoBitRate = (parsed * 1_000_000).toInt() soBundle = soBundle.copy(
videoBitRate = (parsed * 1_000_000f).roundToInt()
)
} }
} }
}, },
enabled = !sessionStarted,
) )
SuperArrow( SuperArrow(
@@ -503,53 +541,34 @@ internal fun ConfigPanel(
.padding(bottom = UiSpacing.CardContent), .padding(bottom = UiSpacing.CardContent),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) { ) {
AnimatedVisibility(!isQuickConnected) { if (isQuickConnected) TextButton(
AnimatedVisibility(!sessionStarted) { text = "断开",
TextButton( onClick = {
text = "启动", onStartStopHaptic?.invoke()
onClick = { onDisconnect()
onStartStopHaptic?.invoke() },
onStart() modifier = Modifier.weight(1f / 4f),
}, enabled = !busy,
modifier = Modifier.fillMaxWidth(), )
enabled = !busy, if (!sessionStarted) TextButton(
colors = ButtonDefaults.textButtonColorsPrimary(), text = "启动",
) onClick = {
} onStartStopHaptic?.invoke()
AnimatedVisibility(sessionStarted) { onStart()
TextButton( },
text = "停止", modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f),
onClick = { enabled = !busy,
onStartStopHaptic?.invoke() colors = ButtonDefaults.textButtonColorsPrimary(),
onStop() )
}, if (sessionStarted) TextButton(
modifier = Modifier.fillMaxWidth(), text = "停止",
enabled = !busy, onClick = {
) onStartStopHaptic?.invoke()
} onStop()
} },
// display them at the same time in quick connection modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f),
AnimatedVisibility(isQuickConnected) { enabled = !busy,
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(),
)
}
} }
} }
} }
@@ -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<String>) {
Card {
TextField(
value = lines.joinToString(separator = "\n"),
onValueChange = {},
readOnly = true,
modifier = Modifier.fillMaxWidth(),
)
}
}
/** /**
* TouchEventHandler * TouchEventHandler
* *
@@ -716,256 +720,6 @@ internal fun LogsPanel(lines: List<String>) {
* - Handles touch event processing for fullscreen control screen * - Handles touch event processing for fullscreen control screen
* - Manages pointer tracking, coordinate mapping, and touch injection * - 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<Int>,
private val activePointerPositions: LinkedHashMap<Int, Offset>,
private val activePointerDevicePositions: LinkedHashMap<Int, Pair<Int, Int>>,
private val pointerLabels: LinkedHashMap<Int, Int>,
private var nextPointerLabel: Int,
private val onInjectTouch: suspend (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit,
private val onActiveTouchCountChanged: (Int) -> Unit,
private val onActiveTouchDebugChanged: (String) -> Unit,
private val onNextPointerLabelChanged: (Int) -> Unit,
) {
private val eventPointerIds = HashSet<Int>(10)
private val eventPositions = HashMap<Int, Offset>(10)
private val eventPressures = HashMap<Int, Float>(10)
private val justPressedPointerIds = HashSet<Int>(10)
fun handleMotionEvent(event: MotionEvent): Boolean {
if (touchAreaSize.width == 0 || touchAreaSize.height == 0) {
return true
}
val bounds = calculateContentBounds()
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
return handleCancelAction(bounds)
}
extractEventData(event)
handleDisappearedPointers(eventPointerIds, bounds)
val endedPointerId = getEndedPointerId(event)
handlePointerDown(event, endedPointerId, bounds)
handlePointerMove(event, endedPointerId, bounds)
handlePointerUp(endedPointerId, bounds)
onActiveTouchCountChanged(activePointerIds.size)
refreshTouchDebug()
return true
}
private data class ContentBounds(
val width: Float,
val height: Float,
val left: Float,
val top: Float,
)
private fun calculateContentBounds(): ContentBounds {
val sessionAspect = if (session.height == 0) {
16f / 9f
} else {
session.width.toFloat() / session.height.toFloat()
}
val containerWidth = touchAreaSize.width.toFloat()
val containerHeight = touchAreaSize.height.toFloat()
val containerAspect = containerWidth / containerHeight
val contentWidth: Float
val contentHeight: Float
if (sessionAspect > containerAspect) {
contentWidth = containerWidth
contentHeight = containerWidth / sessionAspect
} else {
contentHeight = containerHeight
contentWidth = containerHeight * sessionAspect
}
val contentLeft = (containerWidth - contentWidth) / 2f
val contentTop = (containerHeight - contentHeight) / 2f
return ContentBounds(contentWidth, contentHeight, contentLeft, contentTop)
}
private fun isInsideContent(rawX: Float, rawY: Float, bounds: ContentBounds): Boolean {
return rawX in bounds.left..(bounds.left + bounds.width) &&
rawY in bounds.top..(bounds.top + bounds.height)
}
private fun mapToDevice(rawX: Float, rawY: Float, bounds: ContentBounds): Pair<Int, Int> {
val normalizedX = ((rawX - bounds.left) / bounds.width).coerceIn(0f, 1f)
val normalizedY = ((rawY - bounds.top) / bounds.height).coerceIn(0f, 1f)
val x = (normalizedX * (session.width - 1).coerceAtLeast(0)).roundToInt()
.coerceIn(0, (session.width - 1).coerceAtLeast(0))
val y = (normalizedY * (session.height - 1).coerceAtLeast(0)).roundToInt()
.coerceIn(0, (session.height - 1).coerceAtLeast(0))
return x to y
}
private fun getPointerLabel(pointerId: Int): Int {
val existing = pointerLabels[pointerId]
if (existing != null) {
return existing
}
val assigned = nextPointerLabel
nextPointerLabel += 1
onNextPointerLabelChanged(nextPointerLabel)
pointerLabels[pointerId] = assigned
return assigned
}
private fun refreshTouchDebug() {
if (activePointerIds.isEmpty()) {
onActiveTouchDebugChanged("")
return
}
val debug = activePointerIds
.sortedBy { getPointerLabel(it) }
.joinToString(separator = "\n") { pointerId ->
val label = getPointerLabel(pointerId)
val pos = activePointerDevicePositions[pointerId]
if (pos == null) {
"#$label(id=$pointerId):?"
} else {
"#$label(id=$pointerId):${pos.first},${pos.second}"
}
}
onActiveTouchDebugChanged(debug)
}
private fun releasePointer(pointerId: Int, bounds: ContentBounds) {
if (!activePointerIds.contains(pointerId)) return
val pos = activePointerPositions[pointerId] ?: Offset.Zero
val (x, y) = mapToDevice(pos.x, pos.y, bounds)
coroutineScope.launch {
runCatching {
onInjectTouch(UiMotionActions.UP, pointerId.toLong(), x, y, 0f, 0)
}.onFailure { e ->
Log.w(FULLSCREEN_TOUCH_LOG_TAG, "releasePointer failed for pointerId=$pointerId", e)
}
}
activePointerIds -= pointerId
activePointerPositions.remove(pointerId)
activePointerDevicePositions.remove(pointerId)
pointerLabels.remove(pointerId)
}
private fun handleCancelAction(bounds: ContentBounds): Boolean {
val toCancel = activePointerIds.toList()
for (pointerId in toCancel) {
releasePointer(pointerId, bounds)
}
onActiveTouchCountChanged(activePointerIds.size)
refreshTouchDebug()
return true
}
private fun extractEventData(event: MotionEvent) {
eventPointerIds.clear()
eventPositions.clear()
eventPressures.clear()
for (i in 0 until event.pointerCount) {
val pointerId = event.getPointerId(i)
eventPointerIds += pointerId
eventPositions[pointerId] = Offset(event.getX(i), event.getY(i))
eventPressures[pointerId] = event.getPressure(i).coerceIn(0f, 1f)
}
}
private fun handleDisappearedPointers(eventPointerIds: Set<Int>, bounds: ContentBounds) {
val disappearedPointers = activePointerIds.filter { it !in eventPointerIds }
for (pointerId in disappearedPointers) {
releasePointer(pointerId, bounds)
}
}
private fun getEndedPointerId(event: MotionEvent): Int? {
return when (event.actionMasked) {
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> event.getPointerId(event.actionIndex)
else -> null
}
}
private fun handlePointerDown(
event: MotionEvent,
endedPointerId: Int?,
bounds: ContentBounds,
) {
justPressedPointerIds.clear()
for (i in 0 until event.pointerCount) {
val pointerId = event.getPointerId(i)
if (pointerId == endedPointerId) continue
val raw = eventPositions[pointerId] ?: continue
val pressure = eventPressures[pointerId] ?: 0f
if (!activePointerIds.contains(pointerId)) {
if (!isInsideContent(raw.x, raw.y, bounds)) continue
val (x, y) = mapToDevice(raw.x, raw.y, bounds)
activePointerIds += pointerId
activePointerPositions[pointerId] = raw
activePointerDevicePositions[pointerId] = x to y
justPressedPointerIds += pointerId
coroutineScope.launch {
runCatching {
onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, pressure, 0)
}.onFailure { e ->
Log.w(
FULLSCREEN_TOUCH_LOG_TAG,
"handlePointerDown failed for pointerId=$pointerId",
e
)
}
}
}
}
}
private fun handlePointerMove(
event: MotionEvent,
endedPointerId: Int?,
bounds: ContentBounds,
) {
for (i in 0 until event.pointerCount) {
val pointerId = event.getPointerId(i)
if (!activePointerIds.contains(pointerId)) continue
if (pointerId == endedPointerId) continue
if (pointerId in justPressedPointerIds) continue
val raw = eventPositions[pointerId] ?: continue
val pressure = eventPressures[pointerId] ?: 0f
activePointerPositions[pointerId] = raw
val (x, y) = mapToDevice(raw.x, raw.y, bounds)
activePointerDevicePositions[pointerId] = x to y
coroutineScope.launch {
runCatching {
onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, pressure, 0)
}.onFailure { e ->
Log.w(
FULLSCREEN_TOUCH_LOG_TAG,
"handlePointerMove failed for pointerId=$pointerId",
e
)
}
}
}
}
private fun handlePointerUp(
endedPointerId: Int?,
bounds: ContentBounds,
) {
if (endedPointerId != null) {
val endPos = eventPositions[endedPointerId]
if (endPos != null) {
activePointerPositions[endedPointerId] = endPos
}
releasePointer(endedPointerId, bounds)
}
}
}
/** /**
* FullscreenControlScreen * FullscreenControlScreen
@@ -1121,14 +875,22 @@ private fun ScrcpyVideoSurface(
nativeCore: NativeCoreFacade, nativeCore: NativeCoreFacade,
session: Scrcpy.Session.SessionInfo?, session: Scrcpy.Session.SessionInfo?,
) { ) {
val surfaceTag = "video-main"
var currentSurface by remember { mutableStateOf<Surface?>(null) } var currentSurface by remember { mutableStateOf<Surface?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
LaunchedEffect(session, currentSurface) { LaunchedEffect(session, currentSurface) {
val surface = currentSurface val surface = currentSurface
if (session != null && surface != null && surface.isValid) { 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 // Register immediately when surface becomes available
if (session != null) { if (session != null) {
scope.launch { scope.launch {
nativeCore.registerVideoSurface(surfaceTag, newSurface) nativeCore.attachVideoSurface(newSurface)
} }
} }
} }
@@ -1160,9 +922,15 @@ private fun ScrcpyVideoSurface(
) = Unit ) = Unit
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
// Return false to keep the SurfaceTexture alive val surface = currentSurface
// This prevents the surface from being destroyed when the view is detached if (surface != null) {
return false scope.launch {
nativeCore.detachVideoSurface(surface, releaseDecoder = false)
}
surface.release()
currentSurface = null
}
return true
} }
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) = Unit override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) = Unit