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:
4
TODO.md
4
TODO.md
@@ -4,10 +4,6 @@
|
|||||||
|
|
||||||
- Refactoring
|
- Refactoring
|
||||||
|
|
||||||
## BUGS
|
|
||||||
|
|
||||||
- Figure out is virtual display destroyed at the end of scrcpy session
|
|
||||||
|
|
||||||
## WIDGETS
|
## WIDGETS
|
||||||
|
|
||||||
- `SuperTextField` Click to pop a dialog with custom notes/summary
|
- `SuperTextField` Click to pop a dialog with custom notes/summary
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.compose.compiler)
|
alias(libs.plugins.compose.compiler)
|
||||||
|
id("kotlin-parcelize")
|
||||||
}
|
}
|
||||||
|
|
||||||
val defaultAbiList = listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
val defaultAbiList = listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import io.github.miuzarte.scrcpyforandroid.pages.MainPage
|
import io.github.miuzarte.scrcpyforandroid.pages.MainScreen
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration
|
import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -25,7 +25,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
MainPage()
|
MainScreen()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import android.os.Looper
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder
|
import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.nativecore.PersistentVideoRenderer
|
||||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import java.util.ArrayDeque
|
import java.util.ArrayDeque
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
import java.util.concurrent.CopyOnWriteArraySet
|
import java.util.concurrent.CopyOnWriteArraySet
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,11 +24,11 @@ class NativeCoreFacade private constructor() {
|
|||||||
@Volatile
|
@Volatile
|
||||||
var session: Scrcpy.Session? = null
|
var session: Scrcpy.Session? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
private val sessionLifecycleMutex = Mutex()
|
private val sessionLifecycleMutex = Mutex()
|
||||||
private val surfaceMap = ConcurrentHashMap<String, Surface>()
|
private val renderer = PersistentVideoRenderer()
|
||||||
private val surfaceIdentityMap = ConcurrentHashMap<String, Int>()
|
private var activeSurfaceId: Int? = null
|
||||||
private val decoderMap = ConcurrentHashMap<String, AnnexBDecoder>()
|
private var decoder: AnnexBDecoder? = null
|
||||||
private val videoSizeListeners = CopyOnWriteArraySet<(Int, Int) -> Unit>()
|
private val videoSizeListeners = CopyOnWriteArraySet<(Int, Int) -> Unit>()
|
||||||
private val videoFpsListeners = CopyOnWriteArraySet<(Float) -> Unit>()
|
private val videoFpsListeners = CopyOnWriteArraySet<(Float) -> Unit>()
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
@@ -45,71 +45,75 @@ class NativeCoreFacade private constructor() {
|
|||||||
suspend fun close() {
|
suspend fun close() {
|
||||||
sessionLifecycleMutex.withLock {
|
sessionLifecycleMutex.withLock {
|
||||||
releaseAllDecoders()
|
releaseAllDecoders()
|
||||||
|
renderer.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a rendering Surface for a given `tag`.
|
* Register the current rendering [surface].
|
||||||
*
|
*
|
||||||
* - If the surface is already known and the decoder is active, this is a no-op.
|
* - If the surface is already active and the decoder exists, this is a no-op.
|
||||||
* - If a decoder exists but cannot switch output surface, a new decoder is created
|
* - If a decoder exists but cannot switch output surface, a new decoder is created
|
||||||
* and bound to the supplied surface.
|
* and bound to the supplied surface.
|
||||||
* - This method must be called from the UI thread (it is called by the composable
|
|
||||||
* that owns the TextureView). Native decoder operations are performed synchronously
|
|
||||||
* on the UI thread only via the decoder API; heavy work happens inside the decoder.
|
|
||||||
*/
|
*/
|
||||||
suspend fun registerVideoSurface(tag: String, surface: Surface) {
|
suspend fun attachVideoSurface(surface: Surface) {
|
||||||
sessionLifecycleMutex.withLock {
|
sessionLifecycleMutex.withLock {
|
||||||
if (!surface.isValid) {
|
if (!surface.isValid) {
|
||||||
Log.w(TAG, "registerVideoSurface(): skip invalid surface for tag=$tag")
|
Log.w(TAG, "attachVideoSurface(): skip invalid surface")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val newId = System.identityHashCode(surface)
|
val newId = System.identityHashCode(surface)
|
||||||
val oldId = surfaceIdentityMap[tag]
|
if (activeSurfaceId == newId && decoder != null) {
|
||||||
if (oldId != null && oldId == newId && decoderMap.containsKey(tag)) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Log.i(TAG, "registerVideoSurface(): tag=$tag surfaceId=$newId oldSurfaceId=$oldId")
|
Log.i(TAG, "attachVideoSurface(): surfaceId=$newId oldSurfaceId=$activeSurfaceId")
|
||||||
surfaceMap[tag] = surface
|
activeSurfaceId = newId
|
||||||
surfaceIdentityMap[tag] = newId
|
renderer.attachDisplaySurface(surface)
|
||||||
val session = currentSessionInfo ?: return
|
val session = currentSessionInfo ?: return
|
||||||
// ensureVideoConsumerAttached()
|
val currentDecoder = decoder
|
||||||
val decoder = decoderMap[tag]
|
if (currentDecoder != null) {
|
||||||
if (decoder != null) {
|
Log.i(TAG, "attachVideoSurface(): try switch decoder output to persistent surface")
|
||||||
val switched = decoder.switchOutputSurface(surface)
|
val switched = currentDecoder.switchOutputSurface(renderer.getDecoderSurface())
|
||||||
Log.i(TAG, "registerVideoSurface(): switchOutputSurface tag=$tag success=$switched")
|
Log.i(TAG, "attachVideoSurface(): switchOutputSurface success=$switched")
|
||||||
if (switched) {
|
if (switched) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
createOrReplaceDecoder(tag, surface, session)
|
createOrReplaceDecoder(session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unregister the rendering Surface previously bound to `tag`.
|
* Unregister the active rendering [surface].
|
||||||
*
|
*
|
||||||
* - If a stale surface reference is supplied (identity mismatch), the request is ignored.
|
* - If a stale surface reference is supplied (identity mismatch), the request is ignored.
|
||||||
* - When there is no active session, the decoder for the tag is released immediately.
|
* - When [releaseDecoder] is false, only the active display target is cleared so a future
|
||||||
* - This protects the native decoder from feeding into a released Surface.
|
* surface can attempt to rebind via `setOutputSurface()`.
|
||||||
|
* - When [releaseDecoder] is true, the current decoder is also released because the backing
|
||||||
|
* surface is being destroyed for real.
|
||||||
*/
|
*/
|
||||||
suspend fun unregisterVideoSurface(tag: String, surface: Surface? = null) {
|
suspend fun detachVideoSurface(surface: Surface? = null, releaseDecoder: Boolean = false) {
|
||||||
sessionLifecycleMutex.withLock {
|
sessionLifecycleMutex.withLock {
|
||||||
val currentId = surfaceIdentityMap[tag]
|
val currentId = activeSurfaceId
|
||||||
val requestId = surface?.let { System.identityHashCode(it) }
|
val requestId = surface?.let { System.identityHashCode(it) }
|
||||||
if (requestId != null && currentId != null && requestId != currentId) {
|
if (requestId != null && currentId != null && requestId != currentId) {
|
||||||
Log.i(
|
Log.i(
|
||||||
TAG,
|
TAG,
|
||||||
"unregisterVideoSurface(): skip stale request tag=$tag requestSurfaceId=$requestId currentSurfaceId=$currentId"
|
"detachVideoSurface(): skip stale request requestSurfaceId=$requestId currentSurfaceId=$currentId"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Log.i(TAG, "unregisterVideoSurface(): tag=$tag surfaceId=$requestId, releasing decoder")
|
Log.i(
|
||||||
surfaceMap.remove(tag)
|
TAG,
|
||||||
surfaceIdentityMap.remove(tag)
|
"detachVideoSurface(): surfaceId=$requestId releaseDecoder=$releaseDecoder"
|
||||||
// Always release decoder when surface is unregistered
|
)
|
||||||
// This ensures clean state when surface is recreated
|
activeSurfaceId = null
|
||||||
decoderMap.remove(tag)?.release()
|
renderer.detachDisplaySurface(surface, releaseSurface = false)
|
||||||
|
if (releaseDecoder) {
|
||||||
|
Log.i(TAG, "detachVideoSurface(): releasing decoder with destroyed surface")
|
||||||
|
decoder?.release()
|
||||||
|
decoder = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,46 +152,29 @@ class NativeCoreFacade private constructor() {
|
|||||||
bootstrapPackets.clear()
|
bootstrapPackets.clear()
|
||||||
latestConfigPacket = null
|
latestConfigPacket = null
|
||||||
}
|
}
|
||||||
|
if (activeSurfaceId != null) {
|
||||||
surfaceMap.forEach { (tag, surface) ->
|
Log.i(TAG, "onScrcpySessionStarted(): bind decoder to persistent surface")
|
||||||
if (!surface.isValid) {
|
createOrReplaceDecoder(session)
|
||||||
Log.w(TAG, "onScrcpySessionStarted(): skip invalid surface for tag=$tag")
|
|
||||||
return@forEach
|
|
||||||
}
|
|
||||||
Log.i(TAG, "onScrcpySessionStarted(): bind decoder to tag=$tag")
|
|
||||||
createOrReplaceDecoder(tag, surface, session)
|
|
||||||
}
|
}
|
||||||
packetCount = 0
|
packetCount = 0
|
||||||
// if (!request.noVideo) {
|
|
||||||
// ensureVideoConsumerAttached()
|
|
||||||
// }
|
|
||||||
sessionMgr.attachVideoConsumer { packet ->
|
sessionMgr.attachVideoConsumer { packet ->
|
||||||
cacheBootstrapPacket(packet)
|
cacheBootstrapPacket(packet)
|
||||||
packetCount += 1
|
packetCount += 1
|
||||||
if (packetCount == 1L || packetCount % 120L == 0L) {
|
if (packetCount == 1L || packetCount % 120L == 0L) {
|
||||||
Log.i(
|
Log.i(
|
||||||
TAG,
|
TAG,
|
||||||
"videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoders=${decoderMap.size}"
|
"videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoder=${decoder != null}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Snapshot decoders to avoid feeding released decoders during iteration
|
val currentDecoder = decoder ?: return@attachVideoConsumer
|
||||||
val decoders = decoderMap.toMap()
|
if (activeSurfaceId == null) return@attachVideoConsumer
|
||||||
decoders.forEach { (tag, decoder) ->
|
runCatching {
|
||||||
if (!surfaceIdentityMap.containsKey(tag)) {
|
currentDecoder.feedAnnexB(
|
||||||
return@forEach
|
packet.data,
|
||||||
}
|
packet.ptsUs,
|
||||||
// Double-check decoder is still in map before feeding
|
packet.isKeyFrame,
|
||||||
if (decoderMap[tag] != decoder) {
|
packet.isConfig
|
||||||
return@forEach
|
)
|
||||||
}
|
|
||||||
runCatching {
|
|
||||||
decoder.feedAnnexB(
|
|
||||||
packet.data,
|
|
||||||
packet.ptsUs,
|
|
||||||
packet.isKeyFrame,
|
|
||||||
packet.isConfig
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,7 +238,7 @@ class NativeCoreFacade private constructor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create or replace a decoder bound to `surface` for `session`.
|
* Create or replace the active decoder bound to [surface] for [session].
|
||||||
*
|
*
|
||||||
* - Chooses MIME type from `session.codec` and constructs an [AnnexBDecoder].
|
* - Chooses MIME type from `session.codec` and constructs an [AnnexBDecoder].
|
||||||
* - The decoder's `onOutputSizeChanged` callback publishes size changes to
|
* - The decoder's `onOutputSizeChanged` callback publishes size changes to
|
||||||
@@ -259,17 +246,15 @@ class NativeCoreFacade private constructor() {
|
|||||||
* - Newly created decoders are fed with any cached bootstrap packets to allow
|
* - Newly created decoders are fed with any cached bootstrap packets to allow
|
||||||
* faster playback startup.
|
* faster playback startup.
|
||||||
*/
|
*/
|
||||||
private fun createOrReplaceDecoder(tag: String, surface: Surface, session: Scrcpy.Session.SessionInfo) {
|
private fun createOrReplaceDecoder(session: Scrcpy.Session.SessionInfo) {
|
||||||
if (!surface.isValid) {
|
val surface = renderer.getDecoderSurface()
|
||||||
Log.w(TAG, "createOrReplaceDecoder(): skip invalid surface for tag=$tag")
|
decoder?.release()
|
||||||
return
|
decoder = null
|
||||||
}
|
|
||||||
decoderMap.remove(tag)?.release()
|
|
||||||
Log.i(
|
Log.i(
|
||||||
TAG,
|
TAG,
|
||||||
"createOrReplaceDecoder(): tag=$tag codec=${session.codecName} size=${session.width}x${session.height}"
|
"createOrReplaceDecoder(): codec=${session.codecName} size=${session.width}x${session.height} persistent=true"
|
||||||
)
|
)
|
||||||
val decoder = AnnexBDecoder(
|
val newDecoder = AnnexBDecoder(
|
||||||
width = session.width,
|
width = session.width,
|
||||||
height = session.height,
|
height = session.height,
|
||||||
outputSurface = surface,
|
outputSurface = surface,
|
||||||
@@ -303,8 +288,8 @@ class NativeCoreFacade private constructor() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
decoderMap[tag] = decoder
|
decoder = newDecoder
|
||||||
replayBootstrapPackets(decoder)
|
replayBootstrapPackets(newDecoder)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun replayBootstrapPackets(decoder: AnnexBDecoder) {
|
private fun replayBootstrapPackets(decoder: AnnexBDecoder) {
|
||||||
@@ -355,8 +340,7 @@ class NativeCoreFacade private constructor() {
|
|||||||
*
|
*
|
||||||
* - Called when a session is active and at least one decoder exists. Packets are
|
* - Called when a session is active and at least one decoder exists. Packets are
|
||||||
* cached into [bootstrapPackets] to allow late-attaching decoders to catch up.
|
* cached into [bootstrapPackets] to allow late-attaching decoders to catch up.
|
||||||
* - The consumer iterates [decoderMap] and feeds each decoder. Errors are
|
* - Kept only for documentation parity with the old multi-decoder design.
|
||||||
* isolated with `runCatching` so one codec failure doesn't stop others.
|
|
||||||
*/
|
*/
|
||||||
@Deprecated("TODO: Determine if this is really unnecessary")
|
@Deprecated("TODO: Determine if this is really unnecessary")
|
||||||
private suspend fun ensureVideoConsumerAttached(sessionMgr: Scrcpy.Session) {
|
private suspend fun ensureVideoConsumerAttached(sessionMgr: Scrcpy.Session) {
|
||||||
@@ -366,35 +350,24 @@ class NativeCoreFacade private constructor() {
|
|||||||
if (packetCount == 1L || packetCount % 120L == 0L) {
|
if (packetCount == 1L || packetCount % 120L == 0L) {
|
||||||
Log.i(
|
Log.i(
|
||||||
TAG,
|
TAG,
|
||||||
"videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoders=${decoderMap.size}"
|
"videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoder=${decoder != null}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Snapshot decoders to avoid feeding released decoders during iteration
|
val currentDecoder = decoder ?: return@attachVideoConsumer
|
||||||
val decoders = decoderMap.toMap()
|
if (activeSurfaceId == null) return@attachVideoConsumer
|
||||||
decoders.forEach { (tag, decoder) ->
|
runCatching {
|
||||||
if (!surfaceIdentityMap.containsKey(tag)) {
|
currentDecoder.feedAnnexB(
|
||||||
return@forEach
|
packet.data,
|
||||||
}
|
packet.ptsUs,
|
||||||
// Double-check decoder is still in map before feeding
|
packet.isKeyFrame,
|
||||||
if (decoderMap[tag] != decoder) {
|
packet.isConfig
|
||||||
return@forEach
|
)
|
||||||
}
|
|
||||||
runCatching {
|
|
||||||
decoder.feedAnnexB(
|
|
||||||
packet.data,
|
|
||||||
packet.ptsUs,
|
|
||||||
packet.isKeyFrame,
|
|
||||||
packet.isConfig
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun releaseAllDecoders() {
|
private fun releaseAllDecoders() {
|
||||||
decoderMap.values.forEach { decoder ->
|
runCatching { decoder?.release() }
|
||||||
runCatching { decoder.release() }
|
decoder = null
|
||||||
}
|
|
||||||
decoderMap.clear()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 android.util.Log
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.MoreVert
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
|
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||||
@@ -26,20 +32,21 @@ import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
|
|||||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
|
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
|
||||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
|
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
|
||||||
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
|
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
|
||||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
|
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
||||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.EventLogger
|
import io.github.miuzarte.scrcpyforandroid.services.EventLogger
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
|
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo
|
import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage
|
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel
|
import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceEditorScreen
|
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceEditorScreen
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTile
|
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTile
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.LogsPanel
|
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard
|
import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard
|
import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.QuickConnectCard
|
import io.github.miuzarte.scrcpyforandroid.widgets.QuickConnectCard
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
|
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
|
import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.StatusCard
|
import io.github.miuzarte.scrcpyforandroid.widgets.StatusCard
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
|
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
|
||||||
@@ -51,9 +58,21 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import top.yukonga.miuix.kmp.basic.Card
|
||||||
|
import top.yukonga.miuix.kmp.basic.DropdownImpl
|
||||||
|
import top.yukonga.miuix.kmp.basic.Icon
|
||||||
|
import top.yukonga.miuix.kmp.basic.IconButton
|
||||||
|
import top.yukonga.miuix.kmp.basic.ListPopupColumn
|
||||||
|
import top.yukonga.miuix.kmp.basic.ListPopupDefaults
|
||||||
|
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
|
||||||
|
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||||
import top.yukonga.miuix.kmp.basic.ScrollBehavior
|
import top.yukonga.miuix.kmp.basic.ScrollBehavior
|
||||||
import top.yukonga.miuix.kmp.basic.SnackbarHostState
|
import top.yukonga.miuix.kmp.basic.SnackbarHostState
|
||||||
import top.yukonga.miuix.kmp.extra.SuperBottomSheet
|
import top.yukonga.miuix.kmp.basic.Text
|
||||||
|
import top.yukonga.miuix.kmp.basic.TextField
|
||||||
|
import top.yukonga.miuix.kmp.basic.TopAppBar
|
||||||
|
import top.yukonga.miuix.kmp.extra.SuperListPopup
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
|
|
||||||
@@ -66,6 +85,71 @@ private const val ADB_TCP_PROBE_TIMEOUT_MS = 500
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DeviceTabScreen(
|
fun DeviceTabScreen(
|
||||||
|
nativeCore: NativeCoreFacade,
|
||||||
|
adbService: NativeAdbService,
|
||||||
|
scrcpy: Scrcpy,
|
||||||
|
snack: SnackbarHostState,
|
||||||
|
scrollBehavior: ScrollBehavior,
|
||||||
|
onOpenVirtualButtonOrder: () -> Unit,
|
||||||
|
onSessionStartedChange: (Boolean) -> Unit,
|
||||||
|
onOpenReorderDevices: () -> Unit,
|
||||||
|
onOpenAdvancedPage: () -> Unit,
|
||||||
|
onOpenFullscreenPage: (Scrcpy.Session.SessionInfo) -> Unit,
|
||||||
|
) {
|
||||||
|
var showThreePointMenu by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = "设备",
|
||||||
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
onClick = { showThreePointMenu = true },
|
||||||
|
holdDownState = showThreePointMenu,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.MoreVert,
|
||||||
|
contentDescription = "更多"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DeviceMenuPopup(
|
||||||
|
show = showThreePointMenu,
|
||||||
|
onDismissRequest = { showThreePointMenu = false },
|
||||||
|
onReorderDevices = {
|
||||||
|
onOpenReorderDevices()
|
||||||
|
showThreePointMenu = false
|
||||||
|
},
|
||||||
|
onOpenVirtualButtonOrder = {
|
||||||
|
onOpenVirtualButtonOrder()
|
||||||
|
showThreePointMenu = false
|
||||||
|
},
|
||||||
|
canClearLogs = EventLogger.hasLogs(),
|
||||||
|
onClearLogs = {
|
||||||
|
EventLogger.clearLogs()
|
||||||
|
showThreePointMenu = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { pagePadding ->
|
||||||
|
DeviceTabPage(
|
||||||
|
contentPadding = pagePadding,
|
||||||
|
nativeCore = nativeCore,
|
||||||
|
adbService = adbService,
|
||||||
|
scrcpy = scrcpy,
|
||||||
|
snack = snack,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
onSessionStartedChange = onSessionStartedChange,
|
||||||
|
onOpenAdvancedPage = onOpenAdvancedPage,
|
||||||
|
onOpenFullscreenPage = onOpenFullscreenPage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeviceTabPage(
|
||||||
contentPadding: PaddingValues,
|
contentPadding: PaddingValues,
|
||||||
nativeCore: NativeCoreFacade,
|
nativeCore: NativeCoreFacade,
|
||||||
adbService: NativeAdbService,
|
adbService: NativeAdbService,
|
||||||
@@ -73,26 +157,60 @@ fun DeviceTabScreen(
|
|||||||
snack: SnackbarHostState,
|
snack: SnackbarHostState,
|
||||||
scrollBehavior: ScrollBehavior,
|
scrollBehavior: ScrollBehavior,
|
||||||
onSessionStartedChange: (Boolean) -> Unit,
|
onSessionStartedChange: (Boolean) -> Unit,
|
||||||
onClearLogsActionChange: ((() -> Unit)) -> Unit,
|
|
||||||
onCanClearLogsChange: (Boolean) -> Unit,
|
|
||||||
onOpenReorderDevicesActionChange: ((() -> Unit)) -> Unit,
|
|
||||||
onOpenAdvancedPage: () -> Unit,
|
onOpenAdvancedPage: () -> Unit,
|
||||||
onOpenFullscreenPage: (Scrcpy.Session.SessionInfo) -> Unit,
|
onOpenFullscreenPage: (Scrcpy.Session.SessionInfo) -> Unit,
|
||||||
) {
|
) {
|
||||||
val appSettings = Storage.appSettings
|
val scope = rememberCoroutineScope()
|
||||||
val quickDevices = Storage.quickDevices
|
|
||||||
val scrcpyOptions = Storage.scrcpyOptions
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
val haptics = rememberAppHaptics()
|
val haptics = rememberAppHaptics()
|
||||||
|
|
||||||
val virtualButtonsLayout by appSettings.virtualButtonsLayout.asState()
|
val asBundleShared by appSettings.bundleState.collectAsState()
|
||||||
val virtualButtonLayout = remember(virtualButtonsLayout) {
|
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
|
||||||
VirtualButtonActions.splitLayout(VirtualButtonActions.parseStoredLayout(virtualButtonsLayout))
|
var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) }
|
||||||
|
val asBundleLatest by rememberUpdatedState(asBundle)
|
||||||
|
LaunchedEffect(asBundleShared) {
|
||||||
|
if (asBundle != asBundleShared) {
|
||||||
|
asBundle = asBundleShared
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// val initialSettings = remember(context) { loadDevicePageSettings(context) }
|
LaunchedEffect(asBundle) {
|
||||||
val scope = rememberCoroutineScope()
|
delay(Settings.BUNDLE_SAVE_DELAY)
|
||||||
|
if (asBundle != asBundleSharedLatest) {
|
||||||
|
appSettings.saveBundle(asBundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
scope.launch {
|
||||||
|
appSettings.saveBundle(asBundleLatest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val qdBundleShared by quickDevices.bundleState.collectAsState()
|
||||||
|
val qdBundleSharedLatest by rememberUpdatedState(qdBundleShared)
|
||||||
|
var qdBundle by rememberSaveable(qdBundleShared) { mutableStateOf(qdBundleShared) }
|
||||||
|
val qdBundleLatest by rememberUpdatedState(qdBundle)
|
||||||
|
LaunchedEffect(qdBundleShared) {
|
||||||
|
if (qdBundle != qdBundleShared) {
|
||||||
|
qdBundle = qdBundleShared
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(qdBundle) {
|
||||||
|
delay(Settings.BUNDLE_SAVE_DELAY)
|
||||||
|
if (qdBundle != qdBundleSharedLatest) {
|
||||||
|
quickDevices.saveBundle(qdBundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
scope.launch {
|
||||||
|
quickDevices.saveBundle(qdBundleLatest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// read only
|
||||||
|
val soBundleShared by scrcpyOptions.bundleState.collectAsState()
|
||||||
|
|
||||||
// Run adb operations on a dedicated single thread.
|
// Run adb operations on a dedicated single thread.
|
||||||
// Try to avoid blocking UI/recomposition and keeps adb call ordering deterministic.
|
// Try to avoid blocking UI/recomposition and keeps adb call ordering deterministic.
|
||||||
@@ -138,7 +256,6 @@ fun DeviceTabScreen(
|
|||||||
var previewControlsVisible by rememberSaveable { mutableStateOf(false) }
|
var previewControlsVisible by rememberSaveable { mutableStateOf(false) }
|
||||||
var editingDevice by rememberSaveable { mutableStateOf<DeviceShortcut?>(null) }
|
var editingDevice by rememberSaveable { mutableStateOf<DeviceShortcut?>(null) }
|
||||||
var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(null) }
|
var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var showReorderSheet by rememberSaveable { mutableStateOf(false) }
|
|
||||||
var adbConnecting by rememberSaveable { mutableStateOf(false) }
|
var adbConnecting by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
var audioForwardingSupported by rememberSaveable { mutableStateOf(true) }
|
var audioForwardingSupported by rememberSaveable { mutableStateOf(true) }
|
||||||
@@ -146,34 +263,32 @@ fun DeviceTabScreen(
|
|||||||
|
|
||||||
val currentTarget =
|
val currentTarget =
|
||||||
if (currentTargetHost.isNotBlank())
|
if (currentTargetHost.isNotBlank())
|
||||||
ConnectionTarget(
|
ConnectionTarget(currentTargetHost, currentTargetPort)
|
||||||
currentTargetHost,
|
else null
|
||||||
currentTargetPort,
|
|
||||||
) else null
|
|
||||||
|
|
||||||
val sessionReconnectBlacklistHosts = remember { mutableSetOf<String>() }
|
val sessionReconnectBlacklistHosts = remember { mutableSetOf<String>() }
|
||||||
|
|
||||||
LaunchedEffect(EventLogger.eventLog.size) {
|
val virtualButtonLayout = remember(asBundle.virtualButtonsLayout) {
|
||||||
onCanClearLogsChange(EventLogger.hasLogs())
|
VirtualButtonActions.splitLayout(
|
||||||
|
VirtualButtonActions.parseStoredLayout(asBundle.virtualButtonsLayout)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var quickDevicesList by quickDevices.quickDevicesList.asMutableState()
|
var savedShortcuts by remember {
|
||||||
var savedShortcuts by remember { mutableStateOf(DeviceShortcuts.unmarshalFrom(quickDevicesList)) }
|
mutableStateOf(DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList))
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(quickDevicesList) {
|
LaunchedEffect(qdBundle.quickDevicesList) {
|
||||||
savedShortcuts = DeviceShortcuts.unmarshalFrom(quickDevicesList)
|
savedShortcuts = DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList)
|
||||||
}
|
}
|
||||||
// save changes when [savedShortcuts] was modified
|
// save changes when [savedShortcuts] was modified
|
||||||
LaunchedEffect(savedShortcuts) {
|
LaunchedEffect(savedShortcuts) {
|
||||||
val serialized = savedShortcuts.marshalToString()
|
val serialized = savedShortcuts.marshalToString()
|
||||||
if (serialized != quickDevicesList) {
|
if (serialized != qdBundle.quickDevicesList) {
|
||||||
quickDevicesList = serialized
|
qdBundle = qdBundle.copy(quickDevicesList = serialized)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var quickConnectInput by quickDevices.quickConnectInput.asMutableState()
|
|
||||||
var quickConnectInputTemp by remember(quickConnectInput) { mutableStateOf(quickConnectInput) }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disconnect the current ADB connection and stop any running scrcpy session.
|
* Disconnect the current ADB connection and stop any running scrcpy session.
|
||||||
*
|
*
|
||||||
@@ -236,14 +351,13 @@ fun DeviceTabScreen(
|
|||||||
disconnectAdbConnection(clearQuickOnlineForTarget = current)
|
disconnectAdbConnection(clearQuickOnlineForTarget = current)
|
||||||
}
|
}
|
||||||
|
|
||||||
var audio by scrcpyOptions.audio.asMutableState()
|
|
||||||
var videoSource by scrcpyOptions.videoSource.asMutableState()
|
|
||||||
|
|
||||||
fun applyConnectedDeviceCapabilities(sdkInt: Int, release: String) {
|
fun applyConnectedDeviceCapabilities(sdkInt: Int, release: String) {
|
||||||
val audioSupported = sdkInt !in 0..<30
|
val audioSupported = sdkInt !in 0..<30
|
||||||
audioForwardingSupported = audioSupported
|
audioForwardingSupported = audioSupported
|
||||||
if (!audioSupported && audio) {
|
if (!audioSupported && soBundleShared.audio) {
|
||||||
audio = false
|
scope.launch {
|
||||||
|
scrcpyOptions.updateBundle { it.copy(audio = false) }
|
||||||
|
}
|
||||||
logEvent(
|
logEvent(
|
||||||
"设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持音频转发,已自动关闭",
|
"设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持音频转发,已自动关闭",
|
||||||
Log.WARN
|
Log.WARN
|
||||||
@@ -251,8 +365,10 @@ fun DeviceTabScreen(
|
|||||||
}
|
}
|
||||||
val cameraSupported = sdkInt !in 0..<31
|
val cameraSupported = sdkInt !in 0..<31
|
||||||
cameraMirroringSupported = cameraSupported
|
cameraMirroringSupported = cameraSupported
|
||||||
if (!cameraSupported && videoSource == "camera") {
|
if (!cameraSupported && soBundleShared.videoSource == "camera") {
|
||||||
videoSource = "display"
|
scope.launch {
|
||||||
|
scrcpyOptions.updateBundle { it.copy(videoSource = "display") }
|
||||||
|
}
|
||||||
logEvent(
|
logEvent(
|
||||||
"设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持 camera mirroring,已切换为 display",
|
"设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持 camera mirroring,已切换为 display",
|
||||||
Log.WARN
|
Log.WARN
|
||||||
@@ -405,20 +521,17 @@ fun DeviceTabScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var videoEncoder by scrcpyOptions.videoEncoder.asMutableState()
|
|
||||||
var audioEncoder by scrcpyOptions.audioEncoder.asMutableState()
|
|
||||||
|
|
||||||
suspend fun refreshEncoderLists() {
|
suspend fun refreshEncoderLists() {
|
||||||
if (!adbConnected) return
|
if (!adbConnected) return
|
||||||
runCatching {
|
runCatching {
|
||||||
scrcpy.refreshEncoders()
|
scrcpy.refreshEncoders()
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
// Validate current selections
|
// Validate current selections
|
||||||
if (videoEncoder.isNotBlank() && videoEncoder !in scrcpy.videoEncoders) {
|
if (soBundleShared.videoEncoder.isNotBlank() && soBundleShared.videoEncoder !in scrcpy.videoEncoders) {
|
||||||
videoEncoder = ""
|
scrcpyOptions.updateBundle { it.copy(videoEncoder = "") }
|
||||||
}
|
}
|
||||||
if (audioEncoder.isNotBlank() && audioEncoder !in scrcpy.audioEncoders) {
|
if (soBundleShared.audioEncoder.isNotBlank() && soBundleShared.audioEncoder !in scrcpy.audioEncoders) {
|
||||||
audioEncoder = ""
|
scrcpyOptions.updateBundle { it.copy(audioEncoder = "") }
|
||||||
}
|
}
|
||||||
logEvent("编码器列表已刷新: video=${scrcpy.videoEncoders.size} audio=${scrcpy.audioEncoders.size}")
|
logEvent("编码器列表已刷新: video=${scrcpy.videoEncoders.size} audio=${scrcpy.audioEncoders.size}")
|
||||||
if (scrcpy.videoEncoders.isEmpty() && scrcpy.audioEncoders.isEmpty()) {
|
if (scrcpy.videoEncoders.isEmpty() && scrcpy.audioEncoders.isEmpty()) {
|
||||||
@@ -436,16 +549,18 @@ fun DeviceTabScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var cameraSize by scrcpyOptions.cameraSize.asMutableState()
|
|
||||||
|
|
||||||
suspend fun refreshCameraSizeLists() {
|
suspend fun refreshCameraSizeLists() {
|
||||||
if (!adbConnected) return
|
if (!adbConnected) return
|
||||||
runCatching {
|
runCatching {
|
||||||
scrcpy.refreshCameraSizes()
|
scrcpy.refreshCameraSizes()
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
// Validate current selection
|
// Validate current selection
|
||||||
if (cameraSize.isNotBlank() && cameraSize != "custom" && cameraSize !in scrcpy.cameraSizes) {
|
if (
|
||||||
cameraSize = ""
|
soBundleShared.cameraSize.isNotBlank()
|
||||||
|
&& soBundleShared.cameraSize != "custom"
|
||||||
|
&& soBundleShared.cameraSize !in scrcpy.cameraSizes
|
||||||
|
) {
|
||||||
|
scrcpyOptions.updateBundle { it.copy(cameraSize = "") }
|
||||||
}
|
}
|
||||||
logEvent("camera sizes 已刷新: count=${scrcpy.cameraSizes.size}")
|
logEvent("camera sizes 已刷新: count=${scrcpy.cameraSizes.size}")
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
@@ -543,9 +658,9 @@ fun DeviceTabScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val adbPairingAutoDiscoverOnDialogOpen by appSettings.adbPairingAutoDiscoverOnDialogOpen.asState()
|
val adbPairingAutoDiscoverOnDialogOpen = asBundle.adbPairingAutoDiscoverOnDialogOpen
|
||||||
val adbAutoReconnectPairedDevice by appSettings.adbAutoReconnectPairedDevice.asState()
|
val adbAutoReconnectPairedDevice = asBundle.adbAutoReconnectPairedDevice
|
||||||
val adbMdnsLanDiscovery by appSettings.adbMdnsLanDiscovery.asState()
|
val adbMdnsLanDiscovery = asBundle.adbMdnsLanDiscovery
|
||||||
LaunchedEffect(adbConnected, adbAutoReconnectPairedDevice, adbMdnsLanDiscovery) {
|
LaunchedEffect(adbConnected, adbAutoReconnectPairedDevice, adbMdnsLanDiscovery) {
|
||||||
if (adbConnected || !adbAutoReconnectPairedDevice) return@LaunchedEffect
|
if (adbConnected || !adbAutoReconnectPairedDevice) return@LaunchedEffect
|
||||||
|
|
||||||
@@ -676,44 +791,6 @@ fun DeviceTabScreen(
|
|||||||
onSessionStartedChange(sessionInfo != null)
|
onSessionStartedChange(sessionInfo != null)
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onClearLogsActionChange {
|
|
||||||
EventLogger.clearLogs()
|
|
||||||
}
|
|
||||||
onOpenReorderDevicesActionChange {
|
|
||||||
showReorderSheet = true
|
|
||||||
}
|
|
||||||
onDispose {
|
|
||||||
// canClearLogs 是 DevicePage 特有的状态,需要重置
|
|
||||||
onCanClearLogsChange(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SuperBottomSheet(
|
|
||||||
show = showReorderSheet,
|
|
||||||
title = "快速设备排序",
|
|
||||||
onDismissRequest = { showReorderSheet = false },
|
|
||||||
) {
|
|
||||||
val list = remember {
|
|
||||||
ReorderableList(
|
|
||||||
itemsProvider = {
|
|
||||||
savedShortcuts.map { device ->
|
|
||||||
ReorderableList.Item(
|
|
||||||
id = device.id,
|
|
||||||
title = device.name.ifBlank { device.host },
|
|
||||||
subtitle = "${device.host}:${device.port}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSettle = { fromIndex, toIndex ->
|
|
||||||
savedShortcuts = savedShortcuts.move(fromIndex, toIndex)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
list()
|
|
||||||
Spacer(Modifier.height(UiSpacing.BottomSheetBottom))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendVirtualButtonAction(action: VirtualButtonAction) {
|
fun sendVirtualButtonAction(action: VirtualButtonAction) {
|
||||||
val keycode = action.keycode ?: return
|
val keycode = action.keycode ?: return
|
||||||
runBusy("发送 ${action.title}") {
|
runBusy("发送 ${action.title}") {
|
||||||
@@ -744,14 +821,14 @@ fun DeviceTabScreen(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val devicePreviewCardHeightDp by appSettings.devicePreviewCardHeightDp.asState()
|
val devicePreviewCardHeightDp = asBundle.devicePreviewCardHeightDp
|
||||||
val previewVirtualButtonShowText by appSettings.previewVirtualButtonShowText.asState()
|
val previewVirtualButtonShowText = asBundle.previewVirtualButtonShowText
|
||||||
|
|
||||||
val audioBitRate by scrcpyOptions.audioBitRate.asState()
|
val audioBitRate = soBundleShared.audioBitRate
|
||||||
val videoBitRate by scrcpyOptions.videoBitRate.asState()
|
val videoBitRate = soBundleShared.videoBitRate
|
||||||
|
|
||||||
// 设备
|
// 设备
|
||||||
AppPageLazyColumn(
|
LazyColumn(
|
||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
) {
|
) {
|
||||||
@@ -831,26 +908,27 @@ fun DeviceTabScreen(
|
|||||||
if (!adbConnected) item {
|
if (!adbConnected) item {
|
||||||
// "快速连接"
|
// "快速连接"
|
||||||
QuickConnectCard(
|
QuickConnectCard(
|
||||||
input = quickConnectInputTemp,
|
input = qdBundle.quickConnectInput,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
quickConnectInputTemp = it
|
qdBundle = qdBundle.copy(quickConnectInput = it)
|
||||||
quickConnectInput = quickConnectInputTemp
|
|
||||||
},
|
},
|
||||||
// onFocusChange = { quickConnectInput = quickConnectInputTemp },
|
|
||||||
enabled = !adbConnecting,
|
enabled = !adbConnecting,
|
||||||
onAddDevice = {
|
onAddDevice = {
|
||||||
val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp)
|
val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput)
|
||||||
?: return@QuickConnectCard
|
?: return@QuickConnectCard
|
||||||
savedShortcuts = savedShortcuts.upsert(
|
savedShortcuts = savedShortcuts.upsert(
|
||||||
DeviceShortcut(host = target.host, port = target.port)
|
DeviceShortcut(host = target.host, port = target.port)
|
||||||
)
|
)
|
||||||
Log.d("SavedShortcuts", "size: ${savedShortcuts.size}, list: $quickDevicesList")
|
Log.d(
|
||||||
|
"SavedShortcuts",
|
||||||
|
"size: ${savedShortcuts.size}, list: ${qdBundle.quickDevicesList}"
|
||||||
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
snack.showSnackbar("已添加设备: ${target.host}:${target.port}")
|
snack.showSnackbar("已添加设备: ${target.host}:${target.port}")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onConnect = {
|
onConnect = {
|
||||||
val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp)
|
val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput)
|
||||||
?: return@QuickConnectCard
|
?: return@QuickConnectCard
|
||||||
runAdbConnect(
|
runAdbConnect(
|
||||||
"连接 ADB",
|
"连接 ADB",
|
||||||
@@ -914,13 +992,15 @@ fun DeviceTabScreen(
|
|||||||
item {
|
item {
|
||||||
ConfigPanel(
|
ConfigPanel(
|
||||||
busy = busy,
|
busy = busy,
|
||||||
|
snack = snack,
|
||||||
audioForwardingSupported = audioForwardingSupported,
|
audioForwardingSupported = audioForwardingSupported,
|
||||||
cameraMirroringSupported = cameraMirroringSupported,
|
cameraMirroringSupported = cameraMirroringSupported,
|
||||||
|
isQuickConnected = isQuickConnected,
|
||||||
onOpenAdvanced = onOpenAdvancedPage,
|
onOpenAdvanced = onOpenAdvancedPage,
|
||||||
onStartStopHaptic = { haptics.contextClick() },
|
onStartStopHaptic = { haptics.contextClick() },
|
||||||
onStart = {
|
onStart = {
|
||||||
runBusy("启动 scrcpy") {
|
runBusy("启动 scrcpy") {
|
||||||
val options = scrcpyOptions.toClientOptions()
|
val options = scrcpyOptions.toClientOptions(soBundleShared)
|
||||||
val session = scrcpy.start(options)
|
val session = scrcpy.start(options)
|
||||||
sessionInfo = session.copy(
|
sessionInfo = session.copy(
|
||||||
host = currentTargetHost,
|
host = currentTargetHost,
|
||||||
@@ -928,18 +1008,17 @@ fun DeviceTabScreen(
|
|||||||
)
|
)
|
||||||
statusLine = "scrcpy 运行中"
|
statusLine = "scrcpy 运行中"
|
||||||
@SuppressLint("DefaultLocale")
|
@SuppressLint("DefaultLocale")
|
||||||
val videoDetail = if (!options.video) {
|
val videoDetail =
|
||||||
"off"
|
if (!options.video) "off"
|
||||||
} else {
|
else if (videoBitRate <= 0) "${session.codecName} ${session.width}x${session.height} @default"
|
||||||
"${session.codecName} ${session.width}x${session.height} " +
|
else "${session.codecName} ${session.width}x${session.height} " +
|
||||||
"@${String.format("%.1f", videoBitRate / 1_000_000f)}Mbps"
|
"@${String.format("%.1f", videoBitRate / 1_000_000f)}Mbps"
|
||||||
}
|
|
||||||
val audioDetail = if (!audio) {
|
val audioDetail =
|
||||||
"off"
|
if (!soBundleShared.audio) "off"
|
||||||
} else {
|
else if (audioBitRate <= 0) "${options.audioCodec} default source=${options.audioSource}"
|
||||||
val playback = if (!options.audioPlayback) "(no-playback)" else ""
|
else "${options.audioCodec} ${audioBitRate / 1_000f}Kbps source=${options.audioSource}${if (!options.audioPlayback) "(no-playback)" else ""}"
|
||||||
"${options.audioCodec} ${videoBitRate / 1_000f}Kbps source=${options.audioSource}$playback"
|
|
||||||
}
|
|
||||||
logEvent(
|
logEvent(
|
||||||
"scrcpy 已启动: device=${session.deviceName}" +
|
"scrcpy 已启动: device=${session.deviceName}" +
|
||||||
", video=$videoDetail, audio=$audioDetail" +
|
", video=$videoDetail, audio=$audioDetail" +
|
||||||
@@ -1017,9 +1096,91 @@ fun DeviceTabScreen(
|
|||||||
|
|
||||||
if (EventLogger.hasLogs()) item {
|
if (EventLogger.hasLogs()) item {
|
||||||
Spacer(Modifier.height(UiSpacing.PageItem))
|
Spacer(Modifier.height(UiSpacing.PageItem))
|
||||||
LogsPanel(lines = EventLogger.eventLog)
|
Card {
|
||||||
|
TextField(
|
||||||
|
value = EventLogger.eventLog.joinToString(separator = "\n"),
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item { Spacer(Modifier.height(UiSpacing.BottomContent)) }
|
item { Spacer(Modifier.height(UiSpacing.BottomContent)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeviceMenuPopup(
|
||||||
|
show: Boolean,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onReorderDevices: () -> Unit,
|
||||||
|
onOpenVirtualButtonOrder: () -> Unit,
|
||||||
|
canClearLogs: Boolean,
|
||||||
|
onClearLogs: () -> Unit,
|
||||||
|
) {
|
||||||
|
SuperListPopup(
|
||||||
|
show = show,
|
||||||
|
popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
|
||||||
|
alignment = PopupPositionProvider.Align.TopEnd,
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
enableWindowDim = false,
|
||||||
|
) {
|
||||||
|
ListPopupColumn {
|
||||||
|
DeviceMenuPopupItem(
|
||||||
|
text = "快速设备排序",
|
||||||
|
optionSize = 3,
|
||||||
|
index = 0,
|
||||||
|
onClick = onReorderDevices,
|
||||||
|
)
|
||||||
|
DeviceMenuPopupItem(
|
||||||
|
text = "虚拟按钮排序",
|
||||||
|
optionSize = 3,
|
||||||
|
index = 1,
|
||||||
|
onClick = onOpenVirtualButtonOrder,
|
||||||
|
)
|
||||||
|
DeviceMenuPopupItem(
|
||||||
|
text = "清空日志",
|
||||||
|
optionSize = 3,
|
||||||
|
index = 2,
|
||||||
|
enabled = canClearLogs,
|
||||||
|
onClick = onClearLogs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeviceMenuPopupItem(
|
||||||
|
text: String,
|
||||||
|
optionSize: Int,
|
||||||
|
index: Int,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
// TODO: (Int) -> Unit
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
if (enabled) {
|
||||||
|
DropdownImpl(
|
||||||
|
text = text,
|
||||||
|
optionSize = optionSize,
|
||||||
|
isSelected = false,
|
||||||
|
index = index,
|
||||||
|
onSelectedIndexChange = { onClick() },
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val additionalTopPadding = if (index == 0) UiSpacing.PopupHorizontal else UiSpacing.PageItem
|
||||||
|
val additionalBottomPadding =
|
||||||
|
if (index == optionSize - 1) UiSpacing.PopupHorizontal else UiSpacing.PageItem
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
fontSize = MiuixTheme.textStyles.body1.fontSize,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MiuixTheme.colorScheme.disabledOnSecondaryVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = UiSpacing.PopupHorizontal)
|
||||||
|
.padding(top = additionalTopPadding, bottom = additionalBottomPadding),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,10 +8,15 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -22,43 +27,65 @@ import androidx.core.view.WindowInsetsControllerCompat
|
|||||||
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
||||||
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
|
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
|
||||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage
|
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen
|
import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
|
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar
|
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||||
|
|
||||||
data class FullscreenControlLaunch(
|
|
||||||
val deviceName: String,
|
|
||||||
val width: Int,
|
|
||||||
val height: Int,
|
|
||||||
val codec: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FullscreenControlPage(
|
fun FullscreenControlScreen(
|
||||||
launch: FullscreenControlLaunch,
|
onBack: () -> Unit,
|
||||||
|
session: Scrcpy.Session.SessionInfo,
|
||||||
nativeCore: NativeCoreFacade,
|
nativeCore: NativeCoreFacade,
|
||||||
onVideoSizeChanged: (width: Int, height: Int) -> Unit,
|
onVideoSizeChanged: (width: Int, height: Int) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
// Disable predictive back handler temporarily to avoid decoding issues.
|
// Disable predictive back handler temporarily to avoid decoding issues.
|
||||||
BackHandler(enabled = true, onBack = onDismiss)
|
BackHandler(enabled = true, onBack = onBack)
|
||||||
|
|
||||||
val appSettings = Storage.appSettings
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
val haptics = rememberAppHaptics()
|
val haptics = rememberAppHaptics()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val activity = remember(context) { context as? Activity }
|
val activity = remember(context) { context as? Activity }
|
||||||
|
|
||||||
var virtualButtonsLayout by appSettings.virtualButtonsLayout.asMutableState()
|
val asBundleShared by appSettings.bundleState.collectAsState()
|
||||||
val buttonItems = remember(virtualButtonsLayout) {
|
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
|
||||||
VirtualButtonActions.splitLayout(VirtualButtonActions.parseStoredLayout(virtualButtonsLayout))
|
var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) }
|
||||||
|
val asBundleLatest by rememberUpdatedState(asBundle)
|
||||||
|
LaunchedEffect(asBundleShared) {
|
||||||
|
if (asBundle != asBundleShared) {
|
||||||
|
asBundle = asBundleShared
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val fullscreenDebugInfo by appSettings.fullscreenDebugInfo.asState()
|
LaunchedEffect(asBundle) {
|
||||||
val showFullscreenVirtualButtons by appSettings.showFullscreenVirtualButtons.asState()
|
delay(Settings.BUNDLE_SAVE_DELAY)
|
||||||
|
if (asBundle != asBundleSharedLatest) {
|
||||||
|
appSettings.saveBundle(asBundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
scope.launch {
|
||||||
|
appSettings.saveBundle(asBundleLatest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val buttonItems = remember(asBundle.virtualButtonsLayout) {
|
||||||
|
VirtualButtonActions.splitLayout(
|
||||||
|
VirtualButtonActions.parseStoredLayout(asBundle.virtualButtonsLayout)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val fullscreenDebugInfo = asBundle.fullscreenDebugInfo
|
||||||
|
val showFullscreenVirtualButtons = asBundle.showFullscreenVirtualButtons
|
||||||
|
|
||||||
val bar = remember(buttonItems) {
|
val bar = remember(buttonItems) {
|
||||||
VirtualButtonBar(
|
VirtualButtonBar(
|
||||||
@@ -66,19 +93,7 @@ fun FullscreenControlPage(
|
|||||||
moreActions = buttonItems.second,
|
moreActions = buttonItems.second,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
var session by remember(launch) {
|
|
||||||
mutableStateOf(
|
|
||||||
Scrcpy.Session.SessionInfo(
|
|
||||||
width = launch.width,
|
|
||||||
height = launch.height,
|
|
||||||
deviceName = launch.deviceName.ifBlank { "设备" },
|
|
||||||
codecId = 0,
|
|
||||||
codecName = launch.codec.ifBlank { "unknown" },
|
|
||||||
audioCodecId = 0,
|
|
||||||
controlEnabled = true,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
var currentFps by remember { mutableFloatStateOf(0f) }
|
var currentFps by remember { mutableFloatStateOf(0f) }
|
||||||
|
|
||||||
DisposableEffect(activity) {
|
DisposableEffect(activity) {
|
||||||
@@ -103,7 +118,6 @@ fun FullscreenControlPage(
|
|||||||
|
|
||||||
DisposableEffect(nativeCore) {
|
DisposableEffect(nativeCore) {
|
||||||
val listener: (Int, Int) -> Unit = { w, h ->
|
val listener: (Int, Int) -> Unit = { w, h ->
|
||||||
session = session.copy(width = w, height = h)
|
|
||||||
onVideoSizeChanged(w, h)
|
onVideoSizeChanged(w, h)
|
||||||
}
|
}
|
||||||
nativeCore.addVideoSizeListener(listener)
|
nativeCore.addVideoSizeListener(listener)
|
||||||
@@ -124,8 +138,10 @@ fun FullscreenControlPage(
|
|||||||
|
|
||||||
suspend fun sendKeycode(keycode: Int) {
|
suspend fun sendKeycode(keycode: Int) {
|
||||||
runCatching {
|
runCatching {
|
||||||
nativeCore.session?.injectKeycode(0, keycode)
|
withContext(Dispatchers.IO) {
|
||||||
nativeCore.session?.injectKeycode(1, keycode)
|
nativeCore.session?.injectKeycode(0, keycode)
|
||||||
|
nativeCore.session?.injectKeycode(1, keycode)
|
||||||
|
}
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
android.util.Log.w("FullscreenControlPage", "sendKeycode failed for keycode=$keycode", e)
|
android.util.Log.w("FullscreenControlPage", "sendKeycode failed for keycode=$keycode", e)
|
||||||
}
|
}
|
||||||
@@ -140,22 +156,24 @@ fun FullscreenControlPage(
|
|||||||
FullscreenControlScreen(
|
FullscreenControlScreen(
|
||||||
session = session,
|
session = session,
|
||||||
nativeCore = nativeCore,
|
nativeCore = nativeCore,
|
||||||
onDismiss = onDismiss,
|
onDismiss = onBack,
|
||||||
showDebugInfo = fullscreenDebugInfo,
|
showDebugInfo = fullscreenDebugInfo,
|
||||||
currentFps = currentFps,
|
currentFps = currentFps,
|
||||||
enableBackHandler = false,
|
enableBackHandler = false,
|
||||||
onInjectTouch = { action, pointerId, x, y, pressure, buttons ->
|
onInjectTouch = { action, pointerId, x, y, pressure, buttons ->
|
||||||
nativeCore.session?.injectTouch(
|
withContext(Dispatchers.IO) {
|
||||||
action = action,
|
nativeCore.session?.injectTouch(
|
||||||
pointerId = pointerId,
|
action = action,
|
||||||
x = x,
|
pointerId = pointerId,
|
||||||
y = y,
|
x = x,
|
||||||
screenWidth = session.width,
|
y = y,
|
||||||
screenHeight = session.height,
|
screenWidth = session.width,
|
||||||
pressure = pressure,
|
screenHeight = session.height,
|
||||||
actionButton = 0,
|
pressure = pressure,
|
||||||
buttons = buttons,
|
actionButton = 0,
|
||||||
)
|
buttons = buttons,
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -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.Clear
|
||||||
import androidx.compose.material.icons.rounded.FileOpen
|
import androidx.compose.material.icons.rounded.FileOpen
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -25,23 +28,25 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
|
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
||||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
|
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
|
||||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
|
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
|
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration
|
import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
|
import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import top.yukonga.miuix.kmp.basic.Card
|
import top.yukonga.miuix.kmp.basic.Card
|
||||||
import top.yukonga.miuix.kmp.basic.Icon
|
import top.yukonga.miuix.kmp.basic.Icon
|
||||||
import top.yukonga.miuix.kmp.basic.IconButton
|
import top.yukonga.miuix.kmp.basic.IconButton
|
||||||
|
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||||
import top.yukonga.miuix.kmp.basic.ScrollBehavior
|
import top.yukonga.miuix.kmp.basic.ScrollBehavior
|
||||||
import top.yukonga.miuix.kmp.basic.SnackbarHostState
|
import top.yukonga.miuix.kmp.basic.SnackbarHostState
|
||||||
import top.yukonga.miuix.kmp.basic.Text
|
import top.yukonga.miuix.kmp.basic.Text
|
||||||
import top.yukonga.miuix.kmp.basic.TextField
|
import top.yukonga.miuix.kmp.basic.TextField
|
||||||
|
import top.yukonga.miuix.kmp.basic.TopAppBar
|
||||||
import top.yukonga.miuix.kmp.extra.SuperArrow
|
import top.yukonga.miuix.kmp.extra.SuperArrow
|
||||||
import top.yukonga.miuix.kmp.extra.SuperDropdown
|
import top.yukonga.miuix.kmp.extra.SuperDropdown
|
||||||
import top.yukonga.miuix.kmp.extra.SuperSwitch
|
import top.yukonga.miuix.kmp.extra.SuperSwitch
|
||||||
@@ -75,11 +80,39 @@ private fun resolveThemeLabel(baseIndex: Int, monetEnabled: Boolean): String {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
contentPadding: PaddingValues,
|
scrollBehavior: ScrollBehavior,
|
||||||
|
snack: SnackbarHostState,
|
||||||
onOpenReorderDevices: () -> Unit,
|
onOpenReorderDevices: () -> Unit,
|
||||||
onOpenVirtualButtonOrder: () -> Unit,
|
onOpenVirtualButtonOrder: () -> Unit,
|
||||||
onPickServer: () -> Unit,
|
onPickServer: () -> Unit,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = "设置",
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { pagePadding ->
|
||||||
|
SettingsPage(
|
||||||
|
contentPadding = pagePadding,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
snack = snack,
|
||||||
|
onOpenReorderDevices = onOpenReorderDevices,
|
||||||
|
onOpenVirtualButtonOrder = onOpenVirtualButtonOrder,
|
||||||
|
onPickServer = onPickServer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsPage(
|
||||||
|
contentPadding: PaddingValues,
|
||||||
scrollBehavior: ScrollBehavior,
|
scrollBehavior: ScrollBehavior,
|
||||||
|
snack: SnackbarHostState,
|
||||||
|
onOpenReorderDevices: () -> Unit,
|
||||||
|
onOpenVirtualButtonOrder: () -> Unit,
|
||||||
|
onPickServer: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val appContext = LocalContext.current.applicationContext
|
val appContext = LocalContext.current.applicationContext
|
||||||
val appSettings = Storage.appSettings
|
val appSettings = Storage.appSettings
|
||||||
@@ -91,44 +124,57 @@ fun SettingsScreen(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val snackHostState = remember { SnackbarHostState() }
|
|
||||||
|
val asBundleShared by appSettings.bundleState.collectAsState()
|
||||||
|
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
|
||||||
|
var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) }
|
||||||
|
val asBundleLatest by rememberUpdatedState(asBundle)
|
||||||
|
LaunchedEffect(asBundleShared) {
|
||||||
|
if (asBundle != asBundleShared) {
|
||||||
|
asBundle = asBundleShared
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(asBundle) {
|
||||||
|
delay(Settings.BUNDLE_SAVE_DELAY)
|
||||||
|
if (asBundle != asBundleSharedLatest) {
|
||||||
|
appSettings.saveBundle(asBundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
scope.launch {
|
||||||
|
appSettings.saveBundle(asBundleLatest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val baseModeItems = THEME_BASE_OPTIONS.map { it.label }
|
val baseModeItems = THEME_BASE_OPTIONS.map { it.label }
|
||||||
|
|
||||||
// 主题
|
|
||||||
var themeBaseIndex by appSettings.themeBaseIndex.asMutableState()
|
|
||||||
var monet by appSettings.monet.asMutableState()
|
|
||||||
|
|
||||||
// 投屏
|
|
||||||
var fullscreenDebugInfo by appSettings.fullscreenDebugInfo.asMutableState()
|
|
||||||
var keepScreenOnWhenStreaming by appSettings.keepScreenOnWhenStreaming.asMutableState()
|
|
||||||
var devicePreviewCardHeightDp by appSettings.devicePreviewCardHeightDp.asMutableState()
|
|
||||||
var showFullscreenVirtualButtons by appSettings.showFullscreenVirtualButtons.asMutableState()
|
|
||||||
|
|
||||||
// scrcpy-server
|
|
||||||
var customServerUri by appSettings.customServerUri.asMutableState()
|
|
||||||
var serverRemotePath by appSettings.serverRemotePath.asMutableState()
|
|
||||||
var serverRemotePathInput by rememberSaveable {
|
var serverRemotePathInput by rememberSaveable {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
// 默认值留空显示为 hint
|
if (asBundleShared.serverRemotePath == AppSettings.SERVER_REMOTE_PATH.defaultValue) ""
|
||||||
if (runBlocking { appSettings.serverRemotePath.isDefaultValue() }) ""
|
else asBundleShared.serverRemotePath
|
||||||
else serverRemotePath
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(asBundleShared.serverRemotePath) {
|
||||||
|
serverRemotePathInput =
|
||||||
|
if (asBundleShared.serverRemotePath == AppSettings.SERVER_REMOTE_PATH.defaultValue) ""
|
||||||
|
else asBundleShared.serverRemotePath
|
||||||
|
}
|
||||||
|
|
||||||
// ADB
|
|
||||||
var adbKeyName by appSettings.adbKeyName.asMutableState()
|
|
||||||
var adbKeyNameInput by rememberSaveable {
|
var adbKeyNameInput by rememberSaveable {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
if (runBlocking { appSettings.adbKeyName.isDefaultValue() }) ""
|
if (asBundleShared.adbKeyName == AppSettings.ADB_KEY_NAME.defaultValue) ""
|
||||||
else adbKeyName
|
else asBundleShared.adbKeyName
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
var adbPairingAutoDiscoverOnDialogOpen by appSettings.adbPairingAutoDiscoverOnDialogOpen.asMutableState()
|
LaunchedEffect(asBundleShared.adbKeyName) {
|
||||||
var adbAutoReconnectPairedDevice by appSettings.adbAutoReconnectPairedDevice.asMutableState()
|
adbKeyNameInput =
|
||||||
|
if (asBundleShared.adbKeyName == AppSettings.ADB_KEY_NAME.defaultValue) ""
|
||||||
|
else asBundleShared.adbKeyName
|
||||||
|
}
|
||||||
|
|
||||||
// 设置
|
// 设置
|
||||||
AppPageLazyColumn(
|
LazyColumn(
|
||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
) {
|
) {
|
||||||
@@ -137,16 +183,20 @@ fun SettingsScreen(
|
|||||||
Card {
|
Card {
|
||||||
SuperDropdown(
|
SuperDropdown(
|
||||||
title = "外观模式",
|
title = "外观模式",
|
||||||
summary = resolveThemeLabel(themeBaseIndex, monet),
|
summary = resolveThemeLabel(asBundle.themeBaseIndex, asBundle.monet),
|
||||||
items = baseModeItems,
|
items = baseModeItems,
|
||||||
selectedIndex = themeBaseIndex.coerceIn(0, baseModeItems.lastIndex),
|
selectedIndex = asBundle.themeBaseIndex.coerceIn(0, baseModeItems.lastIndex),
|
||||||
onSelectedIndexChange = { themeBaseIndex = it },
|
onSelectedIndexChange = {
|
||||||
|
asBundle = asBundle.copy(themeBaseIndex = it)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
SuperSwitch(
|
SuperSwitch(
|
||||||
title = "Monet",
|
title = "Monet",
|
||||||
summary = "开启后使用 Monet 动态配色",
|
summary = "开启后使用 Monet 动态配色",
|
||||||
checked = monet,
|
checked = asBundle.monet,
|
||||||
onCheckedChange = { monet = it },
|
onCheckedChange = {
|
||||||
|
asBundle = asBundle.copy(monet = it)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,32 +205,40 @@ fun SettingsScreen(
|
|||||||
SuperSwitch(
|
SuperSwitch(
|
||||||
title = "启用调试信息",
|
title = "启用调试信息",
|
||||||
summary = "在全屏界面显示触点数量、设备分辨率和实时 FPS",
|
summary = "在全屏界面显示触点数量、设备分辨率和实时 FPS",
|
||||||
checked = fullscreenDebugInfo,
|
checked = asBundle.fullscreenDebugInfo,
|
||||||
onCheckedChange = { fullscreenDebugInfo = it },
|
onCheckedChange = {
|
||||||
|
asBundle = asBundle.copy(fullscreenDebugInfo = it)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
SuperSwitch(
|
SuperSwitch(
|
||||||
title = "投屏时保持屏幕常亮",
|
title = "投屏时保持屏幕常亮",
|
||||||
summary = "Scrcpy 启动后保持本机屏幕常亮,避免锁屏导致 ADB 断开",
|
summary = "Scrcpy 启动后保持本机屏幕常亮,避免锁屏导致 ADB 断开",
|
||||||
checked = keepScreenOnWhenStreaming,
|
checked = asBundle.keepScreenOnWhenStreaming,
|
||||||
onCheckedChange = { keepScreenOnWhenStreaming = it },
|
onCheckedChange = {
|
||||||
|
asBundle = asBundle.copy(keepScreenOnWhenStreaming = it)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
SuperSlider(
|
SuperSlider(
|
||||||
title = "预览卡高度",
|
title = "预览卡高度",
|
||||||
summary = "设备页预览卡高度",
|
summary = "设备页预览卡高度",
|
||||||
value = devicePreviewCardHeightDp.toFloat(),
|
value = asBundle.devicePreviewCardHeightDp.toFloat(),
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
devicePreviewCardHeightDp = it.roundToInt().coerceAtLeast(120)
|
asBundle = asBundle.copy(
|
||||||
|
devicePreviewCardHeightDp = it.roundToInt().coerceAtLeast(120)
|
||||||
|
)
|
||||||
},
|
},
|
||||||
valueRange = 160f..600f,
|
valueRange = 160f..600f,
|
||||||
steps = 600-160-1,
|
steps = 600-160-2,
|
||||||
unit = "dp",
|
unit = "dp",
|
||||||
displayFormatter = { it.roundToInt().toString() },
|
displayFormatter = { it.roundToInt().toString() },
|
||||||
inputInitialValue = devicePreviewCardHeightDp.toString(),
|
inputInitialValue = asBundle.devicePreviewCardHeightDp.toString(),
|
||||||
inputFilter = { it.filter(Char::isDigit) },
|
inputFilter = { it.filter(Char::isDigit) },
|
||||||
inputValueRange = 120f..UShort.MAX_VALUE.toFloat(),
|
inputValueRange = 120f..UShort.MAX_VALUE.toFloat(),
|
||||||
onInputConfirm = { input ->
|
onInputConfirm = { input ->
|
||||||
input.toIntOrNull()?.let {
|
input.toIntOrNull()?.let {
|
||||||
devicePreviewCardHeightDp = it.coerceAtLeast(120)
|
asBundle = asBundle.copy(
|
||||||
|
devicePreviewCardHeightDp = it.coerceAtLeast(120)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -197,8 +255,10 @@ fun SettingsScreen(
|
|||||||
SuperSwitch(
|
SuperSwitch(
|
||||||
title = "全屏显示虚拟按钮",
|
title = "全屏显示虚拟按钮",
|
||||||
summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮",
|
summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮",
|
||||||
checked = showFullscreenVirtualButtons,
|
checked = asBundle.showFullscreenVirtualButtons,
|
||||||
onCheckedChange = { showFullscreenVirtualButtons = it },
|
onCheckedChange = {
|
||||||
|
asBundle = asBundle.copy(showFullscreenVirtualButtons = it)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,19 +273,21 @@ fun SettingsScreen(
|
|||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
TextField(
|
TextField(
|
||||||
value = customServerUri,
|
value = asBundle.customServerUri,
|
||||||
onValueChange = {},
|
onValueChange = {},
|
||||||
readOnly = true,
|
readOnly = true,
|
||||||
label = "scrcpy-server-v3.3.4",
|
label = "scrcpy-server-v3.3.4",
|
||||||
useLabelAsPlaceholder = customServerUri.isBlank(),
|
useLabelAsPlaceholder = asBundle.customServerUri.isBlank(),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = UiSpacing.CardContent)
|
.padding(horizontal = UiSpacing.CardContent)
|
||||||
.padding(bottom = UiSpacing.CardContent),
|
.padding(bottom = UiSpacing.CardContent),
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
Row(modifier = Modifier.padding(end = UiSpacing.SectionTitleLeadingGap)) {
|
Row(modifier = Modifier.padding(end = UiSpacing.SectionTitleLeadingGap)) {
|
||||||
if (customServerUri.isNotBlank())
|
if (asBundle.customServerUri.isNotBlank())
|
||||||
IconButton(onClick = { customServerUri = "" }) {
|
IconButton(onClick = {
|
||||||
|
asBundle = asBundle.copy(customServerUri = "")
|
||||||
|
}) {
|
||||||
Icon(Icons.Rounded.Clear, contentDescription = "清空")
|
Icon(Icons.Rounded.Clear, contentDescription = "清空")
|
||||||
}
|
}
|
||||||
IconButton(onClick = onPickServer) {
|
IconButton(onClick = onPickServer) {
|
||||||
@@ -247,6 +309,11 @@ fun SettingsScreen(
|
|||||||
onFocusLost = {
|
onFocusLost = {
|
||||||
if (serverRemotePathInput == AppSettings.SERVER_REMOTE_PATH.defaultValue)
|
if (serverRemotePathInput == AppSettings.SERVER_REMOTE_PATH.defaultValue)
|
||||||
serverRemotePathInput = ""
|
serverRemotePathInput = ""
|
||||||
|
asBundle = asBundle.copy(
|
||||||
|
serverRemotePath = serverRemotePathInput.ifBlank {
|
||||||
|
AppSettings.SERVER_REMOTE_PATH.defaultValue
|
||||||
|
}
|
||||||
|
)
|
||||||
},
|
},
|
||||||
label = AppSettings.SERVER_REMOTE_PATH.defaultValue,
|
label = AppSettings.SERVER_REMOTE_PATH.defaultValue,
|
||||||
useLabelAsPlaceholder = true,
|
useLabelAsPlaceholder = true,
|
||||||
@@ -273,6 +340,9 @@ fun SettingsScreen(
|
|||||||
onFocusLost = {
|
onFocusLost = {
|
||||||
if (adbKeyNameInput == AppSettings.ADB_KEY_NAME.defaultValue)
|
if (adbKeyNameInput == AppSettings.ADB_KEY_NAME.defaultValue)
|
||||||
adbKeyNameInput = ""
|
adbKeyNameInput = ""
|
||||||
|
asBundle = asBundle.copy(
|
||||||
|
adbKeyName = adbKeyNameInput.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue }
|
||||||
|
)
|
||||||
},
|
},
|
||||||
label = AppSettings.ADB_KEY_NAME.defaultValue,
|
label = AppSettings.ADB_KEY_NAME.defaultValue,
|
||||||
useLabelAsPlaceholder = true,
|
useLabelAsPlaceholder = true,
|
||||||
@@ -285,14 +355,22 @@ fun SettingsScreen(
|
|||||||
SuperSwitch(
|
SuperSwitch(
|
||||||
title = "配对时自动启用发现服务",
|
title = "配对时自动启用发现服务",
|
||||||
summary = "打开配对弹窗后自动搜索可用配对端口",
|
summary = "打开配对弹窗后自动搜索可用配对端口",
|
||||||
checked = adbPairingAutoDiscoverOnDialogOpen,
|
checked = asBundle.adbPairingAutoDiscoverOnDialogOpen,
|
||||||
onCheckedChange = { adbPairingAutoDiscoverOnDialogOpen = it },
|
onCheckedChange = {
|
||||||
|
asBundle = asBundle.copy(
|
||||||
|
adbPairingAutoDiscoverOnDialogOpen = it
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
SuperSwitch(
|
SuperSwitch(
|
||||||
title = "自动重连已配对设备",
|
title = "自动重连已配对设备",
|
||||||
summary = "自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘)",
|
summary = "自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘)",
|
||||||
checked = adbAutoReconnectPairedDevice,
|
checked = asBundle.adbAutoReconnectPairedDevice,
|
||||||
onCheckedChange = { adbAutoReconnectPairedDevice = it },
|
onCheckedChange = {
|
||||||
|
asBundle = asBundle.copy(
|
||||||
|
adbAutoReconnectPairedDevice = it
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +388,7 @@ fun SettingsScreen(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
val migration = PreferenceMigration(appContext)
|
val migration = PreferenceMigration(appContext)
|
||||||
migration.migrate(clearSharedPrefs = false)
|
migration.migrate(clearSharedPrefs = false)
|
||||||
snackHostState.showSnackbar("迁移完成,应用将重启")
|
snack.showSnackbar("迁移完成,应用将重启")
|
||||||
|
|
||||||
delay(1000)
|
delay(1000)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
import top.yukonga.miuix.kmp.utils.overScrollVertical
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppPageLazyColumn(
|
fun LazyColumn(
|
||||||
contentPadding: PaddingValues,
|
contentPadding: PaddingValues,
|
||||||
scrollBehavior: ScrollBehavior,
|
scrollBehavior: ScrollBehavior,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -3,7 +3,6 @@ package io.github.miuzarte.scrcpyforandroid.scaffolds
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -13,12 +12,10 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
|
||||||
import top.yukonga.miuix.kmp.basic.ButtonDefaults
|
import top.yukonga.miuix.kmp.basic.ButtonDefaults
|
||||||
import top.yukonga.miuix.kmp.basic.Slider
|
import top.yukonga.miuix.kmp.basic.Slider
|
||||||
import top.yukonga.miuix.kmp.basic.Text
|
import top.yukonga.miuix.kmp.basic.Text
|
||||||
import top.yukonga.miuix.kmp.basic.TextButton
|
import top.yukonga.miuix.kmp.basic.TextButton
|
||||||
import top.yukonga.miuix.kmp.basic.TextField
|
|
||||||
import top.yukonga.miuix.kmp.extra.SuperArrow
|
import top.yukonga.miuix.kmp.extra.SuperArrow
|
||||||
import top.yukonga.miuix.kmp.extra.SuperDialog
|
import top.yukonga.miuix.kmp.extra.SuperDialog
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
@@ -40,7 +37,9 @@ fun SuperSlider(
|
|||||||
displayFormatter: (Float) -> String = { it.toInt().toString() },
|
displayFormatter: (Float) -> String = { it.toInt().toString() },
|
||||||
displayText: String? = null,
|
displayText: String? = null,
|
||||||
inputTitle: String = title,
|
inputTitle: String = title,
|
||||||
inputHint: String = unit,
|
inputSummary: String = summary,
|
||||||
|
inputLabel: String = unit,
|
||||||
|
useLabelAsPlaceholder: Boolean = false,
|
||||||
inputInitialValue: String = displayFormatter(value),
|
inputInitialValue: String = displayFormatter(value),
|
||||||
inputFilter: (String) -> String = { text -> text.filter { it.isDigit() || it == '.' } },
|
inputFilter: (String) -> String = { text -> text.filter { it.isDigit() || it == '.' } },
|
||||||
inputValueRange: ClosedFloatingPointRange<Float>? = null,
|
inputValueRange: ClosedFloatingPointRange<Float>? = null,
|
||||||
@@ -60,7 +59,8 @@ fun SuperSlider(
|
|||||||
endActions = {
|
endActions = {
|
||||||
val isZeroState = value == 0f && zeroStateText != null
|
val isZeroState = value == 0f && zeroStateText != null
|
||||||
val valueText =
|
val valueText =
|
||||||
if (isZeroState) zeroStateText else (displayText ?: displayFormatter(value))
|
if (isZeroState) zeroStateText
|
||||||
|
else (displayText ?: displayFormatter(value))
|
||||||
val shouldShowUnit = unit.isNotBlank() && (!isZeroState || showUnitWhenZeroState)
|
val shouldShowUnit = unit.isNotBlank() && (!isZeroState || showUnitWhenZeroState)
|
||||||
val text = if (shouldShowUnit) "$valueText $unit" else valueText
|
val text = if (shouldShowUnit) "$valueText $unit" else valueText
|
||||||
Text(
|
Text(
|
||||||
@@ -86,7 +86,9 @@ fun SuperSlider(
|
|||||||
SliderInputDialog(
|
SliderInputDialog(
|
||||||
showDialog = showInputDialog,
|
showDialog = showInputDialog,
|
||||||
title = inputTitle,
|
title = inputTitle,
|
||||||
summary = inputHint,
|
summary = inputSummary,
|
||||||
|
label = inputLabel,
|
||||||
|
useLabelAsPlaceholder = useLabelAsPlaceholder,
|
||||||
initialValue = inputInitialValue,
|
initialValue = inputInitialValue,
|
||||||
inputFilter = inputFilter,
|
inputFilter = inputFilter,
|
||||||
inputValueRange = inputValueRange ?: valueRange,
|
inputValueRange = inputValueRange ?: valueRange,
|
||||||
@@ -104,6 +106,8 @@ private fun SliderInputDialog(
|
|||||||
showDialog: Boolean,
|
showDialog: Boolean,
|
||||||
title: String,
|
title: String,
|
||||||
summary: String,
|
summary: String,
|
||||||
|
label: String = "",
|
||||||
|
useLabelAsPlaceholder: Boolean = false,
|
||||||
initialValue: String,
|
initialValue: String,
|
||||||
inputFilter: (String) -> String,
|
inputFilter: (String) -> String,
|
||||||
inputValueRange: ClosedFloatingPointRange<Float>,
|
inputValueRange: ClosedFloatingPointRange<Float>,
|
||||||
@@ -119,16 +123,18 @@ private fun SliderInputDialog(
|
|||||||
onDismissFinished = onDismissFinished,
|
onDismissFinished = onDismissFinished,
|
||||||
content = {
|
content = {
|
||||||
var text by remember(initialValue) { mutableStateOf(initialValue) }
|
var text by remember(initialValue) { mutableStateOf(initialValue) }
|
||||||
|
|
||||||
TextField(
|
SuperTextField(
|
||||||
modifier = Modifier.padding(bottom = 16.dp),
|
modifier = Modifier.padding(bottom = 16.dp),
|
||||||
value = text,
|
value = text,
|
||||||
|
label = label,
|
||||||
|
useLabelAsPlaceholder = useLabelAsPlaceholder,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
onValueChange = { newValue ->
|
onValueChange = { newValue ->
|
||||||
text = inputFilter(newValue)
|
text = inputFilter(newValue)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.SpaceBetween) {
|
Row(horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
TextButton(
|
TextButton(
|
||||||
text = "取消",
|
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
|
package io.github.miuzarte.scrcpyforandroid.storage
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
class AdbClientData(context: Context) : Settings(context, "AdbClient") {
|
class AdbClientData(context: Context) : Settings(context, "AdbClient") {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -17,4 +21,31 @@ class AdbClientData(context: Context) : Settings(context, "AdbClient") {
|
|||||||
|
|
||||||
val rsaPrivateKey by setting(RSA_PRIVATE_KEY)
|
val rsaPrivateKey by setting(RSA_PRIVATE_KEY)
|
||||||
val rsaPublicKeyX509 by setting(RSA_PUBLIC_KEY_X509)
|
val rsaPublicKeyX509 by setting(RSA_PUBLIC_KEY_X509)
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class Bundle(
|
||||||
|
val rsaPrivateKey: String,
|
||||||
|
val rsaPublicKeyX509: String,
|
||||||
|
) : Parcelable {
|
||||||
|
}
|
||||||
|
|
||||||
|
private val bundleFields = arrayOf(
|
||||||
|
bundleField(RSA_PRIVATE_KEY) { bundle: Bundle -> bundle.rsaPrivateKey },
|
||||||
|
bundleField(RSA_PUBLIC_KEY_X509) { bundle: Bundle -> bundle.rsaPublicKeyX509 },
|
||||||
|
)
|
||||||
|
|
||||||
|
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
|
||||||
|
|
||||||
|
private fun bundleFromPreferences(preferences: Preferences) = Bundle(
|
||||||
|
rsaPrivateKey = preferences.read(RSA_PRIVATE_KEY),
|
||||||
|
rsaPublicKeyX509 = preferences.read(RSA_PUBLIC_KEY_X509),
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun loadBundle() = loadBundle(::bundleFromPreferences)
|
||||||
|
|
||||||
|
suspend fun saveBundle(new: Bundle) = saveBundle(bundleState.value, new, bundleFields)
|
||||||
|
|
||||||
|
suspend fun updateBundle(transform: (Bundle) -> Bundle) {
|
||||||
|
saveBundle(transform(bundleState.value))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid.storage
|
package io.github.miuzarte.scrcpyforandroid.storage
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
class AppSettings(context: Context) : Settings(context, "AppSettings") {
|
class AppSettings(context: Context) : Settings(context, "AppSettings") {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -87,6 +91,71 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
|
|||||||
val adbAutoReconnectPairedDevice by setting(ADB_AUTO_RECONNECT_PAIRED_DEVICE)
|
val adbAutoReconnectPairedDevice by setting(ADB_AUTO_RECONNECT_PAIRED_DEVICE)
|
||||||
val adbMdnsLanDiscovery by setting(ADB_MDNS_LAN_DISCOVERY)
|
val adbMdnsLanDiscovery by setting(ADB_MDNS_LAN_DISCOVERY)
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class Bundle(
|
||||||
|
val themeBaseIndex: Int,
|
||||||
|
val monet: Boolean,
|
||||||
|
val fullscreenDebugInfo: Boolean,
|
||||||
|
val showFullscreenVirtualButtons: Boolean,
|
||||||
|
val keepScreenOnWhenStreaming: Boolean,
|
||||||
|
val devicePreviewCardHeightDp: Int,
|
||||||
|
val previewVirtualButtonShowText: Boolean,
|
||||||
|
val virtualButtonsLayout: String,
|
||||||
|
val customServerUri: String,
|
||||||
|
val serverRemotePath: String,
|
||||||
|
val adbKeyName: String,
|
||||||
|
val adbPairingAutoDiscoverOnDialogOpen: Boolean,
|
||||||
|
val adbAutoReconnectPairedDevice: Boolean,
|
||||||
|
val adbMdnsLanDiscovery: Boolean,
|
||||||
|
) : Parcelable {
|
||||||
|
}
|
||||||
|
|
||||||
|
private val bundleFields = arrayOf(
|
||||||
|
bundleField(THEME_BASE_INDEX) { bundle: Bundle -> bundle.themeBaseIndex },
|
||||||
|
bundleField(MONET) { bundle: Bundle -> bundle.monet },
|
||||||
|
bundleField(FULLSCREEN_DEBUG_INFO) { bundle: Bundle -> bundle.fullscreenDebugInfo },
|
||||||
|
bundleField(SHOW_FULLSCREEN_VIRTUAL_BUTTONS) { bundle: Bundle -> bundle.showFullscreenVirtualButtons },
|
||||||
|
bundleField(KEEP_SCREEN_ON_WHEN_STREAMING) { bundle: Bundle -> bundle.keepScreenOnWhenStreaming },
|
||||||
|
bundleField(DEVICE_PREVIEW_CARD_HEIGHT_DP) { bundle: Bundle -> bundle.devicePreviewCardHeightDp },
|
||||||
|
bundleField(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) { bundle: Bundle -> bundle.previewVirtualButtonShowText },
|
||||||
|
bundleField(VIRTUAL_BUTTONS_LAYOUT) { bundle: Bundle -> bundle.virtualButtonsLayout },
|
||||||
|
bundleField(CUSTOM_SERVER_URI) { bundle: Bundle -> bundle.customServerUri },
|
||||||
|
bundleField(SERVER_REMOTE_PATH) { bundle: Bundle -> bundle.serverRemotePath },
|
||||||
|
bundleField(ADB_KEY_NAME) { bundle: Bundle -> bundle.adbKeyName },
|
||||||
|
bundleField(ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN) { bundle: Bundle -> bundle.adbPairingAutoDiscoverOnDialogOpen },
|
||||||
|
bundleField(ADB_AUTO_RECONNECT_PAIRED_DEVICE) { bundle: Bundle -> bundle.adbAutoReconnectPairedDevice },
|
||||||
|
bundleField(ADB_MDNS_LAN_DISCOVERY) { bundle: Bundle -> bundle.adbMdnsLanDiscovery },
|
||||||
|
)
|
||||||
|
|
||||||
|
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
|
||||||
|
|
||||||
|
private fun bundleFromPreferences(preferences: Preferences) = Bundle(
|
||||||
|
themeBaseIndex = preferences.read(THEME_BASE_INDEX),
|
||||||
|
monet = preferences.read(MONET),
|
||||||
|
fullscreenDebugInfo = preferences.read(FULLSCREEN_DEBUG_INFO),
|
||||||
|
showFullscreenVirtualButtons = preferences.read(SHOW_FULLSCREEN_VIRTUAL_BUTTONS),
|
||||||
|
keepScreenOnWhenStreaming = preferences.read(KEEP_SCREEN_ON_WHEN_STREAMING),
|
||||||
|
devicePreviewCardHeightDp = preferences.read(DEVICE_PREVIEW_CARD_HEIGHT_DP),
|
||||||
|
previewVirtualButtonShowText = preferences.read(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT),
|
||||||
|
virtualButtonsLayout = preferences.read(VIRTUAL_BUTTONS_LAYOUT),
|
||||||
|
customServerUri = preferences.read(CUSTOM_SERVER_URI),
|
||||||
|
serverRemotePath = preferences.read(SERVER_REMOTE_PATH),
|
||||||
|
adbKeyName = preferences.read(ADB_KEY_NAME),
|
||||||
|
adbPairingAutoDiscoverOnDialogOpen = preferences.read(
|
||||||
|
ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN
|
||||||
|
),
|
||||||
|
adbAutoReconnectPairedDevice = preferences.read(ADB_AUTO_RECONNECT_PAIRED_DEVICE),
|
||||||
|
adbMdnsLanDiscovery = preferences.read(ADB_MDNS_LAN_DISCOVERY),
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun loadBundle() = loadBundle(::bundleFromPreferences)
|
||||||
|
|
||||||
|
suspend fun saveBundle(new: Bundle) = saveBundle(bundleState.value, new, bundleFields)
|
||||||
|
|
||||||
|
suspend fun updateBundle(transform: (Bundle) -> Bundle) {
|
||||||
|
saveBundle(transform(bundleState.value))
|
||||||
|
}
|
||||||
|
|
||||||
// TODO?
|
// TODO?
|
||||||
// fun validate(): Boolean = true
|
// fun validate(): Boolean = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid.storage
|
package io.github.miuzarte.scrcpyforandroid.storage
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
class QuickDevices(context: Context) : Settings(context, "QuickDevices") {
|
class QuickDevices(context: Context) : Settings(context, "QuickDevices") {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -17,4 +21,31 @@ class QuickDevices(context: Context) : Settings(context, "QuickDevices") {
|
|||||||
|
|
||||||
val quickDevicesList by setting(QUICK_DEVICES_LIST)
|
val quickDevicesList by setting(QUICK_DEVICES_LIST)
|
||||||
val quickConnectInput by setting(QUICK_CONNECT_INPUT)
|
val quickConnectInput by setting(QUICK_CONNECT_INPUT)
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class Bundle(
|
||||||
|
val quickDevicesList: String,
|
||||||
|
val quickConnectInput: String,
|
||||||
|
) : Parcelable {
|
||||||
|
}
|
||||||
|
|
||||||
|
private val bundleFields = arrayOf(
|
||||||
|
bundleField(QUICK_DEVICES_LIST) { bundle: Bundle -> bundle.quickDevicesList },
|
||||||
|
bundleField(QUICK_CONNECT_INPUT) { bundle: Bundle -> bundle.quickConnectInput },
|
||||||
|
)
|
||||||
|
|
||||||
|
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
|
||||||
|
|
||||||
|
private fun bundleFromPreferences(preferences: Preferences) = Bundle(
|
||||||
|
quickDevicesList = preferences.read(QUICK_DEVICES_LIST),
|
||||||
|
quickConnectInput = preferences.read(QUICK_CONNECT_INPUT),
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun loadBundle() = loadBundle(::bundleFromPreferences)
|
||||||
|
|
||||||
|
suspend fun saveBundle(new: Bundle) = saveBundle(bundleState.value, new, bundleFields)
|
||||||
|
|
||||||
|
suspend fun updateBundle(transform: (Bundle) -> Bundle) {
|
||||||
|
saveBundle(transform(bundleState.value))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid.storage
|
package io.github.miuzarte.scrcpyforandroid.storage
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
@@ -16,7 +18,9 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Orientation
|
|||||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.OrientationLock
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.OrientationLock
|
||||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick
|
||||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
|
class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -102,11 +106,11 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
|
|||||||
)
|
)
|
||||||
val VIDEO_BIT_RATE = Pair(
|
val VIDEO_BIT_RATE = Pair(
|
||||||
intPreferencesKey("video_bit_rate"),
|
intPreferencesKey("video_bit_rate"),
|
||||||
8000000,
|
0,
|
||||||
)
|
)
|
||||||
val AUDIO_BIT_RATE = Pair(
|
val AUDIO_BIT_RATE = Pair(
|
||||||
intPreferencesKey("audio_bit_rate"),
|
intPreferencesKey("audio_bit_rate"),
|
||||||
128000,
|
0,
|
||||||
)
|
)
|
||||||
val MAX_FPS = Pair(
|
val MAX_FPS = Pair(
|
||||||
stringPreferencesKey("max_fps"),
|
stringPreferencesKey("max_fps"),
|
||||||
@@ -288,6 +292,186 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
|
|||||||
val vdDestroyContent by setting(VD_DESTROY_CONTENT)
|
val vdDestroyContent by setting(VD_DESTROY_CONTENT)
|
||||||
val vdSystemDecorations by setting(VD_SYSTEM_DECORATIONS)
|
val vdSystemDecorations by setting(VD_SYSTEM_DECORATIONS)
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class Bundle(
|
||||||
|
val crop: String,
|
||||||
|
val recordFilename: String,
|
||||||
|
val videoCodecOptions: String,
|
||||||
|
val audioCodecOptions: String,
|
||||||
|
val videoEncoder: String,
|
||||||
|
val audioEncoder: String,
|
||||||
|
val cameraId: String,
|
||||||
|
val cameraSize: String,
|
||||||
|
val cameraSizeCustom: String,
|
||||||
|
val cameraSizeUseCustom: Boolean,
|
||||||
|
val cameraAr: String,
|
||||||
|
val cameraFps: Int,
|
||||||
|
val logLevel: String,
|
||||||
|
val videoCodec: String,
|
||||||
|
val audioCodec: String,
|
||||||
|
val videoSource: String,
|
||||||
|
val audioSource: String,
|
||||||
|
val recordFormat: String,
|
||||||
|
val cameraFacing: String,
|
||||||
|
val maxSize: Int,
|
||||||
|
val videoBitRate: Int,
|
||||||
|
val audioBitRate: Int,
|
||||||
|
val maxFps: String,
|
||||||
|
val angle: String,
|
||||||
|
val captureOrientation: Int,
|
||||||
|
val captureOrientationLock: String,
|
||||||
|
val displayOrientation: Int,
|
||||||
|
val recordOrientation: Int,
|
||||||
|
val displayImePolicy: String,
|
||||||
|
val displayId: Int,
|
||||||
|
val screenOffTimeout: Long,
|
||||||
|
val showTouches: Boolean,
|
||||||
|
val fullscreen: Boolean,
|
||||||
|
val control: Boolean,
|
||||||
|
val videoPlayback: Boolean,
|
||||||
|
val audioPlayback: Boolean,
|
||||||
|
val turnScreenOff: Boolean,
|
||||||
|
val stayAwake: Boolean,
|
||||||
|
val disableScreensaver: Boolean,
|
||||||
|
val powerOffOnClose: Boolean,
|
||||||
|
val cleanup: Boolean,
|
||||||
|
val powerOn: Boolean,
|
||||||
|
val video: Boolean,
|
||||||
|
val audio: Boolean,
|
||||||
|
val requireAudio: Boolean,
|
||||||
|
val killAdbOnClose: Boolean,
|
||||||
|
val cameraHighSpeed: Boolean,
|
||||||
|
val list: String,
|
||||||
|
val audioDup: Boolean,
|
||||||
|
val newDisplay: String,
|
||||||
|
val startApp: String,
|
||||||
|
val vdDestroyContent: Boolean,
|
||||||
|
val vdSystemDecorations: Boolean
|
||||||
|
) : Parcelable {
|
||||||
|
}
|
||||||
|
|
||||||
|
private val bundleFields = arrayOf(
|
||||||
|
bundleField(CROP) { bundle: Bundle -> bundle.crop },
|
||||||
|
bundleField(RECORD_FILENAME) { bundle: Bundle -> bundle.recordFilename },
|
||||||
|
bundleField(VIDEO_CODEC_OPTIONS) { bundle: Bundle -> bundle.videoCodecOptions },
|
||||||
|
bundleField(AUDIO_CODEC_OPTIONS) { bundle: Bundle -> bundle.audioCodecOptions },
|
||||||
|
bundleField(VIDEO_ENCODER) { bundle: Bundle -> bundle.videoEncoder },
|
||||||
|
bundleField(AUDIO_ENCODER) { bundle: Bundle -> bundle.audioEncoder },
|
||||||
|
bundleField(CAMERA_ID) { bundle: Bundle -> bundle.cameraId },
|
||||||
|
bundleField(CAMERA_SIZE) { bundle: Bundle -> bundle.cameraSize },
|
||||||
|
bundleField(CAMERA_SIZE_CUSTOM) { bundle: Bundle -> bundle.cameraSizeCustom },
|
||||||
|
bundleField(CAMERA_SIZE_USE_CUSTOM) { bundle: Bundle -> bundle.cameraSizeUseCustom },
|
||||||
|
bundleField(CAMERA_AR) { bundle: Bundle -> bundle.cameraAr },
|
||||||
|
bundleField(CAMERA_FPS) { bundle: Bundle -> bundle.cameraFps },
|
||||||
|
bundleField(LOG_LEVEL) { bundle: Bundle -> bundle.logLevel },
|
||||||
|
bundleField(VIDEO_CODEC) { bundle: Bundle -> bundle.videoCodec },
|
||||||
|
bundleField(AUDIO_CODEC) { bundle: Bundle -> bundle.audioCodec },
|
||||||
|
bundleField(VIDEO_SOURCE) { bundle: Bundle -> bundle.videoSource },
|
||||||
|
bundleField(AUDIO_SOURCE) { bundle: Bundle -> bundle.audioSource },
|
||||||
|
bundleField(RECORD_FORMAT) { bundle: Bundle -> bundle.recordFormat },
|
||||||
|
bundleField(CAMERA_FACING) { bundle: Bundle -> bundle.cameraFacing },
|
||||||
|
bundleField(MAX_SIZE) { bundle: Bundle -> bundle.maxSize },
|
||||||
|
bundleField(VIDEO_BIT_RATE) { bundle: Bundle -> bundle.videoBitRate },
|
||||||
|
bundleField(AUDIO_BIT_RATE) { bundle: Bundle -> bundle.audioBitRate },
|
||||||
|
bundleField(MAX_FPS) { bundle: Bundle -> bundle.maxFps },
|
||||||
|
bundleField(ANGLE) { bundle: Bundle -> bundle.angle },
|
||||||
|
bundleField(CAPTURE_ORIENTATION) { bundle: Bundle -> bundle.captureOrientation },
|
||||||
|
bundleField(CAPTURE_ORIENTATION_LOCK) { bundle: Bundle -> bundle.captureOrientationLock },
|
||||||
|
bundleField(DISPLAY_ORIENTATION) { bundle: Bundle -> bundle.displayOrientation },
|
||||||
|
bundleField(RECORD_ORIENTATION) { bundle: Bundle -> bundle.recordOrientation },
|
||||||
|
bundleField(DISPLAY_IME_POLICY) { bundle: Bundle -> bundle.displayImePolicy },
|
||||||
|
bundleField(DISPLAY_ID) { bundle: Bundle -> bundle.displayId },
|
||||||
|
bundleField(SCREEN_OFF_TIMEOUT) { bundle: Bundle -> bundle.screenOffTimeout },
|
||||||
|
bundleField(SHOW_TOUCHES) { bundle: Bundle -> bundle.showTouches },
|
||||||
|
bundleField(FULLSCREEN) { bundle: Bundle -> bundle.fullscreen },
|
||||||
|
bundleField(CONTROL) { bundle: Bundle -> bundle.control },
|
||||||
|
bundleField(VIDEO_PLAYBACK) { bundle: Bundle -> bundle.videoPlayback },
|
||||||
|
bundleField(AUDIO_PLAYBACK) { bundle: Bundle -> bundle.audioPlayback },
|
||||||
|
bundleField(TURN_SCREEN_OFF) { bundle: Bundle -> bundle.turnScreenOff },
|
||||||
|
bundleField(STAY_AWAKE) { bundle: Bundle -> bundle.stayAwake },
|
||||||
|
bundleField(DISABLE_SCREENSAVER) { bundle: Bundle -> bundle.disableScreensaver },
|
||||||
|
bundleField(POWER_OFF_ON_CLOSE) { bundle: Bundle -> bundle.powerOffOnClose },
|
||||||
|
bundleField(CLEANUP) { bundle: Bundle -> bundle.cleanup },
|
||||||
|
bundleField(POWER_ON) { bundle: Bundle -> bundle.powerOn },
|
||||||
|
bundleField(VIDEO) { bundle: Bundle -> bundle.video },
|
||||||
|
bundleField(AUDIO) { bundle: Bundle -> bundle.audio },
|
||||||
|
bundleField(REQUIRE_AUDIO) { bundle: Bundle -> bundle.requireAudio },
|
||||||
|
bundleField(KILL_ADB_ON_CLOSE) { bundle: Bundle -> bundle.killAdbOnClose },
|
||||||
|
bundleField(CAMERA_HIGH_SPEED) { bundle: Bundle -> bundle.cameraHighSpeed },
|
||||||
|
bundleField(LIST) { bundle: Bundle -> bundle.list },
|
||||||
|
bundleField(AUDIO_DUP) { bundle: Bundle -> bundle.audioDup },
|
||||||
|
bundleField(NEW_DISPLAY) { bundle: Bundle -> bundle.newDisplay },
|
||||||
|
bundleField(START_APP) { bundle: Bundle -> bundle.startApp },
|
||||||
|
bundleField(VD_DESTROY_CONTENT) { bundle: Bundle -> bundle.vdDestroyContent },
|
||||||
|
bundleField(VD_SYSTEM_DECORATIONS) { bundle: Bundle -> bundle.vdSystemDecorations },
|
||||||
|
)
|
||||||
|
|
||||||
|
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
|
||||||
|
|
||||||
|
private fun bundleFromPreferences(preferences: Preferences) = Bundle(
|
||||||
|
crop = preferences.read(CROP),
|
||||||
|
recordFilename = preferences.read(RECORD_FILENAME),
|
||||||
|
videoCodecOptions = preferences.read(VIDEO_CODEC_OPTIONS),
|
||||||
|
audioCodecOptions = preferences.read(AUDIO_CODEC_OPTIONS),
|
||||||
|
videoEncoder = preferences.read(VIDEO_ENCODER),
|
||||||
|
audioEncoder = preferences.read(AUDIO_ENCODER),
|
||||||
|
cameraId = preferences.read(CAMERA_ID),
|
||||||
|
cameraSize = preferences.read(CAMERA_SIZE),
|
||||||
|
cameraSizeCustom = preferences.read(CAMERA_SIZE_CUSTOM),
|
||||||
|
cameraSizeUseCustom = preferences.read(CAMERA_SIZE_USE_CUSTOM),
|
||||||
|
cameraAr = preferences.read(CAMERA_AR),
|
||||||
|
cameraFps = preferences.read(CAMERA_FPS),
|
||||||
|
logLevel = preferences.read(LOG_LEVEL),
|
||||||
|
videoCodec = preferences.read(VIDEO_CODEC),
|
||||||
|
audioCodec = preferences.read(AUDIO_CODEC),
|
||||||
|
videoSource = preferences.read(VIDEO_SOURCE),
|
||||||
|
audioSource = preferences.read(AUDIO_SOURCE),
|
||||||
|
recordFormat = preferences.read(RECORD_FORMAT),
|
||||||
|
cameraFacing = preferences.read(CAMERA_FACING),
|
||||||
|
maxSize = preferences.read(MAX_SIZE),
|
||||||
|
videoBitRate = preferences.read(VIDEO_BIT_RATE),
|
||||||
|
audioBitRate = preferences.read(AUDIO_BIT_RATE),
|
||||||
|
maxFps = preferences.read(MAX_FPS),
|
||||||
|
angle = preferences.read(ANGLE),
|
||||||
|
captureOrientation = preferences.read(CAPTURE_ORIENTATION),
|
||||||
|
captureOrientationLock = preferences.read(CAPTURE_ORIENTATION_LOCK),
|
||||||
|
displayOrientation = preferences.read(DISPLAY_ORIENTATION),
|
||||||
|
recordOrientation = preferences.read(RECORD_ORIENTATION),
|
||||||
|
displayImePolicy = preferences.read(DISPLAY_IME_POLICY),
|
||||||
|
displayId = preferences.read(DISPLAY_ID),
|
||||||
|
screenOffTimeout = preferences.read(SCREEN_OFF_TIMEOUT),
|
||||||
|
showTouches = preferences.read(SHOW_TOUCHES),
|
||||||
|
fullscreen = preferences.read(FULLSCREEN),
|
||||||
|
control = preferences.read(CONTROL),
|
||||||
|
videoPlayback = preferences.read(VIDEO_PLAYBACK),
|
||||||
|
audioPlayback = preferences.read(AUDIO_PLAYBACK),
|
||||||
|
turnScreenOff = preferences.read(TURN_SCREEN_OFF),
|
||||||
|
stayAwake = preferences.read(STAY_AWAKE),
|
||||||
|
disableScreensaver = preferences.read(DISABLE_SCREENSAVER),
|
||||||
|
powerOffOnClose = preferences.read(POWER_OFF_ON_CLOSE),
|
||||||
|
cleanup = preferences.read(CLEANUP),
|
||||||
|
powerOn = preferences.read(POWER_ON),
|
||||||
|
video = preferences.read(VIDEO),
|
||||||
|
audio = preferences.read(AUDIO),
|
||||||
|
requireAudio = preferences.read(REQUIRE_AUDIO),
|
||||||
|
killAdbOnClose = preferences.read(KILL_ADB_ON_CLOSE),
|
||||||
|
cameraHighSpeed = preferences.read(CAMERA_HIGH_SPEED),
|
||||||
|
list = preferences.read(LIST),
|
||||||
|
audioDup = preferences.read(AUDIO_DUP),
|
||||||
|
newDisplay = preferences.read(NEW_DISPLAY),
|
||||||
|
startApp = preferences.read(START_APP),
|
||||||
|
vdDestroyContent = preferences.read(VD_DESTROY_CONTENT),
|
||||||
|
vdSystemDecorations = preferences.read(VD_SYSTEM_DECORATIONS),
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun loadBundle() = loadBundle(::bundleFromPreferences)
|
||||||
|
|
||||||
|
suspend fun saveBundle(new: Bundle) = saveBundle(bundleState.value, new, bundleFields)
|
||||||
|
|
||||||
|
suspend fun updateBundle(transform: (Bundle) -> Bundle) {
|
||||||
|
saveBundle(transform(bundleState.value))
|
||||||
|
}
|
||||||
|
|
||||||
fun validate(): Boolean = runBlocking {
|
fun validate(): Boolean = runBlocking {
|
||||||
runCatching {
|
runCatching {
|
||||||
toClientOptions().validate()
|
toClientOptions().validate()
|
||||||
@@ -296,59 +480,61 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: 处理空值
|
// TODO: 处理空值
|
||||||
suspend fun toClientOptions() = ClientOptions(
|
fun toClientOptions() = toClientOptions(bundleState.value)
|
||||||
crop = crop.get(),
|
|
||||||
recordFilename = recordFilename.get(),
|
fun toClientOptions(bundle: Bundle) = ClientOptions(
|
||||||
videoCodecOptions = videoCodecOptions.get(),
|
crop = bundle.crop,
|
||||||
audioCodecOptions = audioCodecOptions.get(),
|
recordFilename = bundle.recordFilename,
|
||||||
videoEncoder = videoEncoder.get(),
|
videoCodecOptions = bundle.videoCodecOptions,
|
||||||
audioEncoder = audioEncoder.get(),
|
audioCodecOptions = bundle.audioCodecOptions,
|
||||||
cameraId = cameraId.get(),
|
videoEncoder = bundle.videoEncoder,
|
||||||
cameraSize = if (!cameraSizeUseCustom.get()) cameraSize.get() else cameraSizeCustom.get(),
|
audioEncoder = bundle.audioEncoder,
|
||||||
cameraAr = cameraAr.get(),
|
cameraId = bundle.cameraId,
|
||||||
cameraFps = cameraFps.get().toUShort(),
|
cameraSize = if (!bundle.cameraSizeUseCustom) bundle.cameraSize else bundle.cameraSizeCustom,
|
||||||
logLevel = LogLevel.valueOf(logLevel.get().uppercase()),
|
cameraAr = bundle.cameraAr,
|
||||||
videoCodec = Codec.fromString(videoCodec.get()),
|
cameraFps = bundle.cameraFps.toUShort(),
|
||||||
audioCodec = Codec.fromString(audioCodec.get()),
|
logLevel = LogLevel.valueOf(bundle.logLevel.uppercase()),
|
||||||
videoSource = VideoSource.fromString(videoSource.get()),
|
videoCodec = Codec.fromString(bundle.videoCodec),
|
||||||
audioSource = AudioSource.fromString(audioSource.get()),
|
audioCodec = Codec.fromString(bundle.audioCodec),
|
||||||
recordFormat = ClientOptions.RecordFormat.valueOf(recordFormat.get().uppercase()),
|
videoSource = VideoSource.fromString(bundle.videoSource),
|
||||||
cameraFacing = CameraFacing.fromString(cameraFacing.get()),
|
audioSource = AudioSource.fromString(bundle.audioSource),
|
||||||
maxSize = maxSize.get().toUShort(),
|
recordFormat = ClientOptions.RecordFormat.valueOf(bundle.recordFormat.uppercase()),
|
||||||
videoBitRate = videoBitRate.get(),
|
cameraFacing = CameraFacing.fromString(bundle.cameraFacing),
|
||||||
audioBitRate = audioBitRate.get(),
|
maxSize = bundle.maxSize.toUShort(),
|
||||||
maxFps = maxFps.get(),
|
videoBitRate = bundle.videoBitRate,
|
||||||
angle = angle.get(),
|
audioBitRate = bundle.audioBitRate,
|
||||||
captureOrientation = Orientation.fromInt(captureOrientation.get()),
|
maxFps = bundle.maxFps,
|
||||||
|
angle = bundle.angle,
|
||||||
|
captureOrientation = Orientation.fromInt(bundle.captureOrientation),
|
||||||
captureOrientationLock = OrientationLock.valueOf(
|
captureOrientationLock = OrientationLock.valueOf(
|
||||||
captureOrientationLock.get().uppercase()
|
bundle.captureOrientationLock.uppercase()
|
||||||
),
|
),
|
||||||
displayOrientation = Orientation.fromInt(displayOrientation.get()),
|
displayOrientation = Orientation.fromInt(bundle.displayOrientation),
|
||||||
recordOrientation = Orientation.fromInt(recordOrientation.get()),
|
recordOrientation = Orientation.fromInt(bundle.recordOrientation),
|
||||||
displayImePolicy = DisplayImePolicy.valueOf(displayImePolicy.get().uppercase()),
|
displayImePolicy = DisplayImePolicy.valueOf(bundle.displayImePolicy.uppercase()),
|
||||||
displayId = displayId.get(),
|
displayId = bundle.displayId,
|
||||||
screenOffTimeout = Tick(screenOffTimeout.get()),
|
screenOffTimeout = Tick(bundle.screenOffTimeout),
|
||||||
showTouches = showTouches.get(),
|
showTouches = bundle.showTouches,
|
||||||
fullscreen = fullscreen.get(),
|
fullscreen = bundle.fullscreen,
|
||||||
control = control.get(),
|
control = bundle.control,
|
||||||
videoPlayback = videoPlayback.get(),
|
videoPlayback = bundle.videoPlayback,
|
||||||
audioPlayback = audioPlayback.get(),
|
audioPlayback = bundle.audioPlayback,
|
||||||
turnScreenOff = turnScreenOff.get(),
|
turnScreenOff = bundle.turnScreenOff,
|
||||||
stayAwake = stayAwake.get(),
|
stayAwake = bundle.stayAwake,
|
||||||
disableScreensaver = disableScreensaver.get(),
|
disableScreensaver = bundle.disableScreensaver,
|
||||||
powerOffOnClose = powerOffOnClose.get(),
|
powerOffOnClose = bundle.powerOffOnClose,
|
||||||
cleanUp = cleanup.get(),
|
cleanUp = bundle.cleanup,
|
||||||
powerOn = powerOn.get(),
|
powerOn = bundle.powerOn,
|
||||||
video = video.get(),
|
video = bundle.video,
|
||||||
audio = audio.get(),
|
audio = bundle.audio,
|
||||||
requireAudio = requireAudio.get(),
|
requireAudio = bundle.requireAudio,
|
||||||
killAdbOnClose = killAdbOnClose.get(),
|
killAdbOnClose = bundle.killAdbOnClose,
|
||||||
cameraHighSpeed = cameraHighSpeed.get(),
|
cameraHighSpeed = bundle.cameraHighSpeed,
|
||||||
list = ListOptions.valueOf(list.get().uppercase()),
|
list = ListOptions.valueOf(bundle.list.uppercase()),
|
||||||
audioDup = audioDup.get(),
|
audioDup = bundle.audioDup,
|
||||||
newDisplay = newDisplay.get(),
|
newDisplay = bundle.newDisplay,
|
||||||
startApp = startApp.get(),
|
startApp = bundle.startApp,
|
||||||
vdDestroyContent = vdDestroyContent.get(),
|
vdDestroyContent = bundle.vdDestroyContent,
|
||||||
vdSystemDecorations = vdSystemDecorations.get()
|
vdSystemDecorations = bundle.vdSystemDecorations
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,15 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlin.reflect.KProperty
|
import kotlin.reflect.KProperty
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
private const val TAG = "Settings"
|
private const val TAG = "Settings"
|
||||||
|
|
||||||
@@ -34,6 +39,8 @@ abstract class Settings(
|
|||||||
emptyPreferences()
|
emptyPreferences()
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
private val settingsScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
data class Pair<T>(
|
data class Pair<T>(
|
||||||
val key: Preferences.Key<T>,
|
val key: Preferences.Key<T>,
|
||||||
val defaultValue: T,
|
val defaultValue: T,
|
||||||
@@ -41,6 +48,24 @@ abstract class Settings(
|
|||||||
val name: String get() = key.name
|
val name: String get() = key.name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected fun <T> Preferences.read(pair: Pair<T>): T =
|
||||||
|
this[pair.key] ?: pair.defaultValue
|
||||||
|
|
||||||
|
protected interface BundleField<B> {
|
||||||
|
suspend fun persist(settings: Settings, current: B, new: B)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun <B, T> bundleField(pair: Pair<T>, selector: (B) -> T): BundleField<B> =
|
||||||
|
object : BundleField<B> {
|
||||||
|
override suspend fun persist(settings: Settings, current: B, new: B) {
|
||||||
|
val currentValue = selector(current)
|
||||||
|
val newValue = selector(new)
|
||||||
|
if (currentValue != newValue) {
|
||||||
|
settings.setValue(pair, newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置项委托类,自动提供 get/set/observe/asState/asMutableState 方法
|
* 设置项委托类,自动提供 get/set/observe/asState/asMutableState 方法
|
||||||
*/
|
*/
|
||||||
@@ -90,6 +115,28 @@ abstract class Settings(
|
|||||||
|
|
||||||
protected fun <T> setting(pair: Pair<T>) = SettingProperty(pair)
|
protected fun <T> setting(pair: Pair<T>) = SettingProperty(pair)
|
||||||
|
|
||||||
|
protected fun <B> createBundleState(reader: (Preferences) -> B): StateFlow<B> =
|
||||||
|
dataStore.data
|
||||||
|
.map(reader)
|
||||||
|
.stateIn(
|
||||||
|
scope = settingsScope,
|
||||||
|
started = SharingStarted.Eagerly,
|
||||||
|
initialValue = runBlocking { loadBundle(reader) }
|
||||||
|
)
|
||||||
|
|
||||||
|
protected suspend fun <B> loadBundle(reader: (Preferences) -> B): B =
|
||||||
|
reader(dataStore.data.first())
|
||||||
|
|
||||||
|
protected suspend fun <B> saveBundle(
|
||||||
|
current: B,
|
||||||
|
new: B,
|
||||||
|
fields: Array<out BundleField<B>>,
|
||||||
|
) {
|
||||||
|
for (field in fields) {
|
||||||
|
field.persist(this, current, new)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected suspend fun <T> getValue(pair: Pair<T>): T =
|
protected suspend fun <T> getValue(pair: Pair<T>): T =
|
||||||
dataStore.data.first()[pair.key] ?: pair.defaultValue
|
dataStore.data.first()[pair.key] ?: pair.defaultValue
|
||||||
|
|
||||||
@@ -123,4 +170,8 @@ abstract class Settings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val BUNDLE_SAVE_DELAY = 100.milliseconds
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ package io.github.miuzarte.scrcpyforandroid.widgets
|
|||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.graphics.SurfaceTexture
|
import android.graphics.SurfaceTexture
|
||||||
import android.util.Log
|
|
||||||
import android.view.MotionEvent
|
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import android.view.TextureView
|
import android.view.TextureView
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
@@ -38,12 +36,15 @@ import androidx.compose.material.icons.rounded.Fullscreen
|
|||||||
import androidx.compose.material.icons.rounded.LinkOff
|
import androidx.compose.material.icons.rounded.LinkOff
|
||||||
import androidx.compose.material.icons.rounded.Wifi
|
import androidx.compose.material.icons.rounded.Wifi
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -54,7 +55,6 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.input.pointer.pointerInteropFilter
|
import androidx.compose.ui.input.pointer.pointerInteropFilter
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
@@ -70,14 +70,16 @@ import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets
|
|||||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||||
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
|
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
|
||||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
|
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
|
||||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
|
|
||||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
|
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
|
||||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
|
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
|
||||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import top.yukonga.miuix.kmp.basic.Button
|
import top.yukonga.miuix.kmp.basic.Button
|
||||||
@@ -86,6 +88,7 @@ import top.yukonga.miuix.kmp.basic.Card
|
|||||||
import top.yukonga.miuix.kmp.basic.CardDefaults
|
import top.yukonga.miuix.kmp.basic.CardDefaults
|
||||||
import top.yukonga.miuix.kmp.basic.CircularProgressIndicator
|
import top.yukonga.miuix.kmp.basic.CircularProgressIndicator
|
||||||
import top.yukonga.miuix.kmp.basic.Icon
|
import top.yukonga.miuix.kmp.basic.Icon
|
||||||
|
import top.yukonga.miuix.kmp.basic.SnackbarHostState
|
||||||
import top.yukonga.miuix.kmp.basic.Text
|
import top.yukonga.miuix.kmp.basic.Text
|
||||||
import top.yukonga.miuix.kmp.basic.TextButton
|
import top.yukonga.miuix.kmp.basic.TextButton
|
||||||
import top.yukonga.miuix.kmp.basic.TextField
|
import top.yukonga.miuix.kmp.basic.TextField
|
||||||
@@ -98,17 +101,6 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme.isDynamicColor
|
|||||||
import top.yukonga.miuix.kmp.utils.PressFeedbackType
|
import top.yukonga.miuix.kmp.utils.PressFeedbackType
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private object UiMotionActions {
|
|
||||||
const val DOWN = 0
|
|
||||||
const val UP = 1
|
|
||||||
const val MOVE = 2
|
|
||||||
const val CANCEL = 3
|
|
||||||
const val POINTER_DOWN = 5
|
|
||||||
const val POINTER_UP = 6
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val FULLSCREEN_TOUCH_LOG_TAG = "FullscreenTouch"
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun StatusCard(
|
internal fun StatusCard(
|
||||||
statusLine: String,
|
statusLine: String,
|
||||||
@@ -119,10 +111,8 @@ internal fun StatusCard(
|
|||||||
connectedDeviceLabel: String,
|
connectedDeviceLabel: String,
|
||||||
) {
|
) {
|
||||||
val appSettings = Storage.appSettings
|
val appSettings = Storage.appSettings
|
||||||
|
val appSettingsBundle by appSettings.bundleState.collectAsState()
|
||||||
val context = LocalContext.current
|
val themeBaseIndex = appSettingsBundle.themeBaseIndex
|
||||||
|
|
||||||
val themeBaseIndex by appSettings.themeBaseIndex.asState()
|
|
||||||
|
|
||||||
val cleanStatusLine = normalizeStatusLine(statusLine)
|
val cleanStatusLine = normalizeStatusLine(statusLine)
|
||||||
|
|
||||||
@@ -359,8 +349,10 @@ internal fun VirtualButtonCard(
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun ConfigPanel(
|
internal fun ConfigPanel(
|
||||||
busy: Boolean,
|
busy: Boolean,
|
||||||
|
snack: SnackbarHostState,
|
||||||
audioForwardingSupported: Boolean,
|
audioForwardingSupported: Boolean,
|
||||||
cameraMirroringSupported: Boolean,
|
cameraMirroringSupported: Boolean,
|
||||||
|
isQuickConnected: Boolean,
|
||||||
onOpenAdvanced: () -> Unit,
|
onOpenAdvanced: () -> Unit,
|
||||||
onStartStopHaptic: (() -> Unit)? = null,
|
onStartStopHaptic: (() -> Unit)? = null,
|
||||||
onStart: () -> Unit,
|
onStart: () -> Unit,
|
||||||
@@ -368,49 +360,60 @@ internal fun ConfigPanel(
|
|||||||
sessionInfo: Scrcpy.Session.SessionInfo?,
|
sessionInfo: Scrcpy.Session.SessionInfo?,
|
||||||
onDisconnect: () -> Unit = {},
|
onDisconnect: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val scrcpyOptions = Storage.scrcpyOptions
|
val scope = rememberCoroutineScope()
|
||||||
val quickDevices = Storage.quickDevices
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
val sessionStarted = sessionInfo != null
|
val sessionStarted = sessionInfo != null
|
||||||
|
|
||||||
// Check if device exists in shortcuts
|
val soBundleShared by scrcpyOptions.bundleState.collectAsState()
|
||||||
var quickDevicesList by quickDevices.quickDevicesList.asMutableState()
|
val soBundleSharedLatest by rememberUpdatedState(soBundleShared)
|
||||||
val savedShortcuts = remember(quickDevicesList) {
|
var soBundle by rememberSaveable(soBundleShared) { mutableStateOf(soBundleShared) }
|
||||||
DeviceShortcuts.unmarshalFrom(quickDevicesList)
|
val soBundleLatest by rememberUpdatedState(soBundle)
|
||||||
|
LaunchedEffect(soBundleShared) {
|
||||||
|
if (soBundle != soBundleShared) {
|
||||||
|
soBundle = soBundleShared
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val isQuickConnected = remember(sessionInfo, savedShortcuts) {
|
LaunchedEffect(soBundle) {
|
||||||
sessionInfo?.let { info ->
|
delay(Settings.BUNDLE_SAVE_DELAY)
|
||||||
savedShortcuts.get(info.host, info.port) == null
|
if (soBundle != soBundleSharedLatest) {
|
||||||
} ?: false
|
scrcpyOptions.saveBundle(soBundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
scope.launch {
|
||||||
|
scrcpyOptions.saveBundle(soBundleLatest)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var audio by scrcpyOptions.audio.asMutableState()
|
val audioBitRateVisibility = rememberSaveable(soBundle) {
|
||||||
|
soBundle.audio && (soBundle.audioCodec == "opus" || soBundle.audioCodec == "aac")
|
||||||
|
}
|
||||||
|
|
||||||
var audioCodec by scrcpyOptions.audioCodec.asMutableState()
|
val audioCodecItems = rememberSaveable { Codec.AUDIO.map { it.displayName } }
|
||||||
val audioCodecItems = remember { Codec.AUDIO.map { it.displayName } }
|
val audioCodecIndex = rememberSaveable(soBundle) {
|
||||||
val audioCodecIndex = Codec.AUDIO.indexOfFirst {
|
Codec.AUDIO
|
||||||
it.string == audioCodec
|
.indexOfFirst { it.string == soBundle.audioCodec }
|
||||||
}.coerceAtLeast(0)
|
.coerceAtLeast(0)
|
||||||
var audioBitRate by scrcpyOptions.audioBitRate.asMutableState()
|
}
|
||||||
|
|
||||||
var videoCodec by scrcpyOptions.videoCodec.asMutableState()
|
val videoCodecItems = rememberSaveable { Codec.VIDEO.map { it.displayName } }
|
||||||
val videoCodecItems = remember { Codec.VIDEO.map { it.displayName } }
|
val videoCodecIndex = rememberSaveable(soBundle) {
|
||||||
val videoCodecIndex = Codec.VIDEO.indexOfFirst {
|
Codec.VIDEO
|
||||||
it.string == videoCodec
|
.indexOfFirst { it.string == soBundle.videoCodec }
|
||||||
}.coerceAtLeast(0)
|
.coerceAtLeast(0)
|
||||||
var videoBitRate by scrcpyOptions.videoBitRate.asMutableState()
|
}
|
||||||
val videoBitRateMbps = videoBitRate / 1_000_000f
|
|
||||||
|
|
||||||
SectionSmallTitle("Scrcpy")
|
SectionSmallTitle("Scrcpy")
|
||||||
Card {
|
Card {
|
||||||
SuperSwitch(
|
SuperSwitch(
|
||||||
title = "音频转发",
|
title = "音频转发",
|
||||||
summary = "转发设备音频到本机 (Android 11+)",
|
summary = "转发设备音频到本机 (Android 11+)",
|
||||||
checked = audio,
|
checked = soBundle.audio,
|
||||||
onCheckedChange = { audio = it },
|
onCheckedChange = { soBundle = soBundle.copy(audio = it) },
|
||||||
enabled = !sessionStarted && audioForwardingSupported,
|
enabled = !sessionStarted
|
||||||
|
&& audioForwardingSupported,
|
||||||
)
|
)
|
||||||
|
|
||||||
SuperDropdown(
|
SuperDropdown(
|
||||||
@@ -418,28 +421,47 @@ internal fun ConfigPanel(
|
|||||||
summary = "--audio-codec",
|
summary = "--audio-codec",
|
||||||
items = audioCodecItems,
|
items = audioCodecItems,
|
||||||
selectedIndex = audioCodecIndex,
|
selectedIndex = audioCodecIndex,
|
||||||
onSelectedIndexChange = { audioCodec = Codec.AUDIO[it].string },
|
onSelectedIndexChange = {
|
||||||
enabled = !sessionStarted && audio,
|
val codec = Codec.AUDIO[it]
|
||||||
|
soBundle = soBundle.copy(audioCodec = codec.string)
|
||||||
|
if (codec == Codec.FLAC) {
|
||||||
|
scope.launch {
|
||||||
|
snack.showSnackbar("注意:FLAC编解码会引入较大的延迟")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !sessionStarted && soBundle.audio,
|
||||||
)
|
)
|
||||||
AnimatedVisibility(audio && (audioCodec == "opus" || audioCodec == "aac")) {
|
AnimatedVisibility(audioBitRateVisibility) {
|
||||||
SuperSlider(
|
SuperSlider(
|
||||||
title = "音频码率",
|
title = "音频码率",
|
||||||
summary = "--audio-bit-rate",
|
summary = "--audio-bit-rate",
|
||||||
value = ScrcpyPresets.AudioBitRate.indexOfOrNearest(audioBitRate / 1000).toFloat(),
|
value = if (soBundle.audioBitRate <= 0) 0f
|
||||||
|
else (ScrcpyPresets.AudioBitRate
|
||||||
|
.indexOfOrNearest(soBundle.audioBitRate / 1000) + 1
|
||||||
|
).toFloat(),
|
||||||
onValueChange = { value ->
|
onValueChange = { value ->
|
||||||
val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex)
|
val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.size)
|
||||||
audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1000
|
soBundle = soBundle.copy(
|
||||||
|
audioBitRate =
|
||||||
|
if (idx == 0) 0
|
||||||
|
else ScrcpyPresets.AudioBitRate[idx - 1] * 1000
|
||||||
|
)
|
||||||
},
|
},
|
||||||
valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(),
|
valueRange = 0f..ScrcpyPresets.AudioBitRate.size.toFloat(),
|
||||||
steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0),
|
steps = (ScrcpyPresets.AudioBitRate.size - 1).coerceAtLeast(0),
|
||||||
enabled = !sessionStarted,
|
|
||||||
unit = "Kbps",
|
unit = "Kbps",
|
||||||
displayText = (audioBitRate / 1000).toString(),
|
zeroStateText = "默认",
|
||||||
inputInitialValue = (audioBitRate / 1000).toString(),
|
displayText = (soBundle.audioBitRate / 1_000).toString(),
|
||||||
|
inputInitialValue =
|
||||||
|
if (soBundle.audioBitRate <= 0) ""
|
||||||
|
else (soBundle.audioBitRate / 1_000).toString(),
|
||||||
inputFilter = { it.filter(Char::isDigit) },
|
inputFilter = { it.filter(Char::isDigit) },
|
||||||
inputValueRange = 1f..UShort.MAX_VALUE.toFloat(),
|
inputValueRange = 0f..UShort.MAX_VALUE.toFloat(),
|
||||||
onInputConfirm = { raw ->
|
onInputConfirm = { raw ->
|
||||||
raw.toIntOrNull()?.takeIf { it > 0 }?.let { audioBitRate = it * 1000 }
|
raw.toIntOrNull()
|
||||||
|
?.takeIf { it >= 0 }
|
||||||
|
?.let { soBundle = soBundle.copy(audioBitRate = it * 1000) }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -449,22 +471,35 @@ internal fun ConfigPanel(
|
|||||||
summary = "--video-codec",
|
summary = "--video-codec",
|
||||||
items = videoCodecItems,
|
items = videoCodecItems,
|
||||||
selectedIndex = videoCodecIndex,
|
selectedIndex = videoCodecIndex,
|
||||||
onSelectedIndexChange = { videoCodec = Codec.VIDEO[it].string },
|
onSelectedIndexChange = {
|
||||||
|
val codec = Codec.VIDEO[it]
|
||||||
|
soBundle = soBundle.copy(videoCodec = codec.string)
|
||||||
|
if (codec == Codec.AV1) {
|
||||||
|
scope.launch {
|
||||||
|
snack.showSnackbar("注意:绝大部分设备不支持AV1硬件编码")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
enabled = !sessionStarted,
|
enabled = !sessionStarted,
|
||||||
)
|
)
|
||||||
|
@SuppressLint("DefaultLocale")
|
||||||
SuperSlider(
|
SuperSlider(
|
||||||
title = "视频码率",
|
title = "视频码率",
|
||||||
summary = "--video-bit-rate",
|
summary = "--video-bit-rate",
|
||||||
value = videoBitRateMbps,
|
value = soBundle.videoBitRate / 1_000_000f,
|
||||||
onValueChange = { mbps ->
|
onValueChange = { mbps ->
|
||||||
videoBitRate = (mbps * 1_000_000).toInt()
|
soBundle = soBundle.copy(
|
||||||
|
videoBitRate = (mbps * 10).roundToInt() * (1_000_000 / 10)
|
||||||
|
)
|
||||||
},
|
},
|
||||||
valueRange = 0.1f..40f,
|
valueRange = 0f..40f,
|
||||||
steps = 399,
|
steps = 400 - 1,
|
||||||
enabled = !sessionStarted,
|
|
||||||
unit = "Mbps",
|
unit = "Mbps",
|
||||||
displayFormatter = { formatBitRate(it) },
|
zeroStateText = "默认",
|
||||||
inputInitialValue = formatBitRate(videoBitRateMbps),
|
displayFormatter = { String.format("%.1f", it) },
|
||||||
|
inputInitialValue =
|
||||||
|
if (soBundle.videoBitRate <= 0) ""
|
||||||
|
else String.format("%.1f", soBundle.videoBitRate / 1_000_000f),
|
||||||
inputFilter = { text ->
|
inputFilter = { text ->
|
||||||
var dotUsed = false
|
var dotUsed = false
|
||||||
text.filter { ch ->
|
text.filter { ch ->
|
||||||
@@ -479,14 +514,17 @@ internal fun ConfigPanel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
inputValueRange = 0.1f..UInt.MAX_VALUE.toFloat(),
|
inputValueRange = 0f..UInt.MAX_VALUE.toFloat(),
|
||||||
onInputConfirm = { raw ->
|
onInputConfirm = { raw ->
|
||||||
raw.toFloatOrNull()?.let { parsed ->
|
raw.toFloatOrNull()?.let { parsed ->
|
||||||
if (parsed >= 0.1f) {
|
if (parsed >= 0f) {
|
||||||
videoBitRate = (parsed * 1_000_000).toInt()
|
soBundle = soBundle.copy(
|
||||||
|
videoBitRate = (parsed * 1_000_000f).roundToInt()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
enabled = !sessionStarted,
|
||||||
)
|
)
|
||||||
|
|
||||||
SuperArrow(
|
SuperArrow(
|
||||||
@@ -503,53 +541,34 @@ internal fun ConfigPanel(
|
|||||||
.padding(bottom = UiSpacing.CardContent),
|
.padding(bottom = UiSpacing.CardContent),
|
||||||
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
|
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(!isQuickConnected) {
|
if (isQuickConnected) TextButton(
|
||||||
AnimatedVisibility(!sessionStarted) {
|
text = "断开",
|
||||||
TextButton(
|
onClick = {
|
||||||
text = "启动",
|
onStartStopHaptic?.invoke()
|
||||||
onClick = {
|
onDisconnect()
|
||||||
onStartStopHaptic?.invoke()
|
},
|
||||||
onStart()
|
modifier = Modifier.weight(1f / 4f),
|
||||||
},
|
enabled = !busy,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
)
|
||||||
enabled = !busy,
|
if (!sessionStarted) TextButton(
|
||||||
colors = ButtonDefaults.textButtonColorsPrimary(),
|
text = "启动",
|
||||||
)
|
onClick = {
|
||||||
}
|
onStartStopHaptic?.invoke()
|
||||||
AnimatedVisibility(sessionStarted) {
|
onStart()
|
||||||
TextButton(
|
},
|
||||||
text = "停止",
|
modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f),
|
||||||
onClick = {
|
enabled = !busy,
|
||||||
onStartStopHaptic?.invoke()
|
colors = ButtonDefaults.textButtonColorsPrimary(),
|
||||||
onStop()
|
)
|
||||||
},
|
if (sessionStarted) TextButton(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
text = "停止",
|
||||||
enabled = !busy,
|
onClick = {
|
||||||
)
|
onStartStopHaptic?.invoke()
|
||||||
}
|
onStop()
|
||||||
}
|
},
|
||||||
// display them at the same time in quick connection
|
modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f),
|
||||||
AnimatedVisibility(isQuickConnected) {
|
enabled = !busy,
|
||||||
TextButton(
|
)
|
||||||
text = "断开",
|
|
||||||
onClick = {
|
|
||||||
onStartStopHaptic?.invoke()
|
|
||||||
onDisconnect()
|
|
||||||
},
|
|
||||||
modifier = Modifier.weight(1f/4f),
|
|
||||||
enabled = !busy,
|
|
||||||
)
|
|
||||||
TextButton(
|
|
||||||
text = "启动",
|
|
||||||
onClick = {
|
|
||||||
onStartStopHaptic?.invoke()
|
|
||||||
onStart()
|
|
||||||
},
|
|
||||||
modifier = Modifier.weight(3f/4f),
|
|
||||||
enabled = !busy,
|
|
||||||
colors = ButtonDefaults.textButtonColorsPrimary(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -694,21 +713,6 @@ private fun PairingDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
|
||||||
private fun formatBitRate(value: Float): String = String.format("%.1f", value)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun LogsPanel(lines: List<String>) {
|
|
||||||
Card {
|
|
||||||
TextField(
|
|
||||||
value = lines.joinToString(separator = "\n"),
|
|
||||||
onValueChange = {},
|
|
||||||
readOnly = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TouchEventHandler
|
* TouchEventHandler
|
||||||
*
|
*
|
||||||
@@ -716,256 +720,6 @@ internal fun LogsPanel(lines: List<String>) {
|
|||||||
* - Handles touch event processing for fullscreen control screen
|
* - Handles touch event processing for fullscreen control screen
|
||||||
* - Manages pointer tracking, coordinate mapping, and touch injection
|
* - Manages pointer tracking, coordinate mapping, and touch injection
|
||||||
*/
|
*/
|
||||||
class TouchEventHandler(
|
|
||||||
private val coroutineScope: CoroutineScope,
|
|
||||||
private val session: Scrcpy.Session.SessionInfo,
|
|
||||||
private val touchAreaSize: IntSize,
|
|
||||||
private val activePointerIds: LinkedHashSet<Int>,
|
|
||||||
private val activePointerPositions: LinkedHashMap<Int, Offset>,
|
|
||||||
private val activePointerDevicePositions: LinkedHashMap<Int, Pair<Int, Int>>,
|
|
||||||
private val pointerLabels: LinkedHashMap<Int, Int>,
|
|
||||||
private var nextPointerLabel: Int,
|
|
||||||
private val onInjectTouch: suspend (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit,
|
|
||||||
private val onActiveTouchCountChanged: (Int) -> Unit,
|
|
||||||
private val onActiveTouchDebugChanged: (String) -> Unit,
|
|
||||||
private val onNextPointerLabelChanged: (Int) -> Unit,
|
|
||||||
) {
|
|
||||||
private val eventPointerIds = HashSet<Int>(10)
|
|
||||||
private val eventPositions = HashMap<Int, Offset>(10)
|
|
||||||
private val eventPressures = HashMap<Int, Float>(10)
|
|
||||||
private val justPressedPointerIds = HashSet<Int>(10)
|
|
||||||
|
|
||||||
fun handleMotionEvent(event: MotionEvent): Boolean {
|
|
||||||
if (touchAreaSize.width == 0 || touchAreaSize.height == 0) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
val bounds = calculateContentBounds()
|
|
||||||
|
|
||||||
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
|
||||||
return handleCancelAction(bounds)
|
|
||||||
}
|
|
||||||
|
|
||||||
extractEventData(event)
|
|
||||||
handleDisappearedPointers(eventPointerIds, bounds)
|
|
||||||
|
|
||||||
val endedPointerId = getEndedPointerId(event)
|
|
||||||
handlePointerDown(event, endedPointerId, bounds)
|
|
||||||
handlePointerMove(event, endedPointerId, bounds)
|
|
||||||
handlePointerUp(endedPointerId, bounds)
|
|
||||||
|
|
||||||
onActiveTouchCountChanged(activePointerIds.size)
|
|
||||||
refreshTouchDebug()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class ContentBounds(
|
|
||||||
val width: Float,
|
|
||||||
val height: Float,
|
|
||||||
val left: Float,
|
|
||||||
val top: Float,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun calculateContentBounds(): ContentBounds {
|
|
||||||
val sessionAspect = if (session.height == 0) {
|
|
||||||
16f / 9f
|
|
||||||
} else {
|
|
||||||
session.width.toFloat() / session.height.toFloat()
|
|
||||||
}
|
|
||||||
val containerWidth = touchAreaSize.width.toFloat()
|
|
||||||
val containerHeight = touchAreaSize.height.toFloat()
|
|
||||||
val containerAspect = containerWidth / containerHeight
|
|
||||||
|
|
||||||
val contentWidth: Float
|
|
||||||
val contentHeight: Float
|
|
||||||
if (sessionAspect > containerAspect) {
|
|
||||||
contentWidth = containerWidth
|
|
||||||
contentHeight = containerWidth / sessionAspect
|
|
||||||
} else {
|
|
||||||
contentHeight = containerHeight
|
|
||||||
contentWidth = containerHeight * sessionAspect
|
|
||||||
}
|
|
||||||
val contentLeft = (containerWidth - contentWidth) / 2f
|
|
||||||
val contentTop = (containerHeight - contentHeight) / 2f
|
|
||||||
|
|
||||||
return ContentBounds(contentWidth, contentHeight, contentLeft, contentTop)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isInsideContent(rawX: Float, rawY: Float, bounds: ContentBounds): Boolean {
|
|
||||||
return rawX in bounds.left..(bounds.left + bounds.width) &&
|
|
||||||
rawY in bounds.top..(bounds.top + bounds.height)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mapToDevice(rawX: Float, rawY: Float, bounds: ContentBounds): Pair<Int, Int> {
|
|
||||||
val normalizedX = ((rawX - bounds.left) / bounds.width).coerceIn(0f, 1f)
|
|
||||||
val normalizedY = ((rawY - bounds.top) / bounds.height).coerceIn(0f, 1f)
|
|
||||||
val x = (normalizedX * (session.width - 1).coerceAtLeast(0)).roundToInt()
|
|
||||||
.coerceIn(0, (session.width - 1).coerceAtLeast(0))
|
|
||||||
val y = (normalizedY * (session.height - 1).coerceAtLeast(0)).roundToInt()
|
|
||||||
.coerceIn(0, (session.height - 1).coerceAtLeast(0))
|
|
||||||
return x to y
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getPointerLabel(pointerId: Int): Int {
|
|
||||||
val existing = pointerLabels[pointerId]
|
|
||||||
if (existing != null) {
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
val assigned = nextPointerLabel
|
|
||||||
nextPointerLabel += 1
|
|
||||||
onNextPointerLabelChanged(nextPointerLabel)
|
|
||||||
pointerLabels[pointerId] = assigned
|
|
||||||
return assigned
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refreshTouchDebug() {
|
|
||||||
if (activePointerIds.isEmpty()) {
|
|
||||||
onActiveTouchDebugChanged("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val debug = activePointerIds
|
|
||||||
.sortedBy { getPointerLabel(it) }
|
|
||||||
.joinToString(separator = "\n") { pointerId ->
|
|
||||||
val label = getPointerLabel(pointerId)
|
|
||||||
val pos = activePointerDevicePositions[pointerId]
|
|
||||||
if (pos == null) {
|
|
||||||
"#$label(id=$pointerId):?"
|
|
||||||
} else {
|
|
||||||
"#$label(id=$pointerId):${pos.first},${pos.second}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onActiveTouchDebugChanged(debug)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun releasePointer(pointerId: Int, bounds: ContentBounds) {
|
|
||||||
if (!activePointerIds.contains(pointerId)) return
|
|
||||||
val pos = activePointerPositions[pointerId] ?: Offset.Zero
|
|
||||||
val (x, y) = mapToDevice(pos.x, pos.y, bounds)
|
|
||||||
coroutineScope.launch {
|
|
||||||
runCatching {
|
|
||||||
onInjectTouch(UiMotionActions.UP, pointerId.toLong(), x, y, 0f, 0)
|
|
||||||
}.onFailure { e ->
|
|
||||||
Log.w(FULLSCREEN_TOUCH_LOG_TAG, "releasePointer failed for pointerId=$pointerId", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
activePointerIds -= pointerId
|
|
||||||
activePointerPositions.remove(pointerId)
|
|
||||||
activePointerDevicePositions.remove(pointerId)
|
|
||||||
pointerLabels.remove(pointerId)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleCancelAction(bounds: ContentBounds): Boolean {
|
|
||||||
val toCancel = activePointerIds.toList()
|
|
||||||
for (pointerId in toCancel) {
|
|
||||||
releasePointer(pointerId, bounds)
|
|
||||||
}
|
|
||||||
onActiveTouchCountChanged(activePointerIds.size)
|
|
||||||
refreshTouchDebug()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun extractEventData(event: MotionEvent) {
|
|
||||||
eventPointerIds.clear()
|
|
||||||
eventPositions.clear()
|
|
||||||
eventPressures.clear()
|
|
||||||
for (i in 0 until event.pointerCount) {
|
|
||||||
val pointerId = event.getPointerId(i)
|
|
||||||
eventPointerIds += pointerId
|
|
||||||
eventPositions[pointerId] = Offset(event.getX(i), event.getY(i))
|
|
||||||
eventPressures[pointerId] = event.getPressure(i).coerceIn(0f, 1f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleDisappearedPointers(eventPointerIds: Set<Int>, bounds: ContentBounds) {
|
|
||||||
val disappearedPointers = activePointerIds.filter { it !in eventPointerIds }
|
|
||||||
for (pointerId in disappearedPointers) {
|
|
||||||
releasePointer(pointerId, bounds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getEndedPointerId(event: MotionEvent): Int? {
|
|
||||||
return when (event.actionMasked) {
|
|
||||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> event.getPointerId(event.actionIndex)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handlePointerDown(
|
|
||||||
event: MotionEvent,
|
|
||||||
endedPointerId: Int?,
|
|
||||||
bounds: ContentBounds,
|
|
||||||
) {
|
|
||||||
justPressedPointerIds.clear()
|
|
||||||
for (i in 0 until event.pointerCount) {
|
|
||||||
val pointerId = event.getPointerId(i)
|
|
||||||
if (pointerId == endedPointerId) continue
|
|
||||||
val raw = eventPositions[pointerId] ?: continue
|
|
||||||
val pressure = eventPressures[pointerId] ?: 0f
|
|
||||||
if (!activePointerIds.contains(pointerId)) {
|
|
||||||
if (!isInsideContent(raw.x, raw.y, bounds)) continue
|
|
||||||
val (x, y) = mapToDevice(raw.x, raw.y, bounds)
|
|
||||||
activePointerIds += pointerId
|
|
||||||
activePointerPositions[pointerId] = raw
|
|
||||||
activePointerDevicePositions[pointerId] = x to y
|
|
||||||
justPressedPointerIds += pointerId
|
|
||||||
coroutineScope.launch {
|
|
||||||
runCatching {
|
|
||||||
onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, pressure, 0)
|
|
||||||
}.onFailure { e ->
|
|
||||||
Log.w(
|
|
||||||
FULLSCREEN_TOUCH_LOG_TAG,
|
|
||||||
"handlePointerDown failed for pointerId=$pointerId",
|
|
||||||
e
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handlePointerMove(
|
|
||||||
event: MotionEvent,
|
|
||||||
endedPointerId: Int?,
|
|
||||||
bounds: ContentBounds,
|
|
||||||
) {
|
|
||||||
for (i in 0 until event.pointerCount) {
|
|
||||||
val pointerId = event.getPointerId(i)
|
|
||||||
if (!activePointerIds.contains(pointerId)) continue
|
|
||||||
if (pointerId == endedPointerId) continue
|
|
||||||
if (pointerId in justPressedPointerIds) continue
|
|
||||||
val raw = eventPositions[pointerId] ?: continue
|
|
||||||
val pressure = eventPressures[pointerId] ?: 0f
|
|
||||||
activePointerPositions[pointerId] = raw
|
|
||||||
val (x, y) = mapToDevice(raw.x, raw.y, bounds)
|
|
||||||
activePointerDevicePositions[pointerId] = x to y
|
|
||||||
coroutineScope.launch {
|
|
||||||
runCatching {
|
|
||||||
onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, pressure, 0)
|
|
||||||
}.onFailure { e ->
|
|
||||||
Log.w(
|
|
||||||
FULLSCREEN_TOUCH_LOG_TAG,
|
|
||||||
"handlePointerMove failed for pointerId=$pointerId",
|
|
||||||
e
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handlePointerUp(
|
|
||||||
endedPointerId: Int?,
|
|
||||||
bounds: ContentBounds,
|
|
||||||
) {
|
|
||||||
if (endedPointerId != null) {
|
|
||||||
val endPos = eventPositions[endedPointerId]
|
|
||||||
if (endPos != null) {
|
|
||||||
activePointerPositions[endedPointerId] = endPos
|
|
||||||
}
|
|
||||||
releasePointer(endedPointerId, bounds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FullscreenControlScreen
|
* FullscreenControlScreen
|
||||||
@@ -1121,14 +875,22 @@ private fun ScrcpyVideoSurface(
|
|||||||
nativeCore: NativeCoreFacade,
|
nativeCore: NativeCoreFacade,
|
||||||
session: Scrcpy.Session.SessionInfo?,
|
session: Scrcpy.Session.SessionInfo?,
|
||||||
) {
|
) {
|
||||||
val surfaceTag = "video-main"
|
|
||||||
var currentSurface by remember { mutableStateOf<Surface?>(null) }
|
var currentSurface by remember { mutableStateOf<Surface?>(null) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
LaunchedEffect(session, currentSurface) {
|
LaunchedEffect(session, currentSurface) {
|
||||||
val surface = currentSurface
|
val surface = currentSurface
|
||||||
if (session != null && surface != null && surface.isValid) {
|
if (session != null && surface != null && surface.isValid) {
|
||||||
nativeCore.registerVideoSurface(surfaceTag, surface)
|
nativeCore.attachVideoSurface(surface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
val surface = currentSurface
|
||||||
|
if (surface != null) {
|
||||||
|
scope.launch { nativeCore.detachVideoSurface(surface, releaseDecoder = false) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1148,7 +910,7 @@ private fun ScrcpyVideoSurface(
|
|||||||
// Register immediately when surface becomes available
|
// Register immediately when surface becomes available
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
nativeCore.registerVideoSurface(surfaceTag, newSurface)
|
nativeCore.attachVideoSurface(newSurface)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1160,9 +922,15 @@ private fun ScrcpyVideoSurface(
|
|||||||
) = Unit
|
) = Unit
|
||||||
|
|
||||||
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
|
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
|
||||||
// Return false to keep the SurfaceTexture alive
|
val surface = currentSurface
|
||||||
// This prevents the surface from being destroyed when the view is detached
|
if (surface != null) {
|
||||||
return false
|
scope.launch {
|
||||||
|
nativeCore.detachVideoSurface(surface, releaseDecoder = false)
|
||||||
|
}
|
||||||
|
surface.release()
|
||||||
|
currentSurface = null
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) = Unit
|
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) = Unit
|
||||||
|
|||||||
Reference in New Issue
Block a user