diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e4f808c..374915f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -10,6 +10,7 @@
+
+ android:resizeableActivity="true" />
= _pipModeState
-
- val pipStopAction: RemoteAction by lazy {
- RemoteAction(
- Icon.createWithResource(this, drawable.ic_menu_close_clear_cancel),
- getString(R.string.password_stop_mirroring),
- getString(R.string.password_stop_mirroring),
- PictureInPictureActionReceiver.createPendingIntent(this),
- )
- }
-
- // 每次 进出全屏/进出画中画
- // 都会重建 activity
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
currentActivityRef = WeakReference(this)
AppScreenOn.register(window)
- registerPipActionReceiver()
-
- // 声明要画中画
- basicPip.setEnabled(true)
-
setContent {
StreamScreen(activity = this)
}
-
- /*
- // 可能以后有用
- basicPip.addOnPictureInPictureEventListener(
- executor = mainExecutor,
- listener = object : PictureInPictureDelegate.OnPictureInPictureEventListener {
- override fun onPictureInPictureEvent(
- event: PictureInPictureDelegate.Event,
- config: Configuration?,
- ) {
- // MIUI 只有这些事件
- when (event) {
- PictureInPictureDelegate.Event.ENTER_ANIMATION_START -> {}
- PictureInPictureDelegate.Event.ENTER_ANIMATION_END -> {}
-
- PictureInPictureDelegate.Event.STASHED -> {}
- PictureInPictureDelegate.Event.UNSTASHED -> {}
-
- // 收不到
- // PictureInPictureDelegate.Event.ENTERED -> {}
- // PictureInPictureDelegate.Event.EXITED -> {}
- }
- }
- }
- )
- */
}
- fun configurePip(block: Builder.() -> Builder) =
- basicPip.setPictureInPictureParams(Builder().block().build())
+ override fun onStart() {
+ super.onStart()
+ // 回到前台时隐藏悬浮窗
+ floatingWindow.hide()
+ }
+
+ override fun onStop() {
+ super.onStop()
+ if (AppRuntime.scrcpy?.currentSessionState?.value != null) {
+ // 进入后台时显示悬浮窗
+ floatingWindow.show(
+ onClose = {
+ // 关闭:停止 scrcpy 会话
+ activityScope.launch(Dispatchers.IO) {
+ AppRuntime.scrcpy?.stop(Scrcpy.StopReason.USER)
+ }
+ finish()
+ },
+ onFullscreen = {
+ // 全屏:重新打开 StreamActivity
+ if (!isFinishing && !isDestroyed) {
+ // Activity 仍在,直接将其带到前台
+ startActivity(createIntent(this).apply {
+ addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
+ })
+ } else {
+ // Activity 已销毁,创建新的
+ startActivity(createIntent(this))
+ }
+ },
+ )
+ }
+ }
override fun onDestroy() {
currentActivityRef?.get()
?.takeIf { it === this }
?.let { currentActivityRef = null }
AppScreenOn.unregister(window)
- unregisterPipActionReceiver()
+ floatingWindow.destroy()
+ activityScope.cancel()
super.onDestroy()
}
- /*
- // 回到全屏也会停止, 暂时不做
- override fun onDestroy() {
- super.onDestroy()
-
- if (_pipModeState.value) {
- Thread {
- runBlocking {
- AppRuntime.scrcpy?.stop()
- }
- }.start()
- }
- }
- */
-
- //- onPictureInPictureModeChanged
- //+ onPictureInPictureUiStateChanged
- //- onUserLeaveHint
-
- override fun onPictureInPictureUiStateChanged(pipState: PictureInPictureUiState) {
- super.onPictureInPictureUiStateChanged(pipState)
-
- _pipModeState.value = true
-
- /*
- when {
- // 进入画中画
- pipState.isTransitioningToPip -> {}
- // 收进边缘
- pipState.isStashed -> {}
- }
- */
- }
-
- private fun registerPipActionReceiver() {
- if (isPipActionReceiverRegistered) return
- ContextCompat.registerReceiver(
- this,
- pipActionReceiver,
- PictureInPictureActionReceiver.createIntentFilter(),
- ContextCompat.RECEIVER_NOT_EXPORTED,
- )
- isPipActionReceiverRegistered = true
- }
-
- private fun unregisterPipActionReceiver() {
- if (!isPipActionReceiverRegistered) return
- unregisterReceiver(pipActionReceiver)
- isPipActionReceiverRegistered = false
- }
-
companion object {
private var currentActivityRef: WeakReference? = null
@@ -153,9 +84,8 @@ class StreamActivity: FragmentActivity() {
}
fun dismissActivePictureInPicture() {
- currentActivityRef?.get()
- ?.takeIf { it.isInPictureInPictureMode }
- ?.finish()
+ // 悬浮窗模式:不再使用系统 PiP,改为关闭悬浮窗
+ currentActivityRef?.get()?.floatingWindow?.hide()
}
}
}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt
index 3c23b0a..aaf0cf7 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt
@@ -56,7 +56,6 @@ import top.yukonga.miuix.kmp.basic.Text
fun FullscreenControlScreen(
scrcpy: Scrcpy,
onBack: () -> Unit,
- isInPip: Boolean,
onVideoSizeChanged: (width: Int, height: Int) -> Unit,
onVideoBoundsInWindowChanged: (Rect?) -> Unit,
) {
@@ -388,15 +387,17 @@ fun FullscreenControlScreen(
.padding(contentPadding),
) {
val session = currentSession ?: return@Box
+ var touchEventHandlerRef by remember { mutableStateOf(null) }
+
FullscreenControlPage(
scrcpy = scrcpy,
session = session,
onDismiss = onBack,
- showDebugInfo = fullscreenDebugInfo && !isInPip,
+ showDebugInfo = fullscreenDebugInfo,
currentFps = currentFps,
imeRequestToken = imeRequestToken,
enableBackHandler = false,
- interactive = !isInPip,
+ interactive = true,
onVideoBoundsInWindowChanged = onVideoBoundsInWindowChanged,
onImeCommitText = ::commitImeText,
onInjectTouch = { action, pointerId, x, y, pressure, actionButton, buttons ->
@@ -417,9 +418,10 @@ fun FullscreenControlScreen(
onBackOrScreenOn = { action ->
withContext(Dispatchers.IO) { scrcpy.pressBackOrTurnScreenOn(action) }
},
+ onTouchEventHandlerCreated = { touchEventHandlerRef = it },
)
- if (showFullscreenVirtualButtons && !isInPip) {
+ if (showFullscreenVirtualButtons) {
bar.Fullscreen(
modifier = Modifier.align(
when (fullscreenVirtualButtonDock) {
@@ -439,7 +441,7 @@ fun FullscreenControlScreen(
)
}
- if (asBundle.showFullscreenFloatingButton && !isInPip) {
+ if (asBundle.showFullscreenFloatingButton) {
bar.FloatingBall(
actions = floatingActions,
modifier = Modifier.fillMaxSize(),
@@ -626,6 +628,7 @@ fun FullscreenControlPage(
buttons: Int,
) -> Unit,
onBackOrScreenOn: suspend (action: Int) -> Unit,
+ onTouchEventHandlerCreated: ((TouchEventHandler?) -> Unit)? = null,
) {
BackHandler(enabled = enableBackHandler, onBack = onDismiss)
@@ -661,6 +664,13 @@ fun FullscreenControlPage(
)
}
+ DisposableEffect(touchEventHandler) {
+ onTouchEventHandlerCreated?.invoke(touchEventHandler)
+ onDispose {
+ onTouchEventHandlerCreated?.invoke(null)
+ }
+ }
+
val resizeDebouncer = remember {
Debouncer(300L) { w, h ->
coroutineScope.launch(Dispatchers.IO) {
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt
index dfe76d6..7cbae78 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt
@@ -662,7 +662,6 @@ fun MainScreen() {
FullscreenControlRoute(
scrcpy = scrcpy,
onBack = rootNavigator.pop,
- isInPip = false,
autoExitOnStop = true,
)
}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/StreamScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/StreamScreen.kt
index 4267a7a..a6ffadc 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/StreamScreen.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/StreamScreen.kt
@@ -2,7 +2,6 @@ package io.github.miuzarte.scrcpyforandroid.pages
import android.content.pm.ActivityInfo
import android.graphics.Rect
-import android.util.Rational
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.*
import androidx.lifecycle.Lifecycle
@@ -15,8 +14,6 @@ import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import io.github.miuzarte.scrcpyforandroid.ui.createThemeController
-import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTarget
-import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTargetState
import top.yukonga.miuix.kmp.basic.SnackbarHostState
import top.yukonga.miuix.kmp.theme.MiuixTheme
@@ -25,51 +22,6 @@ fun StreamScreen(activity: StreamActivity) {
val scrcpy = remember { AppRuntime.scrcpy!! }
val asBundle by Storage.appSettings.bundleState.collectAsState()
- val isInPip by activity.pipModeState.collectAsState()
-
- var pipSourceRectHint by remember { mutableStateOf(null) }
- var lastPipAspectRatio by remember { mutableStateOf(null) }
- var lastPipOrientationLandscape by remember { mutableStateOf(null) }
-
- DisposableEffect(isInPip) {
- onDispose {
- VideoOutputTargetState.set(VideoOutputTarget.PREVIEW)
- }
- }
-
- val currentSession by scrcpy.currentSessionState.collectAsState()
-
- 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.configurePip {
- setEnabled(true)
- setAspectRatio(pipAspectRatio)
- setSourceRectHint(pipSourceRectHint)
- setSeamlessResizeEnabled(true)
- setCloseAction(activity.pipStopAction)
- }
- }
-
val themeController = remember(
asBundle.themeBaseIndex,
asBundle.monet,
@@ -99,11 +51,6 @@ fun StreamScreen(activity: StreamActivity) {
FullscreenControlRoute(
scrcpy = scrcpy,
onBack = activity::finish,
- isInPip = isInPip,
- onVideoBoundsInWindowChanged = {
- // 记录下一次进入 PiP 时可用的 sourceRectHint
- pipSourceRectHint = it
- },
)
}
}
@@ -113,7 +60,6 @@ fun StreamScreen(activity: StreamActivity) {
fun FullscreenControlRoute(
scrcpy: Scrcpy,
onBack: () -> Unit,
- isInPip: Boolean = false,
autoExitOnStop: Boolean = false,
onVideoBoundsInWindowChanged: (Rect?) -> Unit = {},
) {
@@ -153,16 +99,13 @@ fun FullscreenControlRoute(
FullscreenControlScreen(
scrcpy = scrcpy,
onBack = onBack,
- isInPip = isInPip,
onVideoSizeChanged = { width, height ->
- if (!isInPip) {
- activity?.requestedOrientation =
- fullscreenRequestedOrientation(
- width = width,
- height = height,
- ignoreSystemRotationLock = asBundle.fullscreenControlIgnoreSystemRotationLock,
- )
- }
+ activity?.requestedOrientation =
+ fullscreenRequestedOrientation(
+ width = width,
+ height = height,
+ ignoreSystemRotationLock = asBundle.fullscreenControlIgnoreSystemRotationLock,
+ )
},
onVideoBoundsInWindowChanged = onVideoBoundsInWindowChanged,
)
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/FloatingVideoWindow.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/FloatingVideoWindow.kt
new file mode 100644
index 0000000..0fe7030
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/FloatingVideoWindow.kt
@@ -0,0 +1,495 @@
+package io.github.miuzarte.scrcpyforandroid.widgets
+
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.graphics.PixelFormat
+import android.graphics.SurfaceTexture
+import android.graphics.drawable.GradientDrawable
+import android.net.Uri
+import android.os.Build
+import android.provider.Settings
+import android.util.Log
+import android.view.*
+import android.widget.FrameLayout
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.unit.IntSize
+import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
+import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
+import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
+import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.collectLatest
+
+/**
+ * 悬浮窗管理器,替代系统画中画(PiP)。
+ *
+ * 使用 WindowManager 创建 TYPE_APPLICATION_OVERLAY 悬浮窗口,
+ * 包含视频渲染(TextureView)、触摸事件处理和最小化控制按钮。
+ *
+ * 生命周期:
+ * - [show] 当 Activity 进入后台时调用,显示悬浮窗
+ * - [hide] 当 Activity 回到前台时调用,隐藏悬浮窗
+ * - [destroy] 彻底清理,释放所有资源
+ */
+class FloatingVideoWindow(private val context: Context) {
+
+ private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+ private val metrics = context.resources.displayMetrics
+
+ private var windowView: FrameLayout? = null
+ private var textureView: TextureView? = null
+ private var controlCircle: View? = null
+ private var controlPanel: LinearLayout? = null
+ private var closeBtn: TextView? = null
+ private var fullscreenBtn: TextView? = null
+ private var currentSurface: android.view.Surface? = null
+ private var touchEventHandler: TouchEventHandler? = null
+
+ // 触摸状态持有者(独立于 Compose,但与 TouchEventHandler 使用相同类型)
+ private val activePointerIds = LinkedHashSet()
+ private val activePointerPositions = LinkedHashMap()
+ private val activePointerDevicePositions = LinkedHashMap>()
+ private val pointerLabels = LinkedHashMap()
+ private var nextPointerLabel = 1
+ private var touchAreaSize = IntSize.Zero
+
+ private var isShowing = false
+ private var controlsVisible = false
+ private var sessionCollectJob: Job? = null
+
+ // 窗口位置
+ private var windowX: Int = 0
+ private var windowY: Int = 0
+
+ // 拖动相关
+ private var dragStartX = 0f
+ private var dragStartY = 0f
+ private var windowStartX = 0
+ private var windowStartY = 0
+ private var isDragging = false
+
+ private var onCloseCallback: (() -> Unit)? = null
+ private var onFullscreenCallback: (() -> Unit)? = null
+
+ companion object {
+ private const val TAG = "FloatingVideoWindow"
+ private const val CONTROL_AUTO_HIDE_MS = 3000L
+ }
+
+ /**
+ * 检查悬浮窗权限是否已授予。
+ */
+ fun canDrawOverlays(): Boolean {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ Settings.canDrawOverlays(context)
+ } else {
+ true
+ }
+ }
+
+ /**
+ * 打开悬浮窗权限设置页面。
+ */
+ fun requestOverlayPermission(activity: Activity, requestCode: Int) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(context)) {
+ val intent = Intent(
+ Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
+ Uri.parse("package:${context.packageName}")
+ )
+ activity.startActivityForResult(intent, requestCode)
+ }
+ }
+
+ /**
+ * 显示悬浮窗。
+ * 在 Activity.onStop() 时调用。
+ */
+ fun show(onClose: () -> Unit, onFullscreen: () -> Unit) {
+ if (isShowing) return
+ if (!canDrawOverlays()) {
+ Log.w(TAG, "show(): SYSTEM_ALERT_WINDOW permission not granted")
+ return
+ }
+
+ onCloseCallback = onClose
+ onFullscreenCallback = onFullscreen
+
+ createWindowView()
+ setupTouchHandler()
+
+ val screenWidth = metrics.widthPixels
+ val screenHeight = metrics.heightPixels
+ val smallerDim = minOf(screenWidth, screenHeight)
+
+ // 悬浮窗大小:短边的 35%
+ val windowWidth = (smallerDim * 0.35f).toInt().coerceIn(dp(120), smallerDim / 2)
+
+ // 计算初始位置:右上角
+ if (windowX == 0 && windowY == 0) {
+ windowX = screenWidth - windowWidth - dp(16)
+ windowY = dp(80)
+ }
+
+ windowParams.apply {
+ x = windowX
+ y = windowY
+ width = windowWidth
+ height = ViewGroup.LayoutParams.WRAP_CONTENT
+ flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
+ WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
+ WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED
+ }
+
+ try {
+ windowManager.addView(windowView, windowParams)
+ isShowing = true
+ Log.i(TAG, "show(): window added at ($windowX, $windowY) size=$windowWidth")
+ } catch (e: Exception) {
+ Log.e(TAG, "show(): failed to add window", e)
+ }
+ }
+
+ /**
+ * 隐藏悬浮窗。
+ * 在 Activity.onStart() 时调用。
+ */
+ fun hide() {
+ if (!isShowing) return
+ Log.i(TAG, "hide(): removing window")
+
+ sessionCollectJob?.cancel()
+ sessionCollectJob = null
+
+ // 保存当前位置
+ windowView?.let {
+ windowX = windowParams.x
+ windowY = windowParams.y
+ }
+
+ // 先分离 Surface
+ currentSurface?.let { surface ->
+ scope.launch {
+ NativeCoreFacade.detachVideoSurface(surface)
+ }
+ currentSurface = null
+ }
+
+ try {
+ windowView?.let { windowManager.removeView(it) }
+ } catch (e: Exception) {
+ Log.w(TAG, "hide(): failed to remove view", e)
+ }
+
+ windowView = null
+ textureView = null
+ controlCircle = null
+ controlPanel = null
+ closeBtn = null
+ fullscreenBtn = null
+ touchEventHandler = null
+ isShowing = false
+ controlsVisible = false
+ }
+
+ /**
+ * 彻底销毁悬浮窗并清理资源。
+ */
+ fun destroy() {
+ hide()
+ scope.cancel()
+ onCloseCallback = null
+ onFullscreenCallback = null
+ }
+
+ private val windowParams = WindowManager.LayoutParams().apply {
+ type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
+ } else {
+ @Suppress("DEPRECATION")
+ WindowManager.LayoutParams.TYPE_PHONE
+ }
+ format = PixelFormat.TRANSLUCENT
+ gravity = Gravity.TOP or Gravity.START
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ private fun createWindowView() {
+ val root = FrameLayout(context).apply {
+ clipChildren = false
+ clipToPadding = false
+ }
+
+ // --- TextureView 用于视频渲染 ---
+ val tv = TextureView(context).apply {
+ layoutParams = FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.WRAP_CONTENT,
+ )
+ // 触摸事件:通过 TouchEventHandler 注入到远程设备
+ setOnTouchListener { _, event ->
+ touchEventHandler?.handleMotionEvent(event) == true
+ }
+
+ surfaceTextureListener = object : TextureView.SurfaceTextureListener {
+ override fun onSurfaceTextureAvailable(
+ surface: SurfaceTexture,
+ width: Int,
+ height: Int,
+ ) {
+ touchAreaSize = IntSize(width, height)
+ val s = android.view.Surface(surface)
+ currentSurface = s
+ scope.launch {
+ NativeCoreFacade.attachVideoSurface(s)
+ }
+ Log.i(TAG, "surfaceTextureAvailable: ${width}x${height}")
+ }
+
+ override fun onSurfaceTextureSizeChanged(
+ surface: SurfaceTexture,
+ width: Int,
+ height: Int,
+ ) {
+ touchAreaSize = IntSize(width, height)
+ // 更新 TouchEventHandler 的触摸区域
+ val sess = AppRuntime.scrcpy?.currentSessionState?.value
+ if (sess != null) {
+ touchEventHandler = buildTouchEventHandler(
+ sess,
+ IntSize(width, height),
+ )
+ }
+ }
+
+ override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
+ currentSurface?.let { s ->
+ scope.launch {
+ NativeCoreFacade.detachVideoSurface(s)
+ }
+ }
+ currentSurface = null
+ return true
+ }
+
+ override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {}
+ }
+ }
+ textureView = tv
+ root.addView(tv)
+
+ // --- 圆圈控制按钮 ---
+ val circleSize = dp(36)
+ val circleBg = GradientDrawable().apply {
+ shape = GradientDrawable.OVAL
+ setColor(0x80000000.toInt())
+ setStroke(dp(1), 0x80FFFFFF.toInt())
+ }
+ val circle = View(context).apply {
+ layoutParams = FrameLayout.LayoutParams(circleSize, circleSize).apply {
+ gravity = Gravity.TOP or Gravity.END
+ topMargin = dp(8)
+ rightMargin = dp(8)
+ }
+ background = circleBg
+ setOnTouchListener { v, event ->
+ handleCircleTouch(v, event, root)
+ }
+ }
+ controlCircle = circle
+ root.addView(circle)
+
+ // --- 控制面板(关闭/全屏按钮)---
+ val panel = LinearLayout(context).apply {
+ layoutParams = FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.WRAP_CONTENT,
+ FrameLayout.LayoutParams.WRAP_CONTENT,
+ ).apply {
+ gravity = Gravity.TOP or Gravity.END
+ topMargin = dp(8) + circleSize + dp(8)
+ rightMargin = dp(8)
+ }
+ orientation = LinearLayout.VERTICAL
+ visibility = View.GONE
+
+ background = GradientDrawable().apply {
+ shape = GradientDrawable.RECTANGLE
+ cornerRadius = dp(8).toFloat()
+ setColor(0xCC222222.toInt())
+ }
+ }
+
+ fun createPanelButton(label: String, onClick: () -> Unit): TextView {
+ return TextView(context).apply {
+ text = label
+ textSize = 13f
+ setTextColor(0xFFFFFFFF.toInt())
+ gravity = Gravity.CENTER
+ setPadding(dp(16), dp(10), dp(16), dp(10))
+ setOnClickListener { onClick() }
+ }
+ }
+
+ closeBtn = createPanelButton("关闭") {
+ hide()
+ onCloseCallback?.invoke()
+ }
+ panel.addView(closeBtn)
+
+ // 分隔线
+ panel.addView(View(context).apply {
+ layoutParams = LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ dp(1),
+ )
+ setBackgroundColor(0x40FFFFFF.toInt())
+ })
+
+ fullscreenBtn = createPanelButton("全屏") {
+ hide()
+ onFullscreenCallback?.invoke()
+ }
+ panel.addView(fullscreenBtn)
+
+ controlPanel = panel
+ root.addView(panel)
+
+ windowView = root
+ }
+
+ private fun handleCircleTouch(v: View, event: MotionEvent, root: View): Boolean {
+ when (event.action) {
+ MotionEvent.ACTION_DOWN -> {
+ dragStartX = event.rawX
+ dragStartY = event.rawY
+ windowStartX = windowParams.x
+ windowStartY = windowParams.y
+ isDragging = false
+ return true
+ }
+
+ MotionEvent.ACTION_MOVE -> {
+ val dx = event.rawX - dragStartX
+ val dy = event.rawY - dragStartY
+ if (kotlin.math.abs(dx) > dp(10).toFloat() || kotlin.math.abs(dy) > dp(10).toFloat()) {
+ isDragging = true
+ windowParams.x = (windowStartX + dx).toInt()
+ windowParams.y = (windowStartY + dy).toInt()
+ windowView?.let { windowManager.updateViewLayout(it, windowParams) }
+ }
+ return true
+ }
+
+ MotionEvent.ACTION_UP -> {
+ if (!isDragging) {
+ // 点击:切换控制面板
+ toggleControls()
+ }
+ // 保存位置
+ windowX = windowParams.x
+ windowY = windowParams.y
+ return true
+ }
+ }
+ return false
+ }
+
+ private fun toggleControls() {
+ val panel = controlPanel ?: return
+ controlsVisible = !controlsVisible
+ panel.visibility = if (controlsVisible) View.VISIBLE else View.GONE
+
+ if (controlsVisible) {
+ // 自动隐藏
+ scope.launch {
+ delay(CONTROL_AUTO_HIDE_MS)
+ if (controlsVisible) {
+ panel.visibility = View.GONE
+ controlsVisible = false
+ }
+ }
+ }
+ }
+
+ private fun setupTouchHandler() {
+ val scrcpy = AppRuntime.scrcpy ?: return
+ val session = scrcpy.currentSessionState.value ?: return
+
+ // 监听 session 变化以重建 TouchEventHandler
+ sessionCollectJob = scope.launch {
+ scrcpy.currentSessionState.collectLatest { sess ->
+ if (sess != null) {
+ touchEventHandler = buildTouchEventHandler(sess, touchAreaSize)
+ } else {
+ touchEventHandler = null
+ // session 结束,关闭悬浮窗
+ hide()
+ onCloseCallback?.invoke()
+ }
+ }
+ }
+ }
+
+ /**
+ * 构建 TouchEventHandler 实例。
+ * 与 FullscreenControlPage 中的创建方式一致,使用相同的状态持有者类型。
+ */
+ private fun buildTouchEventHandler(
+ session: Scrcpy.Session.SessionInfo,
+ areaSize: IntSize,
+ ): TouchEventHandler {
+ // 清除旧的触摸状态
+ resetTouchState()
+
+ return TouchEventHandler(
+ coroutineScope = scope,
+ session = session,
+ touchAreaSize = areaSize,
+ activePointerIds = activePointerIds,
+ activePointerPositions = activePointerPositions,
+ activePointerDevicePositions = activePointerDevicePositions,
+ pointerLabels = pointerLabels,
+ nextPointerLabel = nextPointerLabel,
+ mouseHoverEnabled = session.mouseHover,
+ onInjectTouch = { action, pointerId, x, y, pressure, actionButton, buttons ->
+ withContext(Dispatchers.IO) {
+ AppRuntime.scrcpy?.injectTouch(
+ action = action,
+ pointerId = pointerId,
+ x = x,
+ y = y,
+ screenWidth = session.width,
+ screenHeight = session.height,
+ pressure = pressure,
+ actionButton = actionButton,
+ buttons = buttons,
+ )
+ }
+ },
+ onBackOrScreenOn = { action ->
+ withContext(Dispatchers.IO) {
+ AppRuntime.scrcpy?.pressBackOrTurnScreenOn(action)
+ }
+ },
+ onActiveTouchCountChanged = {},
+ onActiveTouchDebugChanged = {},
+ onNextPointerLabelChanged = { nextPointerLabel = it },
+ )
+ }
+
+ private fun resetTouchState() {
+ activePointerIds.clear()
+ activePointerPositions.clear()
+ activePointerDevicePositions.clear()
+ pointerLabels.clear()
+ nextPointerLabel = 1
+ }
+
+ private fun dp(value: Int): Int {
+ return (value * metrics.density + 0.5f).toInt()
+ }
+}