feat: PreviewCard auto focus
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -333,7 +333,6 @@ fun FullscreenControlPage(
|
||||
)
|
||||
},
|
||||
session = session,
|
||||
target = if (interactive) VideoOutputTarget.FULLSCREEN else VideoOutputTarget.PICTURE_IN_PICTURE,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
enum class VideoOutputTarget {
|
||||
NONE,
|
||||
PREVIEW,
|
||||
FULLSCREEN,
|
||||
PICTURE_IN_PICTURE,
|
||||
}
|
||||
|
||||
object VideoOutputTargetState {
|
||||
|
||||
Reference in New Issue
Block a user