feat: picture in picture
- 移除了 MainScreen 中的全屏方向处理及相关状态管理 - 引入 AppRuntime 用于管理跨 Activity 的共享实例 - 创建 StreamScreen 用于画中画模式 - 实现 VideoOutputTarget 和 VideoOutputTargetState 以管理视频输出状态
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
3
TODO.md
3
TODO.md
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -471,6 +471,7 @@ fun SettingsPage(
|
|||||||
item {
|
item {
|
||||||
SectionSmallTitle("")
|
SectionSmallTitle("")
|
||||||
Card {
|
Card {
|
||||||
|
// TODO: 进入时无视自动更新检查的 CD, 主动触发一次
|
||||||
ArrowPreference(
|
ArrowPreference(
|
||||||
title = "关于",
|
title = "关于",
|
||||||
summary = updateSummary,
|
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(
|
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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user