feat():悬浮窗方案 替代 系统PiP方案
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -36,8 +37,7 @@
|
||||
android:name=".StreamActivity"
|
||||
android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize"
|
||||
android:exported="false"
|
||||
android:resizeableActivity="true"
|
||||
android:supportsPictureInPicture="true" />
|
||||
android:resizeableActivity="true" />
|
||||
|
||||
<activity
|
||||
android:name=".LockscreenPasswordActivity"
|
||||
|
||||
@@ -1,150 +1,81 @@
|
||||
package io.github.miuzarte.scrcpyforandroid
|
||||
|
||||
import android.R.drawable
|
||||
import android.app.PictureInPictureUiState
|
||||
import android.app.RemoteAction
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.core.app.PictureInPictureParamsCompat.Builder
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.pip.BasicPictureInPicture
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import io.github.miuzarte.scrcpyforandroid.pages.StreamScreen
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
||||
import io.github.miuzarte.scrcpyforandroid.services.AppScreenOn
|
||||
import io.github.miuzarte.scrcpyforandroid.services.PictureInPictureActionReceiver
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.FloatingVideoWindow
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
class StreamActivity: FragmentActivity() {
|
||||
private val basicPip by lazy { BasicPictureInPicture(this, ContextCompat.getMainExecutor(this)) }
|
||||
|
||||
private val pipActionReceiver = PictureInPictureActionReceiver()
|
||||
private var isPipActionReceiverRegistered = false
|
||||
private val floatingWindow by lazy { FloatingVideoWindow(this) }
|
||||
private val activityScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
// 是否处于 pip
|
||||
// 回到全屏时会因重建而变回初始值
|
||||
private val _pipModeState = MutableStateFlow(false)
|
||||
val pipModeState: StateFlow<Boolean> = _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 -> {}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
},
|
||||
)
|
||||
*/
|
||||
}
|
||||
|
||||
fun configurePip(block: Builder.() -> Builder) =
|
||||
basicPip.setPictureInPictureParams(Builder().block().build())
|
||||
}
|
||||
|
||||
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<StreamActivity>? = null
|
||||
|
||||
@@ -153,9 +84,8 @@ class StreamActivity: FragmentActivity() {
|
||||
}
|
||||
|
||||
fun dismissActivePictureInPicture() {
|
||||
currentActivityRef?.get()
|
||||
?.takeIf { it.isInPictureInPictureMode }
|
||||
?.finish()
|
||||
// 悬浮窗模式:不再使用系统 PiP,改为关闭悬浮窗
|
||||
currentActivityRef?.get()?.floatingWindow?.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TouchEventHandler?>(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) {
|
||||
|
||||
@@ -662,7 +662,6 @@ fun MainScreen() {
|
||||
FullscreenControlRoute(
|
||||
scrcpy = scrcpy,
|
||||
onBack = rootNavigator.pop,
|
||||
isInPip = false,
|
||||
autoExitOnStop = true,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<Rect?>(null) }
|
||||
var lastPipAspectRatio by remember { mutableStateOf<Rational?>(null) }
|
||||
var lastPipOrientationLandscape by remember { mutableStateOf<Boolean?>(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,
|
||||
)
|
||||
}
|
||||
},
|
||||
onVideoBoundsInWindowChanged = onVideoBoundsInWindowChanged,
|
||||
)
|
||||
|
||||
@@ -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<Int>()
|
||||
private val activePointerPositions = LinkedHashMap<Int, Offset>()
|
||||
private val activePointerDevicePositions = LinkedHashMap<Int, Pair<Int, Int>>()
|
||||
private val pointerLabels = LinkedHashMap<Int, Int>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user