feat: picture in picture
- 移除了 MainScreen 中的全屏方向处理及相关状态管理 - 引入 AppRuntime 用于管理跨 Activity 的共享实例 - 创建 StreamScreen 用于画中画模式 - 实现 VideoOutputTarget 和 VideoOutputTargetState 以管理视频输出状态
This commit is contained in:
@@ -19,6 +19,10 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ScrcpyForAndroid">
|
||||
|
||||
<receiver
|
||||
android:name=".services.PictureInPictureActionReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize"
|
||||
@@ -30,6 +34,13 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".StreamActivity"
|
||||
android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize"
|
||||
android:exported="false"
|
||||
android:resizeableActivity="true"
|
||||
android:supportsPictureInPicture="true" />
|
||||
|
||||
<!-- <profileable android:shell="true" /> -->
|
||||
|
||||
</application>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, Int>? {
|
||||
check(::nsdManager.isInitialized) { "AdbMdnsDiscoverer is not initialized" }
|
||||
val resultPort = AtomicInteger(-1)
|
||||
val resultHost = AtomicReference<String?>(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"
|
||||
}
|
||||
|
||||
@@ -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<PrivateKey, ByteArray> 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<String, Int>? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
|
||||
return AdbMdnsDiscoverer(context).discoverPairingService(timeoutMs, includeLanDevices)
|
||||
}
|
||||
includeLanDevices: Boolean = true,
|
||||
): Pair<String, Int>? =
|
||||
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<String, Int>? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
|
||||
return AdbMdnsDiscoverer(context).discoverConnectService(timeoutMs, includeLanDevices)
|
||||
}
|
||||
includeLanDevices: Boolean = true,
|
||||
): Pair<String, Int>? =
|
||||
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() {
|
||||
|
||||
@@ -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<String, Int>? = 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<String, Int>? = 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"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<NavKey>(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<NavKey> {
|
||||
@@ -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(
|
||||
|
||||
@@ -10,11 +10,3 @@ class RootNavigator(
|
||||
val LocalRootNavigator = staticCompositionLocalOf<RootNavigator> {
|
||||
error("No RootNavigator provided")
|
||||
}
|
||||
|
||||
class FullscreenNavigationState(
|
||||
val setOrientation: (Int) -> Unit,
|
||||
)
|
||||
|
||||
val LocalFullscreenNavigationState = staticCompositionLocalOf<FullscreenNavigationState> {
|
||||
error("No FullscreenNavigationState provided")
|
||||
}
|
||||
|
||||
@@ -471,6 +471,7 @@ fun SettingsPage(
|
||||
item {
|
||||
SectionSmallTitle("")
|
||||
Card {
|
||||
// TODO: 进入时无视自动更新检查的 CD, 主动触发一次
|
||||
ArrowPreference(
|
||||
title = "关于",
|
||||
summary = updateSummary,
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Session.SessionInfo?>(null)
|
||||
val currentSessionState: StateFlow<Session.SessionInfo?> = _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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<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
|
||||
*
|
||||
@@ -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<Surface?>(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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user