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
- 改进: 再次修改图标至 monochrome 主题色填充以避免与上游项目混淆

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,6 +30,7 @@ object NativeCoreFacade {
private val mainHandler = Handler(Looper.getMainLooper())
private val bootstrapLock = Any()
private val bootstrapPackets = ArrayDeque<CachedPacket>()
@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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
),
)
}

View File

@@ -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<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(
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<VirtualButtonAction>,
moreActions: List<VirtualButtonAction>,
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()
}
}
}
}

View File

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

View File

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

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-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" }