diff --git a/README.md b/README.md index ca84b1b..f088aee 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ ## 已知问题 +- 关闭画中画后不会停止 scrcpy 串流,仍然需要回到应用中点击停止 - 虚拟按键的截图实现方式为发送 `keycode 120`,安卓官方([keycodes.h#349](https://android.googlesource.com/platform/frameworks/native/+/master/include/android/keycodes.h#349))的定义为 `System Request / Print Screen key.`,不同的厂商有不同的实现,在某些类原生(`AxionOS`) 上的行为是软重启 @@ -60,6 +61,7 @@ specific abi: - JNI ADB 实现: [rikkaapps/shizuku](https://github.com/rikkaapps/shizuku), [vvb2060/ndk.boringssl](https://github.com/vvb2060), [lsposed/libcxx](https://github.com/lsposed/libcxx) - 界面组件: [YuKongA/miuix](https://github.com/compose-miuix-ui/miuix) - 界面设计参考: [tiann/KernelSU/manager](https://github.com/tiann/KernelSU/tree/main/manager) +- 画中画实现参考: [ClassicOldSong/moonlight-android](https://github.com/ClassicOldSong/moonlight-android) ## License diff --git a/TODO.md b/TODO.md index 531f5c8..3995ed4 100644 --- a/TODO.md +++ b/TODO.md @@ -8,8 +8,6 @@ ## FEATURES -- 设置项连接设备后马上启用scrcpy会话 - ### LOWER PRIORITY 顺序无关 @@ -17,5 +15,4 @@ - 多配置切换 - [音频延迟](https://developer.android.com/ndk/guides/audio/audio-latency) - 横屏布局 -- 原生悬浮窗 [Petterpx/FloatingX](https://github.com/Petterpx/FloatingX) - I18N diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7736ead..6d81a82 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "io.github.miuzarte.scrcpyforandroid" minSdk = 26 targetSdk = 36 - versionCode = 6 - versionName = "0.1.0" + versionCode = 7 + versionName = "0.1.1" externalNativeBuild { cmake { @@ -57,8 +57,8 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } buildFeatures { compose = true @@ -80,6 +80,7 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) + implementation(libs.androidx.core.pip) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.tooling.preview) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 886b7ad..784e834 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,6 +19,10 @@ android:supportsRtl="true" android:theme="@style/Theme.ScrcpyForAndroid"> + + + + diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt index 5686bd8..98dca98 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt @@ -1,20 +1,25 @@ package io.github.miuzarte.scrcpyforandroid +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.annotation.RequiresApi import io.github.miuzarte.scrcpyforandroid.pages.MainScreen +import io.github.miuzarte.scrcpyforandroid.services.AppRuntime import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration import io.github.miuzarte.scrcpyforandroid.storage.Storage import kotlinx.coroutines.runBlocking class MainActivity : ComponentActivity() { + @RequiresApi(Build.VERSION_CODES.R) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // initialize settings singleton Storage.init(applicationContext) + AppRuntime.init(applicationContext) val migration = PreferenceMigration(applicationContext) runBlocking { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt index d5e7b1e..f2ff8aa 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt @@ -1,6 +1,5 @@ package io.github.miuzarte.scrcpyforandroid -import android.content.Context import android.os.Handler import android.os.Looper import android.util.Log @@ -21,11 +20,7 @@ import java.util.concurrent.CopyOnWriteArraySet * - Surface/Decoder management for video rendering * - Video size and FPS monitoring */ -class NativeCoreFacade private constructor() { - @Volatile - var session: Scrcpy.Session? = null - private set - +object NativeCoreFacade { private val sessionLifecycleMutex = Mutex() private val renderer = PersistentVideoRenderer() private var activeSurfaceId: Int? = null @@ -118,25 +113,10 @@ class NativeCoreFacade private constructor() { } } - fun addVideoSizeListener(listener: (Int, Int) -> Unit) { - videoSizeListeners.add(listener) - } - - fun removeVideoSizeListener(listener: (Int, Int) -> Unit) { - videoSizeListeners.remove(listener) - } - - fun addVideoFpsListener(listener: (Float) -> Unit) { - videoFpsListeners.add(listener) - } - - fun removeVideoFpsListener(listener: (Float) -> Unit) { - videoFpsListeners.remove(listener) - } - - suspend fun scrcpyBackOrTurnScreenOn(action: Int = 0) { - session?.pressBackOrTurnScreenOn(action) - } + fun addVideoSizeListener(listener: (Int, Int) -> Unit) = videoSizeListeners.add(listener) + fun removeVideoSizeListener(listener: (Int, Int) -> Unit) = videoSizeListeners.remove(listener) + fun addVideoFpsListener(listener: (Float) -> Unit) = videoFpsListeners.add(listener) + fun removeVideoFpsListener(listener: (Float) -> Unit) = videoFpsListeners.remove(listener) /** * Called by Scrcpy.kt when a session starts. @@ -146,7 +126,6 @@ class NativeCoreFacade private constructor() { session: Scrcpy.Session.SessionInfo, sessionMgr: Scrcpy.Session ) = sessionLifecycleMutex.withLock { - this.session = sessionMgr currentSessionInfo = session releaseAllDecoders() synchronized(bootstrapLock) { @@ -185,7 +164,6 @@ class NativeCoreFacade private constructor() { * Cleans up decoders and resets state. */ suspend fun onScrcpySessionStopped() = sessionLifecycleMutex.withLock { - session = null releaseAllDecoders() synchronized(bootstrapLock) { bootstrapPackets.clear() @@ -194,21 +172,8 @@ class NativeCoreFacade private constructor() { currentSessionInfo = null } - companion object { - private const val TAG = "NativeCoreFacade" - private const val MAX_BOOTSTRAP_PACKETS = 90 - - @Volatile - private var instance: NativeCoreFacade? = null - - // TODO ??? - fun get(context: Context): NativeCoreFacade { - return instance ?: synchronized(this) { - instance ?: NativeCoreFacade().also { instance = it } - } - } - - } + private const val TAG = "NativeCoreFacade" + private const val MAX_BOOTSTRAP_PACKETS = 90 private data class CachedPacket( val data: ByteArray, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/StreamActivity.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/StreamActivity.kt new file mode 100644 index 0000000..fcdfb8e --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/StreamActivity.kt @@ -0,0 +1,160 @@ +package io.github.miuzarte.scrcpyforandroid + +import android.app.PendingIntent +import android.app.RemoteAction +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.graphics.drawable.Icon +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.core.app.PictureInPictureParamsCompat +import androidx.core.pip.BasicPictureInPicture +import androidx.core.pip.PictureInPictureDelegate +import io.github.miuzarte.scrcpyforandroid.pages.StreamScreen +import io.github.miuzarte.scrcpyforandroid.services.PictureInPictureActionReceiver +import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTarget +import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTargetState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class StreamActivity : ComponentActivity() { + private lateinit var basicPip: BasicPictureInPicture // = this + + // pip 是否已被配置、允许进入 + private var pipConfigured: Boolean = false + private var pipParams = PictureInPictureParamsCompat + .Builder() + .setEnabled(false) + .build() + + // 是否处于 PiP。 + // 这台设备上进入 PiP 时,动画事件比 onPictureInPictureModeChanged() 更稳定, + // 所以进入 PiP 直接由动画事件置为 true。 + private val _pipModeState = MutableStateFlow(false) + val pipModeState: StateFlow = _pipModeState + + val pipStopAction: RemoteAction by lazy { + val intent = Intent(this, PictureInPictureActionReceiver::class.java).apply { + action = PictureInPictureActionReceiver.ACTION_STOP_SCRCPY + `package` = packageName + } + val pendingIntent = PendingIntent.getBroadcast( + this, + 1, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + RemoteAction( + Icon.createWithResource(this, android.R.drawable.ic_menu_close_clear_cancel), + "停止投屏", + "停止投屏", + pendingIntent, + ) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + basicPip = BasicPictureInPicture(this) + basicPip.addOnPictureInPictureEventListener( + executor = mainExecutor, + listener = object : PictureInPictureDelegate.OnPictureInPictureEventListener { + override fun onPictureInPictureEvent( + event: PictureInPictureDelegate.Event, + newConfig: Configuration?, + ) { + // HyperOS 3 下稳定收到的是进入动画事件 + // 这里直接把进入动画开始视为已经进入 PiP,尽量少依赖平台回调 + when (event) { + PictureInPictureDelegate.Event.ENTER_ANIMATION_START -> { + _pipModeState.value = true + VideoOutputTargetState.set(VideoOutputTarget.PICTURE_IN_PICTURE) + } + + PictureInPictureDelegate.Event.ENTER_ANIMATION_END -> { + _pipModeState.value = true + } + + PictureInPictureDelegate.Event.STASHED -> {} + PictureInPictureDelegate.Event.UNSTASHED -> {} + + // 收不到 + // PictureInPictureDelegate.Event.ENTERED -> {} + // PictureInPictureDelegate.Event.EXITED -> {} + } + } + } + ) + + setContent { + StreamScreen(activity = this) + } + } + + fun configurePictureInPicture( + enabled: Boolean, + params: PictureInPictureParamsCompat, + ) { + // 由 StreamScreen 决定何时更新 PiP 参数; + // StreamActivity 这里只负责缓存并转发最新配置 + pipConfigured = enabled + pipParams = params + basicPip + .setEnabled(enabled) + .setPictureInPictureParams(params) + } + + fun enterConfiguredPip(): Boolean { + // onUserLeaveHint() 可能发生在 PiP 还没准备好之前, + // 所以这里先做一次兜底判断 + if (!pipConfigured) return false + return runCatching { + enterPictureInPictureMode(pipParams) + true + }.getOrElse { + VideoOutputTargetState.set(VideoOutputTarget.FULLSCREEN) + false + } + } + + override fun onUserLeaveHint() { + super.onUserLeaveHint() + + enterConfiguredPip() + } + + override fun onDestroy() { + super.onDestroy() + + // 回到全屏也会停止 + /* + if (_pipModeState.value) { + Thread { + runBlocking { + AppRuntime.scrcpy?.stop() + } + }.start() + } + */ + } + + // MIUI 不进 + override fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + newConfig: Configuration, + ) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + + if (!isInPictureInPictureMode) { + _pipModeState.value = false + VideoOutputTargetState.set(VideoOutputTarget.FULLSCREEN) + } + } + + companion object { + fun createIntent(context: Context): Intent { + return Intent(context, StreamActivity::class.java) + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AdbMdnsDiscoverer.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AdbMdnsDiscoverer.kt index 6681095..2c0d1bd 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AdbMdnsDiscoverer.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AdbMdnsDiscoverer.kt @@ -1,6 +1,5 @@ package io.github.miuzarte.scrcpyforandroid.nativecore -import android.content.Context import android.net.nsd.NsdManager import android.net.nsd.NsdServiceInfo import android.os.Build @@ -23,9 +22,14 @@ import java.util.concurrent.atomic.AtomicReference * Uses Android's `NsdManager` to resolve services and returns a host:port pair * when a suitable service is found within the provided timeout. */ -internal class AdbMdnsDiscoverer(context: Context) { +internal object AdbMdnsDiscoverer { - private val nsdManager = context.getSystemService(NsdManager::class.java) + private lateinit var nsdManager: NsdManager + + fun init(context: android.content.Context) { + if (::nsdManager.isInitialized) return + nsdManager = context.applicationContext.getSystemService(NsdManager::class.java) + } /** * Discover a device that advertises the ADB connect service via mDNS. @@ -46,6 +50,7 @@ internal class AdbMdnsDiscoverer(context: Context) { timeoutMs: Long, includeLanDevices: Boolean, ): Pair? { + check(::nsdManager.isInitialized) { "AdbMdnsDiscoverer is not initialized" } val resultPort = AtomicInteger(-1) val resultHost = AtomicReference(null) val discoveryFinished = AtomicBoolean(false) @@ -131,9 +136,7 @@ internal class AdbMdnsDiscoverer(context: Context) { true } - companion object { - private const val TAG = "AdbMdnsDiscoverer" - private const val TLS_CONNECT = "_adb-tls-connect._tcp" - private const val TLS_PAIRING = "_adb-tls-pairing._tcp" - } + private const val TAG = "AdbMdnsDiscoverer" + private const val TLS_CONNECT = "_adb-tls-connect._tcp" + private const val TLS_PAIRING = "_adb-tls-pairing._tcp" } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt index 839cdf6..d6a18eb 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt @@ -1,6 +1,5 @@ package io.github.miuzarte.scrcpyforandroid.nativecore -import android.content.Context import android.os.Build import android.util.Base64 import android.util.Log @@ -43,7 +42,7 @@ import kotlin.concurrent.thread * This type is responsible for persisting the private key and performing * pairing/connect discovery helpers. */ -internal class DirectAdbTransport(private val context: Context) { +internal object DirectAdbTransport { private val keys: Pair by lazy { runBlocking { loadOrCreate() } } @@ -87,19 +86,17 @@ internal class DirectAdbTransport(private val context: Context) { fun discoverPairingService( timeoutMs: Long = 12_000, - includeLanDevices: Boolean = true - ): Pair? { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null - return AdbMdnsDiscoverer(context).discoverPairingService(timeoutMs, includeLanDevices) - } + includeLanDevices: Boolean = true, + ): Pair? = + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) null + else AdbMdnsDiscoverer.discoverPairingService(timeoutMs, includeLanDevices) fun discoverConnectService( timeoutMs: Long = 12_000, - includeLanDevices: Boolean = true - ): Pair? { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null - return AdbMdnsDiscoverer(context).discoverConnectService(timeoutMs, includeLanDevices) - } + includeLanDevices: Boolean = true, + ): Pair? = + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) null + else AdbMdnsDiscoverer.discoverConnectService(timeoutMs, includeLanDevices) /** * Load persisted RSA keypair from DataStore, or generate a new one. @@ -200,9 +197,7 @@ internal class DirectAdbTransport(private val context: Context) { return buf.array() } - companion object { - private const val TAG = "DirectAdbTransport" - } + private const val TAG = "DirectAdbTransport" } /** @@ -336,7 +331,9 @@ internal class DirectAdbConnection( */ fun openStream(service: String): AdbSocketStream { val localId = nextLocalId.getAndIncrement() - val stream = AdbSocketStream(localId) { cmd, a0, a1, d -> sendMsg(cmd, a0, a1, d) } + val stream = AdbSocketStream(localId) { command, arg0, arg1, data -> + sendMsg(command, arg0, arg1, data) + } streams[localId] = stream sendMsg(A_OPEN, localId, 0, (service + "\u0000").toByteArray(Charsets.UTF_8)) try { @@ -349,7 +346,8 @@ internal class DirectAdbConnection( } fun shell(command: String): String = - openStream("shell:$command").use { it.inputStream.readBytes().toString(Charsets.UTF_8) } + openStream("shell:$command") + .use { it.inputStream.readBytes().toString(Charsets.UTF_8) } /** * Push raw bytes to a remote path using the minimal ADB "sync" protocol. @@ -357,40 +355,41 @@ internal class DirectAdbConnection( * - Implements SEND/DATA/DONE/OKAY sequence and throws IOException on failure. */ fun push(data: ByteArray, remotePath: String, unixMode: Int = 420) { - openStream("sync:").use { stream -> - val out = stream.outputStream - val inp = stream.inputStream - val pathMode = "$remotePath,$unixMode".toByteArray(Charsets.UTF_8) + openStream("sync:") + .use { stream -> + val out = stream.outputStream + val inp = stream.inputStream + val pathMode = "$remotePath,$unixMode".toByteArray(Charsets.UTF_8) - out.write("SEND".toByteArray(Charsets.US_ASCII)) - out.writeIntLE(pathMode.size) - out.write(pathMode) + out.write("SEND".toByteArray(Charsets.US_ASCII)) + out.writeIntLE(pathMode.size) + out.write(pathMode) - val chunkBuf = ByteArray(64 * 1024) - var offset = 0 - while (offset < data.size) { - val len = minOf(chunkBuf.size, data.size - offset) - out.write("DATA".toByteArray(Charsets.US_ASCII)) - out.writeIntLE(len) - out.write(data, offset, len) - offset += len + val chunkBuf = ByteArray(64 * 1024) + var offset = 0 + while (offset < data.size) { + val len = minOf(chunkBuf.size, data.size - offset) + out.write("DATA".toByteArray(Charsets.US_ASCII)) + out.writeIntLE(len) + out.write(data, offset, len) + offset += len + } + + out.write("DONE".toByteArray(Charsets.US_ASCII)) + out.writeIntLE((System.currentTimeMillis() / 1000).toInt()) + out.flush() + + val idBuf = ByteArray(4).also { inp.readExact(it) } + val msgLen = inp.readIntLE() + val id = String(idBuf, Charsets.US_ASCII) + if (id != "OKAY") { + val msg = if (msgLen > 0) ByteArray(msgLen).also { inp.readExact(it) } + .toString(Charsets.UTF_8) else id + throw IOException("ADB push failed: $msg") + } else if (msgLen > 0) { + inp.skip(msgLen.toLong()) + } } - - out.write("DONE".toByteArray(Charsets.US_ASCII)) - out.writeIntLE((System.currentTimeMillis() / 1000).toInt()) - out.flush() - - val idBuf = ByteArray(4).also { inp.readExact(it) } - val msgLen = inp.readIntLE() - val id = String(idBuf, Charsets.US_ASCII) - if (id != "OKAY") { - val msg = if (msgLen > 0) ByteArray(msgLen).also { inp.readExact(it) } - .toString(Charsets.UTF_8) else id - throw IOException("ADB push failed: $msg") - } else if (msgLen > 0) { - inp.skip(msgLen.toLong()) - } - } } fun isAlive(): Boolean { @@ -568,7 +567,7 @@ internal class DirectAdbConnection( */ class AdbSocketStream( val localId: Int, - private val sender: (cmd: Int, arg0: Int, arg1: Int, data: ByteArray) -> Unit, + private val sender: (cmd: Int, arg0: Int, arg1: Int, `data`: ByteArray) -> Unit, ) : Closeable { companion object { @@ -619,12 +618,13 @@ class AdbSocketStream( } override fun close() { - if (!closed) { - closed = true - val r = remoteId - if (r != 0) runCatching { sender(A_CLSE, localId, r, ByteArray(0)) } - queue.offer(EndOfStreamMarker) + if (closed) return + + closed = true + if (remoteId != 0) runCatching { + sender(A_CLSE, localId, remoteId, ByteArray(0)) } + queue.offer(EndOfStreamMarker) } private inner class InStream : InputStream() { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt index 90ae92a..fce50c7 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt @@ -1,6 +1,5 @@ package io.github.miuzarte.scrcpyforandroid.nativecore -import android.content.Context import android.util.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex @@ -19,8 +18,8 @@ import kotlin.time.Duration * * All network operations are executed on Dispatchers.IO. */ -class NativeAdbService(appContext: Context) { - private val transport = DirectAdbTransport(appContext) +object NativeAdbService { + private val transport = DirectAdbTransport private val mutex = Mutex() @Volatile @@ -55,7 +54,7 @@ class NativeAdbService(appContext: Context) { suspend fun discoverPairingService( timeoutMs: Long = 12_000, - includeLanDevices: Boolean = true + includeLanDevices: Boolean = true, ): Pair? = mutex.withLock { return@withLock try { transport.discoverPairingService(timeoutMs, includeLanDevices) @@ -67,7 +66,7 @@ class NativeAdbService(appContext: Context) { suspend fun discoverConnectService( timeoutMs: Long = 12_000, - includeLanDevices: Boolean = true + includeLanDevices: Boolean = true, ): Pair? = mutex.withLock { return@withLock try { transport.discoverConnectService(timeoutMs, includeLanDevices) @@ -162,7 +161,5 @@ class NativeAdbService(appContext: Context) { ?: throw IllegalStateException("ADB not connected") } - companion object { - private const val TAG = "NativeAdbService" - } + private const val TAG = "NativeAdbService" } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/PersistentVideoRenderer.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/PersistentVideoRenderer.kt index e1a2cc9..e3d61f9 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/PersistentVideoRenderer.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/PersistentVideoRenderer.kt @@ -54,7 +54,7 @@ class PersistentVideoRenderer { private var stMatrixHandle = 0 private var samplerHandle = 0 - private val initLock = Object() + private val initLock = Any() fun getDecoderSurface(): Surface { ensureInitialized() diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt index d9fa078..d137204 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt @@ -2,7 +2,6 @@ package io.github.miuzarte.scrcpyforandroid.pages import android.annotation.SuppressLint import android.app.Activity -import android.content.pm.ActivityInfo import android.util.Log import android.view.WindowManager import androidx.compose.foundation.layout.PaddingValues @@ -26,7 +25,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight -import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade +import io.github.miuzarte.scrcpyforandroid.StreamActivity import io.github.miuzarte.scrcpyforandroid.constants.Defaults import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics @@ -36,6 +35,7 @@ import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy +import io.github.miuzarte.scrcpyforandroid.services.AppRuntime import io.github.miuzarte.scrcpyforandroid.services.EventLogger import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController @@ -88,10 +88,8 @@ private const val ADB_TCP_PROBE_TIMEOUT_MS = 500 @Composable fun DeviceTabScreen( - nativeCore: NativeCoreFacade, - adbService: NativeAdbService, - scrcpy: Scrcpy, scrollBehavior: ScrollBehavior, + scrcpy: Scrcpy, onOpenReorderDevices: () -> Unit, ) { val navigator = LocalRootNavigator.current @@ -135,10 +133,8 @@ fun DeviceTabScreen( ) { pagePadding -> DeviceTabPage( contentPadding = pagePadding, - nativeCore = nativeCore, - adbService = adbService, - scrcpy = scrcpy, scrollBehavior = scrollBehavior, + scrcpy = scrcpy, ) } } @@ -146,10 +142,8 @@ fun DeviceTabScreen( @Composable fun DeviceTabPage( contentPadding: PaddingValues, - nativeCore: NativeCoreFacade, - adbService: NativeAdbService, - scrcpy: Scrcpy, scrollBehavior: ScrollBehavior, + scrcpy: Scrcpy, ) { val context = LocalContext.current val scope = rememberCoroutineScope() @@ -158,7 +152,6 @@ fun DeviceTabPage( val haptics = LocalAppHaptics.current val navigator = LocalRootNavigator.current - val fullscreenNavigationState = LocalFullscreenNavigationState.current val snackbar = LocalSnackbarController.current val asBundleShared by appSettings.bundleState.collectAsState() @@ -281,11 +274,11 @@ fun DeviceTabPage( * Disconnect the current ADB connection and stop any running scrcpy session. * * Concurrency / thread boundary: - * - Native calls that may block are executed on ADB dispatcher using adbService.withAdbDispatcher. + * - Native calls that may block are executed on ADB dispatcher. * - This ensures UI coroutines are never blocked by synchronous native I/O. * * Side effects: - * - Calls `scrcpy.stop()` and `adbService.disconnect()` (best-effort). + * - Calls `scrcpy.stop()` and `NativeAdbService.disconnect()` (best-effort). * - Resets UI-visible connection state: `adbConnected`, `currentTargetHost/Port`, * `sessionInfo`, device capability flags, `statusLine`, and `connectedDeviceLabel`. * - Updates the saved quick-device list via [savedShortcuts.update] when a target is provided. @@ -307,7 +300,7 @@ fun DeviceTabPage( // Also stops scrcpy. runCatching { scrcpy.stop() } setKeepScreenOn(false) - runCatching { adbService.disconnect() } + runCatching { NativeAdbService.disconnect() } adbConnected = false currentTargetHost = "" currentTargetPort = Defaults.ADB_PORT @@ -376,7 +369,7 @@ fun DeviceTabPage( */ suspend fun connectWithTimeout(host: String, port: Int) { return withTimeout(ADB_CONNECT_TIMEOUT_MS) { - adbService.connect(host, port) + NativeAdbService.connect(host, port) } } @@ -395,7 +388,7 @@ fun DeviceTabPage( */ suspend fun keepAliveCheck(host: String, port: Int): Boolean { return withTimeout(ADB_KEEPALIVE_TIMEOUT_MS) { - val connected = adbService.isConnected() + val connected = NativeAdbService.isConnected() if (!connected) { return@withTimeout false } @@ -564,7 +557,7 @@ fun DeviceTabPage( currentTargetHost = host currentTargetPort = port - val info = fetchConnectedDeviceInfo(adbService, host, port) + val info = fetchConnectedDeviceInfo(NativeAdbService, host, port) val fullLabel = if (info.serial.isNotBlank()) { "${info.model} (${info.serial})" } else { @@ -646,7 +639,7 @@ fun DeviceTabPage( } val discovered = withContext(Dispatchers.IO) { - adbService.discoverConnectService( + NativeAdbService.discoverConnectService( timeoutMs = ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS, includeLanDevices = asBundle.adbMdnsLanDiscovery, ) @@ -705,8 +698,8 @@ fun DeviceTabPage( fun sendVirtualButtonAction(action: VirtualButtonAction) { val keycode = action.keycode ?: return runBusy("发送 ${action.title}") { - nativeCore.session?.injectKeycode(0, keycode) - nativeCore.session?.injectKeycode(1, keycode) + scrcpy.injectKeycode(0, keycode) + scrcpy.injectKeycode(1, keycode) } } @@ -870,7 +863,7 @@ fun DeviceTabPage( busy = busy, autoDiscoverOnDialogOpen = asBundle.adbPairingAutoDiscoverOnDialogOpen, onDiscoverTarget = { - adbService.discoverPairingService( + NativeAdbService.discoverPairingService( includeLanDevices = asBundle.adbMdnsLanDiscovery, ) }, @@ -879,7 +872,7 @@ fun DeviceTabPage( val resolvedHost = host.trim() val resolvedPort = port.trim().toIntOrNull() ?: return@runBusy val resolvedCode = code.trim() - val ok = adbService.pair( + val ok = NativeAdbService.pair( resolvedHost, resolvedPort, resolvedCode, @@ -948,24 +941,13 @@ fun DeviceTabPage( item { PreviewCard( sessionInfo = sessionInfo, - nativeCore = nativeCore, previewHeightDp = asBundle.devicePreviewCardHeightDp.coerceAtLeast(120), controlsVisible = previewControlsVisible, onTapped = { previewControlsVisible = !previewControlsVisible }, onOpenFullscreen = { - val currentSession = sessionInfo - if (currentSession != null) { - fullscreenNavigationState.setOrientation( - if (currentSession.width >= currentSession.height) { - ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - } else { - ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } - ) - navigator.push(RootScreen.Fullscreen) - } + context.startActivity(StreamActivity.createIntent(context)) }, ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt index 6b5d267..5df23f0 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt @@ -1,11 +1,20 @@ package io.github.miuzarte.scrcpyforandroid.pages +import android.annotation.SuppressLint import android.app.Activity +import android.graphics.Rect import android.util.Log import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -13,6 +22,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -21,16 +31,27 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.sp import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade -import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics +import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy +import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler 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.ScrcpyVideoSurface +import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTarget import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar import kotlinx.coroutines.CoroutineScope @@ -40,20 +61,20 @@ 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.Text @Composable fun FullscreenControlScreen( scrcpy: Scrcpy, - nativeCore: NativeCoreFacade, + onBack: () -> Unit, + isInPip: Boolean, onVideoSizeChanged: (width: Int, height: Int) -> Unit, + onVideoBoundsInWindowChanged: (Rect?) -> Unit, ) { - val navigator = LocalRootNavigator.current - BackHandler(enabled = true, onBack = navigator.pop) + BackHandler(enabled = true, onBack = onBack) val context = LocalContext.current - val haptics = LocalAppHaptics.current - val scope = rememberCoroutineScope() val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } val activity = remember(context) { context as? Activity } @@ -128,21 +149,21 @@ fun FullscreenControlScreen( onVideoSizeChanged(session.width, session.height) } - DisposableEffect(nativeCore) { + DisposableEffect(Unit) { val listener: (Float) -> Unit = { fps -> currentFps = fps } - nativeCore.addVideoFpsListener(listener) + NativeCoreFacade.addVideoFpsListener(listener) onDispose { - nativeCore.removeVideoFpsListener(listener) + NativeCoreFacade.removeVideoFpsListener(listener) } } suspend fun sendKeycode(keycode: Int) { runCatching { withContext(Dispatchers.IO) { - nativeCore.session?.injectKeycode(0, keycode) - nativeCore.session?.injectKeycode(1, keycode) + scrcpy.injectKeycode(0, keycode) + scrcpy.injectKeycode(1, keycode) } }.onFailure { e -> Log.w( @@ -160,16 +181,17 @@ fun FullscreenControlScreen( .padding(contentPadding), ) { val session = currentSession ?: return@Box - FullscreenControlScreen( + FullscreenControlPage( session = session, - nativeCore = nativeCore, - onDismiss = navigator.pop, - showDebugInfo = fullscreenDebugInfo, + onDismiss = onBack, + showDebugInfo = fullscreenDebugInfo && !isInPip, currentFps = currentFps, enableBackHandler = false, + interactive = !isInPip, + onVideoBoundsInWindowChanged = onVideoBoundsInWindowChanged, onInjectTouch = { action, pointerId, x, y, pressure, buttons -> withContext(Dispatchers.IO) { - nativeCore.session?.injectTouch( + scrcpy.injectTouch( action = action, pointerId = pointerId, x = x, @@ -184,28 +206,174 @@ fun FullscreenControlScreen( }, ) - if (showFullscreenVirtualButtons) { + if (showFullscreenVirtualButtons && !isInPip) { bar.Fullscreen( modifier = Modifier.align(Alignment.BottomCenter), onAction = { action -> - action.keycode?.let { - sendKeycode(it) - } + action.keycode?.let { sendKeycode(it) } }, ) } - if (showFullscreenFloatingButton) { + if (showFullscreenFloatingButton && !isInPip) { bar.FloatingBall( actions = floatingActions, modifier = Modifier.fillMaxSize(), onAction = { action -> - action.keycode?.let { - sendKeycode(it) - } + action.keycode?.let { sendKeycode(it) } }, ) } } } } + +/** + * FullscreenControlScreen + * + * Purpose: + * - Presents a fullscreen interactive touch surface that maps Compose touch events + * to device coordinates and injects them via [onInjectTouch]. + * - Responsible for pointer tracking, multi-touch mapping, coordinate normalization, + * and lifetime of synthetic touch events sent to the device. + * + * Concurrency and side-effects: + * - All heavy computations are local to the UI thread; injection itself is a quick + * callback (`onInjectTouch`) which delegates to native code elsewhere — keep that + * callback lightweight. + * - Use `pointerInteropFilter` to receive raw MotionEvent instances for precise + * multi-touch handling and to map Android pointer IDs to device pointers. + */ +@Composable +fun FullscreenControlPage( + session: Scrcpy.Session.SessionInfo, + onDismiss: () -> Unit, + showDebugInfo: Boolean, + currentFps: Float, + enableBackHandler: Boolean = true, + interactive: Boolean = true, + onVideoBoundsInWindowChanged: (Rect?) -> Unit = {}, + onInjectTouch: suspend (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit, +) { + BackHandler(enabled = enableBackHandler, onBack = onDismiss) + + val coroutineScope = rememberCoroutineScope() + + var touchAreaSize by remember { mutableStateOf(IntSize.Zero) } + + val activePointerIds = remember { linkedSetOf() } + val activePointerPositions = remember { linkedMapOf() } + val activePointerDevicePositions = remember { linkedMapOf>() } + val pointerLabels = remember { linkedMapOf() } + + var nextPointerLabel by remember { mutableIntStateOf(1) } + var activeTouchCount by remember { mutableIntStateOf(0) } + var activeTouchDebug by remember { mutableStateOf("") } + + val touchEventHandler = remember(session, touchAreaSize) { + TouchEventHandler( + coroutineScope = coroutineScope, + session = session, + touchAreaSize = touchAreaSize, + activePointerIds = activePointerIds, + activePointerPositions = activePointerPositions, + activePointerDevicePositions = activePointerDevicePositions, + pointerLabels = pointerLabels, + nextPointerLabel = nextPointerLabel, + onInjectTouch = onInjectTouch, + onActiveTouchCountChanged = { activeTouchCount = it }, + onActiveTouchDebugChanged = { activeTouchDebug = it }, + onNextPointerLabelChanged = { nextPointerLabel = it }, + ) + } + + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .then( + if (interactive) + Modifier.pointerInteropFilter { event -> + touchEventHandler.handleMotionEvent(event) + } + else Modifier + ) + .onSizeChanged { touchAreaSize = it }, + ) { + val sessionAspect = + if (session.height == 0) 16f / 9f + else session.width.toFloat() / session.height.toFloat() + + Box( + modifier = Modifier + .align(Alignment.Center) + .then( + if (sessionAspect > (maxWidth.value / maxHeight.value)) + Modifier + .fillMaxWidth() + .aspectRatio(sessionAspect) + else + Modifier + .fillMaxHeight() + .aspectRatio(sessionAspect) + ), + ) { + ScrcpyVideoSurface( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInWindow() + onVideoBoundsInWindowChanged( + Rect( + bounds.left.toInt(), + bounds.top.toInt(), + bounds.right.toInt(), + bounds.bottom.toInt(), + ) + ) + }, + session = session, + target = if (interactive) VideoOutputTarget.FULLSCREEN else VideoOutputTarget.PICTURE_IN_PICTURE, + ) + } + + if (showDebugInfo) Box( + modifier = Modifier + .align(Alignment.TopStart) + .padding(start = UiSpacing.ContentVertical, top = UiSpacing.ContentVertical) + .background(Color.Black.copy(alpha = 0.5f)) + .padding(horizontal = UiSpacing.ContentVertical, vertical = UiSpacing.Medium), + ) { + Column(verticalArrangement = Arrangement.spacedBy(UiSpacing.Tiny)) { + Text( + text = "分辨率: ${session.width}x${session.height}", + color = Color.White, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + ) + @SuppressLint("DefaultLocale") + Text( + text = "FPS: ${String.format("%.1f", currentFps.coerceAtLeast(0f))}", + color = Color.White, + fontSize = 13.sp, + ) + Text( + text = "触点: $activeTouchCount", + color = Color.White, + fontSize = 13.sp, + ) + if (activeTouchDebug.isNotEmpty()) Text( + text = activeTouchDebug, + color = Color.White, + fontSize = 13.sp, + ) + } + } + } + + DisposableEffect(Unit) { + onDispose { + onVideoBoundsInWindowChanged(null) + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt index 7cdc7df..15586c5 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt @@ -2,7 +2,6 @@ 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.widget.Toast @@ -24,7 +23,6 @@ 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 @@ -49,6 +47,7 @@ import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy +import io.github.miuzarte.scrcpyforandroid.services.AppRuntime import io.github.miuzarte.scrcpyforandroid.services.AppUpdateChecker import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController import io.github.miuzarte.scrcpyforandroid.services.SnackbarController @@ -86,7 +85,6 @@ sealed interface RootScreen : NavKey { data object Advanced : RootScreen data object About : RootScreen data object VirtualButtonOrder : RootScreen - data object Fullscreen : RootScreen } @Composable @@ -95,22 +93,11 @@ fun MainScreen() { val context = LocalContext.current val appContext = context.applicationContext val activity = remember(context) { context as? Activity } - val initialOrientation = remember(activity) { - activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED - } // Scopes val scope = rememberCoroutineScope() val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } - // Core services - val nativeCore = remember(appContext) { - NativeCoreFacade.get(appContext) - } - val adbService = remember(appContext) { - NativeAdbService(appContext) - } - // Global controllers provided to the compose tree val snackHostState = remember { SnackbarHostState() } val snackbarController = remember(scope, snackHostState) { @@ -131,17 +118,8 @@ fun MainScreen() { val rootBackStack = remember { mutableStateListOf(RootScreen.Home) } val currentRootScreen = rootBackStack.lastOrNull() as? RootScreen ?: RootScreen.Home var showReorderDevices by rememberSaveable { mutableStateOf(false) } - var fullscreenOrientation by rememberSaveable { - mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) - } var lastExitBackPressAtMs by rememberSaveable { mutableLongStateOf(0L) } - DisposableEffect(activity) { - onDispose { - activity?.requestedOrientation = initialOrientation - } - } - // Scroll behaviors val devicesPageScrollBehavior = MiuixScrollBehavior( canScroll = { currentTab == MainBottomTabDestination.Device }) @@ -166,11 +144,6 @@ fun MainScreen() { }, ) } - val fullscreenNavigationState = remember { - FullscreenNavigationState( - setOrientation = { fullscreenOrientation = it } - ) - } // Shared settings bundles val asBundleShared by appSettings.bundleState.collectAsState() @@ -228,19 +201,20 @@ fun MainScreen() { .ifBlank { AppSettings.SERVER_REMOTE_PATH.defaultValue } val scrcpy = remember( appContext, - adbService, customServerUri, customServerVersion, serverRemotePath, ) { Scrcpy( appContext = appContext, - adbService = adbService, customServerUri = customServerUri, serverVersion = customServerVersion, serverRemotePath = serverRemotePath, - ) + ).also { + AppRuntime.scrcpy = it + } } + val currentSession by scrcpy.currentSessionState.collectAsState() // Side-effect launchers and composition locals @@ -305,20 +279,18 @@ fun MainScreen() { scope.launch { withContext(Dispatchers.IO) { runCatching { scrcpy.stop() } - runCatching { adbService.disconnect() } + runCatching { NativeAdbService.disconnect() } } activity?.finish() } } } - BackHandler(enabled = currentRootScreen !is RootScreen.Fullscreen) { + BackHandler { handleBackNavigation() } - PredictiveBackHandler( - enabled = canNavigateBack && currentRootScreen !is RootScreen.Fullscreen - ) { progress -> + PredictiveBackHandler(enabled = canNavigateBack) { progress -> try { progress.collect { } handleBackNavigation() @@ -327,39 +299,19 @@ fun MainScreen() { } } - // 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 - } - - DisposableEffect(nativeCore, scrcpy) { + DisposableEffect(scrcpy) { val listener: (Int, Int) -> Unit = { width, height -> scrcpy.updateCurrentSessionSize(width, height) } - nativeCore.addVideoSizeListener(listener) + NativeCoreFacade.addVideoSizeListener(listener) onDispose { - nativeCore.removeVideoSizeListener(listener) - } - } - - LaunchedEffect(currentRootScreen, currentSession?.width, currentSession?.height) { - if (currentRootScreen is RootScreen.Fullscreen) { - val session = currentSession ?: return@LaunchedEffect - fullscreenOrientation = - if (session.width >= session.height) { - ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - } else { - ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } + NativeCoreFacade.removeVideoSizeListener(listener) } } LaunchedEffect(asBundle.adbKeyName) { - adbService.keyName = asBundle.adbKeyName.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue } + NativeAdbService.keyName = + asBundle.adbKeyName.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue } } val rootEntryProvider = entryProvider { @@ -400,10 +352,8 @@ fun MainScreen() { saveableStateHolder.SaveableStateProvider(tab.name) { when (tab) { MainBottomTabDestination.Device -> DeviceTabScreen( - nativeCore = nativeCore, - adbService = adbService, - scrcpy = scrcpy, scrollBehavior = devicesPageScrollBehavior, + scrcpy = scrcpy, onOpenReorderDevices = { showReorderDevices = true }, ) @@ -439,17 +389,6 @@ fun MainScreen() { ) } - entry(RootScreen.Fullscreen) { - FullscreenControlScreen( - scrcpy = scrcpy, - nativeCore = nativeCore, - onVideoSizeChanged = { width, height -> - fullscreenOrientation = - if (width >= height) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - else ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - }, - ) - } } val rootEntries = rememberDecoratedNavEntries( @@ -469,7 +408,6 @@ fun MainScreen() { LocalRootNavigator provides rootNavigator, LocalSnackbarController provides snackbarController, LocalAppHaptics provides haptics, - LocalFullscreenNavigationState provides fullscreenNavigationState, LocalServerPicker provides serverPicker, ) { NavDisplay( diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/RootNavigator.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/RootNavigator.kt index c074763..6be4f7f 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/RootNavigator.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/RootNavigator.kt @@ -10,11 +10,3 @@ class RootNavigator( val LocalRootNavigator = staticCompositionLocalOf { error("No RootNavigator provided") } - -class FullscreenNavigationState( - val setOrientation: (Int) -> Unit, -) - -val LocalFullscreenNavigationState = staticCompositionLocalOf { - error("No FullscreenNavigationState provided") -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt index 751b552..59a8a28 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt @@ -471,6 +471,7 @@ fun SettingsPage( item { SectionSmallTitle("") Card { + // TODO: 进入时无视自动更新检查的 CD, 主动触发一次 ArrowPreference( title = "关于", summary = updateSummary, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/StreamScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/StreamScreen.kt new file mode 100644 index 0000000..1404266 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/StreamScreen.kt @@ -0,0 +1,127 @@ +package io.github.miuzarte.scrcpyforandroid.pages + +import android.content.pm.ActivityInfo +import android.graphics.Rect +import android.util.Rational +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.setValue +import androidx.core.app.PictureInPictureParamsCompat +import io.github.miuzarte.scrcpyforandroid.StreamActivity +import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes +import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics +import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics +import io.github.miuzarte.scrcpyforandroid.services.AppRuntime +import io.github.miuzarte.scrcpyforandroid.storage.Storage +import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTarget +import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTargetState +import top.yukonga.miuix.kmp.theme.ColorSchemeMode +import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.theme.ThemeController + +@Composable +fun StreamScreen(activity: StreamActivity) { + val scrcpy = remember { AppRuntime.scrcpy!! } + val asBundle by Storage.appSettings.bundleState.collectAsState() + + val isInPip by activity.pipModeState.collectAsState() + + val currentSession by scrcpy.currentSessionState.collectAsState() + + var pipSourceRectHint by remember { mutableStateOf(null) } + var lastPipAspectRatio by remember { mutableStateOf(null) } + var lastPipOrientationLandscape by remember { mutableStateOf(null) } + + LaunchedEffect(currentSession) { + if (currentSession == null) { + activity.finish() + } + } + + DisposableEffect(isInPip) { + VideoOutputTargetState.set( + if (isInPip) + VideoOutputTarget.PICTURE_IN_PICTURE + else + VideoOutputTarget.FULLSCREEN + ) + onDispose { + VideoOutputTargetState.set(VideoOutputTarget.PREVIEW) + } + } + + LaunchedEffect( + activity, isInPip, + currentSession?.width, currentSession?.height, + ) { + val session = currentSession ?: return@LaunchedEffect + + val isLandscape = session.width >= session.height + if (lastPipAspectRatio != null && lastPipOrientationLandscape == isLandscape) { + // 一定要只在视频比例变更时才更新, + // .setAspectRatio() 多次传递相同的值时, + // 内部会自行应用其倒数 + return@LaunchedEffect + } + lastPipOrientationLandscape = isLandscape + + val pipAspectRatio = Rational( + session.width.coerceAtLeast(1), + session.height.coerceAtLeast(1), + ).also { ratio -> + lastPipAspectRatio = ratio + } + activity.configurePictureInPicture( + enabled = true, + params = PictureInPictureParamsCompat.Builder() + .setEnabled(true) + .setAspectRatio(pipAspectRatio) + .setSourceRectHint( + if (!isInPip) pipSourceRectHint else null + ) + .setSeamlessResizeEnabled(true) + .setCloseAction(activity.pipStopAction) + .build(), + ) + } + + val themeMode = + when (asBundle.themeBaseIndex.coerceIn(0, ThemeModes.baseOptions.lastIndex)) { + 1 -> if (!asBundle.monet) ColorSchemeMode.Light else ColorSchemeMode.MonetLight + 2 -> if (!asBundle.monet) ColorSchemeMode.Dark else ColorSchemeMode.MonetDark + else -> if (!asBundle.monet) ColorSchemeMode.System else ColorSchemeMode.MonetSystem + } + val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) } + + val haptics = rememberAppHaptics() + + MiuixTheme(controller = themeController) { + CompositionLocalProvider( + LocalAppHaptics provides haptics, + ) { + FullscreenControlScreen( + scrcpy = scrcpy, + onBack = activity::finish, + isInPip = isInPip, + onVideoSizeChanged = { width, height -> + // 只在全屏时跟随视频方向 + if (!isInPip) { + activity.requestedOrientation = + if (width >= height) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + else ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + }, + onVideoBoundsInWindowChanged = { + // 记录下一次进入 PiP 时可用的 sourceRectHint + pipSourceRectHint = it + }, + ) + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt index 22c50b8..814e64d 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt @@ -52,14 +52,14 @@ import kotlin.random.nextUInt */ class Scrcpy( private val appContext: Context, - private val adbService: NativeAdbService, + private val serverAsset: String = DEFAULT_SERVER_ASSET, private val customServerUri: String? = null, private val serverVersion: String = DEFAULT_SERVER_VERSION, private val serverRemotePath: String = DEFAULT_REMOTE_PATH, ) { - private val session = Session(adbService) - private val nativeCore: NativeCoreFacade = NativeCoreFacade.get(appContext) + + private val session = Session() private val _currentSessionState = MutableStateFlow(null) val currentSessionState: StateFlow = _currentSessionState.asStateFlow() @@ -144,7 +144,7 @@ class Scrcpy( // Setup video consumer (notify NativeCoreFacade to setup decoders) if (options.video) { - nativeCore.onScrcpySessionStarted(info, session) + NativeCoreFacade.onScrcpySessionStarted(info, session) } // Setup audio player @@ -192,7 +192,7 @@ class Scrcpy( Log.i(TAG, "stop(): Stopping scrcpy session") return@withContext try { - nativeCore.onScrcpySessionStopped() + NativeCoreFacade.onScrcpySessionStopped() session.clearVideoConsumer() session.clearAudioConsumer() session.stop() @@ -208,15 +208,64 @@ class Scrcpy( } } - suspend fun close() { - stop() - adbService.close() - } - fun isStarted(): Boolean = isRunning && session.isStarted() fun getCurrentSession(): Session.SessionInfo? = currentSessionState.value + suspend fun injectKeycode( + action: Int, + keycode: Int, + repeat: Int = 0, + metaState: Int = 0, + ) = session.injectKeycode( + action = action, + keycode = keycode, + repeat = repeat, + metaState = metaState, + ) + + suspend fun injectText(text: String) = session.injectText(text) + + suspend fun injectTouch( + action: Int, + pointerId: Long, + x: Int, + y: Int, + screenWidth: Int, + screenHeight: Int, + pressure: Float, + actionButton: Int = 0, + buttons: Int = 0, + ) = session.injectTouch( + action = action, + pointerId = pointerId, + x = x, + y = y, + screenWidth = screenWidth, + screenHeight = screenHeight, + pressure = pressure, + actionButton = actionButton, + buttons = buttons, + ) + + suspend fun injectScroll( + x: Int, + y: Int, + screenWidth: Int, + screenHeight: Int, + hScroll: Float, + vScroll: Float, + buttons: Int, + ) = session.injectScroll( + x = x, + y = y, + screenWidth = screenWidth, + screenHeight = screenHeight, + hScroll = hScroll, + vScroll = vScroll, + buttons = buttons, + ) + fun updateCurrentSessionSize(width: Int, height: Int) { val current = _currentSessionState.value ?: return if (current.width == width && current.height == height) return @@ -399,7 +448,7 @@ class Scrcpy( extractUriToCache(customServerUri.toUri()) } - adbService.push(serverJar.toPath(), serverRemotePath) + NativeAdbService.push(serverJar.toPath(), serverRemotePath) val scid = generateScid() val options = ClientOptions( @@ -419,7 +468,7 @@ class Scrcpy( ) Log.i(TAG, "listOptions(): cmd=$serverCommand") - adbService.shell("$serverCommand 2>&1") + NativeAdbService.shell("$serverCommand 2>&1") } private fun logListPreview(list: ListOptions, countSummary: String, output: String) { @@ -553,7 +602,7 @@ class Scrcpy( options: ClientOptions, scid: UInt, ): Session.SessionInfo { - adbService.push(serverJar.toPath(), serverRemotePath) + NativeAdbService.push(serverJar.toPath(), serverRemotePath) val serverParams = options.toServerParams(scid) @@ -605,7 +654,7 @@ class Scrcpy( * Session manager for scrcpy protocol. * Handles socket communication, video/audio streaming, and control input. */ - class Session(private val adbService: NativeAdbService) { + class Session { private val mutex = Mutex() @Volatile @@ -635,7 +684,7 @@ class Scrcpy( val socketName = socketNameFor(scid.toInt()) try { - val serverStream = adbService.openShellStream(serverCommand) + val serverStream = NativeAdbService.openShellStream(serverCommand) val serverLogThread = startServerLogThread(serverStream, socketName) Thread.sleep(SERVER_BOOT_DELAY_MS) @@ -1045,7 +1094,7 @@ class Scrcpy( var lastEx: Exception? = null repeat(CONNECT_RETRY_COUNT) { attempt -> try { - val stream = adbService.openAbstractSocket(socketName) + val stream = NativeAdbService.openAbstractSocket(socketName) if (expectDummyByte) { val value = stream.inputStream.read() if (value < 0) { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/TouchEventHandler.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/TouchEventHandler.kt index fc3cfad..21d0c59 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/TouchEventHandler.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/TouchEventHandler.kt @@ -8,6 +8,13 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlin.math.roundToInt +/** + * TouchEventHandler + * + * Purpose: + * - Handles touch event processing for fullscreen control screen + * - Manages pointer tracking, coordinate mapping, and touch injection + */ class TouchEventHandler( private val coroutineScope: CoroutineScope, private val session: Scrcpy.Session.SessionInfo, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppRuntime.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppRuntime.kt new file mode 100644 index 0000000..8c89459 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppRuntime.kt @@ -0,0 +1,20 @@ +package io.github.miuzarte.scrcpyforandroid.services + +import android.content.Context +import android.os.Build +import androidx.annotation.RequiresApi +import io.github.miuzarte.scrcpyforandroid.nativecore.AdbMdnsDiscoverer +import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy + +// 用于不同 activity 之间传递实例 +object AppRuntime { + private lateinit var appContext: Context + + @RequiresApi(Build.VERSION_CODES.R) + fun init(context: Context) { + appContext = context.applicationContext + AdbMdnsDiscoverer.init(appContext) + } + + var scrcpy: Scrcpy? = null +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/PictureInPictureActionReceiver.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/PictureInPictureActionReceiver.kt new file mode 100644 index 0000000..392b33f --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/PictureInPictureActionReceiver.kt @@ -0,0 +1,35 @@ +package io.github.miuzarte.scrcpyforandroid.services + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.annotation.RequiresApi +import io.github.miuzarte.scrcpyforandroid.storage.Storage +import kotlinx.coroutines.runBlocking + +// MIUI 不进 +class PictureInPictureActionReceiver : BroadcastReceiver() { + @RequiresApi(Build.VERSION_CODES.R) + override fun onReceive(context: Context, intent: Intent?) { + if (intent?.action != ACTION_STOP_SCRCPY) return + val pendingResult = goAsync() + Thread { + try { + val appContext = context.applicationContext + Storage.init(appContext) + AppRuntime.init(appContext) + runBlocking { + AppRuntime.scrcpy?.stop() + } + } finally { + pendingResult.finish() + } + }.start() + } + + companion object { + const val ACTION_STOP_SCRCPY = + "io.github.miuzarte.scrcpyforandroid.action.STOP_SCRCPY_FROM_PIP" + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt index c511454..815d276 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt @@ -4,7 +4,6 @@ import android.annotation.SuppressLint import android.graphics.SurfaceTexture import android.view.Surface import android.view.TextureView -import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background @@ -40,7 +39,6 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -51,20 +49,19 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.input.pointer.pointerInteropFilter -import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade import io.github.miuzarte.scrcpyforandroid.constants.Defaults import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets @@ -75,7 +72,6 @@ import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec -import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Storage @@ -250,7 +246,6 @@ internal fun PairingCard( @Composable internal fun PreviewCard( sessionInfo: Scrcpy.Session.SessionInfo?, - nativeCore: NativeCoreFacade, previewHeightDp: Int, controlsVisible: Boolean, onTapped: () -> Unit, @@ -258,6 +253,27 @@ internal fun PreviewCard( ) { val haptics = rememberAppHaptics() val alpha by animateFloatAsState(if (controlsVisible) 1f else 0f, label = "preview-controls") + val lifecycleOwner = LocalLifecycleOwner.current + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> VideoOutputTargetState.set(VideoOutputTarget.PREVIEW) + Lifecycle.Event.ON_STOP -> + if (VideoOutputTargetState.current.value == VideoOutputTarget.PREVIEW) + VideoOutputTargetState.set(VideoOutputTarget.NONE) + + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + if (VideoOutputTargetState.current.value == VideoOutputTarget.PREVIEW) { + VideoOutputTargetState.set(VideoOutputTarget.NONE) + } + } + } Card { Box( @@ -290,8 +306,8 @@ internal fun PreviewCard( ) { ScrcpyVideoSurface( modifier = Modifier.fillMaxSize(), - nativeCore = nativeCore, session = sessionInfo, + target = VideoOutputTarget.PREVIEW, ) } } @@ -304,7 +320,7 @@ internal fun PreviewCard( ) { Button( onClick = { - if (alpha > 0.1) { + if (alpha > 0.1f) { haptics.contextClick() onOpenFullscreen() } @@ -465,6 +481,7 @@ internal fun ConfigPanel( ?.takeIf { it >= 0 } ?.let { soBundle = soBundle.copy(audioBitRate = it * 1000) } }, + enabled = !sessionStarted, ) } @@ -721,142 +738,6 @@ private fun PairingDialog( ) } -/** - * TouchEventHandler - * - * Purpose: - * - Handles touch event processing for fullscreen control screen - * - Manages pointer tracking, coordinate mapping, and touch injection - */ - -/** - * FullscreenControlScreen - * - * Purpose: - * - Presents a fullscreen interactive touch surface that maps Compose touch events - * to device coordinates and injects them via [onInjectTouch]. - * - Responsible for pointer tracking, multi-touch mapping, coordinate normalization, - * and lifetime of synthetic touch events sent to the device. - * - * Concurrency and side-effects: - * - All heavy computations are local to the UI thread; injection itself is a quick - * callback (`onInjectTouch`) which delegates to native code elsewhere — keep that - * callback lightweight. - * - Use `pointerInteropFilter` to receive raw MotionEvent instances for precise - * multi-touch handling and to map Android pointer IDs to device pointers. - */ -@Composable -fun FullscreenControlScreen( - session: Scrcpy.Session.SessionInfo, - nativeCore: NativeCoreFacade, - onDismiss: () -> Unit, - showDebugInfo: Boolean, - currentFps: Float, - enableBackHandler: Boolean = true, - onInjectTouch: suspend (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit, -) { - BackHandler(enabled = enableBackHandler, onBack = onDismiss) - val coroutineScope = rememberCoroutineScope() - var touchAreaSize by remember { mutableStateOf(IntSize.Zero) } - val activePointerIds = remember { linkedSetOf() } - val activePointerPositions = remember { linkedMapOf() } - val activePointerDevicePositions = remember { linkedMapOf>() } - val pointerLabels = remember { linkedMapOf() } - var nextPointerLabel by remember { mutableIntStateOf(1) } - var activeTouchCount by remember { mutableIntStateOf(0) } - var activeTouchDebug by remember { mutableStateOf("") } - - val touchEventHandler = remember(session, touchAreaSize) { - TouchEventHandler( - coroutineScope = coroutineScope, - session = session, - touchAreaSize = touchAreaSize, - activePointerIds = activePointerIds, - activePointerPositions = activePointerPositions, - activePointerDevicePositions = activePointerDevicePositions, - pointerLabels = pointerLabels, - nextPointerLabel = nextPointerLabel, - onInjectTouch = onInjectTouch, - onActiveTouchCountChanged = { activeTouchCount = it }, - onActiveTouchDebugChanged = { activeTouchDebug = it }, - onNextPointerLabelChanged = { nextPointerLabel = it }, - ) - } - - BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .background(Color.Black) - .pointerInteropFilter { event -> - touchEventHandler.handleMotionEvent(event) - } - .onSizeChanged { touchAreaSize = it }, - ) { - val sessionAspect = if (session.height == 0) { - 16f / 9f - } else { - session.width.toFloat() / session.height.toFloat() - } - val containerAspect = maxWidth.value / maxHeight.value - val fittedModifier = if (sessionAspect > containerAspect) { - Modifier - .fillMaxWidth() - .aspectRatio(sessionAspect) - } else { - Modifier - .fillMaxHeight() - .aspectRatio(sessionAspect) - } - - Box( - modifier = Modifier - .align(Alignment.Center) - .then(fittedModifier), - ) { - ScrcpyVideoSurface( - modifier = Modifier.fillMaxSize(), - nativeCore = nativeCore, - session = session, - ) - } - - if (showDebugInfo) { - Box( - modifier = Modifier - .align(Alignment.TopStart) - .padding(start = UiSpacing.ContentVertical, top = UiSpacing.ContentVertical) - .background(Color.Black.copy(alpha = 0.5f)) - .padding(horizontal = UiSpacing.ContentVertical, vertical = UiSpacing.Medium), - ) { - Column(verticalArrangement = Arrangement.spacedBy(UiSpacing.Tiny)) { - Text( - text = "分辨率: ${session.width}x${session.height}", - color = Color.White, - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - ) - @SuppressLint("DefaultLocale") - Text( - text = "FPS: ${String.format("%.1f", currentFps.coerceAtLeast(0f))}", - color = Color.White, - fontSize = 13.sp, - ) - Text( - text = "触点: $activeTouchCount", - color = Color.White, - fontSize = 13.sp, - ) - if (activeTouchDebug.isNotEmpty()) Text( - text = activeTouchDebug, - color = Color.White, - fontSize = 13.sp, - ) - } - } - } - } -} - /** * ScrcpyVideoSurface * @@ -878,19 +759,38 @@ fun FullscreenControlScreen( * referencing stale surfaces. */ @Composable -private fun ScrcpyVideoSurface( +fun ScrcpyVideoSurface( modifier: Modifier, - nativeCore: NativeCoreFacade, session: Scrcpy.Session.SessionInfo?, + target: VideoOutputTarget, ) { var currentSurface by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } + val lifecycleOwner = LocalLifecycleOwner.current + val currentTarget by VideoOutputTargetState.current.collectAsState() - LaunchedEffect(session, currentSurface) { + LaunchedEffect(session, currentSurface, currentTarget, target) { val surface = currentSurface - if (session != null && surface != null && surface.isValid) { - nativeCore.attachVideoSurface(surface) + if (currentTarget == target && session != null && surface != null && surface.isValid) { + NativeCoreFacade.attachVideoSurface(surface) + } + } + + DisposableEffect(lifecycleOwner, session, currentSurface, currentTarget, target) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_START) { + val surface = currentSurface + if (currentTarget == target && session != null && surface != null && surface.isValid) { + scope.launch { + NativeCoreFacade.attachVideoSurface(surface) + } + } + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) } } @@ -898,7 +798,12 @@ private fun ScrcpyVideoSurface( onDispose { val surface = currentSurface if (surface != null) { - taskScope.launch { nativeCore.detachVideoSurface(surface, releaseDecoder = false) } + taskScope.launch { + NativeCoreFacade.detachVideoSurface( + surface, + releaseDecoder = false + ) + } } } } @@ -917,9 +822,9 @@ private fun ScrcpyVideoSurface( val newSurface = Surface(surfaceTexture) currentSurface = newSurface // Register immediately when surface becomes available - if (session != null) { + if (currentTarget == target && session != null) { scope.launch { - nativeCore.attachVideoSurface(newSurface) + NativeCoreFacade.attachVideoSurface(newSurface) } } } @@ -934,7 +839,7 @@ private fun ScrcpyVideoSurface( val surface = currentSurface if (surface != null) { taskScope.launch { - nativeCore.detachVideoSurface(surface, releaseDecoder = false) + NativeCoreFacade.detachVideoSurface(surface, releaseDecoder = false) } surface.release() currentSurface = null diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VideoOutputTarget.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VideoOutputTarget.kt new file mode 100644 index 0000000..5b23c1b --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VideoOutputTarget.kt @@ -0,0 +1,21 @@ +package io.github.miuzarte.scrcpyforandroid.widgets + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +enum class VideoOutputTarget { + NONE, + PREVIEW, + FULLSCREEN, + PICTURE_IN_PICTURE, +} + +object VideoOutputTargetState { + private val _current = MutableStateFlow(VideoOutputTarget.NONE) + val current: StateFlow = _current.asStateFlow() + + fun set(target: VideoOutputTarget) { + _current.value = target + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8fecced..c3f3cee 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ espressoCore = "3.7.0" miuix = "0.9.0" material = "1.13.0" runtime = "1.10.5" +androidxCorePip = "1.0.0-alpha02" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -36,6 +37,7 @@ miuix-preference = { group = "top.yukonga.miuix.kmp", name = "miuix-preference", miuix-icons = { group = "top.yukonga.miuix.kmp", name = "miuix-icons", version.ref = "miuix" } miuix-navigation3-ui = { group = "top.yukonga.miuix.kmp", name = "miuix-navigation3-ui", version.ref = "miuix" } androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "runtime" } +androidx-core-pip = { group = "androidx.core", name = "core-pip", version.ref = "androidxCorePip" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }