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", "name": "shizuku",
"path": "../shizuku" "path": "../shizuku"
}, },
{
"name": "moonlight-android",
"path": "../moonlight-android"
},
{ {
"name": "KernelSU", "name": "KernelSU",
"path": "../KernelSU" "path": "../KernelSU"
@@ -27,6 +31,9 @@
{ {
"name": "adblib", "name": "adblib",
"path": "../adblib" "path": "../adblib"
},
{
"path": "../socialite"
} }
], ],
} }

View File

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

View File

@@ -1,27 +1,25 @@
package io.github.miuzarte.scrcpyforandroid package io.github.miuzarte.scrcpyforandroid
import android.app.PendingIntent
import android.app.PictureInPictureUiState 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.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.content.ContextCompat
import androidx.core.pip.BasicPictureInPicture import androidx.core.pip.BasicPictureInPicture
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.VideoOutputTargetState
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
class StreamActivity : ComponentActivity() { class StreamActivity : ComponentActivity() {
private val basicPip = BasicPictureInPicture(this) private val basicPip = BasicPictureInPicture(this)
private val pipActionReceiver = PictureInPictureActionReceiver()
private var isPipActionReceiverRegistered = false
// 是否处于 pip // 是否处于 pip
// 回到全屏时会因重建而变回初始值 // 回到全屏时会因重建而变回初始值
@@ -29,22 +27,11 @@ class StreamActivity : ComponentActivity() {
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 {
action = PictureInPictureActionReceiver.ACTION_STOP_SCRCPY
`package` = packageName
}
val pendingIntent = PendingIntent.getBroadcast(
this,
1,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
RemoteAction( RemoteAction(
Icon.createWithResource(this, android.R.drawable.ic_menu_close_clear_cancel), 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
registerPipActionReceiver()
// 声明要画中画 // 声明要画中画
basicPip.setEnabled(true) basicPip.setEnabled(true)
@@ -91,6 +80,11 @@ class StreamActivity : ComponentActivity() {
basicPip.setPictureInPictureParams(params) basicPip.setPictureInPictureParams(params)
} }
override fun onDestroy() {
unregisterPipActionReceiver()
super.onDestroy()
}
/* /*
// 回到全屏也会停止, 暂时不做 // 回到全屏也会停止, 暂时不做
override fun onDestroy() { override fun onDestroy() {
@@ -110,22 +104,37 @@ class StreamActivity : ComponentActivity() {
//+ onPictureInPictureUiStateChanged //+ onPictureInPictureUiStateChanged
//- onUserLeaveHint //- onUserLeaveHint
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) // @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
override fun onPictureInPictureUiStateChanged(pipState: PictureInPictureUiState) { override fun onPictureInPictureUiStateChanged(pipState: PictureInPictureUiState) {
super.onPictureInPictureUiStateChanged(pipState) super.onPictureInPictureUiStateChanged(pipState)
_pipModeState.value = true
/*
when { when {
// 进入画中画 // 进入画中画
pipState.isTransitioningToPip -> if (!_pipModeState.value) { pipState.isTransitioningToPip -> {}
_pipModeState.value = true
VideoOutputTargetState.set(VideoOutputTarget.PICTURE_IN_PICTURE)
}
// 收进边缘 // 收进边缘
pipState.isStashed -> if (!_pipModeState.value) { pipState.isStashed -> {}
_pipModeState.value = true
VideoOutputTargetState.set(VideoOutputTarget.PICTURE_IN_PICTURE)
}
} }
*/
}
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 { companion object {

View File

@@ -16,6 +16,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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_DISCOVER_TIMEOUT_MS = 2_000L
private const val ADB_AUTO_RECONNECT_RETRY_INTERVAL_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 ADB_TCP_PROBE_TIMEOUT_MS = 500
private const val PREVIEW_CARD_ITEM_KEY = "preview_card"
private const val PREVIEW_CARD_ITEM_INDEX = 3
@Composable @Composable
fun DeviceTabScreen( fun DeviceTabScreen(
@@ -241,6 +244,11 @@ fun DeviceTabPage(
var cameraMirroringSupported by rememberSaveable { mutableStateOf(true) } var cameraMirroringSupported by rememberSaveable { mutableStateOf(true) }
var pendingScrollToPreview by rememberSaveable { mutableStateOf(false) } var pendingScrollToPreview by rememberSaveable { mutableStateOf(false) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val isPreviewCardVisible by remember(listState) {
derivedStateOf {
listState.layoutInfo.visibleItemsInfo.any { it.key == PREVIEW_CARD_ITEM_KEY }
}
}
val currentTarget = val currentTarget =
if (currentTargetHost.isNotBlank()) if (currentTargetHost.isNotBlank())
@@ -557,13 +565,10 @@ fun DeviceTabPage(
snackbar.show("scrcpy 已启动") snackbar.show("scrcpy 已启动")
} }
LaunchedEffect(pendingScrollToPreview, sessionInfo) { LaunchedEffect(pendingScrollToPreview, isPreviewCardVisible) {
if (!pendingScrollToPreview) return@LaunchedEffect if (!pendingScrollToPreview) return@LaunchedEffect
val session = sessionInfo ?: return@LaunchedEffect if (isPreviewCardVisible) return@LaunchedEffect
if (session.width <= 0 || session.height <= 0) return@LaunchedEffect listState.animateScrollToItem(PREVIEW_CARD_ITEM_INDEX)
// 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) {
@@ -952,8 +957,9 @@ fun DeviceTabPage(
sessionInfo!!.width > 0 && sessionInfo!!.width > 0 &&
sessionInfo!!.height > 0 sessionInfo!!.height > 0
) { ) {
item { item(key = PREVIEW_CARD_ITEM_KEY) {
PreviewCard( PreviewCard(
modifier = Modifier,
sessionInfo = sessionInfo, sessionInfo = sessionInfo,
previewHeightDp = asBundle.devicePreviewCardHeightDp.coerceAtLeast(120), previewHeightDp = asBundle.devicePreviewCardHeightDp.coerceAtLeast(120),
controlsVisible = previewControlsVisible, controlsVisible = previewControlsVisible,
@@ -963,6 +969,8 @@ fun DeviceTabPage(
onOpenFullscreen = { onOpenFullscreen = {
context.startActivity(StreamActivity.createIntent(context)) context.startActivity(StreamActivity.createIntent(context))
}, },
autoBringIntoView = pendingScrollToPreview,
onAutoBringIntoViewConsumed = { pendingScrollToPreview = false },
) )
} }

View File

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

View File

@@ -45,12 +45,6 @@ fun StreamScreen(activity: StreamActivity) {
} }
DisposableEffect(isInPip) { DisposableEffect(isInPip) {
VideoOutputTargetState.set(
if (isInPip)
VideoOutputTarget.PICTURE_IN_PICTURE
else
VideoOutputTarget.FULLSCREEN
)
onDispose { onDispose {
VideoOutputTargetState.set(VideoOutputTarget.PREVIEW) VideoOutputTargetState.set(VideoOutputTarget.PREVIEW)
} }

View File

@@ -1,14 +1,16 @@
package io.github.miuzarte.scrcpyforandroid.services package io.github.miuzarte.scrcpyforandroid.services
import android.app.PendingIntent
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.os.Build import android.os.Build
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.storage.Storage
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
// MIUI 不进 // not working in MIUI
class PictureInPictureActionReceiver : BroadcastReceiver() { class PictureInPictureActionReceiver : BroadcastReceiver() {
@RequiresApi(Build.VERSION_CODES.R) @RequiresApi(Build.VERSION_CODES.R)
override fun onReceive(context: Context, intent: Intent?) { override fun onReceive(context: Context, intent: Intent?) {
@@ -31,5 +33,18 @@ class PictureInPictureActionReceiver : BroadcastReceiver() {
companion object { companion object {
const val ACTION_STOP_SCRCPY = const val ACTION_STOP_SCRCPY =
"io.github.miuzarte.scrcpyforandroid.action.STOP_SCRCPY_FROM_PIP" "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.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.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@@ -245,15 +247,25 @@ internal fun PairingCard(
@Composable @Composable
internal fun PreviewCard( internal fun PreviewCard(
modifier: Modifier,
sessionInfo: Scrcpy.Session.SessionInfo?, sessionInfo: Scrcpy.Session.SessionInfo?,
previewHeightDp: Int, previewHeightDp: Int,
controlsVisible: Boolean, controlsVisible: Boolean,
onTapped: () -> Unit, onTapped: () -> Unit,
onOpenFullscreen: () -> Unit, onOpenFullscreen: () -> Unit,
autoBringIntoView: Boolean = false,
onAutoBringIntoViewConsumed: () -> Unit = {},
) { ) {
val haptics = rememberAppHaptics() val haptics = rememberAppHaptics()
val alpha by animateFloatAsState(if (controlsVisible) 1f else 0f, label = "preview-controls") val alpha by animateFloatAsState(if (controlsVisible) 1f else 0f, label = "preview-controls")
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val bringIntoViewRequester = remember { BringIntoViewRequester() }
LaunchedEffect(autoBringIntoView) {
if (!autoBringIntoView) return@LaunchedEffect
bringIntoViewRequester.bringIntoView()
onAutoBringIntoViewConsumed()
}
DisposableEffect(lifecycleOwner) { DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
@@ -275,7 +287,7 @@ internal fun PreviewCard(
} }
} }
Card { Card(modifier = Modifier.bringIntoViewRequester(bringIntoViewRequester).then(modifier)) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -283,11 +295,11 @@ internal fun PreviewCard(
.pointerInput(sessionInfo) { detectTapGestures(onTap = { onTapped() }) }, .pointerInput(sessionInfo) { detectTapGestures(onTap = { onTapped() }) },
) { ) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val sessionAspect = if (sessionInfo == null || sessionInfo.height == 0) { val sessionAspect =
16f / 9f if (sessionInfo == null || sessionInfo.height == 0)
} else { 16f / 9f
sessionInfo.width.toFloat() / sessionInfo.height.toFloat() else sessionInfo.width.toFloat() / sessionInfo.height.toFloat()
}
val containerAspect = maxWidth.value / maxHeight.value val containerAspect = maxWidth.value / maxHeight.value
val fittedModifier = if (sessionAspect > containerAspect) { val fittedModifier = if (sessionAspect > containerAspect) {
Modifier Modifier
@@ -307,7 +319,6 @@ internal fun PreviewCard(
ScrcpyVideoSurface( ScrcpyVideoSurface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
session = sessionInfo, session = sessionInfo,
target = VideoOutputTarget.PREVIEW,
) )
} }
} }
@@ -762,7 +773,6 @@ private fun PairingDialog(
fun ScrcpyVideoSurface( fun ScrcpyVideoSurface(
modifier: Modifier, modifier: Modifier,
session: Scrcpy.Session.SessionInfo?, session: Scrcpy.Session.SessionInfo?,
target: VideoOutputTarget,
) { ) {
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
@@ -772,37 +782,29 @@ fun ScrcpyVideoSurface(
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 currentTarget by VideoOutputTargetState.current.collectAsState()
val latestCurrentTarget by rememberUpdatedState(currentTarget)
val latestSession by rememberUpdatedState(session) val latestSession by rememberUpdatedState(session)
val latestRequestedTarget by rememberUpdatedState(target)
LaunchedEffect(session?.width, session?.height, currentSurfaceView, target) { LaunchedEffect(session, currentSurface) {
val surfaceView = currentSurfaceView ?: return@LaunchedEffect val surface = currentSurface ?: return@LaunchedEffect
if (target == VideoOutputTarget.PICTURE_IN_PICTURE) { if (session != null && surface.isValid) {
// In PiP, let SurfaceView buffer follow viewport to avoid stale portrait frame crop. NativeCoreFacade.attachVideoSurface(surface)
surfaceView.holder.setSizeFromLayout()
return@LaunchedEffect
} }
}
LaunchedEffect(session?.width, session?.height, currentSurfaceView) {
val surfaceView = currentSurfaceView ?: 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)
} }
} }
LaunchedEffect(session, currentSurface, currentTarget, target) { DisposableEffect(lifecycleOwner, session, currentSurface) {
val surface = currentSurface ?: return@LaunchedEffect
if (currentTarget == target && session != null && surface.isValid) {
NativeCoreFacade.attachVideoSurface(surface)
}
}
DisposableEffect(lifecycleOwner, session, currentSurface, currentTarget, target) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) { if (event == Lifecycle.Event.ON_START) {
val surface = currentSurface val surface = currentSurface
if (currentTarget == target && session != null && surface != null && surface.isValid) { if (session != null && surface != null && surface.isValid) {
scope.launch { scope.launch {
NativeCoreFacade.attachVideoSurface(surface) NativeCoreFacade.attachVideoSurface(surface)
} }
@@ -820,10 +822,7 @@ fun ScrcpyVideoSurface(
val surface = currentSurface val surface = currentSurface
if (surface != null) { if (surface != null) {
taskScope.launch { taskScope.launch {
NativeCoreFacade.detachVideoSurface( NativeCoreFacade.detachVideoSurface(surface)
surface,
releaseDecoder = false
)
} }
} }
} }
@@ -840,7 +839,7 @@ fun ScrcpyVideoSurface(
if (!newSurface.isValid) return if (!newSurface.isValid) return
currentSurface = newSurface currentSurface = newSurface
// Register immediately when surface becomes available // Register immediately when surface becomes available
if (latestCurrentTarget == target && latestSession != null) { if (latestSession != null) {
scope.launch { scope.launch {
NativeCoreFacade.attachVideoSurface(newSurface) NativeCoreFacade.attachVideoSurface(newSurface)
} }
@@ -857,7 +856,7 @@ fun ScrcpyVideoSurface(
if (!holder.surface.isValid) return if (!holder.surface.isValid) return
val surface = holder.surface val surface = holder.surface
currentSurface = surface currentSurface = surface
if (latestCurrentTarget == latestRequestedTarget && latestSession != null) { if (latestSession != null) {
scope.launch { scope.launch {
NativeCoreFacade.attachVideoSurface(surface) NativeCoreFacade.attachVideoSurface(surface)
} }
@@ -868,7 +867,7 @@ fun ScrcpyVideoSurface(
val surface = currentSurface val surface = currentSurface
if (surface != null) { if (surface != null) {
taskScope.launch { taskScope.launch {
NativeCoreFacade.detachVideoSurface(surface, releaseDecoder = false) NativeCoreFacade.detachVideoSurface(surface)
} }
currentSurface = null currentSurface = null
} }

View File

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