feat: scrcpy following with auto fullscreen

This commit is contained in:
Miuzarte
2026-04-11 19:54:08 +08:00
parent a4e6027097
commit 69e1976e5b
7 changed files with 122 additions and 51 deletions

View File

@@ -33,4 +33,9 @@ class MainActivity : ComponentActivity() {
MainScreen()
}
}
override fun onResume() {
super.onResume()
StreamActivity.dismissActivePictureInPicture()
}
}

View File

@@ -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<StreamActivity>? = null
fun createIntent(context: Context): Intent {
return Intent(context, StreamActivity::class.java)
}
fun dismissActivePictureInPicture() {
currentActivityRef?.get()
?.takeIf { it.isInPictureInPictureMode }
?.finish()
}
}
}

View File

@@ -39,6 +39,7 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut>
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<DeviceShortcut>) : List<DeviceShortcut>
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<DeviceShortcut>) : List<DeviceShortcut>
&& finalHost == old.host
&& finalPort == old.port
&& finalStartScrcpyOnConnect == old.startScrcpyOnConnect
&& finalOpenFullscreenOnStart == old.openFullscreenOnStart
)
return this
@@ -75,6 +78,7 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut>
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
}

View File

@@ -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<String?>(null) }
var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(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))
},

View File

@@ -955,7 +955,7 @@ internal fun ScrcpyAllOptionsPage(
}
},
)
// TODO: 在 OverlaySpinnerPreference / OverlayDropdownPreference 支持展开状态回调后, 在展开时触发获取
// TODO: 等 MIUIX 发版, 在 OverlaySpinnerPreference / OverlayDropdownPreference 支持展开状态回调后, 在展开时触发获取
OverlaySpinnerPreference(
title = "视频编码器",
summary = "--video-encoder",

View File

@@ -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 =

View File

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