feat: landscape layout

This commit is contained in:
Miuzarte
2026-04-24 23:49:19 +08:00
parent d4a2551fec
commit 06316f8fee
24 changed files with 1358 additions and 597 deletions

View File

@@ -2,6 +2,12 @@
~~什么我居然开始写更新日志了~~ ~~什么我居然开始写更新日志了~~
## 0.2.5
- 修复: 预览销毁再重组会导致画面冻结
- 新增: 横屏布局
- 修复: 虚拟按钮 Popup 弹出时被 dock 栏阻挡
## 0.2.4 ## 0.2.4
- 改进: 再次修改图标至 monochrome 主题色填充以避免与上游项目混淆 - 改进: 再次修改图标至 monochrome 主题色填充以避免与上游项目混淆

View File

@@ -1,6 +1,6 @@
<!-- markdownlint-disable MD033 --> <!-- markdownlint-disable MD033 -->
# Scrcpy For Android # Scrcpy for Android
<a href="https://github.com/Genymobile/scrcpy/blob/master/app/data/icon.svg" title="Modified from the original version"> <a href="https://github.com/Genymobile/scrcpy/blob/master/app/data/icon.svg" title="Modified from the original version">
<img src="app/src/main/assets/icon/icon.svg" width="128" height="128" alt="scrcpy" align="right" /> <img src="app/src/main/assets/icon/icon.svg" width="128" height="128" alt="scrcpy" align="right" />
@@ -48,6 +48,8 @@
- 可替换 scrcpy-server - 可替换 scrcpy-server
- 利用 mDNS 服务实现自动连接启用无线调试的设备、自动发现等待配对设备的IP与端口 - 利用 mDNS 服务实现自动连接启用无线调试的设备、自动发现等待配对设备的IP与端口
- 自动横竖屏切换(算吗 - 自动横竖屏切换(算吗
- 横屏布局
- 仅屏幕比例小于 16:9 的设备
- 画中画 - 画中画
- 双向文件传输 - 双向文件传输
- 流式 adb 终端 - 流式 adb 终端
@@ -112,6 +114,9 @@
- /sdcard/Movies/Scrcpy/ - /sdcard/Movies/Scrcpy/
- /sdcard/Download/Scrcpy/ - /sdcard/Download/Scrcpy/
5. 横屏模式对左撇子不太友好
- 右上角有按钮可以对调方向
## 构建 ## 构建
- JDK 17+ - JDK 17+

View File

@@ -2,9 +2,9 @@
# TODO # TODO
## PARAMS - 新的原创图标而非从 scrcpy 的图标派生
### LOWER PRIORITY ## PARAMS
- ~~orientation locking~~ - ~~orientation locking~~
@@ -14,5 +14,4 @@
顺序无关 顺序无关
- 横屏布局
- I18N - I18N

View File

@@ -54,8 +54,8 @@ android {
applicationId = "io.github.miuzarte.scrcpyforandroid" applicationId = "io.github.miuzarte.scrcpyforandroid"
minSdk = 26 minSdk = 26
targetSdk = 37 targetSdk = 37
versionCode = 20 versionCode = 21
versionName = "0.2.4" versionName = "0.2.5"
externalNativeBuild { externalNativeBuild {
cmake { cmake {
@@ -120,6 +120,7 @@ dependencies {
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material3.window.size)
implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.runtime)
implementation(libs.material) implementation(libs.material)

View File

@@ -1,5 +1,8 @@
package io.github.miuzarte.scrcpyforandroid package io.github.miuzarte.scrcpyforandroid
import android.content.pm.ActivityInfo
import android.graphics.Rect
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@@ -16,6 +19,7 @@ import kotlinx.coroutines.runBlocking
class MainActivity : FragmentActivity() { class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
applyMainOrientationPolicy()
AppRuntime.init(applicationContext) AppRuntime.init(applicationContext)
AppScreenOn.register(window) AppScreenOn.register(window)
@@ -39,6 +43,7 @@ class MainActivity : FragmentActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
applyMainOrientationPolicy()
StreamActivity.dismissActivePictureInPicture() StreamActivity.dismissActivePictureInPicture()
} }
@@ -46,4 +51,30 @@ class MainActivity : FragmentActivity() {
AppScreenOn.unregister(window) AppScreenOn.unregister(window)
super.onDestroy() 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
}
} }

View File

@@ -30,6 +30,7 @@ object NativeCoreFacade {
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private val bootstrapLock = Any() private val bootstrapLock = Any()
private val bootstrapPackets = ArrayDeque<CachedPacket>() private val bootstrapPackets = ArrayDeque<CachedPacket>()
@Volatile @Volatile
private var recordingSurfaceAttached = false private var recordingSurfaceAttached = false
@@ -111,9 +112,6 @@ object NativeCoreFacade {
Log.i(TAG, "detachVideoSurface(): releasing decoder with destroyed surface") Log.i(TAG, "detachVideoSurface(): releasing decoder with destroyed surface")
decoder?.release() decoder?.release()
decoder = null decoder = null
} else if (activeSurfaceId == null && !recordingSurfaceAttached) {
decoder?.release()
decoder = null
} }
} }
} }
@@ -182,7 +180,6 @@ object NativeCoreFacade {
) )
} }
val currentDecoder = decoder ?: return@attachVideoConsumer val currentDecoder = decoder ?: return@attachVideoConsumer
if (activeSurfaceId == null) return@attachVideoConsumer
runCatching { runCatching {
currentDecoder.feedAnnexB( currentDecoder.feedAnnexB(
packet.data, packet.data,

View File

@@ -1,11 +1,11 @@
package io.github.miuzarte.scrcpyforandroid.nativecore package io.github.miuzarte.scrcpyforandroid.nativecore
import android.graphics.SurfaceTexture import android.graphics.SurfaceTexture
import android.opengl.EGLExt
import android.opengl.EGL14 import android.opengl.EGL14
import android.opengl.EGLConfig import android.opengl.EGLConfig
import android.opengl.EGLContext import android.opengl.EGLContext
import android.opengl.EGLDisplay import android.opengl.EGLDisplay
import android.opengl.EGLExt
import android.opengl.EGLSurface import android.opengl.EGLSurface
import android.opengl.GLES11Ext import android.opengl.GLES11Ext
import android.opengl.GLES20 import android.opengl.GLES20

View File

@@ -7,11 +7,13 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState 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.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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 taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val layoutDirection = LocalLayoutDirection.current
val asBundle by appSettings.bundleState.collectAsState() val asBundle by appSettings.bundleState.collectAsState()
val pathStack = remember { 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( Scaffold(
topBar = { topBar = {
BlurredBar(backdrop = blurBackdrop) { BlurredBar(backdrop = blurBackdrop) {
@@ -546,104 +633,78 @@ fun FileManagerScreen(
top = pagePadding.calculateTopPadding() + 12.dp, top = pagePadding.calculateTopPadding() + 12.dp,
), ),
) { ) {
LazyColumn( BoxWithConstraints(Modifier.fillMaxWidth()) {
contentPadding = pagePadding, val fileCardMinWidth = 220.dp
bottomInnerPadding = bottomInnerPadding, val listHorizontalPadding =
state = listState, pagePadding.calculateLeftPadding(layoutDirection) +
) { pagePadding.calculateRightPadding(layoutDirection) +
if (loading && cachedEntries == null) { UiSpacing.PageHorizontal * 2
item { FileManagerStatusCard("加载中") } val availableListWidth =
} else if (errorText != null && cachedEntries == null) { (maxWidth - listHorizontalPadding).coerceAtLeast(fileCardMinWidth)
item { FileManagerStatusCard("加载失败: $errorText") } val columns = (
} else if (displayedEntries.isEmpty()) { (availableListWidth.value + UiSpacing.PageItem.value) /
item { FileManagerStatusCard("空目录") } (fileCardMinWidth.value + UiSpacing.PageItem.value)
} else { ).toInt().coerceAtLeast(1)
items(displayedEntries) { entry -> val fileRows = remember(displayedEntries, columns) {
FileManagerItemCard( displayedEntries.chunked(columns)
entry = entry, }
summary = FileManagerService.formatSummary(entry),
onClick = {
when {
entry.isDirectory -> {
rememberCurrentScrollPosition()
pathStack.add(normalizePath(entry.fullPath))
}
entry.kind == RemoteFileKind.Link || entry.symlinkTarget != null -> { @Composable
taskScope.launch { fun FileStateContent() {
val targetPath = resolveLinkTarget(entry) when {
if (targetPath == null) { loading && cachedEntries == null ->
withContext(Dispatchers.Main) { FileManagerStatusCard(
snackbar.show("链接目标不可用,长按查看信息") message = "加载中",
} modifier = Modifier.fillMaxWidth(),
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("长按可查看文件详情") 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),
)
} }
}, repeat(columns - rowEntries.size) {
onLongClick = { Box(Modifier.weight(1f))
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}")
}
}
} }
}, }
) }
} }
} }
} }
@@ -719,8 +780,11 @@ fun FileManagerScreen(
} }
@Composable @Composable
private fun FileManagerStatusCard(message: String) { private fun FileManagerStatusCard(
Card { message: String,
modifier: Modifier = Modifier,
) {
Card(modifier = modifier) {
Text( Text(
text = message, text = message,
modifier = Modifier modifier = Modifier
@@ -737,10 +801,10 @@ private fun FileManagerItemCard(
summary: String, summary: String,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
Card( Card(
modifier = Modifier modifier = modifier
.fillMaxWidth()
.combinedClickable( .combinedClickable(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
@@ -749,7 +813,8 @@ private fun FileManagerItemCard(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp), .fillMaxHeight()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( Icon(

View File

@@ -5,7 +5,6 @@ import android.content.Context
import android.graphics.Rect import android.graphics.Rect
import android.hardware.display.DisplayManager import android.hardware.display.DisplayManager
import android.util.Log import android.util.Log
import android.view.KeyEvent
import android.view.Surface import android.view.Surface
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity import androidx.activity.compose.LocalActivity

View File

@@ -58,12 +58,11 @@ import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import io.github.miuzarte.scrcpyforandroid.BuildConfig import io.github.miuzarte.scrcpyforandroid.BuildConfig
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade 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.constants.UiMotion
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy 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.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.AppScreenOn
import io.github.miuzarte.scrcpyforandroid.services.AppUpdateChecker import io.github.miuzarte.scrcpyforandroid.services.AppUpdateChecker
import io.github.miuzarte.scrcpyforandroid.services.ConnectionController import io.github.miuzarte.scrcpyforandroid.services.ConnectionController
import io.github.miuzarte.scrcpyforandroid.services.ConnectionStateStore 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.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices 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.BlurredBar
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableFloatingBottomBar import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableFloatingBottomBar
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableFloatingBottomBarBlur import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableFloatingBottomBarBlur
import io.github.miuzarte.scrcpyforandroid.ui.component.FloatingBottomBar import io.github.miuzarte.scrcpyforandroid.ui.component.FloatingBottomBar
import io.github.miuzarte.scrcpyforandroid.ui.component.FloatingBottomBarItem import io.github.miuzarte.scrcpyforandroid.ui.component.FloatingBottomBarItem
import io.github.miuzarte.scrcpyforandroid.ui.createThemeController
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope 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.basic.Text
import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import top.yukonga.miuix.kmp.blur.layerBackdrop as miuixLayerBackdrop
import java.io.File import java.io.File
import top.yukonga.miuix.kmp.blur.layerBackdrop as miuixLayerBackdrop
private const val TERMINAL_FONT_RELATIVE_PATH = "terminal/font.ttf" private const val TERMINAL_FONT_RELATIVE_PATH = "terminal/font.ttf"
@@ -191,6 +190,7 @@ fun MainScreen() {
var fileTabCanNavigateUp by remember { mutableStateOf(false) } var fileTabCanNavigateUp by remember { mutableStateOf(false) }
var fileTabNavigateUp by remember { mutableStateOf<(() -> Boolean)?>(null) } var fileTabNavigateUp by remember { mutableStateOf<(() -> Boolean)?>(null) }
var terminalGestureLock by remember { mutableStateOf(false) } var terminalGestureLock by remember { mutableStateOf(false) }
var devicePreviewGestureLock by remember { mutableStateOf(false) }
// Scroll behaviors // Scroll behaviors
val devicesPageScrollBehavior = MiuixScrollBehavior( val devicesPageScrollBehavior = MiuixScrollBehavior(
@@ -538,6 +538,12 @@ fun MainScreen() {
.fillMaxSize() .fillMaxSize()
.then(if (blurBackdrop != null) Modifier.miuixLayerBackdrop(blurBackdrop) else Modifier), .then(if (blurBackdrop != null) Modifier.miuixLayerBackdrop(blurBackdrop) else Modifier),
) { ) {
val pagerGestureLocked =
selectedTabIndex == MainBottomTabDestination.Terminal.ordinal
&& terminalGestureLock
|| selectedTabIndex == MainBottomTabDestination.Devices.ordinal
&& devicePreviewGestureLock
HorizontalPager( HorizontalPager(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -550,7 +556,7 @@ fun MainScreen() {
), ),
state = pagerState, state = pagerState,
beyondViewportPageCount = 1, beyondViewportPageCount = 1,
userScrollEnabled = !(selectedTabIndex == MainBottomTabDestination.Terminal.ordinal && terminalGestureLock), userScrollEnabled = !pagerGestureLocked,
) { page -> ) { page ->
val tab = tabs[page] val tab = tabs[page]
saveableStateHolder.SaveableStateProvider(tab.name) { saveableStateHolder.SaveableStateProvider(tab.name) {
@@ -561,6 +567,9 @@ fun MainScreen() {
connectionServices = deviceConnectionServices, connectionServices = deviceConnectionServices,
bottomInnerPadding = bottomInnerPadding, bottomInnerPadding = bottomInnerPadding,
onOpenReorderDevices = { showReorderDevices = true }, onOpenReorderDevices = { showReorderDevices = true },
onPreviewGestureLockChanged = { locked ->
devicePreviewGestureLock = locked
},
) )
MainBottomTabDestination.Terminal -> TerminalScreen( MainBottomTabDestination.Terminal -> TerminalScreen(

View File

@@ -14,7 +14,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import io.github.miuzarte.scrcpyforandroid.StreamActivity 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.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController import io.github.miuzarte.scrcpyforandroid.services.SnackbarController

View File

@@ -2,15 +2,20 @@ package io.github.miuzarte.scrcpyforandroid.scaffolds
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement 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.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
@@ -33,6 +38,8 @@ fun LazyColumn(
verticalPadding: Dp = UiSpacing.PageVertical, verticalPadding: Dp = UiSpacing.PageVertical,
bottomInnerPadding: Dp? = null, bottomInnerPadding: Dp? = null,
clearFocusOnTap: Boolean = true, clearFocusOnTap: Boolean = true,
limitLandscapeWidth: Boolean = true,
landscapeMaxWidth: Dp = 640.dp,
content: LazyListScope.() -> Unit, content: LazyListScope.() -> Unit,
) { ) {
val layoutDirection = LocalLayoutDirection.current val layoutDirection = LocalLayoutDirection.current
@@ -45,30 +52,45 @@ fun LazyColumn(
bottom = contentPadding.calculateBottomPadding() + verticalPadding, bottom = contentPadding.calculateBottomPadding() + verticalPadding,
) )
LazyColumn( BoxWithConstraints(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.then( .then(
if (clearFocusOnTap) if (clearFocusOnTap)
Modifier.pointerInput(Unit) { Modifier.pointerInput(Unit) {
detectTapGestures(onTap = { focusManager.clearFocus() }) detectTapGestures(onTap = { focusManager.clearFocus() })
} else Modifier }
)
.overScrollVertical()
.then(
if (scrollBehavior != null)
Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
else Modifier else Modifier
), ),
state = state,
contentPadding = mergedContentPadding,
verticalArrangement = Arrangement.spacedBy(itemSpacing),
) { ) {
content() val contentWidthModifier =
bottomInnerPadding?.let { padding -> if (limitLandscapeWidth && maxWidth > maxHeight) Modifier.widthIn(max = landscapeMaxWidth)
(padding - itemSpacing) else Modifier.fillMaxWidth()
.takeIf { it > 0.dp }
?.let { item { Spacer(Modifier.height(it)) } } 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)) } }
}
}
} }
} }
} }

View File

@@ -99,10 +99,13 @@ class Scrcpy(
@Volatile @Volatile
private var audioPlayer: ScrcpyAudioPlayer? = null private var audioPlayer: ScrcpyAudioPlayer? = null
@Volatile @Volatile
private var mp4Recorder: NativeMp4Recorder? = null private var mp4Recorder: NativeMp4Recorder? = null
@Volatile @Volatile
private var wavRecorder: NativeWavRecorder? = null private var wavRecorder: NativeWavRecorder? = null
@Volatile @Volatile
private var aacRecorder: NativeAacRecorder? = null private var aacRecorder: NativeAacRecorder? = null
@@ -228,8 +231,7 @@ class Scrcpy(
val recordFile = RecordingFileResolver.resolve(options, info) val recordFile = RecordingFileResolver.resolve(options, info)
when (options.recordFormat) { when (options.recordFormat) {
ClientOptions.RecordFormat.MP4, ClientOptions.RecordFormat.MP4,
ClientOptions.RecordFormat.M4A, ClientOptions.RecordFormat.M4A -> {
-> {
val recorder = NativeMp4Recorder( val recorder = NativeMp4Recorder(
outputFile = recordFile, outputFile = recordFile,
includeVideo = options.video, includeVideo = options.video,

View File

@@ -330,7 +330,15 @@ class TouchEventHandler(
justPressedPointerIds += pointerId justPressedPointerIds += pointerId
coroutineScope.launch { coroutineScope.launch {
runCatching { runCatching {
onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, pressure, 0, 0) onInjectTouch(
UiMotionActions.DOWN,
pointerId.toLong(),
x,
y,
pressure,
0,
0,
)
}.onFailure { e -> }.onFailure { e ->
Log.w( Log.w(
FULLSCREEN_TOUCH_LOG_TAG, FULLSCREEN_TOUCH_LOG_TAG,

View File

@@ -21,7 +21,9 @@ internal class DeviceAdbAutoReconnectManager(
isForeground = isForeground, isForeground = isForeground,
intervalMs = intervalMs, intervalMs = intervalMs,
keepAliveCheck = { _, _ -> controller.keepAliveCheck(keepAliveTimeoutMs) }, keepAliveCheck = { _, _ -> controller.keepAliveCheck(keepAliveTimeoutMs) },
reconnect = { host, port -> controller.connectWithTimeout(host, port, connectTimeoutMs) }, reconnect = { host, port ->
controller.connectWithTimeout(host, port, connectTimeoutMs)
},
onReconnectSuccess = { host, port -> onReconnectSuccess = { host, port ->
controller.markKeepAliveReconnectSuccess(host, port) controller.markKeepAliveReconnectSuccess(host, port)
onReconnectSuccess(host, port) onReconnectSuccess(host, port)

View File

@@ -207,6 +207,7 @@ class NativeAudioRecorder(
muxer.start() muxer.start()
muxerStarted = true muxerStarted = true
} }
else -> { else -> {
if (index < 0) continue if (index < 0) continue
val outputBuffer = currentEncoder.getOutputBuffer(index) val outputBuffer = currentEncoder.getOutputBuffer(index)

View File

@@ -294,6 +294,7 @@ class NativeMp4Recorder(
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
registerTrackLocked(TrackType.AUDIO, currentEncoder.outputFormat) registerTrackLocked(TrackType.AUDIO, currentEncoder.outputFormat)
} }
else -> { else -> {
if (index < 0) continue if (index < 0) continue
val outputBuffer = currentEncoder.getOutputBuffer(index) val outputBuffer = currentEncoder.getOutputBuffer(index)
@@ -329,6 +330,7 @@ class NativeMp4Recorder(
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
registerTrackLocked(TrackType.VIDEO, currentEncoder.outputFormat) registerTrackLocked(TrackType.VIDEO, currentEncoder.outputFormat)
} }
else -> { else -> {
if (index < 0) continue if (index < 0) continue
val outputBuffer = currentEncoder.getOutputBuffer(index) val outputBuffer = currentEncoder.getOutputBuffer(index)
@@ -362,6 +364,7 @@ class NativeMp4Recorder(
if (audioTrackIndex >= 0) return if (audioTrackIndex >= 0) return
audioTrackIndex = muxer.addTrack(format) audioTrackIndex = muxer.addTrack(format)
} }
TrackType.VIDEO -> { TrackType.VIDEO -> {
if (videoTrackIndex >= 0) return if (videoTrackIndex >= 0) return
videoTrackIndex = muxer.addTrack(format) videoTrackIndex = muxer.addTrack(format)
@@ -451,6 +454,7 @@ class NativeMp4Recorder(
val normalized = (presentationTimeUs - base).coerceAtLeast(0L) val normalized = (presentationTimeUs - base).coerceAtLeast(0L)
normalized.coerceAtLeast(lastAudioPtsUs).also { lastAudioPtsUs = it } normalized.coerceAtLeast(lastAudioPtsUs).also { lastAudioPtsUs = it }
} }
TrackType.VIDEO -> { TrackType.VIDEO -> {
val base = videoBasePtsUs ?: presentationTimeUs.also { val base = videoBasePtsUs ?: presentationTimeUs.also {
videoBasePtsUs = it videoBasePtsUs = it

View File

@@ -122,6 +122,10 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
intPreferencesKey("device_preview_card_height_dp"), intPreferencesKey("device_preview_card_height_dp"),
1080 / 3, 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( val FULLSCREEN_CONTROL_IGNORE_SYSTEM_ROTATION_LOCK = Pair(
booleanPreferencesKey("fullscreen_control_ignore_system_rotation_lock"), booleanPreferencesKey("fullscreen_control_ignore_system_rotation_lock"),
true, true,
@@ -274,6 +278,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
val fullscreenDebugInfo: Boolean, val fullscreenDebugInfo: Boolean,
val hideSimpleConfigItems: Boolean, val hideSimpleConfigItems: Boolean,
val devicePreviewCardHeightDp: Int, val devicePreviewCardHeightDp: Int,
val deviceTwoPaneConfigOnRight: Boolean,
val fullscreenControlIgnoreSystemRotationLock: Boolean, val fullscreenControlIgnoreSystemRotationLock: Boolean,
val showFullscreenVirtualButtons: Boolean, val showFullscreenVirtualButtons: Boolean,
val fullscreenVirtualButtonHeightDp: Int, val fullscreenVirtualButtonHeightDp: Int,
@@ -330,6 +335,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
bundleField(FULLSCREEN_DEBUG_INFO) { it.fullscreenDebugInfo }, bundleField(FULLSCREEN_DEBUG_INFO) { it.fullscreenDebugInfo },
bundleField(HIDE_SIMPLE_CONFIG_ITEMS) { it.hideSimpleConfigItems }, bundleField(HIDE_SIMPLE_CONFIG_ITEMS) { it.hideSimpleConfigItems },
bundleField(DEVICE_PREVIEW_CARD_HEIGHT_DP) { it.devicePreviewCardHeightDp }, 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(FULLSCREEN_CONTROL_IGNORE_SYSTEM_ROTATION_LOCK) { it.fullscreenControlIgnoreSystemRotationLock },
bundleField(SHOW_FULLSCREEN_VIRTUAL_BUTTONS) { it.showFullscreenVirtualButtons }, bundleField(SHOW_FULLSCREEN_VIRTUAL_BUTTONS) { it.showFullscreenVirtualButtons },
bundleField(FULLSCREEN_VIRTUAL_BUTTON_HEIGHT_DP) { it.fullscreenVirtualButtonHeightDp }, 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), fullscreenDebugInfo = preferences.read(FULLSCREEN_DEBUG_INFO),
hideSimpleConfigItems = preferences.read(HIDE_SIMPLE_CONFIG_ITEMS), hideSimpleConfigItems = preferences.read(HIDE_SIMPLE_CONFIG_ITEMS),
devicePreviewCardHeightDp = preferences.read(DEVICE_PREVIEW_CARD_HEIGHT_DP), devicePreviewCardHeightDp = preferences.read(DEVICE_PREVIEW_CARD_HEIGHT_DP),
deviceTwoPaneConfigOnRight = preferences.read(DEVICE_TWO_PANE_CONFIG_ON_RIGHT),
fullscreenControlIgnoreSystemRotationLock = fullscreenControlIgnoreSystemRotationLock =
preferences.read(FULLSCREEN_CONTROL_IGNORE_SYSTEM_ROTATION_LOCK), preferences.read(FULLSCREEN_CONTROL_IGNORE_SYSTEM_ROTATION_LOCK),
showFullscreenVirtualButtons = preferences.read(SHOW_FULLSCREEN_VIRTUAL_BUTTONS), showFullscreenVirtualButtons = preferences.read(SHOW_FULLSCREEN_VIRTUAL_BUTTONS),

View File

@@ -306,67 +306,250 @@ class ScrcpyProfiles(context: Context) : Settings(context, "ScrcpyProfiles") {
val defaults = ScrcpyOptions.defaultBundle() val defaults = ScrcpyOptions.defaultBundle()
val json = bundleJson ?: return defaults val json = bundleJson ?: return defaults
return defaults.copy( return defaults.copy(
crop = json.optStringOrDefault("crop", defaults.crop), crop = json.optStringOrDefault(
recordFilename = json.optStringOrDefault("recordFilename", defaults.recordFilename), "crop",
videoCodecOptions = json.optStringOrDefault("videoCodecOptions", defaults.videoCodecOptions), defaults.crop,
audioCodecOptions = json.optStringOrDefault("audioCodecOptions", defaults.audioCodecOptions), ),
videoEncoder = json.optStringOrDefault("videoEncoder", defaults.videoEncoder), recordFilename = json.optStringOrDefault(
audioEncoder = json.optStringOrDefault("audioEncoder", defaults.audioEncoder), "recordFilename",
cameraId = json.optStringOrDefault("cameraId", defaults.cameraId), defaults.recordFilename,
cameraSize = json.optStringOrDefault("cameraSize", defaults.cameraSize), ),
cameraSizeCustom = json.optStringOrDefault("cameraSizeCustom", defaults.cameraSizeCustom), videoCodecOptions = json.optStringOrDefault(
cameraSizeUseCustom = json.optBooleanOrDefault("cameraSizeUseCustom", defaults.cameraSizeUseCustom), "videoCodecOptions",
cameraAr = json.optStringOrDefault("cameraAr", defaults.cameraAr), defaults.videoCodecOptions,
cameraFps = json.optIntOrDefault("cameraFps", defaults.cameraFps), ),
logLevel = json.optStringOrDefault("logLevel", defaults.logLevel), audioCodecOptions = json.optStringOrDefault(
videoCodec = json.optStringOrDefault("videoCodec", defaults.videoCodec), "audioCodecOptions",
audioCodec = json.optStringOrDefault("audioCodec", defaults.audioCodec), defaults.audioCodecOptions,
videoSource = json.optStringOrDefault("videoSource", defaults.videoSource), ),
audioSource = json.optStringOrDefault("audioSource", defaults.audioSource), videoEncoder = json.optStringOrDefault(
recordFormat = json.optStringOrDefault("recordFormat", defaults.recordFormat), "videoEncoder",
cameraFacing = json.optStringOrDefault("cameraFacing", defaults.cameraFacing), defaults.videoEncoder,
maxSize = json.optIntOrDefault("maxSize", defaults.maxSize), ),
videoBitRate = json.optIntOrDefault("videoBitRate", defaults.videoBitRate), audioEncoder = json.optStringOrDefault(
audioBitRate = json.optIntOrDefault("audioBitRate", defaults.audioBitRate), "audioEncoder",
maxFps = json.optStringOrDefault("maxFps", defaults.maxFps), defaults.audioEncoder,
angle = json.optStringOrDefault("angle", defaults.angle), ),
captureOrientation = json.optIntOrDefault("captureOrientation", defaults.captureOrientation), cameraId = json.optStringOrDefault(
captureOrientationLock = json.optStringOrDefault("captureOrientationLock", defaults.captureOrientationLock), "cameraId",
displayOrientation = json.optIntOrDefault("displayOrientation", defaults.displayOrientation), defaults.cameraId,
recordOrientation = json.optIntOrDefault("recordOrientation", defaults.recordOrientation), ),
displayImePolicy = json.optStringOrDefault("displayImePolicy", defaults.displayImePolicy), cameraSize = json.optStringOrDefault(
displayId = json.optIntOrDefault("displayId", defaults.displayId), "cameraSize",
screenOffTimeout = json.optLongOrDefault("screenOffTimeout", defaults.screenOffTimeout), defaults.cameraSize,
showTouches = json.optBooleanOrDefault("showTouches", defaults.showTouches), ),
fullscreen = json.optBooleanOrDefault("fullscreen", defaults.fullscreen), cameraSizeCustom = json.optStringOrDefault(
control = json.optBooleanOrDefault("control", defaults.control), "cameraSizeCustom",
videoPlayback = json.optBooleanOrDefault("videoPlayback", defaults.videoPlayback), defaults.cameraSizeCustom,
audioPlayback = json.optBooleanOrDefault("audioPlayback", defaults.audioPlayback), ),
turnScreenOff = json.optBooleanOrDefault("turnScreenOff", defaults.turnScreenOff), cameraSizeUseCustom = json.optBooleanOrDefault(
keyInjectMode = json.optStringOrDefault("keyInjectMode", defaults.keyInjectMode), "cameraSizeUseCustom",
forwardKeyRepeat = json.optBooleanOrDefault("forwardKeyRepeat", defaults.forwardKeyRepeat), defaults.cameraSizeUseCustom,
stayAwake = json.optBooleanOrDefault("stayAwake", defaults.stayAwake), ),
disableScreensaver = json.optBooleanOrDefault("disableScreensaver", defaults.disableScreensaver), cameraAr = json.optStringOrDefault(
powerOffOnClose = json.optBooleanOrDefault("powerOffOnClose", defaults.powerOffOnClose), "cameraAr",
legacyPaste = json.optBooleanOrDefault("legacyPaste", defaults.legacyPaste), defaults.cameraAr,
clipboardAutosync = json.optBooleanOrDefault("clipboardAutosync", defaults.clipboardAutosync), ),
downsizeOnError = json.optBooleanOrDefault("downsizeOnError", defaults.downsizeOnError), cameraFps = json.optIntOrDefault(
mouseHover = json.optBooleanOrDefault("mouseHover", defaults.mouseHover), "cameraFps",
cleanup = json.optBooleanOrDefault("cleanup", defaults.cleanup), defaults.cameraFps,
powerOn = json.optBooleanOrDefault("powerOn", defaults.powerOn), ),
video = json.optBooleanOrDefault("video", defaults.video), logLevel = json.optStringOrDefault(
audio = json.optBooleanOrDefault("audio", defaults.audio), "logLevel",
requireAudio = json.optBooleanOrDefault("requireAudio", defaults.requireAudio), defaults.logLevel,
killAdbOnClose = json.optBooleanOrDefault("killAdbOnClose", defaults.killAdbOnClose), ),
cameraHighSpeed = json.optBooleanOrDefault("cameraHighSpeed", defaults.cameraHighSpeed), videoCodec = json.optStringOrDefault(
list = json.optStringOrDefault("list", defaults.list), "videoCodec",
audioDup = json.optBooleanOrDefault("audioDup", defaults.audioDup), defaults.videoCodec,
newDisplay = json.optStringOrDefault("newDisplay", defaults.newDisplay), ),
startApp = json.optStringOrDefault("startApp", defaults.startApp), audioCodec = json.optStringOrDefault(
startAppCustom = json.optStringOrDefault("startAppCustom", defaults.startAppCustom), "audioCodec",
startAppUseCustom = json.optBooleanOrDefault("startAppUseCustom", defaults.startAppUseCustom), defaults.audioCodec,
vdDestroyContent = json.optBooleanOrDefault("vdDestroyContent", defaults.vdDestroyContent), ),
vdSystemDecorations = json.optBooleanOrDefault("vdSystemDecorations", defaults.vdSystemDecorations), 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,
),
) )
} }

View File

@@ -2,6 +2,7 @@ package io.github.miuzarte.scrcpyforandroid.widgets
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MotionEvent
import android.view.Surface import android.view.Surface
import android.view.SurfaceHolder import android.view.SurfaceHolder
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
@@ -41,6 +42,7 @@ 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
@@ -51,14 +53,19 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput 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.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView 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.Defaults
import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing 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.models.DeviceShortcut
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
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.scrcpy.Shared.Codec
import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.storage.Storage
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions
import io.github.miuzarte.scrcpyforandroid.ui.contextClick
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -255,16 +263,35 @@ internal fun PreviewCard(
sessionInfo: Scrcpy.Session.SessionInfo?, sessionInfo: Scrcpy.Session.SessionInfo?,
previewHeightDp: Int, previewHeightDp: Int,
onOpenFullscreen: () -> Unit, 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, imeRequestToken: Int = 0,
onImeCommitText: (suspend (String) -> Unit)? = null, onImeCommitText: (suspend (String) -> Unit)? = null,
onImeDeleteSurroundingText: (suspend (beforeLength: Int, afterLength: Int) -> Unit)? = null, onImeDeleteSurroundingText: (suspend (beforeLength: Int, afterLength: Int) -> Unit)? = null,
onImeKeyEvent: (suspend (KeyEvent) -> Boolean)? = null, onImeKeyEvent: (suspend (KeyEvent) -> Boolean)? = null,
autoBringIntoView: Boolean = false, autoBringIntoView: Boolean = false,
onAutoBringIntoViewConsumed: () -> Unit = {}, onAutoBringIntoViewConsumed: () -> Unit = {},
onTouchActiveChanged: (Boolean) -> Unit = {},
) { ) {
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val coroutineScope = rememberCoroutineScope()
var previewControlsVisible by rememberSaveable { mutableStateOf(false) } var previewControlsVisible by rememberSaveable { mutableStateOf(false) }
var touchAreaSize by remember { mutableStateOf(IntSize.Zero) }
val activePointerIds = remember { linkedSetOf<Int>() }
val activePointerPositions = remember { linkedMapOf<Int, Offset>() }
val activePointerDevicePositions = remember { linkedMapOf<Int, Pair<Int, Int>>() }
val pointerLabels = remember { linkedMapOf<Int, Int>() }
var nextPointerLabel by rememberSaveable { mutableIntStateOf(1) }
val alpha by animateFloatAsState( val alpha by animateFloatAsState(
if (previewControlsVisible) 1f else 0f, if (previewControlsVisible) 1f else 0f,
label = "preview-controls" label = "preview-controls"
@@ -278,6 +305,34 @@ internal fun PreviewCard(
onAutoBringIntoViewConsumed() 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) { DisposableEffect(lifecycleOwner) {
if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
VideoOutputTargetState.set(VideoOutputTarget.PREVIEW) VideoOutputTargetState.set(VideoOutputTarget.PREVIEW)
@@ -294,6 +349,7 @@ internal fun PreviewCard(
} }
lifecycleOwner.lifecycle.addObserver(observer) lifecycleOwner.lifecycle.addObserver(observer)
onDispose { onDispose {
onTouchActiveChanged(false)
lifecycleOwner.lifecycle.removeObserver(observer) lifecycleOwner.lifecycle.removeObserver(observer)
if (VideoOutputTargetState.current.value == VideoOutputTarget.PREVIEW) { if (VideoOutputTargetState.current.value == VideoOutputTarget.PREVIEW) {
VideoOutputTargetState.set(VideoOutputTarget.NONE) VideoOutputTargetState.set(VideoOutputTarget.NONE)
@@ -310,11 +366,25 @@ internal fun PreviewCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(previewHeightDp.coerceAtLeast(120).dp) .height(previewHeightDp.coerceAtLeast(120).dp)
.pointerInput(sessionInfo) { .then(
detectTapGestures(onTap = { if (directControlEnabled && touchEventHandler != null) {
previewControlsVisible = !previewControlsVisible 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()) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val sessionAspect = val sessionAspect =
@@ -379,12 +449,14 @@ internal fun PreviewCard(
@Composable @Composable
internal fun VirtualButtonCard( internal fun VirtualButtonCard(
modifier: Modifier = Modifier,
busy: Boolean, busy: Boolean,
outsideActions: List<VirtualButtonAction>, outsideActions: List<VirtualButtonAction>,
moreActions: List<VirtualButtonAction>, moreActions: List<VirtualButtonAction>,
showText: Boolean, showText: Boolean,
onAction: (VirtualButtonAction) -> Unit, onAction: (VirtualButtonAction) -> Unit,
passwordPopupContent: (@Composable (onDismissRequest: () -> Unit) -> Unit)? = null, passwordPopupContent: (@Composable (onDismissRequest: () -> Unit) -> Unit)? = null,
popupBottomPadding: Dp = 0.dp,
) { ) {
val bar = remember(outsideActions, moreActions) { val bar = remember(outsideActions, moreActions) {
VirtualButtonBar( VirtualButtonBar(
@@ -393,12 +465,13 @@ internal fun VirtualButtonCard(
) )
} }
Card { Card(modifier = modifier) {
bar.Preview( bar.Preview(
enabled = !busy, enabled = true,
showText = showText, showText = showText,
onAction = onAction, onAction = { if (!busy) onAction(it) },
passwordPopupContent = passwordPopupContent, passwordPopupContent = passwordPopupContent,
popupBottomPadding = popupBottomPadding,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(UiSpacing.ContentVertical), .padding(UiSpacing.ContentVertical),
@@ -427,6 +500,9 @@ internal fun ConfigPanel(
onStop: () -> Unit, onStop: () -> Unit,
sessionInfo: Scrcpy.Session.SessionInfo?, sessionInfo: Scrcpy.Session.SessionInfo?,
onDisconnect: () -> Unit = {}, onDisconnect: () -> Unit = {},
showFullscreenAction: Boolean = false,
onOpenFullscreen: () -> Unit = {},
reverseSideActions: Boolean = false,
) { ) {
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
@@ -646,34 +722,71 @@ internal fun ConfigPanel(
.padding(all = UiSpacing.ContentVertical), .padding(all = UiSpacing.ContentVertical),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) { ) {
if (isQuickConnected) TextButton( val sideButtonWeight = 1f / 4f
text = "断开", val mainButtonWeight = 1f -
onClick = { if (isQuickConnected || showFullscreenAction)
onStartStopHaptic() sideButtonWeight * listOf(isQuickConnected, showFullscreenAction)
onDisconnect() .count { it }
}, else 0f
modifier = Modifier.weight(1f / 4f),
enabled = !busy, @Composable
) fun DisconnectButton() {
if (!sessionStarted) TextButton( if (isQuickConnected) TextButton(
text = "启动", text = "断开",
onClick = { onClick = {
onStartStopHaptic() onStartStopHaptic()
onStart() onDisconnect()
}, },
modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f), modifier = Modifier.weight(sideButtonWeight),
enabled = !busy, enabled = !busy,
colors = ButtonDefaults.textButtonColorsPrimary(), )
) }
if (sessionStarted) TextButton(
text = "停止", @Composable
onClick = { fun MainButton() {
onStartStopHaptic() if (!sessionStarted) TextButton(
onStop() text = "启动",
}, onClick = {
modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f), onStartStopHaptic()
enabled = !busy, 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()
}
} }
} }
} }

View File

@@ -3,8 +3,8 @@ package io.github.miuzarte.scrcpyforandroid.widgets
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.getValue
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState 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.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp 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.BasicComponent
import top.yukonga.miuix.kmp.basic.BasicComponentColors import top.yukonga.miuix.kmp.basic.BasicComponentColors
import top.yukonga.miuix.kmp.basic.BasicComponentDefaults import top.yukonga.miuix.kmp.basic.BasicComponentDefaults
import top.yukonga.miuix.kmp.basic.DropdownArrowEndAction 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.DropdownImpl
import top.yukonga.miuix.kmp.basic.HorizontalDivider import top.yukonga.miuix.kmp.basic.HorizontalDivider
import top.yukonga.miuix.kmp.basic.ListPopupColumn import top.yukonga.miuix.kmp.basic.ListPopupColumn

View File

@@ -52,6 +52,9 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset 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 androidx.compose.ui.unit.dp
import io.github.miuzarte.scrcpyforandroid.constants.UiAndroidKeycodes import io.github.miuzarte.scrcpyforandroid.constants.UiAndroidKeycodes
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
@@ -255,6 +258,7 @@ class VirtualButtonBar(
onAction: (VirtualButtonAction) -> Unit, onAction: (VirtualButtonAction) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
passwordPopupContent: (@Composable (onDismissRequest: () -> Unit) -> Unit)? = null, passwordPopupContent: (@Composable (onDismissRequest: () -> Unit) -> Unit)? = null,
popupBottomPadding: Dp = 0.dp,
) { ) {
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
@@ -321,6 +325,7 @@ class VirtualButtonBar(
}, },
passwordPopupContent = passwordPopupContent, passwordPopupContent = passwordPopupContent,
renderInRootScaffold = false, renderInRootScaffold = false,
popupBottomPadding = popupBottomPadding,
) )
} }
if ( if (
@@ -329,7 +334,8 @@ class VirtualButtonBar(
) { ) {
OverlayListPopup( OverlayListPopup(
show = showPasswordPopup, show = showPasswordPopup,
popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider, popupPositionProvider =
rememberBottomSafeContextMenuPositionProvider(popupBottomPadding),
alignment = PopupPositionProvider.Align.TopEnd, alignment = PopupPositionProvider.Align.TopEnd,
onDismissRequest = { showPasswordPopup = false }, onDismissRequest = { showPasswordPopup = false },
renderInRootScaffold = false, renderInRootScaffold = false,
@@ -667,6 +673,7 @@ class VirtualButtonBar(
passwordPopupContent: (@Composable (onDismissRequest: () -> Unit) -> Unit)? = null, passwordPopupContent: (@Composable (onDismissRequest: () -> Unit) -> Unit)? = null,
renderInRootScaffold: Boolean, renderInRootScaffold: Boolean,
popupAlignment: PopupPositionProvider.Align = PopupPositionProvider.Align.TopEnd, popupAlignment: PopupPositionProvider.Align = PopupPositionProvider.Align.TopEnd,
popupBottomPadding: Dp = 0.dp,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
@@ -692,6 +699,7 @@ class VirtualButtonBar(
popupAlignment = popupAlignment, popupAlignment = popupAlignment,
onDismiss = onDismiss, onDismiss = onDismiss,
renderInRootScaffold = renderInRootScaffold, renderInRootScaffold = renderInRootScaffold,
popupBottomPadding = popupBottomPadding,
) { destination, navigateTo, dismiss -> ) { destination, navigateTo, dismiss ->
ListPopupColumn { ListPopupColumn {
if (destination == ActionPopupDestination.Actions) if (destination == ActionPopupDestination.Actions)
@@ -733,6 +741,7 @@ class VirtualButtonBar(
popupAlignment: PopupPositionProvider.Align, popupAlignment: PopupPositionProvider.Align,
onDismiss: () -> Unit, onDismiss: () -> Unit,
renderInRootScaffold: Boolean, renderInRootScaffold: Boolean,
popupBottomPadding: Dp = 0.dp,
content: @Composable ( content: @Composable (
destination: Destination, destination: Destination,
navigateTo: (Destination) -> Unit, navigateTo: (Destination) -> Unit,
@@ -742,7 +751,8 @@ class VirtualButtonBar(
var destination by remember(show, startDestination) { mutableStateOf(startDestination) } var destination by remember(show, startDestination) { mutableStateOf(startDestination) }
OverlayListPopup( OverlayListPopup(
show = show, show = show,
popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider, popupPositionProvider =
rememberBottomSafeContextMenuPositionProvider(popupBottomPadding),
alignment = popupAlignment, alignment = popupAlignment,
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
renderInRootScaffold = renderInRootScaffold, renderInRootScaffold = renderInRootScaffold,
@@ -751,4 +761,39 @@ class VirtualButtonBar(
content(destination, { destination = it }, onDismiss) 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)
}
} }

View File

@@ -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-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } 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 = { 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-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" } androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime-android", version.ref = "androidxNavigation3" }
bcpkix-jdk18on = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bcpkixJdk18on" } bcpkix-jdk18on = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bcpkixJdk18on" }