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 串流,仍然需要回到应用中点击停止 - 关闭画中画后不会停止 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))的定义为

View File

@@ -1,17 +1,18 @@
package io.github.miuzarte.scrcpyforandroid package io.github.miuzarte.scrcpyforandroid
import android.app.PendingIntent import android.app.PendingIntent
import android.app.PictureInPictureUiState
import android.app.RemoteAction import android.app.RemoteAction
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.res.Configuration
import android.graphics.drawable.Icon import android.graphics.drawable.Icon
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.annotation.RequiresApi
import androidx.core.app.PictureInPictureParamsCompat import androidx.core.app.PictureInPictureParamsCompat
import androidx.core.pip.BasicPictureInPicture import androidx.core.pip.BasicPictureInPicture
import androidx.core.pip.PictureInPictureDelegate
import io.github.miuzarte.scrcpyforandroid.pages.StreamScreen import io.github.miuzarte.scrcpyforandroid.pages.StreamScreen
import io.github.miuzarte.scrcpyforandroid.services.PictureInPictureActionReceiver import io.github.miuzarte.scrcpyforandroid.services.PictureInPictureActionReceiver
import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTarget import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTarget
@@ -20,26 +21,19 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
class StreamActivity : ComponentActivity() { class StreamActivity : ComponentActivity() {
private lateinit var basicPip: BasicPictureInPicture // = this private val basicPip = BasicPictureInPicture(this)
// pip 是否已被配置、允许进入 // 是否处于 pip
private var pipConfigured: Boolean = false // 回到全屏时会因重建而变回初始值
private var pipParams = PictureInPictureParamsCompat
.Builder()
.setEnabled(false)
.build()
// 是否处于 PiP
// MIUI 上进入 PiP 时,动画事件比 onPictureInPictureModeChanged() 更稳定,
// 所以进入 PiP 直接由动画事件置为 true
private val _pipModeState = MutableStateFlow(false) private val _pipModeState = MutableStateFlow(false)
val pipModeState: StateFlow<Boolean> = _pipModeState val pipModeState: StateFlow<Boolean> = _pipModeState
val pipStopAction: RemoteAction by lazy { val pipStopAction: RemoteAction by lazy {
val intent = Intent(this, PictureInPictureActionReceiver::class.java).apply { val intent = Intent(this, PictureInPictureActionReceiver::class.java)
action = PictureInPictureActionReceiver.ACTION_STOP_SCRCPY .apply {
`package` = packageName action = PictureInPictureActionReceiver.ACTION_STOP_SCRCPY
} `package` = packageName
}
val pendingIntent = PendingIntent.getBroadcast( val pendingIntent = PendingIntent.getBroadcast(
this, this,
1, 1,
@@ -54,27 +48,31 @@ class StreamActivity : ComponentActivity() {
) )
} }
// 每次 进出全屏/进出画中画
// 都会重建 activity
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
basicPip = BasicPictureInPicture(this)
// 声明要画中画
basicPip.setEnabled(true)
setContent {
StreamScreen(activity = this)
}
/*
// 可能以后有用
basicPip.addOnPictureInPictureEventListener( basicPip.addOnPictureInPictureEventListener(
executor = mainExecutor, executor = mainExecutor,
listener = object : PictureInPictureDelegate.OnPictureInPictureEventListener { listener = object : PictureInPictureDelegate.OnPictureInPictureEventListener {
override fun onPictureInPictureEvent( override fun onPictureInPictureEvent(
event: PictureInPictureDelegate.Event, event: PictureInPictureDelegate.Event,
newConfig: Configuration?, config: Configuration?,
) { ) {
// HyperOS 3 下稳定收到的是进入动画事件 // MIUI 只有这些事件
// 这里直接把进入动画开始视为已经进入 PiP尽量少依赖平台回调
when (event) { when (event) {
PictureInPictureDelegate.Event.ENTER_ANIMATION_START -> { PictureInPictureDelegate.Event.ENTER_ANIMATION_START -> {}
_pipModeState.value = true PictureInPictureDelegate.Event.ENTER_ANIMATION_END -> {}
VideoOutputTargetState.set(VideoOutputTarget.PICTURE_IN_PICTURE)
}
PictureInPictureDelegate.Event.ENTER_ANIMATION_END -> {
_pipModeState.value = true
}
PictureInPictureDelegate.Event.STASHED -> {} PictureInPictureDelegate.Event.STASHED -> {}
PictureInPictureDelegate.Event.UNSTASHED -> {} PictureInPictureDelegate.Event.UNSTASHED -> {}
@@ -86,49 +84,18 @@ class StreamActivity : ComponentActivity() {
} }
} }
) )
*/
setContent {
StreamScreen(activity = this)
}
} }
fun configurePictureInPicture( fun configurePip(params: PictureInPictureParamsCompat) {
enabled: Boolean, basicPip.setPictureInPictureParams(params)
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() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
// 回到全屏也会停止
/*
if (_pipModeState.value) { if (_pipModeState.value) {
Thread { Thread {
runBlocking { runBlocking {
@@ -136,19 +103,28 @@ class StreamActivity : ComponentActivity() {
} }
}.start() }.start()
} }
*/
} }
*/
// MIUI 不进 //- onPictureInPictureModeChanged
override fun onPictureInPictureModeChanged( //+ onPictureInPictureUiStateChanged
isInPictureInPictureMode: Boolean, //- onUserLeaveHint
newConfig: Configuration,
) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
if (!isInPictureInPictureMode) { @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
_pipModeState.value = false override fun onPictureInPictureUiStateChanged(pipState: PictureInPictureUiState) {
VideoOutputTargetState.set(VideoOutputTarget.FULLSCREEN) 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() val lazyListState = rememberLazyListState()
var logoHeightPx by remember { mutableIntStateOf(0) } var logoHeightPx by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
AppUpdateChecker.ensureChecked(BuildConfig.VERSION_NAME)
}
val scrollProgress by remember { val scrollProgress by remember {
derivedStateOf { derivedStateOf {
if (logoHeightPx <= 0) { 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -238,6 +239,8 @@ fun DeviceTabPage(
var audioForwardingSupported by rememberSaveable { mutableStateOf(true) } var audioForwardingSupported by rememberSaveable { mutableStateOf(true) }
var cameraMirroringSupported by rememberSaveable { mutableStateOf(true) } var cameraMirroringSupported by rememberSaveable { mutableStateOf(true) }
var pendingScrollToPreview by rememberSaveable { mutableStateOf(false) }
val listState = rememberLazyListState()
val currentTarget = val currentTarget =
if (currentTargetHost.isNotBlank()) if (currentTargetHost.isNotBlank())
@@ -528,6 +531,7 @@ fun DeviceTabPage(
suspend fun startScrcpySession() { suspend fun startScrcpySession() {
val options = scrcpyOptions.toClientOptions(soBundleShared).fix() val options = scrcpyOptions.toClientOptions(soBundleShared).fix()
val session = scrcpy.start(options) val session = scrcpy.start(options)
pendingScrollToPreview = true
if (options.disableScreensaver) { if (options.disableScreensaver) {
setKeepScreenOn(true) setKeepScreenOn(true)
} }
@@ -553,6 +557,15 @@ fun DeviceTabPage(
snackbar.show("scrcpy 已启动") 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) { suspend fun handleAdbConnected(host: String, port: Int) {
currentTargetHost = host currentTargetHost = host
currentTargetPort = port currentTargetPort = port
@@ -707,6 +720,7 @@ fun DeviceTabPage(
LazyColumn( LazyColumn(
contentPadding = contentPadding, contentPadding = contentPadding,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
state = listState,
) { ) {
item { item {
StatusCard( StatusCard(

View File

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

View File

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

View File

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

View File

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