diff --git a/README.md b/README.md index f088aee..79e706f 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ ## 已知问题 +- 因为没有设备用于(也懒得)测试,应用可能无法正常运行在安卓版本较低的设备上,特别是画中画功能,非常取决于国产 ROM 的实现 - 关闭画中画后不会停止 scrcpy 串流,仍然需要回到应用中点击停止 - 虚拟按键的截图实现方式为发送 `keycode 120`,安卓官方([keycodes.h#349](https://android.googlesource.com/platform/frameworks/native/+/master/include/android/keycodes.h#349))的定义为 diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/StreamActivity.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/StreamActivity.kt index ce3c92d..fd93822 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/StreamActivity.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/StreamActivity.kt @@ -1,17 +1,18 @@ 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.content.res.Configuration 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.pip.BasicPictureInPicture -import androidx.core.pip.PictureInPictureDelegate import io.github.miuzarte.scrcpyforandroid.pages.StreamScreen import io.github.miuzarte.scrcpyforandroid.services.PictureInPictureActionReceiver import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTarget @@ -20,26 +21,19 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow class StreamActivity : ComponentActivity() { - private lateinit var basicPip: BasicPictureInPicture // = this + private val basicPip = BasicPictureInPicture(this) - // pip 是否已被配置、允许进入 - private var pipConfigured: Boolean = false - private var pipParams = PictureInPictureParamsCompat - .Builder() - .setEnabled(false) - .build() - - // 是否处于 PiP - // MIUI 上进入 PiP 时,动画事件比 onPictureInPictureModeChanged() 更稳定, - // 所以进入 PiP 直接由动画事件置为 true + // 是否处于 pip + // 回到全屏时会因重建而变回初始值 private val _pipModeState = MutableStateFlow(false) val pipModeState: StateFlow = _pipModeState val pipStopAction: RemoteAction by lazy { - val intent = Intent(this, PictureInPictureActionReceiver::class.java).apply { - action = PictureInPictureActionReceiver.ACTION_STOP_SCRCPY - `package` = packageName - } + val intent = Intent(this, PictureInPictureActionReceiver::class.java) + .apply { + action = PictureInPictureActionReceiver.ACTION_STOP_SCRCPY + `package` = packageName + } val pendingIntent = PendingIntent.getBroadcast( this, 1, @@ -54,27 +48,31 @@ class StreamActivity : ComponentActivity() { ) } + // 每次 进出全屏/进出画中画 + // 都会重建 activity override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - basicPip = BasicPictureInPicture(this) + + // 声明要画中画 + basicPip.setEnabled(true) + + setContent { + StreamScreen(activity = this) + } + + /* + // 可能以后有用 basicPip.addOnPictureInPictureEventListener( executor = mainExecutor, listener = object : PictureInPictureDelegate.OnPictureInPictureEventListener { override fun onPictureInPictureEvent( event: PictureInPictureDelegate.Event, - newConfig: Configuration?, + config: Configuration?, ) { - // HyperOS 3 下稳定收到的是进入动画事件 - // 这里直接把进入动画开始视为已经进入 PiP,尽量少依赖平台回调 + // MIUI 只有这些事件 when (event) { - PictureInPictureDelegate.Event.ENTER_ANIMATION_START -> { - _pipModeState.value = true - VideoOutputTargetState.set(VideoOutputTarget.PICTURE_IN_PICTURE) - } - - PictureInPictureDelegate.Event.ENTER_ANIMATION_END -> { - _pipModeState.value = true - } + PictureInPictureDelegate.Event.ENTER_ANIMATION_START -> {} + PictureInPictureDelegate.Event.ENTER_ANIMATION_END -> {} PictureInPictureDelegate.Event.STASHED -> {} PictureInPictureDelegate.Event.UNSTASHED -> {} @@ -86,49 +84,18 @@ class StreamActivity : ComponentActivity() { } } ) - - setContent { - StreamScreen(activity = this) - } + */ } - fun configurePictureInPicture( - enabled: Boolean, - 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() + fun configurePip(params: PictureInPictureParamsCompat) { + basicPip.setPictureInPictureParams(params) } + /* + // 回到全屏也会停止, 暂时不做 override fun onDestroy() { super.onDestroy() - // 回到全屏也会停止 - /* if (_pipModeState.value) { Thread { runBlocking { @@ -136,19 +103,28 @@ class StreamActivity : ComponentActivity() { } }.start() } - */ } + */ - // MIUI 不进 - override fun onPictureInPictureModeChanged( - isInPictureInPictureMode: Boolean, - newConfig: Configuration, - ) { - super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + //- onPictureInPictureModeChanged + //+ onPictureInPictureUiStateChanged + //- onUserLeaveHint - if (!isInPictureInPictureMode) { - _pipModeState.value = false - VideoOutputTargetState.set(VideoOutputTarget.FULLSCREEN) + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + override fun onPictureInPictureUiStateChanged(pipState: PictureInPictureUiState) { + 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) + } } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AboutScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AboutScreen.kt index 66d6bee..5bb8937 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AboutScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AboutScreen.kt @@ -80,6 +80,10 @@ internal fun AboutScreen() { val lazyListState = rememberLazyListState() var logoHeightPx by remember { mutableIntStateOf(0) } + LaunchedEffect(Unit) { + AppUpdateChecker.ensureChecked(BuildConfig.VERSION_NAME) + } + val scrollProgress by remember { derivedStateOf { if (logoHeightPx <= 0) { 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 d137204..446bf8f 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 @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.runtime.Composable @@ -238,6 +239,8 @@ fun DeviceTabPage( var audioForwardingSupported by rememberSaveable { mutableStateOf(true) } var cameraMirroringSupported by rememberSaveable { mutableStateOf(true) } + var pendingScrollToPreview by rememberSaveable { mutableStateOf(false) } + val listState = rememberLazyListState() val currentTarget = if (currentTargetHost.isNotBlank()) @@ -528,6 +531,7 @@ fun DeviceTabPage( suspend fun startScrcpySession() { val options = scrcpyOptions.toClientOptions(soBundleShared).fix() val session = scrcpy.start(options) + pendingScrollToPreview = true if (options.disableScreensaver) { setKeepScreenOn(true) } @@ -553,6 +557,15 @@ fun DeviceTabPage( 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) { currentTargetHost = host currentTargetPort = port @@ -707,6 +720,7 @@ fun DeviceTabPage( LazyColumn( contentPadding = contentPadding, scrollBehavior = scrollBehavior, + state = listState, ) { item { StatusCard( 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 1404266..a4e666c 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 @@ -77,14 +77,12 @@ fun StreamScreen(activity: StreamActivity) { ).also { ratio -> lastPipAspectRatio = ratio } - activity.configurePictureInPicture( - enabled = true, - params = PictureInPictureParamsCompat.Builder() + + activity.configurePip( + PictureInPictureParamsCompat.Builder() .setEnabled(true) .setAspectRatio(pipAspectRatio) - .setSourceRectHint( - if (!isInPip) pipSourceRectHint else null - ) + .setSourceRectHint(pipSourceRectHint) .setSeamlessResizeEnabled(true) .setCloseAction(activity.pipStopAction) .build(), diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt index bd9271d..f7adae2 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt @@ -44,7 +44,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { ) val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair( intPreferencesKey("device_preview_card_height_dp"), - 320 + 1080/3 ) val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = Pair( booleanPreferencesKey("preview_virtual_button_show_text"), 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 51db833..4e00329 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 @@ -764,17 +764,27 @@ fun ScrcpyVideoSurface( session: Scrcpy.Session.SessionInfo?, target: VideoOutputTarget, ) { + + val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } + val scope = rememberCoroutineScope() + + val lifecycleOwner = LocalLifecycleOwner.current var currentSurface by remember { mutableStateOf(null) } var currentSurfaceView by remember { mutableStateOf(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 + 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 if (currentSession.width > 0 && currentSession.height > 0) { surfaceView.holder.setFixedSize(currentSession.width, currentSession.height) @@ -782,8 +792,8 @@ fun ScrcpyVideoSurface( } LaunchedEffect(session, currentSurface, currentTarget, target) { - val surface = currentSurface - if (currentTarget == target && session != null && surface != null && surface.isValid) { + val surface = currentSurface ?: return@LaunchedEffect + if (currentTarget == target && session != null && surface.isValid) { NativeCoreFacade.attachVideoSurface(surface) } } @@ -844,9 +854,14 @@ fun ScrcpyVideoSurface( height: Int, ) { if (width <= 0 || height <= 0) return + if (!holder.surface.isValid) return val surface = holder.surface - if (!surface.isValid) return currentSurface = surface + if (latestCurrentTarget == latestRequestedTarget && latestSession != null) { + scope.launch { + NativeCoreFacade.attachVideoSurface(surface) + } + } } override fun surfaceDestroyed(holder: SurfaceHolder) { @@ -940,7 +955,7 @@ internal fun DeviceTile( .weight(1f), verticalAlignment = Alignment.CenterVertically, ) { - // statu dot + // status dot Box( modifier = Modifier .size(8.dp) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt index 11241ef..45b7953 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt @@ -351,6 +351,9 @@ class VirtualButtonBar( maxX * offsetXFraction.coerceIn(0f, 1f) val currentY = maxY * offsetYFraction.coerceIn(0f, 1f) + val popupAlignment = + if (offsetXFraction > 0.5f) PopupPositionProvider.Align.TopEnd + else PopupPositionProvider.Align.TopStart Box( modifier = Modifier @@ -422,6 +425,7 @@ class VirtualButtonBar( showActions = false }, renderInRootScaffold = true, + popupAlignment = popupAlignment, ) } } @@ -434,6 +438,7 @@ class VirtualButtonBar( onDismiss: () -> Unit, onAction: suspend (VirtualButtonAction) -> Unit, renderInRootScaffold: Boolean, + popupAlignment: PopupPositionProvider.Align = PopupPositionProvider.Align.TopEnd, ) { val scope = rememberCoroutineScope() val haptics = LocalAppHaptics.current @@ -456,7 +461,7 @@ class VirtualButtonBar( OverlayListPopup( show = show, popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider, - alignment = PopupPositionProvider.Align.TopEnd, + alignment = popupAlignment, onDismissRequest = onDismiss, renderInRootScaffold = renderInRootScaffold, enableWindowDim = false,