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:
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
"""
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
},
|
||||
)()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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 = "取消",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user