From a4e6027097a5d326e38174d8f06a8dba81d015f6 Mon Sep 17 00:00:00 2001
From: Miuzarte <982809597@qq.com>
Date: Sat, 11 Apr 2026 18:24:34 +0800
Subject: [PATCH] feat: PreviewCard auto focus
---
ScrcpyForAndroid.code-workspace | 7 ++
app/src/main/AndroidManifest.xml | 4 --
.../scrcpyforandroid/StreamActivity.kt | 61 +++++++++--------
.../scrcpyforandroid/pages/DeviceTabScreen.kt | 22 +++++--
.../pages/FullscreenControlScreen.kt | 1 -
.../scrcpyforandroid/pages/StreamScreen.kt | 6 --
.../PictureInPictureActionReceiver.kt | 17 ++++-
.../scrcpyforandroid/widgets/DeviceWidgets.kt | 65 +++++++++----------
.../widgets/VideoOutputTarget.kt | 2 -
9 files changed, 105 insertions(+), 80 deletions(-)
diff --git a/ScrcpyForAndroid.code-workspace b/ScrcpyForAndroid.code-workspace
index 5ed02f7..5a1c1c5 100644
--- a/ScrcpyForAndroid.code-workspace
+++ b/ScrcpyForAndroid.code-workspace
@@ -20,6 +20,10 @@
"name": "shizuku",
"path": "../shizuku"
},
+ {
+ "name": "moonlight-android",
+ "path": "../moonlight-android"
+ },
{
"name": "KernelSU",
"path": "../KernelSU"
@@ -27,6 +31,9 @@
{
"name": "adblib",
"path": "../adblib"
+ },
+ {
+ "path": "../socialite"
}
],
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 784e834..30a4cbe 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -19,10 +19,6 @@
android:supportsRtl="true"
android:theme="@style/Theme.ScrcpyForAndroid">
-
-
= _pipModeState
val pipStopAction: RemoteAction by lazy {
- val intent = Intent(this, PictureInPictureActionReceiver::class.java)
- .apply {
- action = PictureInPictureActionReceiver.ACTION_STOP_SCRCPY
- `package` = packageName
- }
- val pendingIntent = PendingIntent.getBroadcast(
- this,
- 1,
- intent,
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
- )
RemoteAction(
Icon.createWithResource(this, android.R.drawable.ic_menu_close_clear_cancel),
"停止投屏",
"停止投屏",
- pendingIntent,
+ PictureInPictureActionReceiver.createPendingIntent(this),
)
}
@@ -53,6 +40,8 @@ class StreamActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ registerPipActionReceiver()
+
// 声明要画中画
basicPip.setEnabled(true)
@@ -91,6 +80,11 @@ class StreamActivity : ComponentActivity() {
basicPip.setPictureInPictureParams(params)
}
+ override fun onDestroy() {
+ unregisterPipActionReceiver()
+ super.onDestroy()
+ }
+
/*
// 回到全屏也会停止, 暂时不做
override fun onDestroy() {
@@ -110,22 +104,37 @@ class StreamActivity : ComponentActivity() {
//+ onPictureInPictureUiStateChanged
//- onUserLeaveHint
- @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+ // @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
override fun onPictureInPictureUiStateChanged(pipState: PictureInPictureUiState) {
super.onPictureInPictureUiStateChanged(pipState)
+ _pipModeState.value = true
+
+ /*
when {
// 进入画中画
- pipState.isTransitioningToPip -> if (!_pipModeState.value) {
- _pipModeState.value = true
- VideoOutputTargetState.set(VideoOutputTarget.PICTURE_IN_PICTURE)
- }
+ pipState.isTransitioningToPip -> {}
// 收进边缘
- pipState.isStashed -> if (!_pipModeState.value) {
- _pipModeState.value = true
- VideoOutputTargetState.set(VideoOutputTarget.PICTURE_IN_PICTURE)
- }
+ 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 {
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt
index 446bf8f..366d366 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt
@@ -16,6 +16,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -86,6 +87,8 @@ private const val ADB_KEEPALIVE_TIMEOUT_MS = 1_500L
private const val ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS = 2_000L
private const val ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS = 2_000L
private const val ADB_TCP_PROBE_TIMEOUT_MS = 500
+private const val PREVIEW_CARD_ITEM_KEY = "preview_card"
+private const val PREVIEW_CARD_ITEM_INDEX = 3
@Composable
fun DeviceTabScreen(
@@ -241,6 +244,11 @@ fun DeviceTabPage(
var cameraMirroringSupported by rememberSaveable { mutableStateOf(true) }
var pendingScrollToPreview by rememberSaveable { mutableStateOf(false) }
val listState = rememberLazyListState()
+ val isPreviewCardVisible by remember(listState) {
+ derivedStateOf {
+ listState.layoutInfo.visibleItemsInfo.any { it.key == PREVIEW_CARD_ITEM_KEY }
+ }
+ }
val currentTarget =
if (currentTargetHost.isNotBlank())
@@ -557,13 +565,10 @@ fun DeviceTabPage(
snackbar.show("scrcpy 已启动")
}
- LaunchedEffect(pendingScrollToPreview, sessionInfo) {
+ LaunchedEffect(pendingScrollToPreview, isPreviewCardVisible) {
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
+ if (isPreviewCardVisible) return@LaunchedEffect
+ listState.animateScrollToItem(PREVIEW_CARD_ITEM_INDEX)
}
suspend fun handleAdbConnected(host: String, port: Int) {
@@ -952,8 +957,9 @@ fun DeviceTabPage(
sessionInfo!!.width > 0 &&
sessionInfo!!.height > 0
) {
- item {
+ item(key = PREVIEW_CARD_ITEM_KEY) {
PreviewCard(
+ modifier = Modifier,
sessionInfo = sessionInfo,
previewHeightDp = asBundle.devicePreviewCardHeightDp.coerceAtLeast(120),
controlsVisible = previewControlsVisible,
@@ -963,6 +969,8 @@ fun DeviceTabPage(
onOpenFullscreen = {
context.startActivity(StreamActivity.createIntent(context))
},
+ autoBringIntoView = pendingScrollToPreview,
+ onAutoBringIntoViewConsumed = { pendingScrollToPreview = false },
)
}
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 5df23f0..be81c34 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
@@ -333,7 +333,6 @@ fun FullscreenControlPage(
)
},
session = session,
- target = if (interactive) VideoOutputTarget.FULLSCREEN else VideoOutputTarget.PICTURE_IN_PICTURE,
)
}
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 a4e666c..3b3d7ce 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
@@ -45,12 +45,6 @@ fun StreamScreen(activity: StreamActivity) {
}
DisposableEffect(isInPip) {
- VideoOutputTargetState.set(
- if (isInPip)
- VideoOutputTarget.PICTURE_IN_PICTURE
- else
- VideoOutputTarget.FULLSCREEN
- )
onDispose {
VideoOutputTargetState.set(VideoOutputTarget.PREVIEW)
}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/PictureInPictureActionReceiver.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/PictureInPictureActionReceiver.kt
index 392b33f..f754af5 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/PictureInPictureActionReceiver.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/PictureInPictureActionReceiver.kt
@@ -1,14 +1,16 @@
package io.github.miuzarte.scrcpyforandroid.services
+import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
+import android.content.IntentFilter
import android.os.Build
import androidx.annotation.RequiresApi
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import kotlinx.coroutines.runBlocking
-// MIUI 不进
+// not working in MIUI
class PictureInPictureActionReceiver : BroadcastReceiver() {
@RequiresApi(Build.VERSION_CODES.R)
override fun onReceive(context: Context, intent: Intent?) {
@@ -31,5 +33,18 @@ class PictureInPictureActionReceiver : BroadcastReceiver() {
companion object {
const val ACTION_STOP_SCRCPY =
"io.github.miuzarte.scrcpyforandroid.action.STOP_SCRCPY_FROM_PIP"
+ private const val REQUEST_CODE_STOP_SCRCPY = 1
+
+ fun createIntentFilter(): IntentFilter = IntentFilter(ACTION_STOP_SCRCPY)
+
+ fun createPendingIntent(context: Context): PendingIntent {
+ val intent = Intent(ACTION_STOP_SCRCPY).setPackage(context.packageName)
+ return PendingIntent.getBroadcast(
+ context,
+ REQUEST_CODE_STOP_SCRCPY,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
}
}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt
index 4e00329..69faff0 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt
@@ -23,6 +23,8 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.relocation.BringIntoViewRequester
+import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
@@ -245,15 +247,25 @@ internal fun PairingCard(
@Composable
internal fun PreviewCard(
+ modifier: Modifier,
sessionInfo: Scrcpy.Session.SessionInfo?,
previewHeightDp: Int,
controlsVisible: Boolean,
onTapped: () -> Unit,
onOpenFullscreen: () -> Unit,
+ autoBringIntoView: Boolean = false,
+ onAutoBringIntoViewConsumed: () -> Unit = {},
) {
val haptics = rememberAppHaptics()
val alpha by animateFloatAsState(if (controlsVisible) 1f else 0f, label = "preview-controls")
val lifecycleOwner = LocalLifecycleOwner.current
+ val bringIntoViewRequester = remember { BringIntoViewRequester() }
+
+ LaunchedEffect(autoBringIntoView) {
+ if (!autoBringIntoView) return@LaunchedEffect
+ bringIntoViewRequester.bringIntoView()
+ onAutoBringIntoViewConsumed()
+ }
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
@@ -275,7 +287,7 @@ internal fun PreviewCard(
}
}
- Card {
+ Card(modifier = Modifier.bringIntoViewRequester(bringIntoViewRequester).then(modifier)) {
Box(
modifier = Modifier
.fillMaxWidth()
@@ -283,11 +295,11 @@ internal fun PreviewCard(
.pointerInput(sessionInfo) { detectTapGestures(onTap = { onTapped() }) },
) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
- val sessionAspect = if (sessionInfo == null || sessionInfo.height == 0) {
- 16f / 9f
- } else {
- sessionInfo.width.toFloat() / sessionInfo.height.toFloat()
- }
+ val sessionAspect =
+ if (sessionInfo == null || sessionInfo.height == 0)
+ 16f / 9f
+ else sessionInfo.width.toFloat() / sessionInfo.height.toFloat()
+
val containerAspect = maxWidth.value / maxHeight.value
val fittedModifier = if (sessionAspect > containerAspect) {
Modifier
@@ -307,7 +319,6 @@ internal fun PreviewCard(
ScrcpyVideoSurface(
modifier = Modifier.fillMaxSize(),
session = sessionInfo,
- target = VideoOutputTarget.PREVIEW,
)
}
}
@@ -762,7 +773,6 @@ private fun PairingDialog(
fun ScrcpyVideoSurface(
modifier: Modifier,
session: Scrcpy.Session.SessionInfo?,
- target: VideoOutputTarget,
) {
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
@@ -772,37 +782,29 @@ fun ScrcpyVideoSurface(
var currentSurface by remember { mutableStateOf(null) }
var currentSurfaceView by remember { mutableStateOf(null) }
- 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
+ LaunchedEffect(session, currentSurface) {
+ val surface = currentSurface ?: return@LaunchedEffect
+ if (session != null && surface.isValid) {
+ NativeCoreFacade.attachVideoSurface(surface)
}
+ }
+ LaunchedEffect(session?.width, session?.height, currentSurfaceView) {
+ val surfaceView = currentSurfaceView ?: return@LaunchedEffect
val currentSession = session ?: return@LaunchedEffect
+
if (currentSession.width > 0 && currentSession.height > 0) {
surfaceView.holder.setFixedSize(currentSession.width, currentSession.height)
}
}
- LaunchedEffect(session, currentSurface, currentTarget, target) {
- val surface = currentSurface ?: return@LaunchedEffect
- if (currentTarget == target && session != null && surface.isValid) {
- NativeCoreFacade.attachVideoSurface(surface)
- }
- }
-
- DisposableEffect(lifecycleOwner, session, currentSurface, currentTarget, target) {
+ DisposableEffect(lifecycleOwner, session, currentSurface) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) {
val surface = currentSurface
- if (currentTarget == target && session != null && surface != null && surface.isValid) {
+ if (session != null && surface != null && surface.isValid) {
scope.launch {
NativeCoreFacade.attachVideoSurface(surface)
}
@@ -820,10 +822,7 @@ fun ScrcpyVideoSurface(
val surface = currentSurface
if (surface != null) {
taskScope.launch {
- NativeCoreFacade.detachVideoSurface(
- surface,
- releaseDecoder = false
- )
+ NativeCoreFacade.detachVideoSurface(surface)
}
}
}
@@ -840,7 +839,7 @@ fun ScrcpyVideoSurface(
if (!newSurface.isValid) return
currentSurface = newSurface
// Register immediately when surface becomes available
- if (latestCurrentTarget == target && latestSession != null) {
+ if (latestSession != null) {
scope.launch {
NativeCoreFacade.attachVideoSurface(newSurface)
}
@@ -857,7 +856,7 @@ fun ScrcpyVideoSurface(
if (!holder.surface.isValid) return
val surface = holder.surface
currentSurface = surface
- if (latestCurrentTarget == latestRequestedTarget && latestSession != null) {
+ if (latestSession != null) {
scope.launch {
NativeCoreFacade.attachVideoSurface(surface)
}
@@ -868,7 +867,7 @@ fun ScrcpyVideoSurface(
val surface = currentSurface
if (surface != null) {
taskScope.launch {
- NativeCoreFacade.detachVideoSurface(surface, releaseDecoder = false)
+ NativeCoreFacade.detachVideoSurface(surface)
}
currentSurface = null
}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VideoOutputTarget.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VideoOutputTarget.kt
index 5b23c1b..7825584 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VideoOutputTarget.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VideoOutputTarget.kt
@@ -7,8 +7,6 @@ import kotlinx.coroutines.flow.asStateFlow
enum class VideoOutputTarget {
NONE,
PREVIEW,
- FULLSCREEN,
- PICTURE_IN_PICTURE,
}
object VideoOutputTargetState {