diff --git a/CHANGELOG.md b/CHANGELOG.md index 87147cf..ff1e5b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ~~什么我居然开始写更新日志了~~ +## 0.2.5 + +- 修复: 预览销毁再重组会导致画面冻结 +- 新增: 横屏布局 +- 修复: 虚拟按钮 Popup 弹出时被 dock 栏阻挡 + ## 0.2.4 - 改进: 再次修改图标至 monochrome 主题色填充以避免与上游项目混淆 diff --git a/README.md b/README.md index 2c2843e..e76385f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Scrcpy For Android +# Scrcpy for Android scrcpy @@ -48,6 +48,8 @@ - 可替换 scrcpy-server - 利用 mDNS 服务实现自动连接启用无线调试的设备、自动发现等待配对设备的IP与端口 - 自动横竖屏切换(算吗 +- 横屏布局 + - 仅屏幕比例小于 16:9 的设备 - 画中画 - 双向文件传输 - 流式 adb 终端 @@ -112,6 +114,9 @@ - /sdcard/Movies/Scrcpy/ - /sdcard/Download/Scrcpy/ +5. 横屏模式对左撇子不太友好 + - 右上角有按钮可以对调方向 + ## 构建 - JDK 17+ diff --git a/TODO.md b/TODO.md index 129f7a6..964f931 100644 --- a/TODO.md +++ b/TODO.md @@ -2,9 +2,9 @@ # TODO -## PARAMS +- 新的原创图标而非从 scrcpy 的图标派生 -### LOWER PRIORITY +## PARAMS - ~~orientation locking~~ @@ -14,5 +14,4 @@ 顺序无关 -- 横屏布局 - I18N diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 191ccb5..70ad575 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -54,8 +54,8 @@ android { applicationId = "io.github.miuzarte.scrcpyforandroid" minSdk = 26 targetSdk = 37 - versionCode = 20 - versionName = "0.2.4" + versionCode = 21 + versionName = "0.2.5" externalNativeBuild { cmake { @@ -120,6 +120,7 @@ dependencies { implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material3.window.size) implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.navigation3.runtime) implementation(libs.material) 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 4f23b4b..87d934c 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt @@ -1,5 +1,8 @@ package io.github.miuzarte.scrcpyforandroid +import android.content.pm.ActivityInfo +import android.graphics.Rect +import android.os.Build import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -16,6 +19,7 @@ import kotlinx.coroutines.runBlocking class MainActivity : FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + applyMainOrientationPolicy() AppRuntime.init(applicationContext) AppScreenOn.register(window) @@ -39,6 +43,7 @@ class MainActivity : FragmentActivity() { override fun onResume() { super.onResume() + applyMainOrientationPolicy() StreamActivity.dismissActivePictureInPicture() } @@ -46,4 +51,30 @@ class MainActivity : FragmentActivity() { AppScreenOn.unregister(window) super.onDestroy() } + + private fun applyMainOrientationPolicy() { + val aspectRatio = currentDisplayAspectRatio() + requestedOrientation = + if (aspectRatio > PHONE_LANDSCAPE_LOCK_ASPECT_RATIO) + ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT + else + ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + + private fun currentDisplayAspectRatio(): Float { + val bounds = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + windowManager.maximumWindowMetrics.bounds + else resources.displayMetrics.let { metrics -> + Rect(0, 0, metrics.widthPixels, metrics.heightPixels) + } + + val width = bounds.width().coerceAtLeast(1) + val height = bounds.height().coerceAtLeast(1) + return maxOf(width, height).toFloat() / minOf(width, height).toFloat() + } + + private companion object { + private const val PHONE_LANDSCAPE_LOCK_ASPECT_RATIO = 16f / 9f + } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt index d0f32b0..a854cff 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt @@ -30,6 +30,7 @@ object NativeCoreFacade { private val mainHandler = Handler(Looper.getMainLooper()) private val bootstrapLock = Any() private val bootstrapPackets = ArrayDeque() + @Volatile private var recordingSurfaceAttached = false @@ -111,9 +112,6 @@ object NativeCoreFacade { Log.i(TAG, "detachVideoSurface(): releasing decoder with destroyed surface") decoder?.release() decoder = null - } else if (activeSurfaceId == null && !recordingSurfaceAttached) { - decoder?.release() - decoder = null } } } @@ -182,7 +180,6 @@ object NativeCoreFacade { ) } val currentDecoder = decoder ?: return@attachVideoConsumer - if (activeSurfaceId == null) return@attachVideoConsumer runCatching { currentDecoder.feedAnnexB( packet.data, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/PersistentVideoRenderer.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/PersistentVideoRenderer.kt index b454744..277e716 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/PersistentVideoRenderer.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/PersistentVideoRenderer.kt @@ -1,11 +1,11 @@ package io.github.miuzarte.scrcpyforandroid.nativecore import android.graphics.SurfaceTexture -import android.opengl.EGLExt import android.opengl.EGL14 import android.opengl.EGLConfig import android.opengl.EGLContext import android.opengl.EGLDisplay +import android.opengl.EGLExt import android.opengl.EGLSurface import android.opengl.GLES11Ext import android.opengl.GLES20 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 8092a3b..16a84a0 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 @@ -3,10 +3,24 @@ package io.github.miuzarte.scrcpyforandroid.pages import android.annotation.SuppressLint import android.util.Log import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.SwapHoriz +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -20,15 +34,18 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import io.github.miuzarte.scrcpyforandroid.StreamActivity +import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts @@ -83,6 +100,7 @@ import top.yukonga.miuix.kmp.basic.ListPopupDefaults import top.yukonga.miuix.kmp.basic.PopupPositionProvider import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.ScrollBehavior +import top.yukonga.miuix.kmp.basic.SmallTopAppBar import top.yukonga.miuix.kmp.basic.TextField import top.yukonga.miuix.kmp.basic.TopAppBar import top.yukonga.miuix.kmp.blur.layerBackdrop @@ -114,72 +132,111 @@ internal fun DeviceTabScreen( connectionServices: DeviceConnectionServices, bottomInnerPadding: Dp, onOpenReorderDevices: () -> Unit, + onPreviewGestureLockChanged: (Boolean) -> Unit = {}, ) { val navigator = LocalRootNavigator.current var showThreePointMenu by rememberSaveable { mutableStateOf(false) } + var useCompactTopAppBar by remember { mutableStateOf(false) } + var showTwoPaneSideAction by remember { mutableStateOf(false) } + var configPanelOnLeft by remember { mutableStateOf(true) } + var twoPaneSideToggleRequest by remember { mutableIntStateOf(0) } val blurBackdrop = rememberBlurBackdrop(LocalEnableBlur.current) val blurActive = blurBackdrop != null + fun setTopAppBarCompact(compact: Boolean) { + useCompactTopAppBar = compact + } + + fun setTwoPaneSideAction( + visible: Boolean, + configOnLeft: Boolean, + ) { + showTwoPaneSideAction = visible + configPanelOnLeft = configOnLeft + } + Scaffold( topBar = { BlurredBar(backdrop = blurBackdrop) { - TopAppBar( - title = "设备", - color = - if (blurActive) Color.Transparent - else colorScheme.surface, - actions = { - Box { - IconButton( - onClick = { showThreePointMenu = true }, - holdDownState = showThreePointMenu, - ) { - Icon( - imageVector = MiuixIcons.More, - contentDescription = "更多" + val topAppBarColor = + if (blurActive) Color.Transparent + else colorScheme.surface + val topAppBarActions: @Composable RowScope.() -> Unit = { + if (showTwoPaneSideAction) { + IconButton( + onClick = { twoPaneSideToggleRequest++ }, + ) { + Icon( + imageVector = Icons.Rounded.SwapHoriz, + contentDescription = + if (configPanelOnLeft) "配置显示在右侧" + else "配置显示在左侧" + ) + } + } + Box { + IconButton( + onClick = { showThreePointMenu = true }, + holdDownState = showThreePointMenu, + ) { + Icon( + imageVector = MiuixIcons.More, + contentDescription = "更多" + ) + } + OverlayListPopup( + show = showThreePointMenu, + popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider, + alignment = PopupPositionProvider.Align.TopEnd, + onDismissRequest = { showThreePointMenu = false }, + ) { + ListPopupColumn { + PopupMenuItem( + text = "快速设备排序", + optionSize = 3, + index = 0, + onSelectedIndexChange = { + onOpenReorderDevices() + showThreePointMenu = false + }, + ) + PopupMenuItem( + text = "虚拟按钮排序", + optionSize = 3, + index = 1, + onSelectedIndexChange = { + navigator.push(RootScreen.VirtualButtonOrder) + showThreePointMenu = false + }, + ) + PopupMenuItem( + text = "清空日志", + optionSize = 3, + index = 2, + enabled = EventLogger.hasLogs(), + onSelectedIndexChange = { + EventLogger.clearLogs() + showThreePointMenu = false + }, ) } - OverlayListPopup( - show = showThreePointMenu, - popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider, - alignment = PopupPositionProvider.Align.TopEnd, - onDismissRequest = { showThreePointMenu = false }, - ) { - ListPopupColumn { - PopupMenuItem( - text = "快速设备排序", - optionSize = 3, - index = 0, - onSelectedIndexChange = { - onOpenReorderDevices() - showThreePointMenu = false - }, - ) - PopupMenuItem( - text = "虚拟按钮排序", - optionSize = 3, - index = 1, - onSelectedIndexChange = { - navigator.push(RootScreen.VirtualButtonOrder) - showThreePointMenu = false - }, - ) - PopupMenuItem( - text = "清空日志", - optionSize = 3, - index = 2, - enabled = EventLogger.hasLogs(), - onSelectedIndexChange = { - EventLogger.clearLogs() - showThreePointMenu = false - }, - ) - } - } } - }, - scrollBehavior = scrollBehavior, - ) + } + } + if (useCompactTopAppBar) { + SmallTopAppBar( + title = "设备", + color = topAppBarColor, + actions = topAppBarActions, + ) + } else { + TopAppBar( + title = "设备", + color = topAppBarColor, + actions = topAppBarActions, + scrollBehavior = scrollBehavior, + ) + } } }, ) { pagePadding -> @@ -190,11 +247,16 @@ internal fun DeviceTabScreen( scrcpy = scrcpy, connectionServices = connectionServices, bottomInnerPadding = bottomInnerPadding, + twoPaneSideToggleRequest = twoPaneSideToggleRequest, + onPreviewGestureLockChanged = onPreviewGestureLockChanged, + onCompactTopAppBarChanged = ::setTopAppBarCompact, + onTwoPaneSideActionChanged = ::setTwoPaneSideAction, ) } } } +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable internal fun DeviceTabPage( contentPadding: PaddingValues, @@ -202,8 +264,13 @@ internal fun DeviceTabPage( scrcpy: Scrcpy, connectionServices: DeviceConnectionServices, bottomInnerPadding: Dp, + twoPaneSideToggleRequest: Int = 0, + onPreviewGestureLockChanged: (Boolean) -> Unit = {}, + onCompactTopAppBarChanged: (Boolean) -> Unit = {}, + onTwoPaneSideActionChanged: (Boolean, Boolean) -> Unit = { _, _ -> }, ) { val activity = LocalActivity.current + val windowSizeClass = activity?.let { calculateWindowSizeClass(it) } val context = LocalContext.current val scope = rememberCoroutineScope() @@ -222,11 +289,21 @@ internal fun DeviceTabPage( val asBundleSharedLatest by rememberUpdatedState(asBundleShared) var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) } val asBundleLatest by rememberUpdatedState(asBundle) + var handledTwoPaneSideToggleRequest by rememberSaveable { + mutableIntStateOf(twoPaneSideToggleRequest) + } LaunchedEffect(asBundleShared) { if (asBundle != asBundleShared) { asBundle = asBundleShared } } + DisposableEffect(Unit) { + onDispose { + onPreviewGestureLockChanged(false) + onCompactTopAppBarChanged(false) + onTwoPaneSideActionChanged(false, true) + } + } LaunchedEffect(asBundle) { delay(Settings.BUNDLE_SAVE_DELAY) if (asBundle != asBundleSharedLatest) { @@ -923,288 +1000,512 @@ internal fun DeviceTabPage( setActiveDeviceActionId = { activeDeviceActionId = it }, ) - // 设备 - LazyColumn( - contentPadding = contentPadding, - scrollBehavior = scrollBehavior, - state = listState, - bottomInnerPadding = bottomInnerPadding, - ) { - item { - StatusCard( - statusLine = statusLine, - adbConnected = adbConnected, - streaming = sessionInfo != null, - sessionInfo = sessionInfo, - busyLabel = null, - connectedDeviceLabel = connectedDeviceLabel, - ) - } - - item { - DeviceTileList( - devices = savedShortcuts, - isConnected = { device -> - adbConnected - && currentTarget?.host == device.host - && currentTarget.port == device.port - }, - actionEnabled = !busy && !adbConnecting, - actionInProgress = { device -> - adbConnecting && activeDeviceActionId == device.id - }, - editingDeviceId = editingDeviceId, - onClick = { device -> - if (editingDeviceId != device.id) - snackbar.show("长按可编辑") - }, - onLongClick = { device -> - val connected = adbConnected - && currentTarget?.host == device.host - && currentTarget.port == device.port - if (connected) { - snackbar.show("无法修改已连接的设备") - } else { - editingDeviceId = - if (editingDeviceId != device.id) device.id - else null - } - }, - onAction = { device -> - haptic.contextClick() - if (editingDeviceId == device.id) editingDeviceId = null - adbCallbacks.onDeviceAction(device) - }, - onEditorSave = { device, updated -> - savedShortcuts = savedShortcuts.update( - id = device.id, - name = updated.name, - host = updated.host, - port = updated.port, - startScrcpyOnConnect = updated.startScrcpyOnConnect, - openFullscreenOnStart = updated.openFullscreenOnStart, - scrcpyProfileId = updated.scrcpyProfileId, - ) - }, - onEditorDelete = { device -> - savedShortcuts = savedShortcuts.remove(id = device.id) - editingDeviceId = null - }, - onEditorCancel = { editingDeviceId = null }, - ) - } - - if (!adbConnected) { - // "快速连接" - item { - QuickConnectCard( - input = quickConnectInputTemp, - onValueChange = { - quickConnectInputTemp = it - }, - onFocusLost = { - qdBundle = qdBundle.copy( - quickConnectInput = quickConnectInputTemp - ) - }, - enabled = !adbConnecting, - onAddDevice = { - val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp) - ?: return@QuickConnectCard - - savedShortcuts = savedShortcuts.upsert( - DeviceShortcut(host = target.host, port = target.port) - ) - - Log.d( - "SavedShortcuts", - "size: ${savedShortcuts.size}, list: ${qdBundle.quickDevicesList}" - ) - snackbar.show("已添加设备: ${target.host}:${target.port}") - }, - onConnect = { - val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp) - ?: return@QuickConnectCard - - adbCallbacks.onQuickConnect(target) - }, - ) - } - - item { - SectionSmallTitle("无线配对") - // "使用配对码配对设备" - PairingCard( - busy = busy, - autoDiscoverOnDialogOpen = asBundle.adbPairingAutoDiscoverOnDialogOpen, - onDiscoverTarget = adbCallbacks::onDiscoverPairingTarget, - onPair = adbCallbacks::onPair, - ) - } - } - - if (adbConnected) { - item { - SectionSmallTitle("Scrcpy") - ConfigPanel( - busy = busy, - activeProfileId = connectedScrcpyProfileId, - activeBundle = connectedScrcpyBundle, - hideSimpleConfigItems = asBundle.hideSimpleConfigItems, - audioForwardingSupported = adbSession.audioForwardingSupported, - cameraMirroringSupported = adbSession.cameraMirroringSupported, - adbConnecting = adbConnecting, - isQuickConnected = isQuickConnected, - advancedEndActionText = connectedScrcpyProfileName, - allAppsEndActionText = when { - listingsRefreshBusy -> "..." - apps.isNotEmpty() -> apps.size.toString() - else -> "空" - }, - onOpenAllApps = { - showAllAppsSheet = true - if (apps.isEmpty() && !listingsRefreshBusy) { - scope.launch(Dispatchers.IO) { refreshApps() } - } - }, - recentTasksEndActionText = when { - listingsRefreshBusy -> "..." - recentTasks.isNotEmpty() -> recentTasks.size.toString() - else -> "空" - }, - onOpenRecentTasks = { - showRecentTasksSheet = true - if (recentTasks.isEmpty() && !listingsRefreshBusy) { - scope.launch(Dispatchers.IO) { - runCatching { - scrcpy.listings.getRecentTasks(forceRefresh = true) - }.onFailure { error -> - logEvent("获取最近任务失败: ${error.message}", Log.WARN, error) - withContext(Dispatchers.Main) { - snackbar.show("获取最近任务失败") - } - } - } - } - }, - onOpenAdvanced = { navigator.push(RootScreen.Advanced) }, - onStartStopHaptic = haptic::contextClick, - onStart = { - runBusy("启动 scrcpy") { - startScrcpySession() - } - }, - onStop = { - runBusy("停止 scrcpy") { - stopScrcpySession() - } - }, - sessionInfo = sessionInfo, - onDisconnect = { adbCallbacks.onDisconnectCurrent(currentTarget) }, - ) - } - - if ( + val canShowPreviewControls = + adbConnected && connectedVideoPlaybackEnabled && sessionInfo != null && sessionInfo!!.width > 0 && sessionInfo!!.height > 0 - ) { - item(key = PREVIEW_CARD_ITEM_KEY) { - PreviewCard( - modifier = Modifier, - sessionInfo = sessionInfo, - previewHeightDp = asBundle.devicePreviewCardHeightDp.coerceAtLeast(120), - onOpenFullscreen = { - context.startActivity(StreamActivity.createIntent(context)) - }, - imeRequestToken = imeRequestToken, - onImeCommitText = { text -> commitImeText(text) }, - onImeDeleteSurroundingText = { beforeLength, _ -> - submitImeDeleteSurroundingText( - scrcpy = scrcpy, - beforeLength = beforeLength, - afterLength = 0, - ) - }, - onImeKeyEvent = { event -> - submitImeKeyEvent( - scrcpy = scrcpy, - event = event, - keyInjectMode = sessionInfo?.keyInjectMode - ?: ClientOptions.KeyInjectMode.MIXED, - forwardKeyRepeat = sessionInfo?.forwardKeyRepeat ?: true, - ) - }, - autoBringIntoView = pendingScrollToPreview, - onAutoBringIntoViewConsumed = { pendingScrollToPreview = false }, - ) + + fun handleVirtualButtonAction(action: VirtualButtonAction) { + when (action) { + VirtualButtonAction.RECENT_TASKS -> { + showRecentTasksSheet = true + if (recentTasks.isEmpty() && !listingsRefreshBusy) { + scope.launch(Dispatchers.IO) { refreshApps() } + scope.launch(Dispatchers.IO) { refreshRecentTasks() } } + } - item { - VirtualButtonCard( - busy = busy, - outsideActions = virtualButtonLayout.first, - moreActions = virtualButtonLayout.second, - showText = asBundle.previewVirtualButtonShowText, - onAction = { action -> - when (action) { - VirtualButtonAction.RECENT_TASKS -> { - showRecentTasksSheet = true - if (recentTasks.isEmpty() && !listingsRefreshBusy) { - scope.launch(Dispatchers.IO) { refreshApps() } - scope.launch(Dispatchers.IO) { refreshRecentTasks() } - } - } + VirtualButtonAction.ALL_APPS -> { + showAllAppsSheet = true + if (apps.isEmpty() && !listingsRefreshBusy) { + scope.launch(Dispatchers.IO) { refreshApps() } + } + } - VirtualButtonAction.ALL_APPS -> { - showAllAppsSheet = true - if (apps.isEmpty() && !listingsRefreshBusy) { - scope.launch(Dispatchers.IO) { refreshApps() } - } - } + VirtualButtonAction.TOGGLE_IME -> { + imeRequestToken++ + } - VirtualButtonAction.TOGGLE_IME -> { - imeRequestToken++ - } + VirtualButtonAction.PASTE_LOCAL_CLIPBOARD -> { + scope.launch { pasteLocalClipboard() } + } - VirtualButtonAction.PASTE_LOCAL_CLIPBOARD -> { - scope.launch { pasteLocalClipboard() } - } - - else -> { - val keycode = action.keycode ?: return@VirtualButtonCard - runBusy("发送 ${action.title}") { - scrcpy.injectKeycode(0, keycode) - scrcpy.injectKeycode(1, keycode) - } - } - } - }, - passwordPopupContent = { onDismissRequest -> - PasswordPickerPopupContent( - onDismissRequest = onDismissRequest, - ) - }, - ) + else -> { + val keycode = action.keycode ?: return + runBusy("发送 ${action.title}") { + scrcpy.injectKeycode(0, keycode) + scrcpy.injectKeycode(1, keycode) } } } + } + @Composable + fun StatusSection() { + StatusCard( + statusLine = statusLine, + adbConnected = adbConnected, + streaming = sessionInfo != null, + sessionInfo = sessionInfo, + busyLabel = null, + connectedDeviceLabel = connectedDeviceLabel, + ) + } + + @Composable + fun DeviceListSection() { + DeviceTileList( + devices = savedShortcuts, + isConnected = { device -> + adbConnected + && currentTarget?.host == device.host + && currentTarget.port == device.port + }, + actionEnabled = !busy && !adbConnecting, + actionInProgress = { device -> + adbConnecting && activeDeviceActionId == device.id + }, + editingDeviceId = editingDeviceId, + onClick = { device -> + if (editingDeviceId != device.id) + snackbar.show("长按可编辑") + }, + onLongClick = { device -> + val connected = adbConnected + && currentTarget?.host == device.host + && currentTarget.port == device.port + if (connected) { + snackbar.show("无法修改已连接的设备") + } else { + editingDeviceId = + if (editingDeviceId != device.id) device.id + else null + } + }, + onAction = { device -> + haptic.contextClick() + if (editingDeviceId == device.id) editingDeviceId = null + adbCallbacks.onDeviceAction(device) + }, + onEditorSave = { device, updated -> + savedShortcuts = savedShortcuts.update( + id = device.id, + name = updated.name, + host = updated.host, + port = updated.port, + startScrcpyOnConnect = updated.startScrcpyOnConnect, + openFullscreenOnStart = updated.openFullscreenOnStart, + scrcpyProfileId = updated.scrcpyProfileId, + ) + }, + onEditorDelete = { device -> + savedShortcuts = savedShortcuts.remove(id = device.id) + editingDeviceId = null + }, + onEditorCancel = { editingDeviceId = null }, + ) + } + + @Composable + fun QuickConnectSection() { + QuickConnectCard( + input = quickConnectInputTemp, + onValueChange = { quickConnectInputTemp = it }, + onFocusLost = { + qdBundle = qdBundle.copy(quickConnectInput = quickConnectInputTemp) + }, + enabled = !adbConnecting, + onAddDevice = { + val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp) + ?: return@QuickConnectCard + + savedShortcuts = savedShortcuts.upsert( + DeviceShortcut(host = target.host, port = target.port) + ) + + Log.d( + "SavedShortcuts", + "size: ${savedShortcuts.size}, list: ${qdBundle.quickDevicesList}" + ) + snackbar.show("已添加设备: ${target.host}:${target.port}") + }, + onConnect = { + val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp) + ?: return@QuickConnectCard + + adbCallbacks.onQuickConnect(target) + }, + ) + } + + @Composable + fun PairingSection() { + SectionSmallTitle("无线配对") + PairingCard( + busy = busy, + autoDiscoverOnDialogOpen = asBundle.adbPairingAutoDiscoverOnDialogOpen, + onDiscoverTarget = adbCallbacks::onDiscoverPairingTarget, + onPair = adbCallbacks::onPair, + ) + } + + @Composable + fun ScrcpyConfigSection() { + SectionSmallTitle("Scrcpy") + ConfigPanel( + busy = busy, + activeProfileId = connectedScrcpyProfileId, + activeBundle = connectedScrcpyBundle, + hideSimpleConfigItems = asBundle.hideSimpleConfigItems, + audioForwardingSupported = adbSession.audioForwardingSupported, + cameraMirroringSupported = adbSession.cameraMirroringSupported, + adbConnecting = adbConnecting, + isQuickConnected = isQuickConnected, + advancedEndActionText = connectedScrcpyProfileName, + allAppsEndActionText = when { + listingsRefreshBusy -> "..." + apps.isNotEmpty() -> apps.size.toString() + else -> "空" + }, + onOpenAllApps = { + showAllAppsSheet = true + if (apps.isEmpty() && !listingsRefreshBusy) { + scope.launch(Dispatchers.IO) { refreshApps() } + } + }, + recentTasksEndActionText = when { + listingsRefreshBusy -> "..." + recentTasks.isNotEmpty() -> recentTasks.size.toString() + else -> "空" + }, + onOpenRecentTasks = { + showRecentTasksSheet = true + if (recentTasks.isEmpty() && !listingsRefreshBusy) { + scope.launch(Dispatchers.IO) { refreshRecentTasks() } + } + }, + onOpenAdvanced = { navigator.push(RootScreen.Advanced) }, + onStartStopHaptic = haptic::contextClick, + onStart = { + runBusy("启动 scrcpy") { + startScrcpySession() + } + }, + onStop = { + runBusy("停止 scrcpy") { + stopScrcpySession() + } + }, + sessionInfo = sessionInfo, + onDisconnect = { adbCallbacks.onDisconnectCurrent(currentTarget) }, + showFullscreenAction = false, + onOpenFullscreen = { + context.startActivity(StreamActivity.createIntent(context)) + }, + ) + } + + @Composable + fun PreviewSection( + modifier: Modifier = Modifier, + directControlEnabled: Boolean = false, + ) { + PreviewCard( + modifier = modifier, + sessionInfo = sessionInfo, + previewHeightDp = asBundle.devicePreviewCardHeightDp.coerceAtLeast(120), + onOpenFullscreen = { + context.startActivity(StreamActivity.createIntent(context)) + }, + directControlEnabled = directControlEnabled, + onInjectTouch = { action, pointerId, x, y, pressure, actionButton, buttons -> + val session = sessionInfo + if (session != null) { + withContext(Dispatchers.IO) { + scrcpy.injectTouch( + action = action, + pointerId = pointerId, + x = x, + y = y, + screenWidth = session.width, + screenHeight = session.height, + pressure = pressure, + actionButton = actionButton, + buttons = buttons, + ) + } + } + }, + onBackOrScreenOn = { action -> + withContext(Dispatchers.IO) { + scrcpy.pressBackOrTurnScreenOn(action) + } + }, + imeRequestToken = imeRequestToken, + onImeCommitText = { text -> commitImeText(text) }, + onImeDeleteSurroundingText = { beforeLength, _ -> + submitImeDeleteSurroundingText( + scrcpy = scrcpy, + beforeLength = beforeLength, + afterLength = 0, + ) + }, + onImeKeyEvent = { event -> + submitImeKeyEvent( + scrcpy = scrcpy, + event = event, + keyInjectMode = sessionInfo?.keyInjectMode + ?: ClientOptions.KeyInjectMode.MIXED, + forwardKeyRepeat = sessionInfo?.forwardKeyRepeat ?: true, + ) + }, + autoBringIntoView = pendingScrollToPreview && !directControlEnabled, + onAutoBringIntoViewConsumed = { pendingScrollToPreview = false }, + onTouchActiveChanged = { + if (directControlEnabled) onPreviewGestureLockChanged(it) + }, + ) + } + + @Composable + fun ScrcpyConfigSectionForTwoPane() { + SectionSmallTitle("Scrcpy") + ConfigPanel( + busy = busy, + activeProfileId = connectedScrcpyProfileId, + activeBundle = connectedScrcpyBundle, + hideSimpleConfigItems = asBundle.hideSimpleConfigItems, + audioForwardingSupported = adbSession.audioForwardingSupported, + cameraMirroringSupported = adbSession.cameraMirroringSupported, + adbConnecting = adbConnecting, + isQuickConnected = isQuickConnected, + advancedEndActionText = connectedScrcpyProfileName, + allAppsEndActionText = when { + listingsRefreshBusy -> "..." + apps.isNotEmpty() -> apps.size.toString() + else -> "空" + }, + onOpenAllApps = { + showAllAppsSheet = true + if (apps.isEmpty() && !listingsRefreshBusy) { + scope.launch(Dispatchers.IO) { refreshApps() } + } + }, + recentTasksEndActionText = when { + listingsRefreshBusy -> "..." + recentTasks.isNotEmpty() -> recentTasks.size.toString() + else -> "空" + }, + onOpenRecentTasks = { + showRecentTasksSheet = true + if (recentTasks.isEmpty() && !listingsRefreshBusy) { + scope.launch(Dispatchers.IO) { refreshRecentTasks() } + } + }, + onOpenAdvanced = { navigator.push(RootScreen.Advanced) }, + onStartStopHaptic = haptic::contextClick, + onStart = { + runBusy("启动 scrcpy") { + startScrcpySession() + } + }, + onStop = { + runBusy("停止 scrcpy") { + stopScrcpySession() + } + }, + sessionInfo = sessionInfo, + onDisconnect = { adbCallbacks.onDisconnectCurrent(currentTarget) }, + showFullscreenAction = canShowPreviewControls, + onOpenFullscreen = { + context.startActivity(StreamActivity.createIntent(context)) + }, + reverseSideActions = asBundle.deviceTwoPaneConfigOnRight, + ) + } + + @Composable + fun VirtualButtonsSection(modifier: Modifier = Modifier) { + VirtualButtonCard( + busy = busy, + outsideActions = virtualButtonLayout.first, + moreActions = virtualButtonLayout.second, + showText = asBundle.previewVirtualButtonShowText, + onAction = ::handleVirtualButtonAction, + passwordPopupContent = { onDismissRequest -> + PasswordPickerPopupContent(onDismissRequest = onDismissRequest) + }, + popupBottomPadding = bottomInnerPadding, + modifier = modifier, + ) + } + + @Composable + fun LogsSection() { if (EventLogger.hasLogs()) { - item { - SectionSmallTitle("日志") - Card { - TextField( - value = EventLogger.eventLog.joinToString(separator = "\n"), - onValueChange = {}, - readOnly = true, - modifier = Modifier.fillMaxWidth(), + SectionSmallTitle("日志") + Card { + TextField( + value = EventLogger.eventLog.joinToString(separator = "\n"), + onValueChange = {}, + readOnly = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + + val pageContentPadding = contentPadding + val pageBottomInnerPadding = bottomInnerPadding + + @Composable + fun DeviceListContent( + state: LazyListState, + includeInlinePreviewControls: Boolean, + useTwoPaneConfigPanel: Boolean = false, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = pageContentPadding, + bottomInnerPadding: Dp? = pageBottomInnerPadding, + ) { + LazyColumn( + modifier = modifier, + contentPadding = contentPadding, + scrollBehavior = scrollBehavior, + state = state, + bottomInnerPadding = bottomInnerPadding, + ) { + item { StatusSection() } + item { DeviceListSection() } + + if (!adbConnected) { + item { QuickConnectSection() } + item { PairingSection() } + } + + if (adbConnected) { + item { + if (useTwoPaneConfigPanel) ScrcpyConfigSectionForTwoPane() + else ScrcpyConfigSection() + } + + if (includeInlinePreviewControls && canShowPreviewControls) { + item(key = PREVIEW_CARD_ITEM_KEY) { PreviewSection() } + item { VirtualButtonsSection() } + } + } + + item { LogsSection() } + } + } + + BoxWithConstraints(Modifier.fillMaxSize()) { + val useTwoPane = maxWidth > maxHeight + || when (windowSizeClass?.widthSizeClass) { + WindowWidthSizeClass.Compact -> false + WindowWidthSizeClass.Medium -> false + WindowWidthSizeClass.Expanded -> true + else -> false + } + val availableMaxWidth = maxWidth + val compactTopAppBar = useTwoPane && canShowPreviewControls + val showTwoPaneSideAction = useTwoPane && canShowPreviewControls + val configOnLeft = !asBundle.deviceTwoPaneConfigOnRight + + LaunchedEffect(compactTopAppBar) { + onCompactTopAppBarChanged(compactTopAppBar) + } + LaunchedEffect(showTwoPaneSideAction, configOnLeft) { + onTwoPaneSideActionChanged( + showTwoPaneSideAction, + configOnLeft, + ) + } + LaunchedEffect(twoPaneSideToggleRequest, showTwoPaneSideAction) { + if (twoPaneSideToggleRequest == handledTwoPaneSideToggleRequest) return@LaunchedEffect + handledTwoPaneSideToggleRequest = twoPaneSideToggleRequest + if (!showTwoPaneSideAction) return@LaunchedEffect + asBundle = asBundleLatest.copy( + deviceTwoPaneConfigOnRight = !asBundleLatest.deviceTwoPaneConfigOnRight + ) + } + + if (!useTwoPane || !canShowPreviewControls) { + DeviceListContent( + state = listState, + includeInlinePreviewControls = true, + ) + } else { + Row( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .padding( + horizontal = UiSpacing.PageHorizontal, + vertical = UiSpacing.PageVertical, + ), + horizontalArrangement = Arrangement.spacedBy(UiSpacing.PageItem), + ) { + val currentSession = sessionInfo!! + val twoPaneContentWidth = + (availableMaxWidth - UiSpacing.PageHorizontal * 2 - UiSpacing.PageItem) + .coerceAtLeast(0.dp) + val desiredPreviewWidth = + (asBundle.devicePreviewCardHeightDp.coerceAtLeast(120) + .toFloat() * currentSession.width / currentSession.height).dp + val previewWidth = + desiredPreviewWidth.coerceAtMost(twoPaneContentWidth * 2f / 3f) + val configColumnWidth = + (twoPaneContentWidth - previewWidth) + .coerceIn(0.dp, DEVICE_TWO_PANE_CONFIG_MAX_WIDTH) + val previewPaneWidth = + (twoPaneContentWidth - configColumnWidth) + .coerceAtLeast(0.dp) + + @Composable + fun ConfigColumn() { + DeviceListContent( + modifier = Modifier.width(configColumnWidth), + state = listState, + includeInlinePreviewControls = false, + useTwoPaneConfigPanel = true, + contentPadding = PaddingValues(0.dp), + bottomInnerPadding = bottomInnerPadding, ) } + + @Composable + fun PreviewColumn() { + Box( + modifier = Modifier + .width(previewPaneWidth) + .fillMaxHeight() + .padding(bottom = bottomInnerPadding), + contentAlignment = Alignment.TopCenter, + ) { + Column( + modifier = Modifier.width(previewWidth), + verticalArrangement = Arrangement.spacedBy(UiSpacing.PageItem), + ) { + PreviewSection( + modifier = Modifier.width(previewWidth), + directControlEnabled = true, + ) + VirtualButtonsSection(modifier = Modifier.width(previewWidth)) + } + } + } + + if (configOnLeft) { + ConfigColumn() + PreviewColumn() + } else { + PreviewColumn() + ConfigColumn() + } } } - } AppListBottomSheet( @@ -1303,45 +1604,6 @@ internal fun DeviceTabPage( onDismissRequest = { showAllAppsSheet = false }, onRefresh = { scope.launch(Dispatchers.IO) { refreshApps() } }, ) - -} - -@Composable -private fun DeviceMenuPopup( - show: Boolean, - onDismissRequest: () -> Unit, - onReorderDevices: () -> Unit, - onOpenVirtualButtonOrder: () -> Unit, - canClearLogs: Boolean, - onClearLogs: () -> Unit, -) { - OverlayListPopup( - show = show, - popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider, - alignment = PopupPositionProvider.Align.TopEnd, - onDismissRequest = onDismissRequest, - ) { - ListPopupColumn { - PopupMenuItem( - text = "快速设备排序", - optionSize = 3, - index = 0, - onSelectedIndexChange = { onReorderDevices() }, - ) - PopupMenuItem( - text = "虚拟按钮排序", - optionSize = 3, - index = 1, - onSelectedIndexChange = { onOpenVirtualButtonOrder() }, - ) - PopupMenuItem( - text = "清空日志", - optionSize = 3, - index = 2, - enabled = canClearLogs, - onSelectedIndexChange = { onClearLogs() }, - ) - } - } } +private val DEVICE_TWO_PANE_CONFIG_MAX_WIDTH = 640.dp diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerScreen.kt index eaeb10d..113677c 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerScreen.kt @@ -7,11 +7,13 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -45,6 +47,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -132,6 +135,7 @@ fun FileManagerScreen( val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } val pullToRefreshState = rememberPullToRefreshState() val listState = rememberLazyListState() + val layoutDirection = LocalLayoutDirection.current val asBundle by appSettings.bundleState.collectAsState() val pathStack = remember { @@ -398,6 +402,89 @@ fun FileManagerScreen( } } + fun openEntry(entry: RemoteFileEntry) { + when { + entry.isDirectory -> { + rememberCurrentScrollPosition() + pathStack.add(normalizePath(entry.fullPath)) + } + + entry.kind == RemoteFileKind.Link || entry.symlinkTarget != null -> { + taskScope.launch { + val targetPath = resolveLinkTarget(entry) + if (targetPath == null) { + withContext(Dispatchers.Main) { + snackbar.show("链接目标不可用,长按查看信息") + } + return@launch + } + val result = runCatching { + FileManagerService.stat(targetPath) + } + withContext(Dispatchers.Main) { + result.onSuccess { targetStat -> + if (isDirectoryStat(targetStat)) { + jumpToPath(targetPath) + } else { + snackbar.show("链接目标不是文件夹,长按查看信息") + } + }.onFailure { error -> + snackbar.show("读取链接目标失败: ${error.message ?: error.javaClass.simpleName}") + } + } + } + } + + else -> snackbar.show("长按可查看文件详情") + } + } + + fun showEntryDetails(entry: RemoteFileEntry) { + clearDetails() + selectedEntry = entry + showDetailsSheet = true + detailLoading = true + taskScope.launch { + val statResult = runCatching { + FileManagerService.stat(entry.fullPath) + } + val linkTargetPath = resolveLinkTarget(entry) + val targetStatResult = + if (linkTargetPath != null) + runCatching { FileManagerService.stat(linkTargetPath) } + else null + + val snapshotResult = + if (entry.isDirectory) + runCatching { + val session = DirectorySnapshotSession.open() + withContext(Dispatchers.Main) { + activeSnapshotSession = session + } + session.load(entry.fullPath) + } + else null + + withContext(Dispatchers.Main) { + detailLoading = false + statResult + .onSuccess { selectedStat = it } + .onFailure { error -> + snackbar.show("读取详情失败: ${error.message ?: error.javaClass.simpleName}") + if (selectedEntry === entry) + selectedEntry = null + } + targetStatResult + ?.onSuccess { selectedTargetStat = it } + snapshotResult + ?.onSuccess { selectedSnapshot = it } + ?.onFailure { error -> + snackbar.show("目录扫描失败: ${error.message ?: error.javaClass.simpleName}") + } + } + } + } + Scaffold( topBar = { BlurredBar(backdrop = blurBackdrop) { @@ -546,104 +633,78 @@ fun FileManagerScreen( top = pagePadding.calculateTopPadding() + 12.dp, ), ) { - LazyColumn( - contentPadding = pagePadding, - bottomInnerPadding = bottomInnerPadding, - state = listState, - ) { - if (loading && cachedEntries == null) { - item { FileManagerStatusCard("加载中") } - } else if (errorText != null && cachedEntries == null) { - item { FileManagerStatusCard("加载失败: $errorText") } - } else if (displayedEntries.isEmpty()) { - item { FileManagerStatusCard("空目录") } - } else { - items(displayedEntries) { entry -> - FileManagerItemCard( - entry = entry, - summary = FileManagerService.formatSummary(entry), - onClick = { - when { - entry.isDirectory -> { - rememberCurrentScrollPosition() - pathStack.add(normalizePath(entry.fullPath)) - } + BoxWithConstraints(Modifier.fillMaxWidth()) { + val fileCardMinWidth = 220.dp + val listHorizontalPadding = + pagePadding.calculateLeftPadding(layoutDirection) + + pagePadding.calculateRightPadding(layoutDirection) + + UiSpacing.PageHorizontal * 2 + val availableListWidth = + (maxWidth - listHorizontalPadding).coerceAtLeast(fileCardMinWidth) + val columns = ( + (availableListWidth.value + UiSpacing.PageItem.value) / + (fileCardMinWidth.value + UiSpacing.PageItem.value) + ).toInt().coerceAtLeast(1) + val fileRows = remember(displayedEntries, columns) { + displayedEntries.chunked(columns) + } - entry.kind == RemoteFileKind.Link || entry.symlinkTarget != null -> { - taskScope.launch { - val targetPath = resolveLinkTarget(entry) - if (targetPath == null) { - withContext(Dispatchers.Main) { - snackbar.show("链接目标不可用,长按查看信息") - } - return@launch - } - val result = runCatching { - FileManagerService.stat(targetPath) - } - withContext(Dispatchers.Main) { - result.onSuccess { targetStat -> - if (isDirectoryStat(targetStat)) { - jumpToPath(targetPath) - } else { - snackbar.show("链接目标不是文件夹,长按查看信息") - } - }.onFailure { error -> - snackbar.show("读取链接目标失败: ${error.message ?: error.javaClass.simpleName}") - } - } - } - } + @Composable + fun FileStateContent() { + when { + loading && cachedEntries == null -> + FileManagerStatusCard( + message = "加载中", + modifier = Modifier.fillMaxWidth(), + ) - else -> snackbar.show("长按可查看文件详情") + errorText != null && cachedEntries == null -> + FileManagerStatusCard( + message = "加载失败: $errorText", + modifier = Modifier.fillMaxWidth(), + ) + + displayedEntries.isEmpty() -> + FileManagerStatusCard( + message = "空目录", + modifier = Modifier.fillMaxWidth(), + ) + } + } + + LazyColumn( + contentPadding = pagePadding, + bottomInnerPadding = bottomInnerPadding, + state = listState, + limitLandscapeWidth = false, + ) { + if (loading && cachedEntries == null || + errorText != null && cachedEntries == null || + displayedEntries.isEmpty() + ) { + item { FileStateContent() } + } else { + items(fileRows) { rowEntries -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(UiSpacing.PageItem), + ) { + rowEntries.forEach { entry -> + FileManagerItemCard( + entry = entry, + summary = FileManagerService.formatSummary(entry), + onClick = { openEntry(entry) }, + onLongClick = { showEntryDetails(entry) }, + modifier = Modifier + .weight(1f) + .height(72.dp), + ) } - }, - onLongClick = { - clearDetails() - selectedEntry = entry - showDetailsSheet = true - detailLoading = true - taskScope.launch { - val statResult = runCatching { - FileManagerService.stat(entry.fullPath) - } - val linkTargetPath = resolveLinkTarget(entry) - val targetStatResult = - if (linkTargetPath != null) - runCatching { FileManagerService.stat(linkTargetPath) } - else null - - val snapshotResult = - if (entry.isDirectory) - runCatching { - val session = DirectorySnapshotSession.open() - withContext(Dispatchers.Main) { - activeSnapshotSession = session - } - session.load(entry.fullPath) - } - else null - - withContext(Dispatchers.Main) { - detailLoading = false - statResult - .onSuccess { selectedStat = it } - .onFailure { error -> - snackbar.show("读取详情失败: ${error.message ?: error.javaClass.simpleName}") - if (selectedEntry === entry) - selectedEntry = null - } - targetStatResult - ?.onSuccess { selectedTargetStat = it } - snapshotResult - ?.onSuccess { selectedSnapshot = it } - ?.onFailure { error -> - snackbar.show("目录扫描失败: ${error.message ?: error.javaClass.simpleName}") - } - } + repeat(columns - rowEntries.size) { + Box(Modifier.weight(1f)) } - }, - ) + } + } } } } @@ -719,8 +780,11 @@ fun FileManagerScreen( } @Composable -private fun FileManagerStatusCard(message: String) { - Card { +private fun FileManagerStatusCard( + message: String, + modifier: Modifier = Modifier, +) { + Card(modifier = modifier) { Text( text = message, modifier = Modifier @@ -737,10 +801,10 @@ private fun FileManagerItemCard( summary: String, onClick: () -> Unit, onLongClick: () -> Unit, + modifier: Modifier = Modifier, ) { Card( - modifier = Modifier - .fillMaxWidth() + modifier = modifier .combinedClickable( onClick = onClick, onLongClick = onLongClick, @@ -749,7 +813,8 @@ private fun FileManagerItemCard( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), + .fillMaxHeight() + .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt index 54e2500..1bf1148 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt @@ -5,7 +5,6 @@ import android.content.Context import android.graphics.Rect import android.hardware.display.DisplayManager import android.util.Log -import android.view.KeyEvent import android.view.Surface import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalActivity diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt index c5f0f5c..ff1fceb 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt @@ -58,12 +58,11 @@ import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop import io.github.miuzarte.scrcpyforandroid.BuildConfig import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade -import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes import io.github.miuzarte.scrcpyforandroid.constants.UiMotion import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy -import io.github.miuzarte.scrcpyforandroid.services.AppScreenOn import io.github.miuzarte.scrcpyforandroid.services.AppRuntime +import io.github.miuzarte.scrcpyforandroid.services.AppScreenOn import io.github.miuzarte.scrcpyforandroid.services.AppUpdateChecker import io.github.miuzarte.scrcpyforandroid.services.ConnectionController import io.github.miuzarte.scrcpyforandroid.services.ConnectionStateStore @@ -75,13 +74,13 @@ import io.github.miuzarte.scrcpyforandroid.storage.AppSettings import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices -import io.github.miuzarte.scrcpyforandroid.ui.createThemeController import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableFloatingBottomBar import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableFloatingBottomBarBlur import io.github.miuzarte.scrcpyforandroid.ui.component.FloatingBottomBar import io.github.miuzarte.scrcpyforandroid.ui.component.FloatingBottomBarItem +import io.github.miuzarte.scrcpyforandroid.ui.createThemeController import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -101,8 +100,8 @@ import top.yukonga.miuix.kmp.basic.SnackbarHostState import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme -import top.yukonga.miuix.kmp.blur.layerBackdrop as miuixLayerBackdrop import java.io.File +import top.yukonga.miuix.kmp.blur.layerBackdrop as miuixLayerBackdrop private const val TERMINAL_FONT_RELATIVE_PATH = "terminal/font.ttf" @@ -191,6 +190,7 @@ fun MainScreen() { var fileTabCanNavigateUp by remember { mutableStateOf(false) } var fileTabNavigateUp by remember { mutableStateOf<(() -> Boolean)?>(null) } var terminalGestureLock by remember { mutableStateOf(false) } + var devicePreviewGestureLock by remember { mutableStateOf(false) } // Scroll behaviors val devicesPageScrollBehavior = MiuixScrollBehavior( @@ -538,6 +538,12 @@ fun MainScreen() { .fillMaxSize() .then(if (blurBackdrop != null) Modifier.miuixLayerBackdrop(blurBackdrop) else Modifier), ) { + val pagerGestureLocked = + selectedTabIndex == MainBottomTabDestination.Terminal.ordinal + && terminalGestureLock + || selectedTabIndex == MainBottomTabDestination.Devices.ordinal + && devicePreviewGestureLock + HorizontalPager( modifier = Modifier .fillMaxSize() @@ -550,7 +556,7 @@ fun MainScreen() { ), state = pagerState, beyondViewportPageCount = 1, - userScrollEnabled = !(selectedTabIndex == MainBottomTabDestination.Terminal.ordinal && terminalGestureLock), + userScrollEnabled = !pagerGestureLocked, ) { page -> val tab = tabs[page] saveableStateHolder.SaveableStateProvider(tab.name) { @@ -561,6 +567,9 @@ fun MainScreen() { connectionServices = deviceConnectionServices, bottomInnerPadding = bottomInnerPadding, onOpenReorderDevices = { showReorderDevices = true }, + onPreviewGestureLockChanged = { locked -> + devicePreviewGestureLock = locked + }, ) MainBottomTabDestination.Terminal -> TerminalScreen( 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 ce13b6e..57ac175 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 @@ -14,7 +14,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import io.github.miuzarte.scrcpyforandroid.StreamActivity -import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes import io.github.miuzarte.scrcpyforandroid.services.AppRuntime import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController import io.github.miuzarte.scrcpyforandroid.services.SnackbarController diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/LazyColumn.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/LazyColumn.kt index 4429f46..7869e52 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/LazyColumn.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/LazyColumn.kt @@ -2,15 +2,20 @@ package io.github.miuzarte.scrcpyforandroid.scaffolds import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput @@ -33,6 +38,8 @@ fun LazyColumn( verticalPadding: Dp = UiSpacing.PageVertical, bottomInnerPadding: Dp? = null, clearFocusOnTap: Boolean = true, + limitLandscapeWidth: Boolean = true, + landscapeMaxWidth: Dp = 640.dp, content: LazyListScope.() -> Unit, ) { val layoutDirection = LocalLayoutDirection.current @@ -45,30 +52,45 @@ fun LazyColumn( bottom = contentPadding.calculateBottomPadding() + verticalPadding, ) - LazyColumn( + BoxWithConstraints( modifier = modifier .fillMaxSize() .then( if (clearFocusOnTap) Modifier.pointerInput(Unit) { detectTapGestures(onTap = { focusManager.clearFocus() }) - } else Modifier - ) - .overScrollVertical() - .then( - if (scrollBehavior != null) - Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + } else Modifier ), - state = state, - contentPadding = mergedContentPadding, - verticalArrangement = Arrangement.spacedBy(itemSpacing), ) { - content() - bottomInnerPadding?.let { padding -> - (padding - itemSpacing) - .takeIf { it > 0.dp } - ?.let { item { Spacer(Modifier.height(it)) } } + val contentWidthModifier = + if (limitLandscapeWidth && maxWidth > maxHeight) Modifier.widthIn(max = landscapeMaxWidth) + else Modifier.fillMaxWidth() + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter, + ) { + LazyColumn( + modifier = contentWidthModifier + .fillMaxSize() + .overScrollVertical() + .then( + if (scrollBehavior != null) + Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + else Modifier + ), + state = state, + contentPadding = mergedContentPadding, + verticalArrangement = Arrangement.spacedBy(itemSpacing), + ) { + content() + bottomInnerPadding?.let { padding -> + (padding - itemSpacing) + .takeIf { it > 0.dp } + ?.let { item { Spacer(Modifier.height(it)) } } + } + } } } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt index bfa925f..ac705a3 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt @@ -99,10 +99,13 @@ class Scrcpy( @Volatile private var audioPlayer: ScrcpyAudioPlayer? = null + @Volatile private var mp4Recorder: NativeMp4Recorder? = null + @Volatile private var wavRecorder: NativeWavRecorder? = null + @Volatile private var aacRecorder: NativeAacRecorder? = null @@ -228,8 +231,7 @@ class Scrcpy( val recordFile = RecordingFileResolver.resolve(options, info) when (options.recordFormat) { ClientOptions.RecordFormat.MP4, - ClientOptions.RecordFormat.M4A, - -> { + ClientOptions.RecordFormat.M4A -> { val recorder = NativeMp4Recorder( outputFile = recordFile, includeVideo = options.video, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/TouchEventHandler.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/TouchEventHandler.kt index df2350b..6c4e945 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/TouchEventHandler.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/TouchEventHandler.kt @@ -330,7 +330,15 @@ class TouchEventHandler( justPressedPointerIds += pointerId coroutineScope.launch { runCatching { - onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, pressure, 0, 0) + onInjectTouch( + UiMotionActions.DOWN, + pointerId.toLong(), + x, + y, + pressure, + 0, + 0, + ) }.onFailure { e -> Log.w( FULLSCREEN_TOUCH_LOG_TAG, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbAutoReconnectManager.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbAutoReconnectManager.kt index b61a733..f64dc29 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbAutoReconnectManager.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbAutoReconnectManager.kt @@ -21,7 +21,9 @@ internal class DeviceAdbAutoReconnectManager( isForeground = isForeground, intervalMs = intervalMs, keepAliveCheck = { _, _ -> controller.keepAliveCheck(keepAliveTimeoutMs) }, - reconnect = { host, port -> controller.connectWithTimeout(host, port, connectTimeoutMs) }, + reconnect = { host, port -> + controller.connectWithTimeout(host, port, connectTimeoutMs) + }, onReconnectSuccess = { host, port -> controller.markKeepAliveReconnectSuccess(host, port) onReconnectSuccess(host, port) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/NativeAudioRecorder.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/NativeAudioRecorder.kt index 1d393e5..2417670 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/NativeAudioRecorder.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/NativeAudioRecorder.kt @@ -207,6 +207,7 @@ class NativeAudioRecorder( muxer.start() muxerStarted = true } + else -> { if (index < 0) continue val outputBuffer = currentEncoder.getOutputBuffer(index) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/NativeMp4Recorder.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/NativeMp4Recorder.kt index dfc3e8c..95f7387 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/NativeMp4Recorder.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/NativeMp4Recorder.kt @@ -294,6 +294,7 @@ class NativeMp4Recorder( MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { registerTrackLocked(TrackType.AUDIO, currentEncoder.outputFormat) } + else -> { if (index < 0) continue val outputBuffer = currentEncoder.getOutputBuffer(index) @@ -329,6 +330,7 @@ class NativeMp4Recorder( MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { registerTrackLocked(TrackType.VIDEO, currentEncoder.outputFormat) } + else -> { if (index < 0) continue val outputBuffer = currentEncoder.getOutputBuffer(index) @@ -362,6 +364,7 @@ class NativeMp4Recorder( if (audioTrackIndex >= 0) return audioTrackIndex = muxer.addTrack(format) } + TrackType.VIDEO -> { if (videoTrackIndex >= 0) return videoTrackIndex = muxer.addTrack(format) @@ -451,6 +454,7 @@ class NativeMp4Recorder( val normalized = (presentationTimeUs - base).coerceAtLeast(0L) normalized.coerceAtLeast(lastAudioPtsUs).also { lastAudioPtsUs = it } } + TrackType.VIDEO -> { val base = videoBasePtsUs ?: presentationTimeUs.also { videoBasePtsUs = it 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 b06c2a1..625386e 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 @@ -122,6 +122,10 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { intPreferencesKey("device_preview_card_height_dp"), 1080 / 3, ) + val DEVICE_TWO_PANE_CONFIG_ON_RIGHT = Pair( + booleanPreferencesKey("device_two_pane_config_on_right"), + false, + ) val FULLSCREEN_CONTROL_IGNORE_SYSTEM_ROTATION_LOCK = Pair( booleanPreferencesKey("fullscreen_control_ignore_system_rotation_lock"), true, @@ -274,6 +278,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { val fullscreenDebugInfo: Boolean, val hideSimpleConfigItems: Boolean, val devicePreviewCardHeightDp: Int, + val deviceTwoPaneConfigOnRight: Boolean, val fullscreenControlIgnoreSystemRotationLock: Boolean, val showFullscreenVirtualButtons: Boolean, val fullscreenVirtualButtonHeightDp: Int, @@ -330,6 +335,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { bundleField(FULLSCREEN_DEBUG_INFO) { it.fullscreenDebugInfo }, bundleField(HIDE_SIMPLE_CONFIG_ITEMS) { it.hideSimpleConfigItems }, bundleField(DEVICE_PREVIEW_CARD_HEIGHT_DP) { it.devicePreviewCardHeightDp }, + bundleField(DEVICE_TWO_PANE_CONFIG_ON_RIGHT) { it.deviceTwoPaneConfigOnRight }, bundleField(FULLSCREEN_CONTROL_IGNORE_SYSTEM_ROTATION_LOCK) { it.fullscreenControlIgnoreSystemRotationLock }, bundleField(SHOW_FULLSCREEN_VIRTUAL_BUTTONS) { it.showFullscreenVirtualButtons }, bundleField(FULLSCREEN_VIRTUAL_BUTTON_HEIGHT_DP) { it.fullscreenVirtualButtonHeightDp }, @@ -387,6 +393,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { fullscreenDebugInfo = preferences.read(FULLSCREEN_DEBUG_INFO), hideSimpleConfigItems = preferences.read(HIDE_SIMPLE_CONFIG_ITEMS), devicePreviewCardHeightDp = preferences.read(DEVICE_PREVIEW_CARD_HEIGHT_DP), + deviceTwoPaneConfigOnRight = preferences.read(DEVICE_TWO_PANE_CONFIG_ON_RIGHT), fullscreenControlIgnoreSystemRotationLock = preferences.read(FULLSCREEN_CONTROL_IGNORE_SYSTEM_ROTATION_LOCK), showFullscreenVirtualButtons = preferences.read(SHOW_FULLSCREEN_VIRTUAL_BUTTONS), diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyProfiles.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyProfiles.kt index 2079dea..d7baee2 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyProfiles.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyProfiles.kt @@ -306,67 +306,250 @@ class ScrcpyProfiles(context: Context) : Settings(context, "ScrcpyProfiles") { val defaults = ScrcpyOptions.defaultBundle() val json = bundleJson ?: return defaults return defaults.copy( - crop = json.optStringOrDefault("crop", defaults.crop), - recordFilename = json.optStringOrDefault("recordFilename", defaults.recordFilename), - videoCodecOptions = json.optStringOrDefault("videoCodecOptions", defaults.videoCodecOptions), - audioCodecOptions = json.optStringOrDefault("audioCodecOptions", defaults.audioCodecOptions), - videoEncoder = json.optStringOrDefault("videoEncoder", defaults.videoEncoder), - audioEncoder = json.optStringOrDefault("audioEncoder", defaults.audioEncoder), - cameraId = json.optStringOrDefault("cameraId", defaults.cameraId), - cameraSize = json.optStringOrDefault("cameraSize", defaults.cameraSize), - cameraSizeCustom = json.optStringOrDefault("cameraSizeCustom", defaults.cameraSizeCustom), - cameraSizeUseCustom = json.optBooleanOrDefault("cameraSizeUseCustom", defaults.cameraSizeUseCustom), - cameraAr = json.optStringOrDefault("cameraAr", defaults.cameraAr), - cameraFps = json.optIntOrDefault("cameraFps", defaults.cameraFps), - logLevel = json.optStringOrDefault("logLevel", defaults.logLevel), - videoCodec = json.optStringOrDefault("videoCodec", defaults.videoCodec), - audioCodec = json.optStringOrDefault("audioCodec", defaults.audioCodec), - videoSource = json.optStringOrDefault("videoSource", defaults.videoSource), - audioSource = json.optStringOrDefault("audioSource", defaults.audioSource), - recordFormat = json.optStringOrDefault("recordFormat", defaults.recordFormat), - cameraFacing = json.optStringOrDefault("cameraFacing", defaults.cameraFacing), - maxSize = json.optIntOrDefault("maxSize", defaults.maxSize), - videoBitRate = json.optIntOrDefault("videoBitRate", defaults.videoBitRate), - audioBitRate = json.optIntOrDefault("audioBitRate", defaults.audioBitRate), - maxFps = json.optStringOrDefault("maxFps", defaults.maxFps), - angle = json.optStringOrDefault("angle", defaults.angle), - captureOrientation = json.optIntOrDefault("captureOrientation", defaults.captureOrientation), - captureOrientationLock = json.optStringOrDefault("captureOrientationLock", defaults.captureOrientationLock), - displayOrientation = json.optIntOrDefault("displayOrientation", defaults.displayOrientation), - recordOrientation = json.optIntOrDefault("recordOrientation", defaults.recordOrientation), - displayImePolicy = json.optStringOrDefault("displayImePolicy", defaults.displayImePolicy), - displayId = json.optIntOrDefault("displayId", defaults.displayId), - screenOffTimeout = json.optLongOrDefault("screenOffTimeout", defaults.screenOffTimeout), - showTouches = json.optBooleanOrDefault("showTouches", defaults.showTouches), - fullscreen = json.optBooleanOrDefault("fullscreen", defaults.fullscreen), - control = json.optBooleanOrDefault("control", defaults.control), - videoPlayback = json.optBooleanOrDefault("videoPlayback", defaults.videoPlayback), - audioPlayback = json.optBooleanOrDefault("audioPlayback", defaults.audioPlayback), - turnScreenOff = json.optBooleanOrDefault("turnScreenOff", defaults.turnScreenOff), - keyInjectMode = json.optStringOrDefault("keyInjectMode", defaults.keyInjectMode), - forwardKeyRepeat = json.optBooleanOrDefault("forwardKeyRepeat", defaults.forwardKeyRepeat), - stayAwake = json.optBooleanOrDefault("stayAwake", defaults.stayAwake), - disableScreensaver = json.optBooleanOrDefault("disableScreensaver", defaults.disableScreensaver), - powerOffOnClose = json.optBooleanOrDefault("powerOffOnClose", defaults.powerOffOnClose), - legacyPaste = json.optBooleanOrDefault("legacyPaste", defaults.legacyPaste), - clipboardAutosync = json.optBooleanOrDefault("clipboardAutosync", defaults.clipboardAutosync), - downsizeOnError = json.optBooleanOrDefault("downsizeOnError", defaults.downsizeOnError), - mouseHover = json.optBooleanOrDefault("mouseHover", defaults.mouseHover), - cleanup = json.optBooleanOrDefault("cleanup", defaults.cleanup), - powerOn = json.optBooleanOrDefault("powerOn", defaults.powerOn), - video = json.optBooleanOrDefault("video", defaults.video), - audio = json.optBooleanOrDefault("audio", defaults.audio), - requireAudio = json.optBooleanOrDefault("requireAudio", defaults.requireAudio), - killAdbOnClose = json.optBooleanOrDefault("killAdbOnClose", defaults.killAdbOnClose), - cameraHighSpeed = json.optBooleanOrDefault("cameraHighSpeed", defaults.cameraHighSpeed), - list = json.optStringOrDefault("list", defaults.list), - audioDup = json.optBooleanOrDefault("audioDup", defaults.audioDup), - newDisplay = json.optStringOrDefault("newDisplay", defaults.newDisplay), - startApp = json.optStringOrDefault("startApp", defaults.startApp), - startAppCustom = json.optStringOrDefault("startAppCustom", defaults.startAppCustom), - startAppUseCustom = json.optBooleanOrDefault("startAppUseCustom", defaults.startAppUseCustom), - vdDestroyContent = json.optBooleanOrDefault("vdDestroyContent", defaults.vdDestroyContent), - vdSystemDecorations = json.optBooleanOrDefault("vdSystemDecorations", defaults.vdSystemDecorations), + crop = json.optStringOrDefault( + "crop", + defaults.crop, + ), + recordFilename = json.optStringOrDefault( + "recordFilename", + defaults.recordFilename, + ), + videoCodecOptions = json.optStringOrDefault( + "videoCodecOptions", + defaults.videoCodecOptions, + ), + audioCodecOptions = json.optStringOrDefault( + "audioCodecOptions", + defaults.audioCodecOptions, + ), + videoEncoder = json.optStringOrDefault( + "videoEncoder", + defaults.videoEncoder, + ), + audioEncoder = json.optStringOrDefault( + "audioEncoder", + defaults.audioEncoder, + ), + cameraId = json.optStringOrDefault( + "cameraId", + defaults.cameraId, + ), + cameraSize = json.optStringOrDefault( + "cameraSize", + defaults.cameraSize, + ), + cameraSizeCustom = json.optStringOrDefault( + "cameraSizeCustom", + defaults.cameraSizeCustom, + ), + cameraSizeUseCustom = json.optBooleanOrDefault( + "cameraSizeUseCustom", + defaults.cameraSizeUseCustom, + ), + cameraAr = json.optStringOrDefault( + "cameraAr", + defaults.cameraAr, + ), + cameraFps = json.optIntOrDefault( + "cameraFps", + defaults.cameraFps, + ), + logLevel = json.optStringOrDefault( + "logLevel", + defaults.logLevel, + ), + videoCodec = json.optStringOrDefault( + "videoCodec", + defaults.videoCodec, + ), + audioCodec = json.optStringOrDefault( + "audioCodec", + defaults.audioCodec, + ), + videoSource = json.optStringOrDefault( + "videoSource", + defaults.videoSource, + ), + audioSource = json.optStringOrDefault( + "audioSource", + defaults.audioSource, + ), + recordFormat = json.optStringOrDefault( + "recordFormat", + defaults.recordFormat, + ), + cameraFacing = json.optStringOrDefault( + "cameraFacing", + defaults.cameraFacing, + ), + maxSize = json.optIntOrDefault( + "maxSize", + defaults.maxSize, + ), + videoBitRate = json.optIntOrDefault( + "videoBitRate", + defaults.videoBitRate, + ), + audioBitRate = json.optIntOrDefault( + "audioBitRate", + defaults.audioBitRate, + ), + maxFps = json.optStringOrDefault( + "maxFps", + defaults.maxFps, + ), + angle = json.optStringOrDefault( + "angle", + defaults.angle, + ), + captureOrientation = json.optIntOrDefault( + "captureOrientation", + defaults.captureOrientation, + ), + captureOrientationLock = json.optStringOrDefault( + "captureOrientationLock", + defaults.captureOrientationLock, + ), + displayOrientation = json.optIntOrDefault( + "displayOrientation", + defaults.displayOrientation, + ), + recordOrientation = json.optIntOrDefault( + "recordOrientation", + defaults.recordOrientation, + ), + displayImePolicy = json.optStringOrDefault( + "displayImePolicy", + defaults.displayImePolicy, + ), + displayId = json.optIntOrDefault( + "displayId", + defaults.displayId, + ), + screenOffTimeout = json.optLongOrDefault( + "screenOffTimeout", + defaults.screenOffTimeout, + ), + showTouches = json.optBooleanOrDefault( + "showTouches", + defaults.showTouches, + ), + fullscreen = json.optBooleanOrDefault( + "fullscreen", + defaults.fullscreen, + ), + control = json.optBooleanOrDefault( + "control", + defaults.control, + ), + videoPlayback = json.optBooleanOrDefault( + "videoPlayback", + defaults.videoPlayback, + ), + audioPlayback = json.optBooleanOrDefault( + "audioPlayback", + defaults.audioPlayback, + ), + turnScreenOff = json.optBooleanOrDefault( + "turnScreenOff", + defaults.turnScreenOff, + ), + keyInjectMode = json.optStringOrDefault( + "keyInjectMode", + defaults.keyInjectMode, + ), + forwardKeyRepeat = json.optBooleanOrDefault( + "forwardKeyRepeat", + defaults.forwardKeyRepeat, + ), + stayAwake = json.optBooleanOrDefault( + "stayAwake", + defaults.stayAwake, + ), + disableScreensaver = json.optBooleanOrDefault( + "disableScreensaver", + defaults.disableScreensaver, + ), + powerOffOnClose = json.optBooleanOrDefault( + "powerOffOnClose", + defaults.powerOffOnClose, + ), + legacyPaste = json.optBooleanOrDefault( + "legacyPaste", + defaults.legacyPaste, + ), + clipboardAutosync = json.optBooleanOrDefault( + "clipboardAutosync", + defaults.clipboardAutosync, + ), + downsizeOnError = json.optBooleanOrDefault( + "downsizeOnError", + defaults.downsizeOnError, + ), + mouseHover = json.optBooleanOrDefault( + "mouseHover", + defaults.mouseHover, + ), + cleanup = json.optBooleanOrDefault( + "cleanup", + defaults.cleanup, + ), + powerOn = json.optBooleanOrDefault( + "powerOn", + defaults.powerOn, + ), + video = json.optBooleanOrDefault( + "video", + defaults.video, + ), + audio = json.optBooleanOrDefault( + "audio", + defaults.audio, + ), + requireAudio = json.optBooleanOrDefault( + "requireAudio", + defaults.requireAudio, + ), + killAdbOnClose = json.optBooleanOrDefault( + "killAdbOnClose", + defaults.killAdbOnClose, + ), + cameraHighSpeed = json.optBooleanOrDefault( + "cameraHighSpeed", + defaults.cameraHighSpeed, + ), + list = json.optStringOrDefault( + "list", + defaults.list, + ), + audioDup = json.optBooleanOrDefault( + "audioDup", + defaults.audioDup, + ), + newDisplay = json.optStringOrDefault( + "newDisplay", + defaults.newDisplay, + ), + startApp = json.optStringOrDefault( + "startApp", + defaults.startApp, + ), + startAppCustom = json.optStringOrDefault( + "startAppCustom", + defaults.startAppCustom, + ), + startAppUseCustom = json.optBooleanOrDefault( + "startAppUseCustom", + defaults.startAppUseCustom, + ), + vdDestroyContent = json.optBooleanOrDefault( + "vdDestroyContent", + defaults.vdDestroyContent, + ), + vdSystemDecorations = json.optBooleanOrDefault( + "vdSystemDecorations", + defaults.vdSystemDecorations, + ), ) } 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 dfcc0d8..a78c9f7 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 @@ -2,6 +2,7 @@ package io.github.miuzarte.scrcpyforandroid.widgets import android.annotation.SuppressLint import android.view.KeyEvent +import android.view.MotionEvent import android.view.Surface import android.view.SurfaceHolder import androidx.compose.animation.AnimatedVisibility @@ -41,6 +42,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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 @@ -51,14 +53,19 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView @@ -69,17 +76,18 @@ import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade import io.github.miuzarte.scrcpyforandroid.constants.Defaults import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing -import io.github.miuzarte.scrcpyforandroid.ui.contextClick import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec +import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions +import io.github.miuzarte.scrcpyforandroid.ui.contextClick import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -255,16 +263,35 @@ internal fun PreviewCard( sessionInfo: Scrcpy.Session.SessionInfo?, previewHeightDp: Int, onOpenFullscreen: () -> Unit, + directControlEnabled: Boolean = false, + onInjectTouch: (suspend ( + action: Int, + pointerId: Long, + x: Int, + y: Int, + pressure: Float, + actionButton: Int, + buttons: Int, + ) -> Unit)? = null, + onBackOrScreenOn: (suspend (action: Int) -> Unit)? = null, imeRequestToken: Int = 0, onImeCommitText: (suspend (String) -> Unit)? = null, onImeDeleteSurroundingText: (suspend (beforeLength: Int, afterLength: Int) -> Unit)? = null, onImeKeyEvent: (suspend (KeyEvent) -> Boolean)? = null, autoBringIntoView: Boolean = false, onAutoBringIntoViewConsumed: () -> Unit = {}, + onTouchActiveChanged: (Boolean) -> Unit = {}, ) { val haptic = LocalHapticFeedback.current + val coroutineScope = rememberCoroutineScope() var previewControlsVisible by rememberSaveable { mutableStateOf(false) } + var touchAreaSize by remember { mutableStateOf(IntSize.Zero) } + val activePointerIds = remember { linkedSetOf() } + val activePointerPositions = remember { linkedMapOf() } + val activePointerDevicePositions = remember { linkedMapOf>() } + val pointerLabels = remember { linkedMapOf() } + var nextPointerLabel by rememberSaveable { mutableIntStateOf(1) } val alpha by animateFloatAsState( if (previewControlsVisible) 1f else 0f, label = "preview-controls" @@ -278,6 +305,34 @@ internal fun PreviewCard( onAutoBringIntoViewConsumed() } + val touchEventHandler = remember( + directControlEnabled, + sessionInfo, + touchAreaSize, + onInjectTouch, + onBackOrScreenOn, + ) { + if (!directControlEnabled || sessionInfo == null || onInjectTouch == null) + null + else TouchEventHandler( + coroutineScope = coroutineScope, + session = sessionInfo, + touchAreaSize = touchAreaSize, + activePointerIds = activePointerIds, + activePointerPositions = activePointerPositions, + activePointerDevicePositions = activePointerDevicePositions, + pointerLabels = pointerLabels, + nextPointerLabel = nextPointerLabel, + mouseHoverEnabled = sessionInfo.mouseHover, + onInjectTouch = onInjectTouch, + onBackOrScreenOn = onBackOrScreenOn ?: {}, + onActiveTouchCountChanged = {}, + onActiveTouchDebugChanged = {}, + onNextPointerLabelChanged = { nextPointerLabel = it }, + ) + + } + DisposableEffect(lifecycleOwner) { if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { VideoOutputTargetState.set(VideoOutputTarget.PREVIEW) @@ -294,6 +349,7 @@ internal fun PreviewCard( } lifecycleOwner.lifecycle.addObserver(observer) onDispose { + onTouchActiveChanged(false) lifecycleOwner.lifecycle.removeObserver(observer) if (VideoOutputTargetState.current.value == VideoOutputTarget.PREVIEW) { VideoOutputTargetState.set(VideoOutputTarget.NONE) @@ -310,11 +366,25 @@ internal fun PreviewCard( modifier = Modifier .fillMaxWidth() .height(previewHeightDp.coerceAtLeast(120).dp) - .pointerInput(sessionInfo) { - detectTapGestures(onTap = { - previewControlsVisible = !previewControlsVisible - }) - }, + .then( + if (directControlEnabled && touchEventHandler != null) { + Modifier.pointerInteropFilter { event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> onTouchActiveChanged(true) + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> onTouchActiveChanged(false) + } + touchEventHandler.handleMotionEvent(event) + } + } else { + Modifier.pointerInput(sessionInfo) { + detectTapGestures(onTap = { + previewControlsVisible = !previewControlsVisible + }) + } + } + ) + .onSizeChanged { touchAreaSize = it }, ) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val sessionAspect = @@ -379,12 +449,14 @@ internal fun PreviewCard( @Composable internal fun VirtualButtonCard( + modifier: Modifier = Modifier, busy: Boolean, outsideActions: List, moreActions: List, showText: Boolean, onAction: (VirtualButtonAction) -> Unit, passwordPopupContent: (@Composable (onDismissRequest: () -> Unit) -> Unit)? = null, + popupBottomPadding: Dp = 0.dp, ) { val bar = remember(outsideActions, moreActions) { VirtualButtonBar( @@ -393,12 +465,13 @@ internal fun VirtualButtonCard( ) } - Card { + Card(modifier = modifier) { bar.Preview( - enabled = !busy, + enabled = true, showText = showText, - onAction = onAction, + onAction = { if (!busy) onAction(it) }, passwordPopupContent = passwordPopupContent, + popupBottomPadding = popupBottomPadding, modifier = Modifier .fillMaxWidth() .padding(UiSpacing.ContentVertical), @@ -427,6 +500,9 @@ internal fun ConfigPanel( onStop: () -> Unit, sessionInfo: Scrcpy.Session.SessionInfo?, onDisconnect: () -> Unit = {}, + showFullscreenAction: Boolean = false, + onOpenFullscreen: () -> Unit = {}, + reverseSideActions: Boolean = false, ) { val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } @@ -646,34 +722,71 @@ internal fun ConfigPanel( .padding(all = UiSpacing.ContentVertical), horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), ) { - if (isQuickConnected) TextButton( - text = "断开", - onClick = { - onStartStopHaptic() - onDisconnect() - }, - modifier = Modifier.weight(1f / 4f), - enabled = !busy, - ) - if (!sessionStarted) TextButton( - text = "启动", - onClick = { - onStartStopHaptic() - onStart() - }, - modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f), - enabled = !busy, - colors = ButtonDefaults.textButtonColorsPrimary(), - ) - if (sessionStarted) TextButton( - text = "停止", - onClick = { - onStartStopHaptic() - onStop() - }, - modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f), - enabled = !busy, - ) + val sideButtonWeight = 1f / 4f + val mainButtonWeight = 1f - + if (isQuickConnected || showFullscreenAction) + sideButtonWeight * listOf(isQuickConnected, showFullscreenAction) + .count { it } + else 0f + + @Composable + fun DisconnectButton() { + if (isQuickConnected) TextButton( + text = "断开", + onClick = { + onStartStopHaptic() + onDisconnect() + }, + modifier = Modifier.weight(sideButtonWeight), + enabled = !busy, + ) + } + + @Composable + fun MainButton() { + if (!sessionStarted) TextButton( + text = "启动", + onClick = { + onStartStopHaptic() + onStart() + }, + modifier = Modifier.weight(mainButtonWeight), + enabled = !busy, + colors = ButtonDefaults.textButtonColorsPrimary(), + ) + if (sessionStarted) TextButton( + text = "停止", + onClick = { + onStartStopHaptic() + onStop() + }, + modifier = Modifier.weight(mainButtonWeight), + enabled = !busy, + ) + } + + @Composable + fun FullscreenButton() { + if (showFullscreenAction) TextButton( + text = "全屏", + onClick = { + onStartStopHaptic() + onOpenFullscreen() + }, + modifier = Modifier.weight(sideButtonWeight), + enabled = !busy, + ) + } + + if (reverseSideActions) { + FullscreenButton() + MainButton() + DisconnectButton() + } else { + DisconnectButton() + MainButton() + FullscreenButton() + } } } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/MultiGroupsDropdown.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/MultiGroupsDropdown.kt index 4951708..22e0ec3 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/MultiGroupsDropdown.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/MultiGroupsDropdown.kt @@ -3,8 +3,8 @@ package io.github.miuzarte.scrcpyforandroid.widgets import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.getValue import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -13,12 +13,12 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import top.yukonga.miuix.kmp.basic.DropdownColors -import top.yukonga.miuix.kmp.basic.DropdownDefaults import top.yukonga.miuix.kmp.basic.BasicComponent import top.yukonga.miuix.kmp.basic.BasicComponentColors import top.yukonga.miuix.kmp.basic.BasicComponentDefaults import top.yukonga.miuix.kmp.basic.DropdownArrowEndAction +import top.yukonga.miuix.kmp.basic.DropdownColors +import top.yukonga.miuix.kmp.basic.DropdownDefaults import top.yukonga.miuix.kmp.basic.DropdownImpl import top.yukonga.miuix.kmp.basic.HorizontalDivider import top.yukonga.miuix.kmp.basic.ListPopupColumn 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 01bd235..4130071 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 @@ -52,6 +52,9 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import io.github.miuzarte.scrcpyforandroid.constants.UiAndroidKeycodes import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing @@ -255,6 +258,7 @@ class VirtualButtonBar( onAction: (VirtualButtonAction) -> Unit, modifier: Modifier = Modifier, passwordPopupContent: (@Composable (onDismissRequest: () -> Unit) -> Unit)? = null, + popupBottomPadding: Dp = 0.dp, ) { val haptic = LocalHapticFeedback.current @@ -321,6 +325,7 @@ class VirtualButtonBar( }, passwordPopupContent = passwordPopupContent, renderInRootScaffold = false, + popupBottomPadding = popupBottomPadding, ) } if ( @@ -329,7 +334,8 @@ class VirtualButtonBar( ) { OverlayListPopup( show = showPasswordPopup, - popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider, + popupPositionProvider = + rememberBottomSafeContextMenuPositionProvider(popupBottomPadding), alignment = PopupPositionProvider.Align.TopEnd, onDismissRequest = { showPasswordPopup = false }, renderInRootScaffold = false, @@ -667,6 +673,7 @@ class VirtualButtonBar( passwordPopupContent: (@Composable (onDismissRequest: () -> Unit) -> Unit)? = null, renderInRootScaffold: Boolean, popupAlignment: PopupPositionProvider.Align = PopupPositionProvider.Align.TopEnd, + popupBottomPadding: Dp = 0.dp, ) { val scope = rememberCoroutineScope() val haptic = LocalHapticFeedback.current @@ -692,6 +699,7 @@ class VirtualButtonBar( popupAlignment = popupAlignment, onDismiss = onDismiss, renderInRootScaffold = renderInRootScaffold, + popupBottomPadding = popupBottomPadding, ) { destination, navigateTo, dismiss -> ListPopupColumn { if (destination == ActionPopupDestination.Actions) @@ -733,6 +741,7 @@ class VirtualButtonBar( popupAlignment: PopupPositionProvider.Align, onDismiss: () -> Unit, renderInRootScaffold: Boolean, + popupBottomPadding: Dp = 0.dp, content: @Composable ( destination: Destination, navigateTo: (Destination) -> Unit, @@ -742,7 +751,8 @@ class VirtualButtonBar( var destination by remember(show, startDestination) { mutableStateOf(startDestination) } OverlayListPopup( show = show, - popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider, + popupPositionProvider = + rememberBottomSafeContextMenuPositionProvider(popupBottomPadding), alignment = popupAlignment, onDismissRequest = onDismiss, renderInRootScaffold = renderInRootScaffold, @@ -751,4 +761,39 @@ class VirtualButtonBar( content(destination, { destination = it }, onDismiss) } } + + @Composable + private fun rememberBottomSafeContextMenuPositionProvider( + bottomPadding: Dp, + ): PopupPositionProvider = remember(bottomPadding) { + if (bottomPadding <= 0.dp) { + ListPopupDefaults.ContextMenuPositionProvider + } else { + BottomSafeContextMenuPositionProvider(bottomPadding) + } + } + + private class BottomSafeContextMenuPositionProvider( + private val bottomPadding: Dp, + ) : PopupPositionProvider { + private val delegate = ListPopupDefaults.ContextMenuPositionProvider + + override fun calculatePosition( + anchorBounds: IntRect, + windowBounds: IntRect, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + popupMargin: IntRect, + alignment: PopupPositionProvider.Align, + ): IntOffset = delegate.calculatePosition( + anchorBounds = anchorBounds, + windowBounds = windowBounds, + layoutDirection = layoutDirection, + popupContentSize = popupContentSize, + popupMargin = popupMargin, + alignment = alignment, + ) + + override fun getMargins(): PaddingValues = PaddingValues(bottom = bottomPadding) + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d564a9f..cd7e936 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,6 +35,7 @@ androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-toolin androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-material3-window-size = { group = "androidx.compose.material3", name = "material3-window-size-class" } androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime-android", version.ref = "androidxNavigation3" } bcpkix-jdk18on = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bcpkixJdk18on" }