feat: PreviewCard auto focus

This commit is contained in:
Miuzarte
2026-04-11 18:24:34 +08:00
parent 350f817c27
commit a4e6027097
9 changed files with 105 additions and 80 deletions

View File

@@ -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"
}
],
}

View File

@@ -19,10 +19,6 @@
android:supportsRtl="true"
android:theme="@style/Theme.ScrcpyForAndroid">
<receiver
android:name=".services.PictureInPictureActionReceiver"
android:exported="false" />
<activity
android:name=".MainActivity"
android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize"

View File

@@ -1,27 +1,25 @@
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.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.content.ContextCompat
import androidx.core.pip.BasicPictureInPicture
import io.github.miuzarte.scrcpyforandroid.pages.StreamScreen
import io.github.miuzarte.scrcpyforandroid.services.PictureInPictureActionReceiver
import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTarget
import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTargetState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class StreamActivity : ComponentActivity() {
private val basicPip = BasicPictureInPicture(this)
private val pipActionReceiver = PictureInPictureActionReceiver()
private var isPipActionReceiverRegistered = false
// 是否处于 pip
// 回到全屏时会因重建而变回初始值
@@ -29,22 +27,11 @@ class StreamActivity : ComponentActivity() {
val pipModeState: StateFlow<Boolean> = _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 {

View File

@@ -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 },
)
}

View File

@@ -333,7 +333,6 @@ fun FullscreenControlPage(
)
},
session = session,
target = if (interactive) VideoOutputTarget.FULLSCREEN else VideoOutputTarget.PICTURE_IN_PICTURE,
)
}

View File

@@ -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)
}

View File

@@ -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,
)
}
}
}

View File

@@ -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<Surface?>(null) }
var currentSurfaceView by remember { mutableStateOf<SurfaceView?>(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
}

View File

@@ -7,8 +7,6 @@ import kotlinx.coroutines.flow.asStateFlow
enum class VideoOutputTarget {
NONE,
PREVIEW,
FULLSCREEN,
PICTURE_IN_PICTURE,
}
object VideoOutputTargetState {