fix: video orientation handling

- 用 StateFlow 实现监听复用
This commit is contained in:
Miuzarte
2026-04-10 02:26:23 +08:00
parent 731612c16e
commit 026fa50be2
6 changed files with 78 additions and 109 deletions

View File

@@ -23,15 +23,16 @@
- 利用 mDNS 服务实现自动连接启用无线调试的设备、自动发现等待配对设备的IP与端口 - 利用 mDNS 服务实现自动连接启用无线调试的设备、自动发现等待配对设备的IP与端口
- 自动横竖屏切换(算吗 - 自动横竖屏切换(算吗
## 已知问题 / TODO ## 已知问题
- 退出全屏时的横竖屏状态可能不对,断开 scrcpy 重连就好 - 退出全屏时的横竖屏状态可能不对,断开 scrcpy 重连就好
- 如果受控机在锁屏时处理网络连接较慢,会导致应用界面无响应过长时间被系统杀掉(点名你米)
- 虚拟按键的截图实现方式为发送 - 虚拟按键的截图实现方式为发送
`keycode 120`,安卓官方([keycodes.h#349](https://android.googlesource.com/platform/frameworks/native/+/master/include/android/keycodes.h#349))的定义为 `keycode 120`,安卓官方([keycodes.h#349](https://android.googlesource.com/platform/frameworks/native/+/master/include/android/keycodes.h#349))的定义为
`System Request / Print Screen key.`,不同的厂商有不同的实现,在某些类原生(`AxionOS`) 上的行为是软重启 `System Request / Print Screen key.`,不同的厂商有不同的实现,在某些类原生(`AxionOS`) 上的行为是软重启
- I18N
- More: [TODO.md](TODO.md) ## [TODO](TODO.md)
\> [TODO.md](TODO.md)
## 建议搭配模块 ## 建议搭配模块

View File

@@ -1,5 +1,8 @@
# TODO # TODO
- miuix v0.9
- 在线更新检查
## PARAMS ## PARAMS
- orientation locking - orientation locking
@@ -18,3 +21,4 @@
- [音频延迟](https://developer.android.com/ndk/guides/audio/audio-latency) - [音频延迟](https://developer.android.com/ndk/guides/audio/audio-latency)
- 横屏布局 - 横屏布局
- 原生悬浮窗 [Petterpx/FloatingX](https://github.com/Petterpx/FloatingX) - 原生悬浮窗 [Petterpx/FloatingX](https://github.com/Petterpx/FloatingX)
- I18N

View File

@@ -16,7 +16,6 @@ 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.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -36,7 +35,6 @@ import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import io.github.miuzarte.scrcpyforandroid.services.EventLogger import io.github.miuzarte.scrcpyforandroid.services.EventLogger
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
@@ -97,7 +95,7 @@ fun DeviceTabScreen(
onOpenVirtualButtonOrder: () -> Unit, onOpenVirtualButtonOrder: () -> Unit,
onOpenReorderDevices: () -> Unit, onOpenReorderDevices: () -> Unit,
onOpenAdvancedPage: () -> Unit, onOpenAdvancedPage: () -> Unit,
onOpenFullscreenPage: (Scrcpy.Session.SessionInfo) -> Unit, onOpenFullscreenPage: () -> Unit,
) { ) {
var showThreePointMenu by rememberSaveable { mutableStateOf(false) } var showThreePointMenu by rememberSaveable { mutableStateOf(false) }
@@ -159,7 +157,7 @@ fun DeviceTabPage(
snackbar: SnackbarController, snackbar: SnackbarController,
scrollBehavior: ScrollBehavior, scrollBehavior: ScrollBehavior,
onOpenAdvancedPage: () -> Unit, onOpenAdvancedPage: () -> Unit,
onOpenFullscreenPage: (Scrcpy.Session.SessionInfo) -> Unit, onOpenFullscreenPage: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -241,39 +239,9 @@ fun DeviceTabPage(
var adbConnected by rememberSaveable { mutableStateOf(false) } var adbConnected by rememberSaveable { mutableStateOf(false) }
var isQuickConnected by rememberSaveable { mutableStateOf(false) } var isQuickConnected by rememberSaveable { mutableStateOf(false) }
var currentTargetHost by rememberSaveable { mutableStateOf("") } var currentTargetHost by rememberSaveable { mutableStateOf("") }
var currentTargetPort by rememberSaveable { mutableIntStateOf(Defaults.ADB_PORT) } var currentTargetPort by rememberSaveable { mutableStateOf(Defaults.ADB_PORT) }
var connectedDeviceLabel by rememberSaveable { mutableStateOf("未连接") } var connectedDeviceLabel by rememberSaveable { mutableStateOf("未连接") }
var sessionInfoWidth by rememberSaveable { mutableIntStateOf(0) } val sessionInfo by scrcpy.currentSessionState.collectAsState()
var sessionInfoHeight by rememberSaveable { mutableIntStateOf(0) }
var sessionInfoDeviceName by rememberSaveable { mutableStateOf("") }
var sessionInfoCodec by rememberSaveable { mutableStateOf<Codec?>(null) }
var sessionInfoControlEnabled by rememberSaveable { mutableStateOf(false) }
var sessionInfo by remember {
mutableStateOf<Scrcpy.Session.SessionInfo?>(null)
}
LaunchedEffect(
sessionInfoWidth,
sessionInfoHeight,
sessionInfoDeviceName,
sessionInfoCodec,
sessionInfoControlEnabled
) {
sessionInfo = if (sessionInfoDeviceName.isNotBlank()) {
Scrcpy.Session.SessionInfo(
width = sessionInfoWidth,
height = sessionInfoHeight,
deviceName = sessionInfoDeviceName,
codecId = 0,
codec = sessionInfoCodec,
audioCodecId = 0,
controlEnabled = sessionInfoControlEnabled,
host = currentTargetHost,
port = currentTargetPort,
)
} else {
null
}
}
var previewControlsVisible by rememberSaveable { mutableStateOf(false) } var previewControlsVisible by rememberSaveable { mutableStateOf(false) }
var editingDeviceId by rememberSaveable { mutableStateOf<String?>(null) } var editingDeviceId by rememberSaveable { mutableStateOf<String?>(null) }
var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(null) } var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(null) }
@@ -349,7 +317,6 @@ fun DeviceTabPage(
currentTargetPort = Defaults.ADB_PORT currentTargetPort = Defaults.ADB_PORT
audioForwardingSupported = true audioForwardingSupported = true
cameraMirroringSupported = true cameraMirroringSupported = true
sessionInfo = null
statusLine = "未连接" statusLine = "未连接"
connectedDeviceLabel = "未连接" connectedDeviceLabel = "未连接"
clearQuickOnlineForTarget?.let { target -> clearQuickOnlineForTarget?.let { target ->
@@ -702,32 +669,6 @@ fun DeviceTabPage(
} }
} }
DisposableEffect(nativeCore) {
val listener: (Int, Int) -> Unit = { width, height ->
sessionInfo = sessionInfo?.copy(width = width, height = height)
}
nativeCore.addVideoSizeListener(listener)
onDispose {
nativeCore.removeVideoSizeListener(listener)
}
}
LaunchedEffect(sessionInfo) {
if (sessionInfo != null) {
sessionInfoWidth = sessionInfo?.width ?: 0
sessionInfoHeight = sessionInfo?.height ?: 0
sessionInfoDeviceName = sessionInfo?.deviceName.orEmpty()
sessionInfoCodec = sessionInfo?.codec
sessionInfoControlEnabled = sessionInfo?.controlEnabled == true
} else {
sessionInfoWidth = 0
sessionInfoHeight = 0
sessionInfoDeviceName = ""
sessionInfoCodec = null
sessionInfoControlEnabled = false
}
}
fun sendVirtualButtonAction(action: VirtualButtonAction) { fun sendVirtualButtonAction(action: VirtualButtonAction) {
val keycode = action.keycode ?: return val keycode = action.keycode ?: return
runBusy("发送 ${action.title}") { runBusy("发送 ${action.title}") {
@@ -942,10 +883,6 @@ fun DeviceTabPage(
if (options.disableScreensaver) { if (options.disableScreensaver) {
setKeepScreenOn(true) setKeepScreenOn(true)
} }
sessionInfo = session.copy(
host = currentTargetHost,
port = currentTargetPort
)
statusLine = "scrcpy 运行中" statusLine = "scrcpy 运行中"
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
val videoDetail = val videoDetail =
@@ -972,7 +909,6 @@ fun DeviceTabPage(
runBusy("停止 scrcpy") { runBusy("停止 scrcpy") {
scrcpy.stop() scrcpy.stop()
setKeepScreenOn(false) setKeepScreenOn(false)
sessionInfo = null
statusLine = "${currentTarget!!.host}:${currentTarget.port}" statusLine = "${currentTarget!!.host}:${currentTarget.port}"
logEvent("scrcpy 已停止") logEvent("scrcpy 已停止")
snackbar.show("scrcpy 已停止") snackbar.show("scrcpy 已停止")
@@ -1013,8 +949,9 @@ fun DeviceTabPage(
previewControlsVisible = !previewControlsVisible previewControlsVisible = !previewControlsVisible
}, },
onOpenFullscreen = { onOpenFullscreen = {
val info = sessionInfo ?: return@PreviewCard if (sessionInfo != null) {
onOpenFullscreenPage(info) onOpenFullscreenPage()
}
}, },
) )
} }

View File

@@ -43,14 +43,12 @@ import top.yukonga.miuix.kmp.basic.Scaffold
@Composable @Composable
fun FullscreenControlScreen( fun FullscreenControlScreen(
onBack: () -> Unit, onBack: () -> Unit,
session: Scrcpy.Session.SessionInfo, scrcpy: Scrcpy,
nativeCore: NativeCoreFacade, nativeCore: NativeCoreFacade,
onVideoSizeChanged: (width: Int, height: Int) -> Unit, onVideoSizeChanged: (width: Int, height: Int) -> Unit,
) { ) {
// Disable predictive back handler temporarily to avoid decoding issues.
BackHandler(enabled = true, onBack = onBack) BackHandler(enabled = true, onBack = onBack)
val context = LocalContext.current val context = LocalContext.current
val haptics = rememberAppHaptics() val haptics = rememberAppHaptics()
@@ -58,6 +56,7 @@ fun FullscreenControlScreen(
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val activity = remember(context) { context as? Activity } val activity = remember(context) { context as? Activity }
val currentSession by scrcpy.currentSessionState.collectAsState()
val asBundleShared by appSettings.bundleState.collectAsState() val asBundleShared by appSettings.bundleState.collectAsState()
val asBundleSharedLatest by rememberUpdatedState(asBundleShared) val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
@@ -119,14 +118,9 @@ fun FullscreenControlScreen(
} }
} }
DisposableEffect(nativeCore) { LaunchedEffect(currentSession?.width, currentSession?.height) {
val listener: (Int, Int) -> Unit = { w, h -> val session = currentSession ?: return@LaunchedEffect
onVideoSizeChanged(w, h) onVideoSizeChanged(session.width, session.height)
}
nativeCore.addVideoSizeListener(listener)
onDispose {
nativeCore.removeVideoSizeListener(listener)
}
} }
DisposableEffect(nativeCore) { DisposableEffect(nativeCore) {
@@ -156,6 +150,7 @@ fun FullscreenControlScreen(
.fillMaxSize() .fillMaxSize()
.padding(contentPadding), .padding(contentPadding),
) { ) {
val session = currentSession ?: return@Box
FullscreenControlScreen( FullscreenControlScreen(
session = session, session = session,
nativeCore = nativeCore, nativeCore = nativeCore,

View File

@@ -82,7 +82,7 @@ sealed interface RootScreen : NavKey {
data object Home : RootScreen data object Home : RootScreen
data object Advanced : RootScreen data object Advanced : RootScreen
data object VirtualButtonOrder : RootScreen data object VirtualButtonOrder : RootScreen
data class Fullscreen(val session: Scrcpy.Session.SessionInfo) : RootScreen data object Fullscreen : RootScreen
} }
@Composable @Composable
@@ -215,6 +215,7 @@ fun MainScreen() {
serverRemotePath = serverRemotePath, serverRemotePath = serverRemotePath,
) )
} }
val currentSession by scrcpy.currentSessionState.collectAsState()
fun handleBackNavigation() { fun handleBackNavigation() {
if (rootBackStack.size > 1) { if (rootBackStack.size > 1) {
@@ -247,6 +248,11 @@ fun MainScreen() {
} }
} }
var showReorderDevices by rememberSaveable { mutableStateOf(false) }
var fullscreenOrientation by rememberSaveable {
mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)
}
val canNavigateBack = rootBackStack.size > 1 val canNavigateBack = rootBackStack.size > 1
|| pagerState.currentPage != MainBottomTabDestination.Device.ordinal || pagerState.currentPage != MainBottomTabDestination.Device.ordinal
@@ -265,11 +271,6 @@ fun MainScreen() {
} }
} }
var showReorderDevices by rememberSaveable { mutableStateOf(false) }
var fullscreenOrientation by rememberSaveable {
mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)
}
// Fullscreen route can force orientation based on stream ratio; all other routes are portrait. // Fullscreen route can force orientation based on stream ratio; all other routes are portrait.
LaunchedEffect(activity, currentRootScreen, fullscreenOrientation) { LaunchedEffect(activity, currentRootScreen, fullscreenOrientation) {
val targetOrientation = when (currentRootScreen) { val targetOrientation = when (currentRootScreen) {
@@ -279,6 +280,28 @@ fun MainScreen() {
activity?.requestedOrientation = targetOrientation activity?.requestedOrientation = targetOrientation
} }
DisposableEffect(nativeCore, scrcpy) {
val listener: (Int, Int) -> Unit = { width, height ->
scrcpy.updateCurrentSessionSize(width, height)
}
nativeCore.addVideoSizeListener(listener)
onDispose {
nativeCore.removeVideoSizeListener(listener)
}
}
LaunchedEffect(currentRootScreen, currentSession?.width, currentSession?.height) {
if (currentRootScreen is RootScreen.Fullscreen) {
val session = currentSession ?: return@LaunchedEffect
fullscreenOrientation =
if (session.width >= session.height) {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
} else {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
}
}
LaunchedEffect(asBundle.adbKeyName) { LaunchedEffect(asBundle.adbKeyName) {
adbService.keyName = asBundle.adbKeyName.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue } adbService.keyName = asBundle.adbKeyName.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue }
} }
@@ -341,16 +364,16 @@ fun MainScreen() {
onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) }, onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) },
onOpenReorderDevices = { showReorderDevices = true }, onOpenReorderDevices = { showReorderDevices = true },
onOpenAdvancedPage = { rootBackStack.add(RootScreen.Advanced) }, onOpenAdvancedPage = { rootBackStack.add(RootScreen.Advanced) },
onOpenFullscreenPage = { session -> onOpenFullscreenPage = {
currentSession?.let { session ->
fullscreenOrientation = fullscreenOrientation =
if (session.width >= session.height) { if (session.width >= session.height) {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
} else { } else {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
} }
rootBackStack.add( rootBackStack.add(RootScreen.Fullscreen)
RootScreen.Fullscreen(session), }
)
}, },
) )
@@ -396,10 +419,10 @@ fun MainScreen() {
) )
} }
entry<RootScreen.Fullscreen> { screen -> entry(RootScreen.Fullscreen) {
FullscreenControlScreen( FullscreenControlScreen(
onBack = ::popRoot, onBack = ::popRoot,
session = screen.session, scrcpy = scrcpy,
nativeCore = nativeCore, nativeCore = nativeCore,
onVideoSizeChanged = { width, height -> onVideoSizeChanged = { width, height ->
fullscreenOrientation = fullscreenOrientation =

View File

@@ -16,6 +16,9 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.EncoderType
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -58,8 +61,8 @@ class Scrcpy(
private val session = Session(adbService) private val session = Session(adbService)
private val nativeCore: NativeCoreFacade = NativeCoreFacade.get(appContext) private val nativeCore: NativeCoreFacade = NativeCoreFacade.get(appContext)
@Volatile private val _currentSessionState = MutableStateFlow<Session.SessionInfo?>(null)
private var currentSession: Session.SessionInfo? = null val currentSessionState: StateFlow<Session.SessionInfo?> = _currentSessionState.asStateFlow()
@Volatile @Volatile
private var isRunning: Boolean = false private var isRunning: Boolean = false
@@ -136,7 +139,7 @@ class Scrcpy(
} }
// Create session info // Create session info
currentSession = info _currentSessionState.value = info
isRunning = true isRunning = true
// Setup video consumer (notify NativeCoreFacade to setup decoders) // Setup video consumer (notify NativeCoreFacade to setup decoders)
@@ -175,7 +178,7 @@ class Scrcpy(
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "start(): Failed to start scrcpy session", e) Log.e(TAG, "start(): Failed to start scrcpy session", e)
isRunning = false isRunning = false
currentSession = null _currentSessionState.value = null
throw e throw e
} }
} }
@@ -196,7 +199,7 @@ class Scrcpy(
audioPlayer?.release() audioPlayer?.release()
audioPlayer = null audioPlayer = null
isRunning = false isRunning = false
currentSession = null _currentSessionState.value = null
Log.i(TAG, "stop(): Session stopped successfully") Log.i(TAG, "stop(): Session stopped successfully")
true true
} catch (e: Exception) { } catch (e: Exception) {
@@ -212,7 +215,13 @@ class Scrcpy(
fun isStarted(): Boolean = isRunning && session.isStarted() fun isStarted(): Boolean = isRunning && session.isStarted()
fun getCurrentSession(): Session.SessionInfo? = currentSession fun getCurrentSession(): Session.SessionInfo? = currentSessionState.value
fun updateCurrentSessionSize(width: Int, height: Int) {
val current = _currentSessionState.value ?: return
if (current.width == width && current.height == height) return
_currentSessionState.value = current.copy(width = width, height = height)
}
sealed class ListResult { sealed class ListResult {
data class Encoders( data class Encoders(