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() + } +}