feat: picture in picture

- 移除了 MainScreen 中的全屏方向处理及相关状态管理
- 引入 AppRuntime 用于管理跨 Activity 的共享实例
- 创建 StreamScreen 用于画中画模式
- 实现 VideoOutputTarget 和 VideoOutputTargetState 以管理视频输出状态
This commit is contained in:
Miuzarte
2026-04-11 15:26:03 +08:00
parent 98443040e6
commit dbf83b33c2
24 changed files with 823 additions and 435 deletions

View File

@@ -25,6 +25,7 @@
## 已知问题 ## 已知问题
- 关闭画中画后不会停止 scrcpy 串流,仍然需要回到应用中点击停止
- 虚拟按键的截图实现方式为发送 - 虚拟按键的截图实现方式为发送
`keycode 120`,安卓官方([keycodes.h#349](https://android.googlesource.com/platform/frameworks/native/+/master/include/android/keycodes.h#349))的定义为 `keycode 120`,安卓官方([keycodes.h#349](https://android.googlesource.com/platform/frameworks/native/+/master/include/android/keycodes.h#349))的定义为
`System Request / Print Screen key.`,不同的厂商有不同的实现,在某些类原生(`AxionOS`) 上的行为是软重启 `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) - 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) - 界面组件: [YuKongA/miuix](https://github.com/compose-miuix-ui/miuix)
- 界面设计参考: [tiann/KernelSU/manager](https://github.com/tiann/KernelSU/tree/main/manager) - 界面设计参考: [tiann/KernelSU/manager](https://github.com/tiann/KernelSU/tree/main/manager)
- 画中画实现参考: [ClassicOldSong/moonlight-android](https://github.com/ClassicOldSong/moonlight-android)
## License ## License

View File

@@ -8,8 +8,6 @@
## FEATURES ## FEATURES
- 设置项连接设备后马上启用scrcpy会话
### LOWER PRIORITY ### LOWER PRIORITY
顺序无关 顺序无关
@@ -17,5 +15,4 @@
- 多配置切换 - 多配置切换
- [音频延迟](https://developer.android.com/ndk/guides/audio/audio-latency) - [音频延迟](https://developer.android.com/ndk/guides/audio/audio-latency)
- 横屏布局 - 横屏布局
- 原生悬浮窗 [Petterpx/FloatingX](https://github.com/Petterpx/FloatingX)
- I18N - I18N

View File

@@ -20,8 +20,8 @@ android {
applicationId = "io.github.miuzarte.scrcpyforandroid" applicationId = "io.github.miuzarte.scrcpyforandroid"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 6 versionCode = 7
versionName = "0.1.0" versionName = "0.1.1"
externalNativeBuild { externalNativeBuild {
cmake { cmake {
@@ -57,8 +57,8 @@ android {
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_17
} }
buildFeatures { buildFeatures {
compose = true compose = true
@@ -80,6 +80,7 @@ dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.pip)
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)

View File

@@ -19,6 +19,10 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.ScrcpyForAndroid"> android:theme="@style/Theme.ScrcpyForAndroid">
<receiver
android:name=".services.PictureInPictureActionReceiver"
android:exported="false" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize" android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize"
@@ -30,6 +34,13 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".StreamActivity"
android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize"
android:exported="false"
android:resizeableActivity="true"
android:supportsPictureInPicture="true" />
<!-- <profileable android:shell="true" /> --> <!-- <profileable android:shell="true" /> -->
</application> </application>

View File

@@ -1,20 +1,25 @@
package io.github.miuzarte.scrcpyforandroid package io.github.miuzarte.scrcpyforandroid
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
import io.github.miuzarte.scrcpyforandroid.pages.MainScreen 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.PreferenceMigration
import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.storage.Storage
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@RequiresApi(Build.VERSION_CODES.R)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// initialize settings singleton // initialize settings singleton
Storage.init(applicationContext) Storage.init(applicationContext)
AppRuntime.init(applicationContext)
val migration = PreferenceMigration(applicationContext) val migration = PreferenceMigration(applicationContext)
runBlocking { runBlocking {

View File

@@ -1,6 +1,5 @@
package io.github.miuzarte.scrcpyforandroid package io.github.miuzarte.scrcpyforandroid
import android.content.Context
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
@@ -21,11 +20,7 @@ import java.util.concurrent.CopyOnWriteArraySet
* - Surface/Decoder management for video rendering * - Surface/Decoder management for video rendering
* - Video size and FPS monitoring * - Video size and FPS monitoring
*/ */
class NativeCoreFacade private constructor() { object NativeCoreFacade {
@Volatile
var session: Scrcpy.Session? = null
private set
private val sessionLifecycleMutex = Mutex() private val sessionLifecycleMutex = Mutex()
private val renderer = PersistentVideoRenderer() private val renderer = PersistentVideoRenderer()
private var activeSurfaceId: Int? = null private var activeSurfaceId: Int? = null
@@ -118,25 +113,10 @@ class NativeCoreFacade private constructor() {
} }
} }
fun addVideoSizeListener(listener: (Int, Int) -> Unit) { fun addVideoSizeListener(listener: (Int, Int) -> Unit) = videoSizeListeners.add(listener)
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)
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)
}
/** /**
* Called by Scrcpy.kt when a session starts. * Called by Scrcpy.kt when a session starts.
@@ -146,7 +126,6 @@ class NativeCoreFacade private constructor() {
session: Scrcpy.Session.SessionInfo, session: Scrcpy.Session.SessionInfo,
sessionMgr: Scrcpy.Session sessionMgr: Scrcpy.Session
) = sessionLifecycleMutex.withLock { ) = sessionLifecycleMutex.withLock {
this.session = sessionMgr
currentSessionInfo = session currentSessionInfo = session
releaseAllDecoders() releaseAllDecoders()
synchronized(bootstrapLock) { synchronized(bootstrapLock) {
@@ -185,7 +164,6 @@ class NativeCoreFacade private constructor() {
* Cleans up decoders and resets state. * Cleans up decoders and resets state.
*/ */
suspend fun onScrcpySessionStopped() = sessionLifecycleMutex.withLock { suspend fun onScrcpySessionStopped() = sessionLifecycleMutex.withLock {
session = null
releaseAllDecoders() releaseAllDecoders()
synchronized(bootstrapLock) { synchronized(bootstrapLock) {
bootstrapPackets.clear() bootstrapPackets.clear()
@@ -194,21 +172,8 @@ class NativeCoreFacade private constructor() {
currentSessionInfo = null currentSessionInfo = null
} }
companion object { private const val TAG = "NativeCoreFacade"
private const val TAG = "NativeCoreFacade" private const val MAX_BOOTSTRAP_PACKETS = 90
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 data class CachedPacket( private data class CachedPacket(
val data: ByteArray, val data: ByteArray,

View File

@@ -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<Boolean> = _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)
}
}
}

View File

@@ -1,6 +1,5 @@
package io.github.miuzarte.scrcpyforandroid.nativecore package io.github.miuzarte.scrcpyforandroid.nativecore
import android.content.Context
import android.net.nsd.NsdManager import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo import android.net.nsd.NsdServiceInfo
import android.os.Build 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 * Uses Android's `NsdManager` to resolve services and returns a host:port pair
* when a suitable service is found within the provided timeout. * 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. * Discover a device that advertises the ADB connect service via mDNS.
@@ -46,6 +50,7 @@ internal class AdbMdnsDiscoverer(context: Context) {
timeoutMs: Long, timeoutMs: Long,
includeLanDevices: Boolean, includeLanDevices: Boolean,
): Pair<String, Int>? { ): Pair<String, Int>? {
check(::nsdManager.isInitialized) { "AdbMdnsDiscoverer is not initialized" }
val resultPort = AtomicInteger(-1) val resultPort = AtomicInteger(-1)
val resultHost = AtomicReference<String?>(null) val resultHost = AtomicReference<String?>(null)
val discoveryFinished = AtomicBoolean(false) val discoveryFinished = AtomicBoolean(false)
@@ -131,9 +136,7 @@ internal class AdbMdnsDiscoverer(context: Context) {
true true
} }
companion object { private const val TAG = "AdbMdnsDiscoverer"
private const val TAG = "AdbMdnsDiscoverer" private const val TLS_CONNECT = "_adb-tls-connect._tcp"
private const val TLS_CONNECT = "_adb-tls-connect._tcp" private const val TLS_PAIRING = "_adb-tls-pairing._tcp"
private const val TLS_PAIRING = "_adb-tls-pairing._tcp"
}
} }

View File

@@ -1,6 +1,5 @@
package io.github.miuzarte.scrcpyforandroid.nativecore package io.github.miuzarte.scrcpyforandroid.nativecore
import android.content.Context
import android.os.Build import android.os.Build
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
@@ -43,7 +42,7 @@ import kotlin.concurrent.thread
* This type is responsible for persisting the private key and performing * This type is responsible for persisting the private key and performing
* pairing/connect discovery helpers. * pairing/connect discovery helpers.
*/ */
internal class DirectAdbTransport(private val context: Context) { internal object DirectAdbTransport {
private val keys: Pair<PrivateKey, ByteArray> by lazy { runBlocking { loadOrCreate() } } private val keys: Pair<PrivateKey, ByteArray> by lazy { runBlocking { loadOrCreate() } }
@@ -87,19 +86,17 @@ internal class DirectAdbTransport(private val context: Context) {
fun discoverPairingService( fun discoverPairingService(
timeoutMs: Long = 12_000, timeoutMs: Long = 12_000,
includeLanDevices: Boolean = true includeLanDevices: Boolean = true,
): Pair<String, Int>? { ): Pair<String, Int>? =
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) null
return AdbMdnsDiscoverer(context).discoverPairingService(timeoutMs, includeLanDevices) else AdbMdnsDiscoverer.discoverPairingService(timeoutMs, includeLanDevices)
}
fun discoverConnectService( fun discoverConnectService(
timeoutMs: Long = 12_000, timeoutMs: Long = 12_000,
includeLanDevices: Boolean = true includeLanDevices: Boolean = true,
): Pair<String, Int>? { ): Pair<String, Int>? =
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) null
return AdbMdnsDiscoverer(context).discoverConnectService(timeoutMs, includeLanDevices) else AdbMdnsDiscoverer.discoverConnectService(timeoutMs, includeLanDevices)
}
/** /**
* Load persisted RSA keypair from DataStore, or generate a new one. * 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() 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 { fun openStream(service: String): AdbSocketStream {
val localId = nextLocalId.getAndIncrement() 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 streams[localId] = stream
sendMsg(A_OPEN, localId, 0, (service + "\u0000").toByteArray(Charsets.UTF_8)) sendMsg(A_OPEN, localId, 0, (service + "\u0000").toByteArray(Charsets.UTF_8))
try { try {
@@ -349,7 +346,8 @@ internal class DirectAdbConnection(
} }
fun shell(command: String): String = 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. * 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. * - Implements SEND/DATA/DONE/OKAY sequence and throws IOException on failure.
*/ */
fun push(data: ByteArray, remotePath: String, unixMode: Int = 420) { fun push(data: ByteArray, remotePath: String, unixMode: Int = 420) {
openStream("sync:").use { stream -> openStream("sync:")
val out = stream.outputStream .use { stream ->
val inp = stream.inputStream val out = stream.outputStream
val pathMode = "$remotePath,$unixMode".toByteArray(Charsets.UTF_8) val inp = stream.inputStream
val pathMode = "$remotePath,$unixMode".toByteArray(Charsets.UTF_8)
out.write("SEND".toByteArray(Charsets.US_ASCII)) out.write("SEND".toByteArray(Charsets.US_ASCII))
out.writeIntLE(pathMode.size) out.writeIntLE(pathMode.size)
out.write(pathMode) out.write(pathMode)
val chunkBuf = ByteArray(64 * 1024) val chunkBuf = ByteArray(64 * 1024)
var offset = 0 var offset = 0
while (offset < data.size) { while (offset < data.size) {
val len = minOf(chunkBuf.size, data.size - offset) val len = minOf(chunkBuf.size, data.size - offset)
out.write("DATA".toByteArray(Charsets.US_ASCII)) out.write("DATA".toByteArray(Charsets.US_ASCII))
out.writeIntLE(len) out.writeIntLE(len)
out.write(data, offset, len) out.write(data, offset, len)
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 { fun isAlive(): Boolean {
@@ -568,7 +567,7 @@ internal class DirectAdbConnection(
*/ */
class AdbSocketStream( class AdbSocketStream(
val localId: Int, 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 { ) : Closeable {
companion object { companion object {
@@ -619,12 +618,13 @@ class AdbSocketStream(
} }
override fun close() { override fun close() {
if (!closed) { if (closed) return
closed = true
val r = remoteId closed = true
if (r != 0) runCatching { sender(A_CLSE, localId, r, ByteArray(0)) } if (remoteId != 0) runCatching {
queue.offer(EndOfStreamMarker) sender(A_CLSE, localId, remoteId, ByteArray(0))
} }
queue.offer(EndOfStreamMarker)
} }
private inner class InStream : InputStream() { private inner class InStream : InputStream() {

View File

@@ -1,6 +1,5 @@
package io.github.miuzarte.scrcpyforandroid.nativecore package io.github.miuzarte.scrcpyforandroid.nativecore
import android.content.Context
import android.util.Log import android.util.Log
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@@ -19,8 +18,8 @@ import kotlin.time.Duration
* *
* All network operations are executed on Dispatchers.IO. * All network operations are executed on Dispatchers.IO.
*/ */
class NativeAdbService(appContext: Context) { object NativeAdbService {
private val transport = DirectAdbTransport(appContext) private val transport = DirectAdbTransport
private val mutex = Mutex() private val mutex = Mutex()
@Volatile @Volatile
@@ -55,7 +54,7 @@ class NativeAdbService(appContext: Context) {
suspend fun discoverPairingService( suspend fun discoverPairingService(
timeoutMs: Long = 12_000, timeoutMs: Long = 12_000,
includeLanDevices: Boolean = true includeLanDevices: Boolean = true,
): Pair<String, Int>? = mutex.withLock { ): Pair<String, Int>? = mutex.withLock {
return@withLock try { return@withLock try {
transport.discoverPairingService(timeoutMs, includeLanDevices) transport.discoverPairingService(timeoutMs, includeLanDevices)
@@ -67,7 +66,7 @@ class NativeAdbService(appContext: Context) {
suspend fun discoverConnectService( suspend fun discoverConnectService(
timeoutMs: Long = 12_000, timeoutMs: Long = 12_000,
includeLanDevices: Boolean = true includeLanDevices: Boolean = true,
): Pair<String, Int>? = mutex.withLock { ): Pair<String, Int>? = mutex.withLock {
return@withLock try { return@withLock try {
transport.discoverConnectService(timeoutMs, includeLanDevices) transport.discoverConnectService(timeoutMs, includeLanDevices)
@@ -162,7 +161,5 @@ class NativeAdbService(appContext: Context) {
?: throw IllegalStateException("ADB not connected") ?: throw IllegalStateException("ADB not connected")
} }
companion object { private const val TAG = "NativeAdbService"
private const val TAG = "NativeAdbService"
}
} }

View File

@@ -54,7 +54,7 @@ class PersistentVideoRenderer {
private var stMatrixHandle = 0 private var stMatrixHandle = 0
private var samplerHandle = 0 private var samplerHandle = 0
private val initLock = Object() private val initLock = Any()
fun getDecoderSurface(): Surface { fun getDecoderSurface(): Surface {
ensureInitialized() ensureInitialized()

View File

@@ -2,7 +2,6 @@ package io.github.miuzarte.scrcpyforandroid.pages
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.pm.ActivityInfo
import android.util.Log import android.util.Log
import android.view.WindowManager import android.view.WindowManager
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -26,7 +25,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight 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.Defaults
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics 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.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.EventLogger import io.github.miuzarte.scrcpyforandroid.services.EventLogger
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
@@ -88,10 +88,8 @@ private const val ADB_TCP_PROBE_TIMEOUT_MS = 500
@Composable @Composable
fun DeviceTabScreen( fun DeviceTabScreen(
nativeCore: NativeCoreFacade,
adbService: NativeAdbService,
scrcpy: Scrcpy,
scrollBehavior: ScrollBehavior, scrollBehavior: ScrollBehavior,
scrcpy: Scrcpy,
onOpenReorderDevices: () -> Unit, onOpenReorderDevices: () -> Unit,
) { ) {
val navigator = LocalRootNavigator.current val navigator = LocalRootNavigator.current
@@ -135,10 +133,8 @@ fun DeviceTabScreen(
) { pagePadding -> ) { pagePadding ->
DeviceTabPage( DeviceTabPage(
contentPadding = pagePadding, contentPadding = pagePadding,
nativeCore = nativeCore,
adbService = adbService,
scrcpy = scrcpy,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
scrcpy = scrcpy,
) )
} }
} }
@@ -146,10 +142,8 @@ fun DeviceTabScreen(
@Composable @Composable
fun DeviceTabPage( fun DeviceTabPage(
contentPadding: PaddingValues, contentPadding: PaddingValues,
nativeCore: NativeCoreFacade,
adbService: NativeAdbService,
scrcpy: Scrcpy,
scrollBehavior: ScrollBehavior, scrollBehavior: ScrollBehavior,
scrcpy: Scrcpy,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -158,7 +152,6 @@ fun DeviceTabPage(
val haptics = LocalAppHaptics.current val haptics = LocalAppHaptics.current
val navigator = LocalRootNavigator.current val navigator = LocalRootNavigator.current
val fullscreenNavigationState = LocalFullscreenNavigationState.current
val snackbar = LocalSnackbarController.current val snackbar = LocalSnackbarController.current
val asBundleShared by appSettings.bundleState.collectAsState() val asBundleShared by appSettings.bundleState.collectAsState()
@@ -281,11 +274,11 @@ fun DeviceTabPage(
* Disconnect the current ADB connection and stop any running scrcpy session. * Disconnect the current ADB connection and stop any running scrcpy session.
* *
* Concurrency / thread boundary: * 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. * - This ensures UI coroutines are never blocked by synchronous native I/O.
* *
* Side effects: * 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`, * - Resets UI-visible connection state: `adbConnected`, `currentTargetHost/Port`,
* `sessionInfo`, device capability flags, `statusLine`, and `connectedDeviceLabel`. * `sessionInfo`, device capability flags, `statusLine`, and `connectedDeviceLabel`.
* - Updates the saved quick-device list via [savedShortcuts.update] when a target is provided. * - Updates the saved quick-device list via [savedShortcuts.update] when a target is provided.
@@ -307,7 +300,7 @@ fun DeviceTabPage(
// Also stops scrcpy. // Also stops scrcpy.
runCatching { scrcpy.stop() } runCatching { scrcpy.stop() }
setKeepScreenOn(false) setKeepScreenOn(false)
runCatching { adbService.disconnect() } runCatching { NativeAdbService.disconnect() }
adbConnected = false adbConnected = false
currentTargetHost = "" currentTargetHost = ""
currentTargetPort = Defaults.ADB_PORT currentTargetPort = Defaults.ADB_PORT
@@ -376,7 +369,7 @@ fun DeviceTabPage(
*/ */
suspend fun connectWithTimeout(host: String, port: Int) { suspend fun connectWithTimeout(host: String, port: Int) {
return withTimeout(ADB_CONNECT_TIMEOUT_MS) { 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 { suspend fun keepAliveCheck(host: String, port: Int): Boolean {
return withTimeout(ADB_KEEPALIVE_TIMEOUT_MS) { return withTimeout(ADB_KEEPALIVE_TIMEOUT_MS) {
val connected = adbService.isConnected() val connected = NativeAdbService.isConnected()
if (!connected) { if (!connected) {
return@withTimeout false return@withTimeout false
} }
@@ -564,7 +557,7 @@ fun DeviceTabPage(
currentTargetHost = host currentTargetHost = host
currentTargetPort = port currentTargetPort = port
val info = fetchConnectedDeviceInfo(adbService, host, port) val info = fetchConnectedDeviceInfo(NativeAdbService, host, port)
val fullLabel = if (info.serial.isNotBlank()) { val fullLabel = if (info.serial.isNotBlank()) {
"${info.model} (${info.serial})" "${info.model} (${info.serial})"
} else { } else {
@@ -646,7 +639,7 @@ fun DeviceTabPage(
} }
val discovered = withContext(Dispatchers.IO) { val discovered = withContext(Dispatchers.IO) {
adbService.discoverConnectService( NativeAdbService.discoverConnectService(
timeoutMs = ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS, timeoutMs = ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS,
includeLanDevices = asBundle.adbMdnsLanDiscovery, includeLanDevices = asBundle.adbMdnsLanDiscovery,
) )
@@ -705,8 +698,8 @@ fun DeviceTabPage(
fun sendVirtualButtonAction(action: VirtualButtonAction) { fun sendVirtualButtonAction(action: VirtualButtonAction) {
val keycode = action.keycode ?: return val keycode = action.keycode ?: return
runBusy("发送 ${action.title}") { runBusy("发送 ${action.title}") {
nativeCore.session?.injectKeycode(0, keycode) scrcpy.injectKeycode(0, keycode)
nativeCore.session?.injectKeycode(1, keycode) scrcpy.injectKeycode(1, keycode)
} }
} }
@@ -870,7 +863,7 @@ fun DeviceTabPage(
busy = busy, busy = busy,
autoDiscoverOnDialogOpen = asBundle.adbPairingAutoDiscoverOnDialogOpen, autoDiscoverOnDialogOpen = asBundle.adbPairingAutoDiscoverOnDialogOpen,
onDiscoverTarget = { onDiscoverTarget = {
adbService.discoverPairingService( NativeAdbService.discoverPairingService(
includeLanDevices = asBundle.adbMdnsLanDiscovery, includeLanDevices = asBundle.adbMdnsLanDiscovery,
) )
}, },
@@ -879,7 +872,7 @@ fun DeviceTabPage(
val resolvedHost = host.trim() val resolvedHost = host.trim()
val resolvedPort = port.trim().toIntOrNull() ?: return@runBusy val resolvedPort = port.trim().toIntOrNull() ?: return@runBusy
val resolvedCode = code.trim() val resolvedCode = code.trim()
val ok = adbService.pair( val ok = NativeAdbService.pair(
resolvedHost, resolvedHost,
resolvedPort, resolvedPort,
resolvedCode, resolvedCode,
@@ -948,24 +941,13 @@ fun DeviceTabPage(
item { item {
PreviewCard( PreviewCard(
sessionInfo = sessionInfo, sessionInfo = sessionInfo,
nativeCore = nativeCore,
previewHeightDp = asBundle.devicePreviewCardHeightDp.coerceAtLeast(120), previewHeightDp = asBundle.devicePreviewCardHeightDp.coerceAtLeast(120),
controlsVisible = previewControlsVisible, controlsVisible = previewControlsVisible,
onTapped = { onTapped = {
previewControlsVisible = !previewControlsVisible previewControlsVisible = !previewControlsVisible
}, },
onOpenFullscreen = { onOpenFullscreen = {
val currentSession = sessionInfo context.startActivity(StreamActivity.createIntent(context))
if (currentSession != null) {
fullscreenNavigationState.setOrientation(
if (currentSession.width >= currentSession.height) {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
} else {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
)
navigator.push(RootScreen.Fullscreen)
}
}, },
) )
} }

View File

@@ -1,11 +1,20 @@
package io.github.miuzarte.scrcpyforandroid.pages package io.github.miuzarte.scrcpyforandroid.pages
import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.graphics.Rect
import android.util.Log import android.util.Log
import androidx.activity.compose.BackHandler 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.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@@ -13,6 +22,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -21,16 +31,27 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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.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.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade 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.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings 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.VirtualButtonActions
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -40,20 +61,20 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.Text
@Composable @Composable
fun FullscreenControlScreen( fun FullscreenControlScreen(
scrcpy: Scrcpy, scrcpy: Scrcpy,
nativeCore: NativeCoreFacade, onBack: () -> Unit,
isInPip: Boolean,
onVideoSizeChanged: (width: Int, height: Int) -> Unit, onVideoSizeChanged: (width: Int, height: Int) -> Unit,
onVideoBoundsInWindowChanged: (Rect?) -> Unit,
) { ) {
val navigator = LocalRootNavigator.current BackHandler(enabled = true, onBack = onBack)
BackHandler(enabled = true, onBack = navigator.pop)
val context = LocalContext.current val context = LocalContext.current
val haptics = LocalAppHaptics.current
val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val activity = remember(context) { context as? Activity } val activity = remember(context) { context as? Activity }
@@ -128,21 +149,21 @@ fun FullscreenControlScreen(
onVideoSizeChanged(session.width, session.height) onVideoSizeChanged(session.width, session.height)
} }
DisposableEffect(nativeCore) { DisposableEffect(Unit) {
val listener: (Float) -> Unit = { fps -> val listener: (Float) -> Unit = { fps ->
currentFps = fps currentFps = fps
} }
nativeCore.addVideoFpsListener(listener) NativeCoreFacade.addVideoFpsListener(listener)
onDispose { onDispose {
nativeCore.removeVideoFpsListener(listener) NativeCoreFacade.removeVideoFpsListener(listener)
} }
} }
suspend fun sendKeycode(keycode: Int) { suspend fun sendKeycode(keycode: Int) {
runCatching { runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
nativeCore.session?.injectKeycode(0, keycode) scrcpy.injectKeycode(0, keycode)
nativeCore.session?.injectKeycode(1, keycode) scrcpy.injectKeycode(1, keycode)
} }
}.onFailure { e -> }.onFailure { e ->
Log.w( Log.w(
@@ -160,16 +181,17 @@ fun FullscreenControlScreen(
.padding(contentPadding), .padding(contentPadding),
) { ) {
val session = currentSession ?: return@Box val session = currentSession ?: return@Box
FullscreenControlScreen( FullscreenControlPage(
session = session, session = session,
nativeCore = nativeCore, onDismiss = onBack,
onDismiss = navigator.pop, showDebugInfo = fullscreenDebugInfo && !isInPip,
showDebugInfo = fullscreenDebugInfo,
currentFps = currentFps, currentFps = currentFps,
enableBackHandler = false, enableBackHandler = false,
interactive = !isInPip,
onVideoBoundsInWindowChanged = onVideoBoundsInWindowChanged,
onInjectTouch = { action, pointerId, x, y, pressure, buttons -> onInjectTouch = { action, pointerId, x, y, pressure, buttons ->
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
nativeCore.session?.injectTouch( scrcpy.injectTouch(
action = action, action = action,
pointerId = pointerId, pointerId = pointerId,
x = x, x = x,
@@ -184,28 +206,174 @@ fun FullscreenControlScreen(
}, },
) )
if (showFullscreenVirtualButtons) { if (showFullscreenVirtualButtons && !isInPip) {
bar.Fullscreen( bar.Fullscreen(
modifier = Modifier.align(Alignment.BottomCenter), modifier = Modifier.align(Alignment.BottomCenter),
onAction = { action -> onAction = { action ->
action.keycode?.let { action.keycode?.let { sendKeycode(it) }
sendKeycode(it)
}
}, },
) )
} }
if (showFullscreenFloatingButton) { if (showFullscreenFloatingButton && !isInPip) {
bar.FloatingBall( bar.FloatingBall(
actions = floatingActions, actions = floatingActions,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
onAction = { action -> onAction = { action ->
action.keycode?.let { action.keycode?.let { sendKeycode(it) }
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<Int>() }
val activePointerPositions = remember { linkedMapOf<Int, Offset>() }
val activePointerDevicePositions = remember { linkedMapOf<Int, Pair<Int, Int>>() }
val pointerLabels = remember { linkedMapOf<Int, Int>() }
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)
}
}
}

View File

@@ -2,7 +2,6 @@ package io.github.miuzarte.scrcpyforandroid.pages
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import android.content.pm.ActivityInfo
import android.net.Uri import android.net.Uri
import android.os.SystemClock import android.os.SystemClock
import android.widget.Toast import android.widget.Toast
@@ -24,7 +23,6 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf 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.haptics.rememberAppHaptics
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy 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.AppUpdateChecker
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
@@ -86,7 +85,6 @@ sealed interface RootScreen : NavKey {
data object Advanced : RootScreen data object Advanced : RootScreen
data object About : RootScreen data object About : RootScreen
data object VirtualButtonOrder : RootScreen data object VirtualButtonOrder : RootScreen
data object Fullscreen : RootScreen
} }
@Composable @Composable
@@ -95,22 +93,11 @@ fun MainScreen() {
val context = LocalContext.current val context = LocalContext.current
val appContext = context.applicationContext val appContext = context.applicationContext
val activity = remember(context) { context as? Activity } val activity = remember(context) { context as? Activity }
val initialOrientation = remember(activity) {
activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
// Scopes // Scopes
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } 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 // Global controllers provided to the compose tree
val snackHostState = remember { SnackbarHostState() } val snackHostState = remember { SnackbarHostState() }
val snackbarController = remember(scope, snackHostState) { val snackbarController = remember(scope, snackHostState) {
@@ -131,17 +118,8 @@ fun MainScreen() {
val rootBackStack = remember { mutableStateListOf<NavKey>(RootScreen.Home) } val rootBackStack = remember { mutableStateListOf<NavKey>(RootScreen.Home) }
val currentRootScreen = rootBackStack.lastOrNull() as? RootScreen ?: RootScreen.Home val currentRootScreen = rootBackStack.lastOrNull() as? RootScreen ?: RootScreen.Home
var showReorderDevices by rememberSaveable { mutableStateOf(false) } var showReorderDevices by rememberSaveable { mutableStateOf(false) }
var fullscreenOrientation by rememberSaveable {
mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)
}
var lastExitBackPressAtMs by rememberSaveable { mutableLongStateOf(0L) } var lastExitBackPressAtMs by rememberSaveable { mutableLongStateOf(0L) }
DisposableEffect(activity) {
onDispose {
activity?.requestedOrientation = initialOrientation
}
}
// Scroll behaviors // Scroll behaviors
val devicesPageScrollBehavior = MiuixScrollBehavior( val devicesPageScrollBehavior = MiuixScrollBehavior(
canScroll = { currentTab == MainBottomTabDestination.Device }) canScroll = { currentTab == MainBottomTabDestination.Device })
@@ -166,11 +144,6 @@ fun MainScreen() {
}, },
) )
} }
val fullscreenNavigationState = remember {
FullscreenNavigationState(
setOrientation = { fullscreenOrientation = it }
)
}
// Shared settings bundles // Shared settings bundles
val asBundleShared by appSettings.bundleState.collectAsState() val asBundleShared by appSettings.bundleState.collectAsState()
@@ -228,19 +201,20 @@ fun MainScreen() {
.ifBlank { AppSettings.SERVER_REMOTE_PATH.defaultValue } .ifBlank { AppSettings.SERVER_REMOTE_PATH.defaultValue }
val scrcpy = remember( val scrcpy = remember(
appContext, appContext,
adbService,
customServerUri, customServerUri,
customServerVersion, customServerVersion,
serverRemotePath, serverRemotePath,
) { ) {
Scrcpy( Scrcpy(
appContext = appContext, appContext = appContext,
adbService = adbService,
customServerUri = customServerUri, customServerUri = customServerUri,
serverVersion = customServerVersion, serverVersion = customServerVersion,
serverRemotePath = serverRemotePath, serverRemotePath = serverRemotePath,
) ).also {
AppRuntime.scrcpy = it
}
} }
val currentSession by scrcpy.currentSessionState.collectAsState() val currentSession by scrcpy.currentSessionState.collectAsState()
// Side-effect launchers and composition locals // Side-effect launchers and composition locals
@@ -305,20 +279,18 @@ fun MainScreen() {
scope.launch { scope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
runCatching { scrcpy.stop() } runCatching { scrcpy.stop() }
runCatching { adbService.disconnect() } runCatching { NativeAdbService.disconnect() }
} }
activity?.finish() activity?.finish()
} }
} }
} }
BackHandler(enabled = currentRootScreen !is RootScreen.Fullscreen) { BackHandler {
handleBackNavigation() handleBackNavigation()
} }
PredictiveBackHandler( PredictiveBackHandler(enabled = canNavigateBack) { progress ->
enabled = canNavigateBack && currentRootScreen !is RootScreen.Fullscreen
) { progress ->
try { try {
progress.collect { } progress.collect { }
handleBackNavigation() handleBackNavigation()
@@ -327,39 +299,19 @@ fun MainScreen() {
} }
} }
// Fullscreen route can force orientation based on stream ratio; all other routes are portrait. DisposableEffect(scrcpy) {
LaunchedEffect(activity, currentRootScreen, fullscreenOrientation) {
val targetOrientation = when (currentRootScreen) {
is RootScreen.Fullscreen -> fullscreenOrientation
else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
activity?.requestedOrientation = targetOrientation
}
DisposableEffect(nativeCore, scrcpy) {
val listener: (Int, Int) -> Unit = { width, height -> val listener: (Int, Int) -> Unit = { width, height ->
scrcpy.updateCurrentSessionSize(width, height) scrcpy.updateCurrentSessionSize(width, height)
} }
nativeCore.addVideoSizeListener(listener) NativeCoreFacade.addVideoSizeListener(listener)
onDispose { onDispose {
nativeCore.removeVideoSizeListener(listener) NativeCoreFacade.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
}
} }
} }
LaunchedEffect(asBundle.adbKeyName) { 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<NavKey> { val rootEntryProvider = entryProvider<NavKey> {
@@ -400,10 +352,8 @@ fun MainScreen() {
saveableStateHolder.SaveableStateProvider(tab.name) { saveableStateHolder.SaveableStateProvider(tab.name) {
when (tab) { when (tab) {
MainBottomTabDestination.Device -> DeviceTabScreen( MainBottomTabDestination.Device -> DeviceTabScreen(
nativeCore = nativeCore,
adbService = adbService,
scrcpy = scrcpy,
scrollBehavior = devicesPageScrollBehavior, scrollBehavior = devicesPageScrollBehavior,
scrcpy = scrcpy,
onOpenReorderDevices = { showReorderDevices = true }, 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( val rootEntries = rememberDecoratedNavEntries(
@@ -469,7 +408,6 @@ fun MainScreen() {
LocalRootNavigator provides rootNavigator, LocalRootNavigator provides rootNavigator,
LocalSnackbarController provides snackbarController, LocalSnackbarController provides snackbarController,
LocalAppHaptics provides haptics, LocalAppHaptics provides haptics,
LocalFullscreenNavigationState provides fullscreenNavigationState,
LocalServerPicker provides serverPicker, LocalServerPicker provides serverPicker,
) { ) {
NavDisplay( NavDisplay(

View File

@@ -10,11 +10,3 @@ class RootNavigator(
val LocalRootNavigator = staticCompositionLocalOf<RootNavigator> { val LocalRootNavigator = staticCompositionLocalOf<RootNavigator> {
error("No RootNavigator provided") error("No RootNavigator provided")
} }
class FullscreenNavigationState(
val setOrientation: (Int) -> Unit,
)
val LocalFullscreenNavigationState = staticCompositionLocalOf<FullscreenNavigationState> {
error("No FullscreenNavigationState provided")
}

View File

@@ -471,6 +471,7 @@ fun SettingsPage(
item { item {
SectionSmallTitle("") SectionSmallTitle("")
Card { Card {
// TODO: 进入时无视自动更新检查的 CD, 主动触发一次
ArrowPreference( ArrowPreference(
title = "关于", title = "关于",
summary = updateSummary, summary = updateSummary,

View File

@@ -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<Rect?>(null) }
var lastPipAspectRatio by remember { mutableStateOf<Rational?>(null) }
var lastPipOrientationLandscape by remember { mutableStateOf<Boolean?>(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
},
)
}
}
}

View File

@@ -52,14 +52,14 @@ import kotlin.random.nextUInt
*/ */
class Scrcpy( class Scrcpy(
private val appContext: Context, private val appContext: Context,
private val adbService: NativeAdbService,
private val serverAsset: String = DEFAULT_SERVER_ASSET, private val serverAsset: String = DEFAULT_SERVER_ASSET,
private val customServerUri: String? = null, private val customServerUri: String? = null,
private val serverVersion: String = DEFAULT_SERVER_VERSION, private val serverVersion: String = DEFAULT_SERVER_VERSION,
private val serverRemotePath: String = DEFAULT_REMOTE_PATH, 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<Session.SessionInfo?>(null) private val _currentSessionState = MutableStateFlow<Session.SessionInfo?>(null)
val currentSessionState: StateFlow<Session.SessionInfo?> = _currentSessionState.asStateFlow() val currentSessionState: StateFlow<Session.SessionInfo?> = _currentSessionState.asStateFlow()
@@ -144,7 +144,7 @@ class Scrcpy(
// Setup video consumer (notify NativeCoreFacade to setup decoders) // Setup video consumer (notify NativeCoreFacade to setup decoders)
if (options.video) { if (options.video) {
nativeCore.onScrcpySessionStarted(info, session) NativeCoreFacade.onScrcpySessionStarted(info, session)
} }
// Setup audio player // Setup audio player
@@ -192,7 +192,7 @@ class Scrcpy(
Log.i(TAG, "stop(): Stopping scrcpy session") Log.i(TAG, "stop(): Stopping scrcpy session")
return@withContext try { return@withContext try {
nativeCore.onScrcpySessionStopped() NativeCoreFacade.onScrcpySessionStopped()
session.clearVideoConsumer() session.clearVideoConsumer()
session.clearAudioConsumer() session.clearAudioConsumer()
session.stop() session.stop()
@@ -208,15 +208,64 @@ class Scrcpy(
} }
} }
suspend fun close() {
stop()
adbService.close()
}
fun isStarted(): Boolean = isRunning && session.isStarted() fun isStarted(): Boolean = isRunning && session.isStarted()
fun getCurrentSession(): Session.SessionInfo? = currentSessionState.value 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) { fun updateCurrentSessionSize(width: Int, height: Int) {
val current = _currentSessionState.value ?: return val current = _currentSessionState.value ?: return
if (current.width == width && current.height == height) return if (current.width == width && current.height == height) return
@@ -399,7 +448,7 @@ class Scrcpy(
extractUriToCache(customServerUri.toUri()) extractUriToCache(customServerUri.toUri())
} }
adbService.push(serverJar.toPath(), serverRemotePath) NativeAdbService.push(serverJar.toPath(), serverRemotePath)
val scid = generateScid() val scid = generateScid()
val options = ClientOptions( val options = ClientOptions(
@@ -419,7 +468,7 @@ class Scrcpy(
) )
Log.i(TAG, "listOptions(): cmd=$serverCommand") 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) { private fun logListPreview(list: ListOptions, countSummary: String, output: String) {
@@ -553,7 +602,7 @@ class Scrcpy(
options: ClientOptions, options: ClientOptions,
scid: UInt, scid: UInt,
): Session.SessionInfo { ): Session.SessionInfo {
adbService.push(serverJar.toPath(), serverRemotePath) NativeAdbService.push(serverJar.toPath(), serverRemotePath)
val serverParams = options.toServerParams(scid) val serverParams = options.toServerParams(scid)
@@ -605,7 +654,7 @@ class Scrcpy(
* Session manager for scrcpy protocol. * Session manager for scrcpy protocol.
* Handles socket communication, video/audio streaming, and control input. * Handles socket communication, video/audio streaming, and control input.
*/ */
class Session(private val adbService: NativeAdbService) { class Session {
private val mutex = Mutex() private val mutex = Mutex()
@Volatile @Volatile
@@ -635,7 +684,7 @@ class Scrcpy(
val socketName = socketNameFor(scid.toInt()) val socketName = socketNameFor(scid.toInt())
try { try {
val serverStream = adbService.openShellStream(serverCommand) val serverStream = NativeAdbService.openShellStream(serverCommand)
val serverLogThread = startServerLogThread(serverStream, socketName) val serverLogThread = startServerLogThread(serverStream, socketName)
Thread.sleep(SERVER_BOOT_DELAY_MS) Thread.sleep(SERVER_BOOT_DELAY_MS)
@@ -1045,7 +1094,7 @@ class Scrcpy(
var lastEx: Exception? = null var lastEx: Exception? = null
repeat(CONNECT_RETRY_COUNT) { attempt -> repeat(CONNECT_RETRY_COUNT) { attempt ->
try { try {
val stream = adbService.openAbstractSocket(socketName) val stream = NativeAdbService.openAbstractSocket(socketName)
if (expectDummyByte) { if (expectDummyByte) {
val value = stream.inputStream.read() val value = stream.inputStream.read()
if (value < 0) { if (value < 0) {

View File

@@ -8,6 +8,13 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.roundToInt import kotlin.math.roundToInt
/**
* TouchEventHandler
*
* Purpose:
* - Handles touch event processing for fullscreen control screen
* - Manages pointer tracking, coordinate mapping, and touch injection
*/
class TouchEventHandler( class TouchEventHandler(
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val session: Scrcpy.Session.SessionInfo, private val session: Scrcpy.Session.SessionInfo,

View File

@@ -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
}

View File

@@ -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"
}
}

View File

@@ -4,7 +4,6 @@ import android.annotation.SuppressLint
import android.graphics.SurfaceTexture import android.graphics.SurfaceTexture
import android.view.Surface import android.view.Surface
import android.view.TextureView import android.view.TextureView
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -40,7 +39,6 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -51,20 +49,19 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView 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.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.constants.Defaults import io.github.miuzarte.scrcpyforandroid.constants.Defaults
import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets 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.scaffolds.SuperTextField
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.storage.Storage
@@ -250,7 +246,6 @@ internal fun PairingCard(
@Composable @Composable
internal fun PreviewCard( internal fun PreviewCard(
sessionInfo: Scrcpy.Session.SessionInfo?, sessionInfo: Scrcpy.Session.SessionInfo?,
nativeCore: NativeCoreFacade,
previewHeightDp: Int, previewHeightDp: Int,
controlsVisible: Boolean, controlsVisible: Boolean,
onTapped: () -> Unit, onTapped: () -> Unit,
@@ -258,6 +253,27 @@ internal fun PreviewCard(
) { ) {
val haptics = rememberAppHaptics() val haptics = rememberAppHaptics()
val alpha by animateFloatAsState(if (controlsVisible) 1f else 0f, label = "preview-controls") 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 { Card {
Box( Box(
@@ -290,8 +306,8 @@ internal fun PreviewCard(
) { ) {
ScrcpyVideoSurface( ScrcpyVideoSurface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
nativeCore = nativeCore,
session = sessionInfo, session = sessionInfo,
target = VideoOutputTarget.PREVIEW,
) )
} }
} }
@@ -304,7 +320,7 @@ internal fun PreviewCard(
) { ) {
Button( Button(
onClick = { onClick = {
if (alpha > 0.1) { if (alpha > 0.1f) {
haptics.contextClick() haptics.contextClick()
onOpenFullscreen() onOpenFullscreen()
} }
@@ -465,6 +481,7 @@ internal fun ConfigPanel(
?.takeIf { it >= 0 } ?.takeIf { it >= 0 }
?.let { soBundle = soBundle.copy(audioBitRate = it * 1000) } ?.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<Int>() }
val activePointerPositions = remember { linkedMapOf<Int, Offset>() }
val activePointerDevicePositions = remember { linkedMapOf<Int, Pair<Int, Int>>() }
val pointerLabels = remember { linkedMapOf<Int, Int>() }
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 * ScrcpyVideoSurface
* *
@@ -878,19 +759,38 @@ fun FullscreenControlScreen(
* referencing stale surfaces. * referencing stale surfaces.
*/ */
@Composable @Composable
private fun ScrcpyVideoSurface( fun ScrcpyVideoSurface(
modifier: Modifier, modifier: Modifier,
nativeCore: NativeCoreFacade,
session: Scrcpy.Session.SessionInfo?, session: Scrcpy.Session.SessionInfo?,
target: VideoOutputTarget,
) { ) {
var currentSurface by remember { mutableStateOf<Surface?>(null) } var currentSurface by remember { mutableStateOf<Surface?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } 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 val surface = currentSurface
if (session != null && surface != null && surface.isValid) { if (currentTarget == target && session != null && surface != null && surface.isValid) {
nativeCore.attachVideoSurface(surface) 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 { onDispose {
val surface = currentSurface val surface = currentSurface
if (surface != null) { 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) val newSurface = Surface(surfaceTexture)
currentSurface = newSurface currentSurface = newSurface
// Register immediately when surface becomes available // Register immediately when surface becomes available
if (session != null) { if (currentTarget == target && session != null) {
scope.launch { scope.launch {
nativeCore.attachVideoSurface(newSurface) NativeCoreFacade.attachVideoSurface(newSurface)
} }
} }
} }
@@ -934,7 +839,7 @@ private fun ScrcpyVideoSurface(
val surface = currentSurface val surface = currentSurface
if (surface != null) { if (surface != null) {
taskScope.launch { taskScope.launch {
nativeCore.detachVideoSurface(surface, releaseDecoder = false) NativeCoreFacade.detachVideoSurface(surface, releaseDecoder = false)
} }
surface.release() surface.release()
currentSurface = null currentSurface = null

View File

@@ -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<VideoOutputTarget> = _current.asStateFlow()
fun set(target: VideoOutputTarget) {
_current.value = target
}
}

View File

@@ -12,6 +12,7 @@ espressoCore = "3.7.0"
miuix = "0.9.0" miuix = "0.9.0"
material = "1.13.0" material = "1.13.0"
runtime = "1.10.5" runtime = "1.10.5"
androidxCorePip = "1.0.0-alpha02"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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-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" } 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-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "runtime" }
androidx-core-pip = { group = "androidx.core", name = "core-pip", version.ref = "androidxCorePip" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }