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

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

View File

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

View File

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

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 androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
@@ -26,20 +32,21 @@ import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.services.EventLogger
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions
import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceEditorScreen
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTile
import io.github.miuzarte.scrcpyforandroid.widgets.LogsPanel
import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard
import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard
import io.github.miuzarte.scrcpyforandroid.widgets.QuickConnectCard
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
import io.github.miuzarte.scrcpyforandroid.widgets.StatusCard
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
@@ -51,9 +58,21 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.DropdownImpl
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.ListPopupColumn
import top.yukonga.miuix.kmp.basic.ListPopupDefaults
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.SnackbarHostState
import top.yukonga.miuix.kmp.extra.SuperBottomSheet
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.extra.SuperListPopup
import top.yukonga.miuix.kmp.theme.MiuixTheme
import java.net.InetSocketAddress
import java.net.Socket
@@ -66,6 +85,71 @@ private const val ADB_TCP_PROBE_TIMEOUT_MS = 500
@Composable
fun DeviceTabScreen(
nativeCore: NativeCoreFacade,
adbService: NativeAdbService,
scrcpy: Scrcpy,
snack: SnackbarHostState,
scrollBehavior: ScrollBehavior,
onOpenVirtualButtonOrder: () -> Unit,
onSessionStartedChange: (Boolean) -> Unit,
onOpenReorderDevices: () -> Unit,
onOpenAdvancedPage: () -> Unit,
onOpenFullscreenPage: (Scrcpy.Session.SessionInfo) -> Unit,
) {
var showThreePointMenu by rememberSaveable { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = "设备",
actions = {
IconButton(
onClick = { showThreePointMenu = true },
holdDownState = showThreePointMenu,
) {
Icon(
Icons.Rounded.MoreVert,
contentDescription = "更多"
)
}
DeviceMenuPopup(
show = showThreePointMenu,
onDismissRequest = { showThreePointMenu = false },
onReorderDevices = {
onOpenReorderDevices()
showThreePointMenu = false
},
onOpenVirtualButtonOrder = {
onOpenVirtualButtonOrder()
showThreePointMenu = false
},
canClearLogs = EventLogger.hasLogs(),
onClearLogs = {
EventLogger.clearLogs()
showThreePointMenu = false
},
)
},
scrollBehavior = scrollBehavior,
)
},
) { pagePadding ->
DeviceTabPage(
contentPadding = pagePadding,
nativeCore = nativeCore,
adbService = adbService,
scrcpy = scrcpy,
snack = snack,
scrollBehavior = scrollBehavior,
onSessionStartedChange = onSessionStartedChange,
onOpenAdvancedPage = onOpenAdvancedPage,
onOpenFullscreenPage = onOpenFullscreenPage,
)
}
}
@Composable
fun DeviceTabPage(
contentPadding: PaddingValues,
nativeCore: NativeCoreFacade,
adbService: NativeAdbService,
@@ -73,26 +157,60 @@ fun DeviceTabScreen(
snack: SnackbarHostState,
scrollBehavior: ScrollBehavior,
onSessionStartedChange: (Boolean) -> Unit,
onClearLogsActionChange: ((() -> Unit)) -> Unit,
onCanClearLogsChange: (Boolean) -> Unit,
onOpenReorderDevicesActionChange: ((() -> Unit)) -> Unit,
onOpenAdvancedPage: () -> Unit,
onOpenFullscreenPage: (Scrcpy.Session.SessionInfo) -> Unit,
) {
val appSettings = Storage.appSettings
val quickDevices = Storage.quickDevices
val scrcpyOptions = Storage.scrcpyOptions
val context = LocalContext.current
val scope = rememberCoroutineScope()
val haptics = rememberAppHaptics()
val virtualButtonsLayout by appSettings.virtualButtonsLayout.asState()
val virtualButtonLayout = remember(virtualButtonsLayout) {
VirtualButtonActions.splitLayout(VirtualButtonActions.parseStoredLayout(virtualButtonsLayout))
val asBundleShared by appSettings.bundleState.collectAsState()
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) }
val asBundleLatest by rememberUpdatedState(asBundle)
LaunchedEffect(asBundleShared) {
if (asBundle != asBundleShared) {
asBundle = asBundleShared
}
}
// val initialSettings = remember(context) { loadDevicePageSettings(context) }
val scope = rememberCoroutineScope()
LaunchedEffect(asBundle) {
delay(Settings.BUNDLE_SAVE_DELAY)
if (asBundle != asBundleSharedLatest) {
appSettings.saveBundle(asBundle)
}
}
DisposableEffect(Unit) {
onDispose {
scope.launch {
appSettings.saveBundle(asBundleLatest)
}
}
}
val qdBundleShared by quickDevices.bundleState.collectAsState()
val qdBundleSharedLatest by rememberUpdatedState(qdBundleShared)
var qdBundle by rememberSaveable(qdBundleShared) { mutableStateOf(qdBundleShared) }
val qdBundleLatest by rememberUpdatedState(qdBundle)
LaunchedEffect(qdBundleShared) {
if (qdBundle != qdBundleShared) {
qdBundle = qdBundleShared
}
}
LaunchedEffect(qdBundle) {
delay(Settings.BUNDLE_SAVE_DELAY)
if (qdBundle != qdBundleSharedLatest) {
quickDevices.saveBundle(qdBundle)
}
}
DisposableEffect(Unit) {
onDispose {
scope.launch {
quickDevices.saveBundle(qdBundleLatest)
}
}
}
// read only
val soBundleShared by scrcpyOptions.bundleState.collectAsState()
// Run adb operations on a dedicated single thread.
// Try to avoid blocking UI/recomposition and keeps adb call ordering deterministic.
@@ -138,7 +256,6 @@ fun DeviceTabScreen(
var previewControlsVisible by rememberSaveable { mutableStateOf(false) }
var editingDevice by rememberSaveable { mutableStateOf<DeviceShortcut?>(null) }
var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(null) }
var showReorderSheet by rememberSaveable { mutableStateOf(false) }
var adbConnecting by rememberSaveable { mutableStateOf(false) }
var audioForwardingSupported by rememberSaveable { mutableStateOf(true) }
@@ -146,34 +263,32 @@ fun DeviceTabScreen(
val currentTarget =
if (currentTargetHost.isNotBlank())
ConnectionTarget(
currentTargetHost,
currentTargetPort,
) else null
ConnectionTarget(currentTargetHost, currentTargetPort)
else null
val sessionReconnectBlacklistHosts = remember { mutableSetOf<String>() }
LaunchedEffect(EventLogger.eventLog.size) {
onCanClearLogsChange(EventLogger.hasLogs())
val virtualButtonLayout = remember(asBundle.virtualButtonsLayout) {
VirtualButtonActions.splitLayout(
VirtualButtonActions.parseStoredLayout(asBundle.virtualButtonsLayout)
)
}
var quickDevicesList by quickDevices.quickDevicesList.asMutableState()
var savedShortcuts by remember { mutableStateOf(DeviceShortcuts.unmarshalFrom(quickDevicesList)) }
var savedShortcuts by remember {
mutableStateOf(DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList))
}
LaunchedEffect(quickDevicesList) {
savedShortcuts = DeviceShortcuts.unmarshalFrom(quickDevicesList)
LaunchedEffect(qdBundle.quickDevicesList) {
savedShortcuts = DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList)
}
// save changes when [savedShortcuts] was modified
LaunchedEffect(savedShortcuts) {
val serialized = savedShortcuts.marshalToString()
if (serialized != quickDevicesList) {
quickDevicesList = serialized
if (serialized != qdBundle.quickDevicesList) {
qdBundle = qdBundle.copy(quickDevicesList = serialized)
}
}
var quickConnectInput by quickDevices.quickConnectInput.asMutableState()
var quickConnectInputTemp by remember(quickConnectInput) { mutableStateOf(quickConnectInput) }
/**
* Disconnect the current ADB connection and stop any running scrcpy session.
*
@@ -236,14 +351,13 @@ fun DeviceTabScreen(
disconnectAdbConnection(clearQuickOnlineForTarget = current)
}
var audio by scrcpyOptions.audio.asMutableState()
var videoSource by scrcpyOptions.videoSource.asMutableState()
fun applyConnectedDeviceCapabilities(sdkInt: Int, release: String) {
val audioSupported = sdkInt !in 0..<30
audioForwardingSupported = audioSupported
if (!audioSupported && audio) {
audio = false
if (!audioSupported && soBundleShared.audio) {
scope.launch {
scrcpyOptions.updateBundle { it.copy(audio = false) }
}
logEvent(
"设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持音频转发,已自动关闭",
Log.WARN
@@ -251,8 +365,10 @@ fun DeviceTabScreen(
}
val cameraSupported = sdkInt !in 0..<31
cameraMirroringSupported = cameraSupported
if (!cameraSupported && videoSource == "camera") {
videoSource = "display"
if (!cameraSupported && soBundleShared.videoSource == "camera") {
scope.launch {
scrcpyOptions.updateBundle { it.copy(videoSource = "display") }
}
logEvent(
"设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持 camera mirroring已切换为 display",
Log.WARN
@@ -405,20 +521,17 @@ fun DeviceTabScreen(
}
}
var videoEncoder by scrcpyOptions.videoEncoder.asMutableState()
var audioEncoder by scrcpyOptions.audioEncoder.asMutableState()
suspend fun refreshEncoderLists() {
if (!adbConnected) return
runCatching {
scrcpy.refreshEncoders()
}.onSuccess {
// Validate current selections
if (videoEncoder.isNotBlank() && videoEncoder !in scrcpy.videoEncoders) {
videoEncoder = ""
if (soBundleShared.videoEncoder.isNotBlank() && soBundleShared.videoEncoder !in scrcpy.videoEncoders) {
scrcpyOptions.updateBundle { it.copy(videoEncoder = "") }
}
if (audioEncoder.isNotBlank() && audioEncoder !in scrcpy.audioEncoders) {
audioEncoder = ""
if (soBundleShared.audioEncoder.isNotBlank() && soBundleShared.audioEncoder !in scrcpy.audioEncoders) {
scrcpyOptions.updateBundle { it.copy(audioEncoder = "") }
}
logEvent("编码器列表已刷新: video=${scrcpy.videoEncoders.size} audio=${scrcpy.audioEncoders.size}")
if (scrcpy.videoEncoders.isEmpty() && scrcpy.audioEncoders.isEmpty()) {
@@ -436,16 +549,18 @@ fun DeviceTabScreen(
}
}
var cameraSize by scrcpyOptions.cameraSize.asMutableState()
suspend fun refreshCameraSizeLists() {
if (!adbConnected) return
runCatching {
scrcpy.refreshCameraSizes()
}.onSuccess {
// Validate current selection
if (cameraSize.isNotBlank() && cameraSize != "custom" && cameraSize !in scrcpy.cameraSizes) {
cameraSize = ""
if (
soBundleShared.cameraSize.isNotBlank()
&& soBundleShared.cameraSize != "custom"
&& soBundleShared.cameraSize !in scrcpy.cameraSizes
) {
scrcpyOptions.updateBundle { it.copy(cameraSize = "") }
}
logEvent("camera sizes 已刷新: count=${scrcpy.cameraSizes.size}")
}.onFailure { e ->
@@ -543,9 +658,9 @@ fun DeviceTabScreen(
}
}
val adbPairingAutoDiscoverOnDialogOpen by appSettings.adbPairingAutoDiscoverOnDialogOpen.asState()
val adbAutoReconnectPairedDevice by appSettings.adbAutoReconnectPairedDevice.asState()
val adbMdnsLanDiscovery by appSettings.adbMdnsLanDiscovery.asState()
val adbPairingAutoDiscoverOnDialogOpen = asBundle.adbPairingAutoDiscoverOnDialogOpen
val adbAutoReconnectPairedDevice = asBundle.adbAutoReconnectPairedDevice
val adbMdnsLanDiscovery = asBundle.adbMdnsLanDiscovery
LaunchedEffect(adbConnected, adbAutoReconnectPairedDevice, adbMdnsLanDiscovery) {
if (adbConnected || !adbAutoReconnectPairedDevice) return@LaunchedEffect
@@ -676,44 +791,6 @@ fun DeviceTabScreen(
onSessionStartedChange(sessionInfo != null)
}
DisposableEffect(Unit) {
onClearLogsActionChange {
EventLogger.clearLogs()
}
onOpenReorderDevicesActionChange {
showReorderSheet = true
}
onDispose {
// canClearLogs 是 DevicePage 特有的状态,需要重置
onCanClearLogsChange(false)
}
}
SuperBottomSheet(
show = showReorderSheet,
title = "快速设备排序",
onDismissRequest = { showReorderSheet = false },
) {
val list = remember {
ReorderableList(
itemsProvider = {
savedShortcuts.map { device ->
ReorderableList.Item(
id = device.id,
title = device.name.ifBlank { device.host },
subtitle = "${device.host}:${device.port}",
)
}
},
onSettle = { fromIndex, toIndex ->
savedShortcuts = savedShortcuts.move(fromIndex, toIndex)
},
)
}
list()
Spacer(Modifier.height(UiSpacing.BottomSheetBottom))
}
fun sendVirtualButtonAction(action: VirtualButtonAction) {
val keycode = action.keycode ?: return
runBusy("发送 ${action.title}") {
@@ -744,14 +821,14 @@ fun DeviceTabScreen(
return
}
val devicePreviewCardHeightDp by appSettings.devicePreviewCardHeightDp.asState()
val previewVirtualButtonShowText by appSettings.previewVirtualButtonShowText.asState()
val devicePreviewCardHeightDp = asBundle.devicePreviewCardHeightDp
val previewVirtualButtonShowText = asBundle.previewVirtualButtonShowText
val audioBitRate by scrcpyOptions.audioBitRate.asState()
val videoBitRate by scrcpyOptions.videoBitRate.asState()
val audioBitRate = soBundleShared.audioBitRate
val videoBitRate = soBundleShared.videoBitRate
// 设备
AppPageLazyColumn(
LazyColumn(
contentPadding = contentPadding,
scrollBehavior = scrollBehavior,
) {
@@ -831,26 +908,27 @@ fun DeviceTabScreen(
if (!adbConnected) item {
// "快速连接"
QuickConnectCard(
input = quickConnectInputTemp,
input = qdBundle.quickConnectInput,
onValueChange = {
quickConnectInputTemp = it
quickConnectInput = quickConnectInputTemp
qdBundle = qdBundle.copy(quickConnectInput = it)
},
// onFocusChange = { quickConnectInput = quickConnectInputTemp },
enabled = !adbConnecting,
onAddDevice = {
val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp)
val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput)
?: return@QuickConnectCard
savedShortcuts = savedShortcuts.upsert(
DeviceShortcut(host = target.host, port = target.port)
)
Log.d("SavedShortcuts", "size: ${savedShortcuts.size}, list: $quickDevicesList")
Log.d(
"SavedShortcuts",
"size: ${savedShortcuts.size}, list: ${qdBundle.quickDevicesList}"
)
scope.launch {
snack.showSnackbar("已添加设备: ${target.host}:${target.port}")
}
},
onConnect = {
val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp)
val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput)
?: return@QuickConnectCard
runAdbConnect(
"连接 ADB",
@@ -914,13 +992,15 @@ fun DeviceTabScreen(
item {
ConfigPanel(
busy = busy,
snack = snack,
audioForwardingSupported = audioForwardingSupported,
cameraMirroringSupported = cameraMirroringSupported,
isQuickConnected = isQuickConnected,
onOpenAdvanced = onOpenAdvancedPage,
onStartStopHaptic = { haptics.contextClick() },
onStart = {
runBusy("启动 scrcpy") {
val options = scrcpyOptions.toClientOptions()
val options = scrcpyOptions.toClientOptions(soBundleShared)
val session = scrcpy.start(options)
sessionInfo = session.copy(
host = currentTargetHost,
@@ -928,18 +1008,17 @@ fun DeviceTabScreen(
)
statusLine = "scrcpy 运行中"
@SuppressLint("DefaultLocale")
val videoDetail = if (!options.video) {
"off"
} else {
"${session.codecName} ${session.width}x${session.height} " +
val videoDetail =
if (!options.video) "off"
else if (videoBitRate <= 0) "${session.codecName} ${session.width}x${session.height} @default"
else "${session.codecName} ${session.width}x${session.height} " +
"@${String.format("%.1f", videoBitRate / 1_000_000f)}Mbps"
}
val audioDetail = if (!audio) {
"off"
} else {
val playback = if (!options.audioPlayback) "(no-playback)" else ""
"${options.audioCodec} ${videoBitRate / 1_000f}Kbps source=${options.audioSource}$playback"
}
val audioDetail =
if (!soBundleShared.audio) "off"
else if (audioBitRate <= 0) "${options.audioCodec} default source=${options.audioSource}"
else "${options.audioCodec} ${audioBitRate / 1_000f}Kbps source=${options.audioSource}${if (!options.audioPlayback) "(no-playback)" else ""}"
logEvent(
"scrcpy 已启动: device=${session.deviceName}" +
", video=$videoDetail, audio=$audioDetail" +
@@ -1017,9 +1096,91 @@ fun DeviceTabScreen(
if (EventLogger.hasLogs()) item {
Spacer(Modifier.height(UiSpacing.PageItem))
LogsPanel(lines = EventLogger.eventLog)
Card {
TextField(
value = EventLogger.eventLog.joinToString(separator = "\n"),
onValueChange = {},
readOnly = true,
modifier = Modifier.fillMaxWidth(),
)
}
}
item { Spacer(Modifier.height(UiSpacing.BottomContent)) }
}
}
@Composable
private fun DeviceMenuPopup(
show: Boolean,
onDismissRequest: () -> Unit,
onReorderDevices: () -> Unit,
onOpenVirtualButtonOrder: () -> Unit,
canClearLogs: Boolean,
onClearLogs: () -> Unit,
) {
SuperListPopup(
show = show,
popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
alignment = PopupPositionProvider.Align.TopEnd,
onDismissRequest = onDismissRequest,
enableWindowDim = false,
) {
ListPopupColumn {
DeviceMenuPopupItem(
text = "快速设备排序",
optionSize = 3,
index = 0,
onClick = onReorderDevices,
)
DeviceMenuPopupItem(
text = "虚拟按钮排序",
optionSize = 3,
index = 1,
onClick = onOpenVirtualButtonOrder,
)
DeviceMenuPopupItem(
text = "清空日志",
optionSize = 3,
index = 2,
enabled = canClearLogs,
onClick = onClearLogs,
)
}
}
}
@Composable
private fun DeviceMenuPopupItem(
text: String,
optionSize: Int,
index: Int,
enabled: Boolean = true,
// TODO: (Int) -> Unit
onClick: () -> Unit,
) {
if (enabled) {
DropdownImpl(
text = text,
optionSize = optionSize,
isSelected = false,
index = index,
onSelectedIndexChange = { onClick() },
)
return
}
val additionalTopPadding = if (index == 0) UiSpacing.PopupHorizontal else UiSpacing.PageItem
val additionalBottomPadding =
if (index == optionSize - 1) UiSpacing.PopupHorizontal else UiSpacing.PageItem
Text(
text = text,
fontSize = MiuixTheme.textStyles.body1.fontSize,
fontWeight = FontWeight.Medium,
color = MiuixTheme.colorScheme.disabledOnSecondaryVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.PopupHorizontal)
.padding(top = additionalTopPadding, bottom = additionalBottomPadding),
)
}

View File

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

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

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

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
import android.content.Context
import android.os.Parcelable
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.StateFlow
import kotlinx.parcelize.Parcelize
class AdbClientData(context: Context) : Settings(context, "AdbClient") {
companion object {
@@ -17,4 +21,31 @@ class AdbClientData(context: Context) : Settings(context, "AdbClient") {
val rsaPrivateKey by setting(RSA_PRIVATE_KEY)
val rsaPublicKeyX509 by setting(RSA_PUBLIC_KEY_X509)
@Parcelize
data class Bundle(
val rsaPrivateKey: String,
val rsaPublicKeyX509: String,
) : Parcelable {
}
private val bundleFields = arrayOf(
bundleField(RSA_PRIVATE_KEY) { bundle: Bundle -> bundle.rsaPrivateKey },
bundleField(RSA_PUBLIC_KEY_X509) { bundle: Bundle -> bundle.rsaPublicKeyX509 },
)
val bundleState: StateFlow<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
import android.content.Context
import android.os.Parcelable
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.StateFlow
import kotlinx.parcelize.Parcelize
class AppSettings(context: Context) : Settings(context, "AppSettings") {
companion object {
@@ -87,6 +91,71 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
val adbAutoReconnectPairedDevice by setting(ADB_AUTO_RECONNECT_PAIRED_DEVICE)
val adbMdnsLanDiscovery by setting(ADB_MDNS_LAN_DISCOVERY)
@Parcelize
data class Bundle(
val themeBaseIndex: Int,
val monet: Boolean,
val fullscreenDebugInfo: Boolean,
val showFullscreenVirtualButtons: Boolean,
val keepScreenOnWhenStreaming: Boolean,
val devicePreviewCardHeightDp: Int,
val previewVirtualButtonShowText: Boolean,
val virtualButtonsLayout: String,
val customServerUri: String,
val serverRemotePath: String,
val adbKeyName: String,
val adbPairingAutoDiscoverOnDialogOpen: Boolean,
val adbAutoReconnectPairedDevice: Boolean,
val adbMdnsLanDiscovery: Boolean,
) : Parcelable {
}
private val bundleFields = arrayOf(
bundleField(THEME_BASE_INDEX) { bundle: Bundle -> bundle.themeBaseIndex },
bundleField(MONET) { bundle: Bundle -> bundle.monet },
bundleField(FULLSCREEN_DEBUG_INFO) { bundle: Bundle -> bundle.fullscreenDebugInfo },
bundleField(SHOW_FULLSCREEN_VIRTUAL_BUTTONS) { bundle: Bundle -> bundle.showFullscreenVirtualButtons },
bundleField(KEEP_SCREEN_ON_WHEN_STREAMING) { bundle: Bundle -> bundle.keepScreenOnWhenStreaming },
bundleField(DEVICE_PREVIEW_CARD_HEIGHT_DP) { bundle: Bundle -> bundle.devicePreviewCardHeightDp },
bundleField(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) { bundle: Bundle -> bundle.previewVirtualButtonShowText },
bundleField(VIRTUAL_BUTTONS_LAYOUT) { bundle: Bundle -> bundle.virtualButtonsLayout },
bundleField(CUSTOM_SERVER_URI) { bundle: Bundle -> bundle.customServerUri },
bundleField(SERVER_REMOTE_PATH) { bundle: Bundle -> bundle.serverRemotePath },
bundleField(ADB_KEY_NAME) { bundle: Bundle -> bundle.adbKeyName },
bundleField(ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN) { bundle: Bundle -> bundle.adbPairingAutoDiscoverOnDialogOpen },
bundleField(ADB_AUTO_RECONNECT_PAIRED_DEVICE) { bundle: Bundle -> bundle.adbAutoReconnectPairedDevice },
bundleField(ADB_MDNS_LAN_DISCOVERY) { bundle: Bundle -> bundle.adbMdnsLanDiscovery },
)
val bundleState: StateFlow<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?
// fun validate(): Boolean = true
}

View File

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

View File

@@ -18,10 +18,15 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlin.reflect.KProperty
import kotlin.time.Duration.Companion.milliseconds
private const val TAG = "Settings"
@@ -34,6 +39,8 @@ abstract class Settings(
emptyPreferences()
}
) {
private val settingsScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
data class Pair<T>(
val key: Preferences.Key<T>,
val defaultValue: T,
@@ -41,6 +48,24 @@ abstract class Settings(
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 方法
*/
@@ -90,6 +115,28 @@ abstract class Settings(
protected fun <T> setting(pair: Pair<T>) = SettingProperty(pair)
protected fun <B> createBundleState(reader: (Preferences) -> B): StateFlow<B> =
dataStore.data
.map(reader)
.stateIn(
scope = settingsScope,
started = SharingStarted.Eagerly,
initialValue = runBlocking { loadBundle(reader) }
)
protected suspend fun <B> loadBundle(reader: (Preferences) -> B): B =
reader(dataStore.data.first())
protected suspend fun <B> saveBundle(
current: B,
new: B,
fields: Array<out BundleField<B>>,
) {
for (field in fields) {
field.persist(this, current, new)
}
}
protected suspend fun <T> getValue(pair: Pair<T>): T =
dataStore.data.first()[pair.key] ?: pair.defaultValue
@@ -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.graphics.SurfaceTexture
import android.util.Log
import android.view.MotionEvent
import android.view.Surface
import android.view.TextureView
import androidx.activity.compose.BackHandler
@@ -38,12 +36,15 @@ import androidx.compose.material.icons.rounded.Fullscreen
import androidx.compose.material.icons.rounded.LinkOff
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -54,7 +55,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
@@ -70,14 +70,16 @@ import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import kotlinx.coroutines.CoroutineScope
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.basic.Button
@@ -86,6 +88,7 @@ import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.CardDefaults
import top.yukonga.miuix.kmp.basic.CircularProgressIndicator
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.SnackbarHostState
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.basic.TextField
@@ -98,17 +101,6 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme.isDynamicColor
import top.yukonga.miuix.kmp.utils.PressFeedbackType
import kotlin.math.roundToInt
private object UiMotionActions {
const val DOWN = 0
const val UP = 1
const val MOVE = 2
const val CANCEL = 3
const val POINTER_DOWN = 5
const val POINTER_UP = 6
}
private const val FULLSCREEN_TOUCH_LOG_TAG = "FullscreenTouch"
@Composable
internal fun StatusCard(
statusLine: String,
@@ -119,10 +111,8 @@ internal fun StatusCard(
connectedDeviceLabel: String,
) {
val appSettings = Storage.appSettings
val context = LocalContext.current
val themeBaseIndex by appSettings.themeBaseIndex.asState()
val appSettingsBundle by appSettings.bundleState.collectAsState()
val themeBaseIndex = appSettingsBundle.themeBaseIndex
val cleanStatusLine = normalizeStatusLine(statusLine)
@@ -359,8 +349,10 @@ internal fun VirtualButtonCard(
@Composable
internal fun ConfigPanel(
busy: Boolean,
snack: SnackbarHostState,
audioForwardingSupported: Boolean,
cameraMirroringSupported: Boolean,
isQuickConnected: Boolean,
onOpenAdvanced: () -> Unit,
onStartStopHaptic: (() -> Unit)? = null,
onStart: () -> Unit,
@@ -368,49 +360,60 @@ internal fun ConfigPanel(
sessionInfo: Scrcpy.Session.SessionInfo?,
onDisconnect: () -> Unit = {},
) {
val scrcpyOptions = Storage.scrcpyOptions
val quickDevices = Storage.quickDevices
val context = LocalContext.current
val scope = rememberCoroutineScope()
val sessionStarted = sessionInfo != null
// Check if device exists in shortcuts
var quickDevicesList by quickDevices.quickDevicesList.asMutableState()
val savedShortcuts = remember(quickDevicesList) {
DeviceShortcuts.unmarshalFrom(quickDevicesList)
val soBundleShared by scrcpyOptions.bundleState.collectAsState()
val soBundleSharedLatest by rememberUpdatedState(soBundleShared)
var soBundle by rememberSaveable(soBundleShared) { mutableStateOf(soBundleShared) }
val soBundleLatest by rememberUpdatedState(soBundle)
LaunchedEffect(soBundleShared) {
if (soBundle != soBundleShared) {
soBundle = soBundleShared
}
}
val isQuickConnected = remember(sessionInfo, savedShortcuts) {
sessionInfo?.let { info ->
savedShortcuts.get(info.host, info.port) == null
} ?: false
LaunchedEffect(soBundle) {
delay(Settings.BUNDLE_SAVE_DELAY)
if (soBundle != soBundleSharedLatest) {
scrcpyOptions.saveBundle(soBundle)
}
}
DisposableEffect(Unit) {
onDispose {
scope.launch {
scrcpyOptions.saveBundle(soBundleLatest)
}
}
}
var audio by scrcpyOptions.audio.asMutableState()
val audioBitRateVisibility = rememberSaveable(soBundle) {
soBundle.audio && (soBundle.audioCodec == "opus" || soBundle.audioCodec == "aac")
}
var audioCodec by scrcpyOptions.audioCodec.asMutableState()
val audioCodecItems = remember { Codec.AUDIO.map { it.displayName } }
val audioCodecIndex = Codec.AUDIO.indexOfFirst {
it.string == audioCodec
}.coerceAtLeast(0)
var audioBitRate by scrcpyOptions.audioBitRate.asMutableState()
val audioCodecItems = rememberSaveable { Codec.AUDIO.map { it.displayName } }
val audioCodecIndex = rememberSaveable(soBundle) {
Codec.AUDIO
.indexOfFirst { it.string == soBundle.audioCodec }
.coerceAtLeast(0)
}
var videoCodec by scrcpyOptions.videoCodec.asMutableState()
val videoCodecItems = remember { Codec.VIDEO.map { it.displayName } }
val videoCodecIndex = Codec.VIDEO.indexOfFirst {
it.string == videoCodec
}.coerceAtLeast(0)
var videoBitRate by scrcpyOptions.videoBitRate.asMutableState()
val videoBitRateMbps = videoBitRate / 1_000_000f
val videoCodecItems = rememberSaveable { Codec.VIDEO.map { it.displayName } }
val videoCodecIndex = rememberSaveable(soBundle) {
Codec.VIDEO
.indexOfFirst { it.string == soBundle.videoCodec }
.coerceAtLeast(0)
}
SectionSmallTitle("Scrcpy")
Card {
SuperSwitch(
title = "音频转发",
summary = "转发设备音频到本机 (Android 11+)",
checked = audio,
onCheckedChange = { audio = it },
enabled = !sessionStarted && audioForwardingSupported,
checked = soBundle.audio,
onCheckedChange = { soBundle = soBundle.copy(audio = it) },
enabled = !sessionStarted
&& audioForwardingSupported,
)
SuperDropdown(
@@ -418,28 +421,47 @@ internal fun ConfigPanel(
summary = "--audio-codec",
items = audioCodecItems,
selectedIndex = audioCodecIndex,
onSelectedIndexChange = { audioCodec = Codec.AUDIO[it].string },
enabled = !sessionStarted && audio,
onSelectedIndexChange = {
val codec = Codec.AUDIO[it]
soBundle = soBundle.copy(audioCodec = codec.string)
if (codec == Codec.FLAC) {
scope.launch {
snack.showSnackbar("注意FLAC编解码会引入较大的延迟")
}
}
},
enabled = !sessionStarted && soBundle.audio,
)
AnimatedVisibility(audio && (audioCodec == "opus" || audioCodec == "aac")) {
AnimatedVisibility(audioBitRateVisibility) {
SuperSlider(
title = "音频码率",
summary = "--audio-bit-rate",
value = ScrcpyPresets.AudioBitRate.indexOfOrNearest(audioBitRate / 1000).toFloat(),
value = if (soBundle.audioBitRate <= 0) 0f
else (ScrcpyPresets.AudioBitRate
.indexOfOrNearest(soBundle.audioBitRate / 1000) + 1
).toFloat(),
onValueChange = { value ->
val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex)
audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1000
val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.size)
soBundle = soBundle.copy(
audioBitRate =
if (idx == 0) 0
else ScrcpyPresets.AudioBitRate[idx - 1] * 1000
)
},
valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(),
steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0),
enabled = !sessionStarted,
valueRange = 0f..ScrcpyPresets.AudioBitRate.size.toFloat(),
steps = (ScrcpyPresets.AudioBitRate.size - 1).coerceAtLeast(0),
unit = "Kbps",
displayText = (audioBitRate / 1000).toString(),
inputInitialValue = (audioBitRate / 1000).toString(),
zeroStateText = "默认",
displayText = (soBundle.audioBitRate / 1_000).toString(),
inputInitialValue =
if (soBundle.audioBitRate <= 0) ""
else (soBundle.audioBitRate / 1_000).toString(),
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 1f..UShort.MAX_VALUE.toFloat(),
inputValueRange = 0f..UShort.MAX_VALUE.toFloat(),
onInputConfirm = { raw ->
raw.toIntOrNull()?.takeIf { it > 0 }?.let { audioBitRate = it * 1000 }
raw.toIntOrNull()
?.takeIf { it >= 0 }
?.let { soBundle = soBundle.copy(audioBitRate = it * 1000) }
},
)
}
@@ -449,22 +471,35 @@ internal fun ConfigPanel(
summary = "--video-codec",
items = videoCodecItems,
selectedIndex = videoCodecIndex,
onSelectedIndexChange = { videoCodec = Codec.VIDEO[it].string },
onSelectedIndexChange = {
val codec = Codec.VIDEO[it]
soBundle = soBundle.copy(videoCodec = codec.string)
if (codec == Codec.AV1) {
scope.launch {
snack.showSnackbar("注意绝大部分设备不支持AV1硬件编码")
}
}
},
enabled = !sessionStarted,
)
@SuppressLint("DefaultLocale")
SuperSlider(
title = "视频码率",
summary = "--video-bit-rate",
value = videoBitRateMbps,
value = soBundle.videoBitRate / 1_000_000f,
onValueChange = { mbps ->
videoBitRate = (mbps * 1_000_000).toInt()
soBundle = soBundle.copy(
videoBitRate = (mbps * 10).roundToInt() * (1_000_000 / 10)
)
},
valueRange = 0.1f..40f,
steps = 399,
enabled = !sessionStarted,
valueRange = 0f..40f,
steps = 400 - 1,
unit = "Mbps",
displayFormatter = { formatBitRate(it) },
inputInitialValue = formatBitRate(videoBitRateMbps),
zeroStateText = "默认",
displayFormatter = { String.format("%.1f", it) },
inputInitialValue =
if (soBundle.videoBitRate <= 0) ""
else String.format("%.1f", soBundle.videoBitRate / 1_000_000f),
inputFilter = { text ->
var dotUsed = false
text.filter { ch ->
@@ -479,14 +514,17 @@ internal fun ConfigPanel(
}
}
},
inputValueRange = 0.1f..UInt.MAX_VALUE.toFloat(),
inputValueRange = 0f..UInt.MAX_VALUE.toFloat(),
onInputConfirm = { raw ->
raw.toFloatOrNull()?.let { parsed ->
if (parsed >= 0.1f) {
videoBitRate = (parsed * 1_000_000).toInt()
if (parsed >= 0f) {
soBundle = soBundle.copy(
videoBitRate = (parsed * 1_000_000f).roundToInt()
)
}
}
},
enabled = !sessionStarted,
)
SuperArrow(
@@ -503,53 +541,34 @@ internal fun ConfigPanel(
.padding(bottom = UiSpacing.CardContent),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
AnimatedVisibility(!isQuickConnected) {
AnimatedVisibility(!sessionStarted) {
TextButton(
text = "启动",
onClick = {
onStartStopHaptic?.invoke()
onStart()
},
modifier = Modifier.fillMaxWidth(),
enabled = !busy,
colors = ButtonDefaults.textButtonColorsPrimary(),
)
}
AnimatedVisibility(sessionStarted) {
TextButton(
text = "停止",
onClick = {
onStartStopHaptic?.invoke()
onStop()
},
modifier = Modifier.fillMaxWidth(),
enabled = !busy,
)
}
}
// display them at the same time in quick connection
AnimatedVisibility(isQuickConnected) {
TextButton(
text = "断开",
onClick = {
onStartStopHaptic?.invoke()
onDisconnect()
},
modifier = Modifier.weight(1f/4f),
enabled = !busy,
)
TextButton(
text = "启动",
onClick = {
onStartStopHaptic?.invoke()
onStart()
},
modifier = Modifier.weight(3f/4f),
enabled = !busy,
colors = ButtonDefaults.textButtonColorsPrimary(),
)
}
if (isQuickConnected) TextButton(
text = "断开",
onClick = {
onStartStopHaptic?.invoke()
onDisconnect()
},
modifier = Modifier.weight(1f / 4f),
enabled = !busy,
)
if (!sessionStarted) TextButton(
text = "启动",
onClick = {
onStartStopHaptic?.invoke()
onStart()
},
modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f),
enabled = !busy,
colors = ButtonDefaults.textButtonColorsPrimary(),
)
if (sessionStarted) TextButton(
text = "停止",
onClick = {
onStartStopHaptic?.invoke()
onStop()
},
modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f),
enabled = !busy,
)
}
}
}
@@ -694,21 +713,6 @@ private fun PairingDialog(
)
}
@SuppressLint("DefaultLocale")
private fun formatBitRate(value: Float): String = String.format("%.1f", value)
@Composable
internal fun LogsPanel(lines: List<String>) {
Card {
TextField(
value = lines.joinToString(separator = "\n"),
onValueChange = {},
readOnly = true,
modifier = Modifier.fillMaxWidth(),
)
}
}
/**
* TouchEventHandler
*
@@ -716,256 +720,6 @@ internal fun LogsPanel(lines: List<String>) {
* - Handles touch event processing for fullscreen control screen
* - Manages pointer tracking, coordinate mapping, and touch injection
*/
class TouchEventHandler(
private val coroutineScope: CoroutineScope,
private val session: Scrcpy.Session.SessionInfo,
private val touchAreaSize: IntSize,
private val activePointerIds: LinkedHashSet<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
@@ -1121,14 +875,22 @@ private fun ScrcpyVideoSurface(
nativeCore: NativeCoreFacade,
session: Scrcpy.Session.SessionInfo?,
) {
val surfaceTag = "video-main"
var currentSurface by remember { mutableStateOf<Surface?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(session, currentSurface) {
val surface = currentSurface
if (session != null && surface != null && surface.isValid) {
nativeCore.registerVideoSurface(surfaceTag, surface)
nativeCore.attachVideoSurface(surface)
}
}
DisposableEffect(Unit) {
onDispose {
val surface = currentSurface
if (surface != null) {
scope.launch { nativeCore.detachVideoSurface(surface, releaseDecoder = false) }
}
}
}
@@ -1148,7 +910,7 @@ private fun ScrcpyVideoSurface(
// Register immediately when surface becomes available
if (session != null) {
scope.launch {
nativeCore.registerVideoSurface(surfaceTag, newSurface)
nativeCore.attachVideoSurface(newSurface)
}
}
}
@@ -1160,9 +922,15 @@ private fun ScrcpyVideoSurface(
) = Unit
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
// Return false to keep the SurfaceTexture alive
// This prevents the surface from being destroyed when the view is detached
return false
val surface = currentSurface
if (surface != null) {
scope.launch {
nativeCore.detachVideoSurface(surface, releaseDecoder = false)
}
surface.release()
currentSurface = null
}
return true
}
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) = Unit