From dbf83b33c2504705b4fed47e75e513519f95bb69 Mon Sep 17 00:00:00 2001
From: Miuzarte <982809597@qq.com>
Date: Sat, 11 Apr 2026 15:26:03 +0800
Subject: [PATCH] feat: picture in picture
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 移除了 MainScreen 中的全屏方向处理及相关状态管理
- 引入 AppRuntime 用于管理跨 Activity 的共享实例
- 创建 StreamScreen 用于画中画模式
- 实现 VideoOutputTarget 和 VideoOutputTargetState 以管理视频输出状态
---
README.md | 2 +
TODO.md | 3 -
app/build.gradle.kts | 9 +-
app/src/main/AndroidManifest.xml | 11 +
.../miuzarte/scrcpyforandroid/MainActivity.kt | 5 +
.../scrcpyforandroid/NativeCoreFacade.kt | 49 +---
.../scrcpyforandroid/StreamActivity.kt | 160 +++++++++++++
.../nativecore/AdbMdnsDiscoverer.kt | 19 +-
.../nativecore/DirectAdbClient.kt | 108 ++++-----
.../nativecore/NativeAdbService.kt | 13 +-
.../nativecore/PersistentVideoRenderer.kt | 2 +-
.../scrcpyforandroid/pages/DeviceTabScreen.kt | 52 ++---
.../pages/FullscreenControlScreen.kt | 218 ++++++++++++++++--
.../scrcpyforandroid/pages/MainScreen.kt | 90 ++------
.../scrcpyforandroid/pages/RootNavigator.kt | 8 -
.../scrcpyforandroid/pages/SettingsScreen.kt | 1 +
.../scrcpyforandroid/pages/StreamScreen.kt | 127 ++++++++++
.../scrcpyforandroid/scrcpy/Scrcpy.kt | 81 +++++--
.../scrcpy/TouchEventHandler.kt | 7 +
.../scrcpyforandroid/services/AppRuntime.kt | 20 ++
.../PictureInPictureActionReceiver.kt | 35 +++
.../scrcpyforandroid/widgets/DeviceWidgets.kt | 215 +++++------------
.../widgets/VideoOutputTarget.kt | 21 ++
gradle/libs.versions.toml | 2 +
24 files changed, 823 insertions(+), 435 deletions(-)
create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/StreamActivity.kt
create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/StreamScreen.kt
create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppRuntime.kt
create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/PictureInPictureActionReceiver.kt
create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VideoOutputTarget.kt
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" }