refactor: pip optimize

This commit is contained in:
Miuzarte
2026-04-11 17:36:50 +08:00
parent 65d5f12344
commit 350f817c27
8 changed files with 107 additions and 94 deletions

View File

@@ -25,6 +25,7 @@
## 已知问题
- 因为没有设备用于(也懒得)测试,应用可能无法正常运行在安卓版本较低的设备上,特别是画中画功能,非常取决于国产 ROM 的实现
- 关闭画中画后不会停止 scrcpy 串流,仍然需要回到应用中点击停止
- 虚拟按键的截图实现方式为发送
`keycode 120`,安卓官方([keycodes.h#349](https://android.googlesource.com/platform/frameworks/native/+/master/include/android/keycodes.h#349))的定义为

View File

@@ -1,17 +1,18 @@
package io.github.miuzarte.scrcpyforandroid
import android.app.PendingIntent
import android.app.PictureInPictureUiState
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.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.annotation.RequiresApi
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
@@ -20,23 +21,16 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class StreamActivity : ComponentActivity() {
private lateinit var basicPip: BasicPictureInPicture // = this
private val basicPip = BasicPictureInPicture(this)
// pip 是否已被配置、允许进入
private var pipConfigured: Boolean = false
private var pipParams = PictureInPictureParamsCompat
.Builder()
.setEnabled(false)
.build()
// 是否处于 PiP
// MIUI 上进入 PiP 时,动画事件比 onPictureInPictureModeChanged() 更稳定,
// 所以进入 PiP 直接由动画事件置为 true
// 是否处于 pip
// 回到全屏时会因重建而变回初始值
private val _pipModeState = MutableStateFlow(false)
val pipModeState: StateFlow<Boolean> = _pipModeState
val pipStopAction: RemoteAction by lazy {
val intent = Intent(this, PictureInPictureActionReceiver::class.java).apply {
val intent = Intent(this, PictureInPictureActionReceiver::class.java)
.apply {
action = PictureInPictureActionReceiver.ACTION_STOP_SCRCPY
`package` = packageName
}
@@ -54,27 +48,31 @@ class StreamActivity : ComponentActivity() {
)
}
// 每次 进出全屏/进出画中画
// 都会重建 activity
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
basicPip = BasicPictureInPicture(this)
// 声明要画中画
basicPip.setEnabled(true)
setContent {
StreamScreen(activity = this)
}
/*
// 可能以后有用
basicPip.addOnPictureInPictureEventListener(
executor = mainExecutor,
listener = object : PictureInPictureDelegate.OnPictureInPictureEventListener {
override fun onPictureInPictureEvent(
event: PictureInPictureDelegate.Event,
newConfig: Configuration?,
config: Configuration?,
) {
// HyperOS 3 下稳定收到的是进入动画事件
// 这里直接把进入动画开始视为已经进入 PiP尽量少依赖平台回调
// MIUI 只有这些事件
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.ENTER_ANIMATION_START -> {}
PictureInPictureDelegate.Event.ENTER_ANIMATION_END -> {}
PictureInPictureDelegate.Event.STASHED -> {}
PictureInPictureDelegate.Event.UNSTASHED -> {}
@@ -86,49 +84,18 @@ class StreamActivity : ComponentActivity() {
}
}
)
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()
fun configurePip(params: PictureInPictureParamsCompat) {
basicPip.setPictureInPictureParams(params)
}
/*
// 回到全屏也会停止, 暂时不做
override fun onDestroy() {
super.onDestroy()
// 回到全屏也会停止
/*
if (_pipModeState.value) {
Thread {
runBlocking {
@@ -136,19 +103,28 @@ class StreamActivity : ComponentActivity() {
}
}.start()
}
*/
}
*/
// MIUI 不进
override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean,
newConfig: Configuration,
) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
//- onPictureInPictureModeChanged
//+ onPictureInPictureUiStateChanged
//- onUserLeaveHint
if (!isInPictureInPictureMode) {
_pipModeState.value = false
VideoOutputTargetState.set(VideoOutputTarget.FULLSCREEN)
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
override fun onPictureInPictureUiStateChanged(pipState: PictureInPictureUiState) {
super.onPictureInPictureUiStateChanged(pipState)
when {
// 进入画中画
pipState.isTransitioningToPip -> if (!_pipModeState.value) {
_pipModeState.value = true
VideoOutputTargetState.set(VideoOutputTarget.PICTURE_IN_PICTURE)
}
// 收进边缘
pipState.isStashed -> if (!_pipModeState.value) {
_pipModeState.value = true
VideoOutputTargetState.set(VideoOutputTarget.PICTURE_IN_PICTURE)
}
}
}

View File

@@ -80,6 +80,10 @@ internal fun AboutScreen() {
val lazyListState = rememberLazyListState()
var logoHeightPx by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
AppUpdateChecker.ensureChecked(BuildConfig.VERSION_NAME)
}
val scrollProgress by remember {
derivedStateOf {
if (logoHeightPx <= 0) {

View File

@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.runtime.Composable
@@ -238,6 +239,8 @@ fun DeviceTabPage(
var audioForwardingSupported by rememberSaveable { mutableStateOf(true) }
var cameraMirroringSupported by rememberSaveable { mutableStateOf(true) }
var pendingScrollToPreview by rememberSaveable { mutableStateOf(false) }
val listState = rememberLazyListState()
val currentTarget =
if (currentTargetHost.isNotBlank())
@@ -528,6 +531,7 @@ fun DeviceTabPage(
suspend fun startScrcpySession() {
val options = scrcpyOptions.toClientOptions(soBundleShared).fix()
val session = scrcpy.start(options)
pendingScrollToPreview = true
if (options.disableScreensaver) {
setKeepScreenOn(true)
}
@@ -553,6 +557,15 @@ fun DeviceTabPage(
snackbar.show("scrcpy 已启动")
}
LaunchedEffect(pendingScrollToPreview, sessionInfo) {
if (!pendingScrollToPreview) return@LaunchedEffect
val session = sessionInfo ?: return@LaunchedEffect
if (session.width <= 0 || session.height <= 0) return@LaunchedEffect
// status/device list/scrcpy panel are above the preview card
listState.animateScrollToItem(index = 3)
pendingScrollToPreview = false
}
suspend fun handleAdbConnected(host: String, port: Int) {
currentTargetHost = host
currentTargetPort = port
@@ -707,6 +720,7 @@ fun DeviceTabPage(
LazyColumn(
contentPadding = contentPadding,
scrollBehavior = scrollBehavior,
state = listState,
) {
item {
StatusCard(

View File

@@ -77,14 +77,12 @@ fun StreamScreen(activity: StreamActivity) {
).also { ratio ->
lastPipAspectRatio = ratio
}
activity.configurePictureInPicture(
enabled = true,
params = PictureInPictureParamsCompat.Builder()
activity.configurePip(
PictureInPictureParamsCompat.Builder()
.setEnabled(true)
.setAspectRatio(pipAspectRatio)
.setSourceRectHint(
if (!isInPip) pipSourceRectHint else null
)
.setSourceRectHint(pipSourceRectHint)
.setSeamlessResizeEnabled(true)
.setCloseAction(activity.pipStopAction)
.build(),

View File

@@ -44,7 +44,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
)
val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair(
intPreferencesKey("device_preview_card_height_dp"),
320
1080/3
)
val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = Pair(
booleanPreferencesKey("preview_virtual_button_show_text"),

View File

@@ -764,17 +764,27 @@ fun ScrcpyVideoSurface(
session: Scrcpy.Session.SessionInfo?,
target: VideoOutputTarget,
) {
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val scope = rememberCoroutineScope()
val lifecycleOwner = LocalLifecycleOwner.current
var currentSurface by remember { mutableStateOf<Surface?>(null) }
var currentSurfaceView by remember { mutableStateOf<SurfaceView?>(null) }
val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val lifecycleOwner = LocalLifecycleOwner.current
val currentTarget by VideoOutputTargetState.current.collectAsState()
val latestSession by rememberUpdatedState(session)
val latestCurrentTarget by rememberUpdatedState(currentTarget)
LaunchedEffect(session?.width, session?.height, currentSurfaceView) {
val currentTarget by VideoOutputTargetState.current.collectAsState()
val latestCurrentTarget by rememberUpdatedState(currentTarget)
val latestSession by rememberUpdatedState(session)
val latestRequestedTarget by rememberUpdatedState(target)
LaunchedEffect(session?.width, session?.height, currentSurfaceView, target) {
val surfaceView = currentSurfaceView ?: return@LaunchedEffect
if (target == VideoOutputTarget.PICTURE_IN_PICTURE) {
// In PiP, let SurfaceView buffer follow viewport to avoid stale portrait frame crop.
surfaceView.holder.setSizeFromLayout()
return@LaunchedEffect
}
val currentSession = session ?: return@LaunchedEffect
if (currentSession.width > 0 && currentSession.height > 0) {
surfaceView.holder.setFixedSize(currentSession.width, currentSession.height)
@@ -782,8 +792,8 @@ fun ScrcpyVideoSurface(
}
LaunchedEffect(session, currentSurface, currentTarget, target) {
val surface = currentSurface
if (currentTarget == target && session != null && surface != null && surface.isValid) {
val surface = currentSurface ?: return@LaunchedEffect
if (currentTarget == target && session != null && surface.isValid) {
NativeCoreFacade.attachVideoSurface(surface)
}
}
@@ -844,9 +854,14 @@ fun ScrcpyVideoSurface(
height: Int,
) {
if (width <= 0 || height <= 0) return
if (!holder.surface.isValid) return
val surface = holder.surface
if (!surface.isValid) return
currentSurface = surface
if (latestCurrentTarget == latestRequestedTarget && latestSession != null) {
scope.launch {
NativeCoreFacade.attachVideoSurface(surface)
}
}
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
@@ -940,7 +955,7 @@ internal fun DeviceTile(
.weight(1f),
verticalAlignment = Alignment.CenterVertically,
) {
// statu dot
// status dot
Box(
modifier = Modifier
.size(8.dp)

View File

@@ -351,6 +351,9 @@ class VirtualButtonBar(
maxX * offsetXFraction.coerceIn(0f, 1f)
val currentY =
maxY * offsetYFraction.coerceIn(0f, 1f)
val popupAlignment =
if (offsetXFraction > 0.5f) PopupPositionProvider.Align.TopEnd
else PopupPositionProvider.Align.TopStart
Box(
modifier = Modifier
@@ -422,6 +425,7 @@ class VirtualButtonBar(
showActions = false
},
renderInRootScaffold = true,
popupAlignment = popupAlignment,
)
}
}
@@ -434,6 +438,7 @@ class VirtualButtonBar(
onDismiss: () -> Unit,
onAction: suspend (VirtualButtonAction) -> Unit,
renderInRootScaffold: Boolean,
popupAlignment: PopupPositionProvider.Align = PopupPositionProvider.Align.TopEnd,
) {
val scope = rememberCoroutineScope()
val haptics = LocalAppHaptics.current
@@ -456,7 +461,7 @@ class VirtualButtonBar(
OverlayListPopup(
show = show,
popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
alignment = PopupPositionProvider.Align.TopEnd,
alignment = popupAlignment,
onDismissRequest = onDismiss,
renderInRootScaffold = renderInRootScaffold,
enableWindowDim = false,