From 69e1976e5b46e6e28b30a5a3aa1f3ec51afb6423 Mon Sep 17 00:00:00 2001 From: Miuzarte <982809597@qq.com> Date: Sat, 11 Apr 2026 19:54:08 +0800 Subject: [PATCH] feat: scrcpy following with auto fullscreen --- .../miuzarte/scrcpyforandroid/MainActivity.kt | 5 ++ .../scrcpyforandroid/StreamActivity.kt | 24 ++++++-- .../scrcpyforandroid/models/DeviceModels.kt | 16 +++++- .../scrcpyforandroid/pages/DeviceTabScreen.kt | 56 ++++++++++--------- .../pages/ScrcpyAllOptionsScreen.kt | 2 +- .../scrcpyforandroid/pages/StreamScreen.kt | 17 +++--- .../scrcpyforandroid/widgets/DeviceWidgets.kt | 53 +++++++++++++++--- 7 files changed, 122 insertions(+), 51 deletions(-) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt index 98dca98..cdf7972 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt @@ -33,4 +33,9 @@ class MainActivity : ComponentActivity() { MainScreen() } } + + override fun onResume() { + super.onResume() + StreamActivity.dismissActivePictureInPicture() + } } 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 631d5e1..6e285ef 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/StreamActivity.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/StreamActivity.kt @@ -1,5 +1,6 @@ package io.github.miuzarte.scrcpyforandroid +import android.R.drawable import android.app.PictureInPictureUiState import android.app.RemoteAction import android.content.Context @@ -8,16 +9,18 @@ import android.graphics.drawable.Icon import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.core.app.PictureInPictureParamsCompat +import androidx.core.app.PictureInPictureParamsCompat.Builder 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 kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import java.lang.ref.WeakReference class StreamActivity : ComponentActivity() { private val basicPip = BasicPictureInPicture(this) + private val pipActionReceiver = PictureInPictureActionReceiver() private var isPipActionReceiverRegistered = false @@ -28,7 +31,7 @@ class StreamActivity : ComponentActivity() { val pipStopAction: RemoteAction by lazy { RemoteAction( - Icon.createWithResource(this, android.R.drawable.ic_menu_close_clear_cancel), + Icon.createWithResource(this, drawable.ic_menu_close_clear_cancel), "停止投屏", "停止投屏", PictureInPictureActionReceiver.createPendingIntent(this), @@ -39,6 +42,7 @@ class StreamActivity : ComponentActivity() { // 都会重建 activity override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + currentActivityRef = WeakReference(this) registerPipActionReceiver() @@ -76,11 +80,13 @@ class StreamActivity : ComponentActivity() { */ } - fun configurePip(params: PictureInPictureParamsCompat) { - basicPip.setPictureInPictureParams(params) - } + fun configurePip(block: Builder.() -> Builder) = + basicPip.setPictureInPictureParams(Builder().block().build()) override fun onDestroy() { + currentActivityRef?.get() + ?.takeIf { it === this } + ?.let { currentActivityRef = null } unregisterPipActionReceiver() super.onDestroy() } @@ -138,8 +144,16 @@ class StreamActivity : ComponentActivity() { } companion object { + private var currentActivityRef: WeakReference? = null + fun createIntent(context: Context): Intent { return Intent(context, StreamActivity::class.java) } + + fun dismissActivePictureInPicture() { + currentActivityRef?.get() + ?.takeIf { it.isInPictureInPictureMode } + ?.finish() + } } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt index fbc46c9..137733c 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt @@ -39,6 +39,7 @@ class DeviceShortcuts(val devices: List) : List port: Int? = null, name: String? = null, startScrcpyOnConnect: Boolean? = null, + openFullscreenOnStart: Boolean? = null, newPort: Int? = null, updateNameOnlyWhenEmpty: Boolean = false, ): DeviceShortcuts { @@ -59,6 +60,7 @@ class DeviceShortcuts(val devices: List) : List val finalHost = if (updateById) host ?: old.host else old.host val finalPort = if (updateById) port ?: old.port else newPort ?: old.port val finalStartScrcpyOnConnect = startScrcpyOnConnect ?: old.startScrcpyOnConnect + val finalOpenFullscreenOnStart = openFullscreenOnStart ?: old.openFullscreenOnStart // 若无任何变化,返回原实例 if ( @@ -66,6 +68,7 @@ class DeviceShortcuts(val devices: List) : List && finalHost == old.host && finalPort == old.port && finalStartScrcpyOnConnect == old.startScrcpyOnConnect + && finalOpenFullscreenOnStart == old.openFullscreenOnStart ) return this @@ -75,6 +78,7 @@ class DeviceShortcuts(val devices: List) : List host = finalHost, port = finalPort, startScrcpyOnConnect = finalStartScrcpyOnConnect, + openFullscreenOnStart = finalOpenFullscreenOnStart, ) } return DeviceShortcuts( @@ -128,13 +132,18 @@ data class DeviceShortcut( val host: String, val port: Int = Defaults.ADB_PORT, val startScrcpyOnConnect: Boolean = false, + val openFullscreenOnStart: Boolean = false, ) { val id: String get() = "$host:$port" fun marshalToString( separator: String = DEFAULT_SEPARATOR, ): String = listOf( - name.trim(), host.trim(), port.toString(), if (startScrcpyOnConnect) "1" else "0" + name.trim(), + host.trim(), + port.toString(), + if (startScrcpyOnConnect) "1" else "0", + if (openFullscreenOnStart) "1" else "0", ).joinToString( separator = separator ) @@ -147,16 +156,19 @@ data class DeviceShortcut( ): DeviceShortcut? { val parts = s.split(separator) return when (parts.size) { - 3, 4 -> { + 3, 4, 5 -> { val name = parts[0].trim() val host = parts[1].trim() val port = parts[2].trim().toIntOrNull() ?: Defaults.ADB_PORT val startScrcpyOnConnect = parts.getOrNull(3)?.trim() == "1" + val openFullscreenOnStart = + startScrcpyOnConnect && parts.getOrNull(4)?.trim() == "1" if (host.isNotBlank()) DeviceShortcut( name = name, host = host, port = port, startScrcpyOnConnect = startScrcpyOnConnect, + openFullscreenOnStart = openFullscreenOnStart, ) else null } 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 366d366..4c3fde1 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 @@ -18,6 +18,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -37,7 +38,6 @@ import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy -import io.github.miuzarte.scrcpyforandroid.services.AppRuntime import io.github.miuzarte.scrcpyforandroid.services.EventLogger import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController @@ -232,10 +232,9 @@ fun DeviceTabPage( var adbConnected by rememberSaveable { mutableStateOf(false) } var isQuickConnected by rememberSaveable { mutableStateOf(false) } var currentTargetHost by rememberSaveable { mutableStateOf("") } - var currentTargetPort by rememberSaveable { mutableStateOf(Defaults.ADB_PORT) } + var currentTargetPort by rememberSaveable { mutableIntStateOf(Defaults.ADB_PORT) } var connectedDeviceLabel by rememberSaveable { mutableStateOf("未连接") } val sessionInfo by scrcpy.currentSessionState.collectAsState() - var previewControlsVisible by rememberSaveable { mutableStateOf(false) } var editingDeviceId by rememberSaveable { mutableStateOf(null) } var activeDeviceActionId by rememberSaveable { mutableStateOf(null) } var adbConnecting by rememberSaveable { mutableStateOf(false) } @@ -277,9 +276,6 @@ fun DeviceTabPage( qdBundle = qdBundle.copy(quickDevicesList = serialized) } } - val editingDevice = remember(savedShortcuts, editingDeviceId) { - editingDeviceId?.let(savedShortcuts::get) - } /** * Disconnect the current ADB connection and stop any running scrcpy session. @@ -400,10 +396,7 @@ fun DeviceTabPage( suspend fun keepAliveCheck(host: String, port: Int): Boolean { return withTimeout(ADB_KEEPALIVE_TIMEOUT_MS) { val connected = NativeAdbService.isConnected() - if (!connected) { - return@withTimeout false - } - return@withTimeout true + return@withTimeout connected } } @@ -536,10 +529,15 @@ fun DeviceTabPage( } } - suspend fun startScrcpySession() { + suspend fun startScrcpySession(openFullscreen: Boolean = false) { val options = scrcpyOptions.toClientOptions(soBundleShared).fix() val session = scrcpy.start(options) pendingScrollToPreview = true + if (openFullscreen) { + withContext(Dispatchers.Main) { + context.startActivity(StreamActivity.createIntent(context)) + } + } if (options.disableScreensaver) { setKeepScreenOn(true) } @@ -571,7 +569,11 @@ fun DeviceTabPage( listState.animateScrollToItem(PREVIEW_CARD_ITEM_INDEX) } - suspend fun handleAdbConnected(host: String, port: Int) { + suspend fun handleAdbConnected( + host: String, port: Int, + autoStartScrcpy: Boolean = false, + autoEnterFullScreen: Boolean = false, + ) { currentTargetHost = host currentTargetPort = port @@ -603,15 +605,11 @@ fun DeviceTabPage( ) snackbar.show("ADB 已连接") - if ( - savedShortcuts.get(host, port)?.startScrcpyOnConnect == true && - sessionInfo == null - ) { - runBusy("启动 scrcpy") { - startScrcpySession() - } + if (autoStartScrcpy && sessionInfo == null) runBusy("启动 scrcpy") { + startScrcpySession(openFullscreen = autoStartScrcpy && autoEnterFullScreen) } } + LaunchedEffect( adbConnected, asBundle.adbAutoReconnectPairedDevice, @@ -751,8 +749,9 @@ fun DeviceTabPage( adbConnecting && activeDeviceActionId == device.id }, editingDeviceId = editingDeviceId, - onClick = { - snackbar.show("长按可编辑") + onClick = { device -> + if (editingDeviceId != device.id) + snackbar.show("长按可编辑") }, onLongClick = { device -> val connected = adbConnected @@ -768,11 +767,14 @@ fun DeviceTabPage( }, onAction = { device -> haptics.contextClick() + if (editingDeviceId == device.id) editingDeviceId = null + val host = device.host val port = device.port val connected = adbConnected && currentTarget?.host == host && currentTarget.port == port + if (!connected) { runAdbConnect( "连接 ADB", @@ -787,7 +789,12 @@ fun DeviceTabPage( savedShortcuts = savedShortcuts.update( host = host, port = port, ) - handleAdbConnected(host, port) + handleAdbConnected( + host = host, port = port, + autoStartScrcpy = device.startScrcpyOnConnect, + autoEnterFullScreen = device.startScrcpyOnConnect + && device.openFullscreenOnStart, + ) } catch (e: Exception) { statusLine = "ADB 连接失败" logEvent("ADB 连接失败: $e", Log.ERROR) @@ -817,6 +824,7 @@ fun DeviceTabPage( host = updated.host, port = updated.port, startScrcpyOnConnect = updated.startScrcpyOnConnect, + openFullscreenOnStart = updated.openFullscreenOnStart, ) }, onEditorDelete = { device -> @@ -962,10 +970,6 @@ fun DeviceTabPage( modifier = Modifier, sessionInfo = sessionInfo, previewHeightDp = asBundle.devicePreviewCardHeightDp.coerceAtLeast(120), - controlsVisible = previewControlsVisible, - onTapped = { - previewControlsVisible = !previewControlsVisible - }, onOpenFullscreen = { context.startActivity(StreamActivity.createIntent(context)) }, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt index 6442daf..46b9850 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt @@ -955,7 +955,7 @@ internal fun ScrcpyAllOptionsPage( } }, ) - // TODO: 在 OverlaySpinnerPreference / OverlayDropdownPreference 支持展开状态回调后, 在展开时触发获取 + // TODO: 等 MIUIX 发版, 在 OverlaySpinnerPreference / OverlayDropdownPreference 支持展开状态回调后, 在展开时触发获取 OverlaySpinnerPreference( title = "视频编码器", summary = "--video-encoder", 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 3b3d7ce..2b7298b 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 @@ -12,7 +12,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.core.app.PictureInPictureParamsCompat import io.github.miuzarte.scrcpyforandroid.StreamActivity import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics @@ -72,15 +71,13 @@ fun StreamScreen(activity: StreamActivity) { lastPipAspectRatio = ratio } - activity.configurePip( - PictureInPictureParamsCompat.Builder() - .setEnabled(true) - .setAspectRatio(pipAspectRatio) - .setSourceRectHint(pipSourceRectHint) - .setSeamlessResizeEnabled(true) - .setCloseAction(activity.pipStopAction) - .build(), - ) + activity.configurePip { + setEnabled(true) + setAspectRatio(pipAspectRatio) + setSourceRectHint(pipSourceRectHint) + setSeamlessResizeEnabled(true) + setCloseAction(activity.pipStopAction) + } } val themeMode = 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 69faff0..80361cf 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,10 +23,10 @@ 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.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -250,14 +250,17 @@ 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") + + var previewControlsVisible by rememberSaveable { mutableStateOf(false) } + val alpha by animateFloatAsState( + if (previewControlsVisible) 1f else 0f, + label = "preview-controls" + ) val lifecycleOwner = LocalLifecycleOwner.current val bringIntoViewRequester = remember { BringIntoViewRequester() } @@ -268,6 +271,9 @@ internal fun PreviewCard( } DisposableEffect(lifecycleOwner) { + if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { + VideoOutputTargetState.set(VideoOutputTarget.PREVIEW) + } val observer = LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_START -> VideoOutputTargetState.set(VideoOutputTarget.PREVIEW) @@ -287,12 +293,20 @@ internal fun PreviewCard( } } - Card(modifier = Modifier.bringIntoViewRequester(bringIntoViewRequester).then(modifier)) { + Card( + modifier = Modifier + .bringIntoViewRequester(bringIntoViewRequester) + .then(modifier) + ) { Box( modifier = Modifier .fillMaxWidth() .height(previewHeightDp.coerceAtLeast(120).dp) - .pointerInput(sessionInfo) { detectTapGestures(onTap = { onTapped() }) }, + .pointerInput(sessionInfo) { + detectTapGestures(onTap = { + previewControlsVisible = !previewControlsVisible + }) + }, ) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val sessionAspect = @@ -801,6 +815,17 @@ fun ScrcpyVideoSurface( } DisposableEffect(lifecycleOwner, session, currentSurface) { + val surface = currentSurface + if ( + lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) && + session != null && + surface != null && + surface.isValid + ) { + scope.launch { + NativeCoreFacade.attachVideoSurface(surface) + } + } val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_START) { val surface = currentSurface @@ -916,6 +941,8 @@ internal fun DeviceTile( host = trimmedHost, port = currentDraft.port, startScrcpyOnConnect = currentDraft.startScrcpyOnConnect, + openFullscreenOnStart = currentDraft.startScrcpyOnConnect + && currentDraft.openFullscreenOnStart, ) if (updated != device) { onEditorSave(updated) @@ -1048,6 +1075,18 @@ internal fun DeviceTile( draft = currentDraft.copy(startScrcpyOnConnect = it) }, ) + AnimatedVisibility(currentDraft.startScrcpyOnConnect) { + CheckboxPreference( + title = "直接进入全屏", + checkboxLocation = CheckboxLocation.End, + checked = currentDraft.startScrcpyOnConnect + && currentDraft.openFullscreenOnStart, + enabled = currentDraft.startScrcpyOnConnect, + onCheckedChange = { + draft = currentDraft.copy(openFullscreenOnStart = it) + }, + ) + } } Row( modifier = Modifier