refactor: introducing ViewModel (#17)
* move to viewModel; use submodule to import miuix * replace widgets with which implemented in miuix * update CHANGELOG.md * fix: coroutine handling * chore: code formatting * viewModel for TerminalScreen * viewModel for FileManagerScreen * chore: code formatting * fix for review * chore: optimize imports
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -34,6 +34,7 @@ google-services.json
|
|||||||
# Android Profiling
|
# Android Profiling
|
||||||
*.hprof
|
*.hprof
|
||||||
|
|
||||||
|
.claude/
|
||||||
.kiro/
|
.kiro/
|
||||||
|
|
||||||
.kotlin/
|
.kotlin/
|
||||||
|
|||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "submodule/miuix"]
|
||||||
|
path = submodule/miuix
|
||||||
|
url = https://github.com/compose-miuix-ui/miuix.git
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
# Change Log
|
# Change Log
|
||||||
|
|
||||||
~~什么我居然开始写更新日志了~~
|
## 0.3.0
|
||||||
|
|
||||||
|
该版本主要进行代码重构,引入 ViewModel 以优化代码结构
|
||||||
|
|
||||||
|
- 重构: 部分自行实现的组件迁移至 miuix
|
||||||
|
- 引入子模块以在本地编译 miuix 的最新提交
|
||||||
|
- 新增: 所有参数页中的五个列表展开即触发懒加载
|
||||||
|
|
||||||
## 0.2.9
|
## 0.2.9
|
||||||
|
|
||||||
|
|||||||
@@ -128,9 +128,17 @@
|
|||||||
- Android NDK `29.0.14206865`
|
- Android NDK `29.0.14206865`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
git clone --recursive https://github.com/Miuzarte/ScrcpyForAndroid.git
|
||||||
|
cd ScrcpyForAndroid
|
||||||
./gradlew assembleDebug
|
./gradlew assembleDebug
|
||||||
```
|
```
|
||||||
|
|
||||||
|
已克隆但没有拉取子模块:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git submodule update --init --recursive
|
||||||
|
```
|
||||||
|
|
||||||
specific abi:
|
specific abi:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ androidComponents {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.core.pip)
|
implementation(libs.androidx.core.pip)
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid.scaffolds
|
package io.github.miuzarte.scrcpyforandroid.miuix
|
||||||
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
@@ -26,14 +26,17 @@ import top.yukonga.miuix.kmp.basic.ListPopupColumn
|
|||||||
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
|
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
|
||||||
import top.yukonga.miuix.kmp.basic.SpinnerColors
|
import top.yukonga.miuix.kmp.basic.SpinnerColors
|
||||||
import top.yukonga.miuix.kmp.basic.SpinnerDefaults
|
import top.yukonga.miuix.kmp.basic.SpinnerDefaults
|
||||||
import top.yukonga.miuix.kmp.basic.SpinnerEntry
|
|
||||||
import top.yukonga.miuix.kmp.basic.SpinnerItemImpl
|
|
||||||
import top.yukonga.miuix.kmp.basic.Text
|
import top.yukonga.miuix.kmp.basic.Text
|
||||||
import top.yukonga.miuix.kmp.overlay.OverlayListPopup
|
import top.yukonga.miuix.kmp.overlay.OverlayListPopup
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy of miuix [OverlaySpinnerPreference] (popup mode) using our custom
|
||||||
|
* [SpinnerEntry] with per-item [SpinnerEntry.enabled] passed through to
|
||||||
|
* [SpinnerItemImpl].
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SuperSpinner(
|
fun OverlaySpinnerPreference(
|
||||||
items: List<SpinnerEntry>,
|
items: List<SpinnerEntry>,
|
||||||
selectedIndex: Int,
|
selectedIndex: Int,
|
||||||
title: String,
|
title: String,
|
||||||
@@ -49,21 +52,26 @@ fun SuperSpinner(
|
|||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
showValue: Boolean = true,
|
showValue: Boolean = true,
|
||||||
renderInRootScaffold: Boolean = true,
|
renderInRootScaffold: Boolean = true,
|
||||||
|
onExpandedChange: ((Boolean) -> Unit)? = null,
|
||||||
onSelectedIndexChange: ((Int) -> Unit)? = null,
|
onSelectedIndexChange: ((Int) -> Unit)? = null,
|
||||||
overrideEndActionValue: String? = null,
|
|
||||||
) {
|
) {
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
val isDropdownExpanded = rememberSaveable { mutableStateOf(false) }
|
val isDropdownExpanded = rememberSaveable { mutableStateOf(false) }
|
||||||
val isHoldDown = remember { mutableStateOf(false) }
|
val isHoldDown = remember { mutableStateOf(false) }
|
||||||
val haptic = LocalHapticFeedback.current
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
val hapticLatest by rememberUpdatedState(haptic)
|
val currentHapticFeedback by rememberUpdatedState(hapticFeedback)
|
||||||
|
val currentOnExpandedChange = rememberUpdatedState(onExpandedChange)
|
||||||
|
val setExpanded: (Boolean) -> Unit = remember {
|
||||||
|
{ expanded ->
|
||||||
|
if (isDropdownExpanded.value != expanded) {
|
||||||
|
isDropdownExpanded.value = expanded
|
||||||
|
currentOnExpandedChange.value?.invoke(expanded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val itemsNotEmpty = items.isNotEmpty()
|
val itemsNotEmpty = items.isNotEmpty()
|
||||||
val actualEnabled = enabled && itemsNotEmpty
|
val actualEnabled = enabled && itemsNotEmpty
|
||||||
val forceOverrideValue = overrideEndActionValue != null
|
|
||||||
val endActionText = overrideEndActionValue
|
|
||||||
?.takeIf { it.isNotBlank() }
|
|
||||||
?: if (showValue && itemsNotEmpty) items[selectedIndex].title.orEmpty() else null
|
|
||||||
|
|
||||||
val actionColor = if (actualEnabled) {
|
val actionColor = if (actualEnabled) {
|
||||||
MiuixTheme.colorScheme.onSurfaceVariantActions
|
MiuixTheme.colorScheme.onSurfaceVariantActions
|
||||||
@@ -74,10 +82,10 @@ fun SuperSpinner(
|
|||||||
val handleClick = remember(actualEnabled) {
|
val handleClick = remember(actualEnabled) {
|
||||||
{
|
{
|
||||||
if (actualEnabled) {
|
if (actualEnabled) {
|
||||||
isDropdownExpanded.value = !isDropdownExpanded.value
|
setExpanded(!isDropdownExpanded.value)
|
||||||
if (isDropdownExpanded.value) {
|
if (isDropdownExpanded.value) {
|
||||||
isHoldDown.value = true
|
isHoldDown.value = true
|
||||||
hapticLatest.performHapticFeedback(HapticFeedbackType.ContextClick)
|
currentHapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,9 +101,9 @@ fun SuperSpinner(
|
|||||||
summaryColor = summaryColor,
|
summaryColor = summaryColor,
|
||||||
startAction = startAction,
|
startAction = startAction,
|
||||||
endActions = {
|
endActions = {
|
||||||
if (endActionText != null) {
|
if (showValue && itemsNotEmpty) {
|
||||||
Text(
|
Text(
|
||||||
text = endActionText,
|
text = items[selectedIndex].title ?: "",
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(end = 8.dp)
|
.padding(end = 8.dp)
|
||||||
.align(Alignment.CenterVertically)
|
.align(Alignment.CenterVertically)
|
||||||
@@ -105,22 +113,19 @@ fun SuperSpinner(
|
|||||||
textAlign = TextAlign.End,
|
textAlign = TextAlign.End,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
DropdownArrowEndAction(
|
DropdownArrowEndAction(actionColor = actionColor)
|
||||||
actionColor = actionColor,
|
|
||||||
)
|
|
||||||
if (itemsNotEmpty) {
|
if (itemsNotEmpty) {
|
||||||
SuperSpinnerPopup(
|
OverlaySpinnerPopup(
|
||||||
items = items,
|
items = items,
|
||||||
selectedIndex = selectedIndex,
|
selectedIndex = selectedIndex,
|
||||||
isDropdownExpanded = isDropdownExpanded.value,
|
isDropdownExpanded = isDropdownExpanded.value,
|
||||||
onDismiss = { isDropdownExpanded.value = false },
|
onDismiss = { setExpanded(false) },
|
||||||
onDismissFinished = { isHoldDown.value = false },
|
onDismissFinished = { isHoldDown.value = false },
|
||||||
maxHeight = maxHeight,
|
maxHeight = maxHeight,
|
||||||
hapticFeedback = haptic,
|
hapticFeedback = hapticFeedback,
|
||||||
spinnerColors = spinnerColors,
|
spinnerColors = spinnerColors,
|
||||||
renderInRootScaffold = renderInRootScaffold,
|
renderInRootScaffold = renderInRootScaffold,
|
||||||
onSelectedIndexChange = onSelectedIndexChange,
|
onSelectedIndexChange = onSelectedIndexChange,
|
||||||
forceNoSelectedState = forceOverrideValue,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -132,7 +137,7 @@ fun SuperSpinner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SuperSpinnerPopup(
|
private fun OverlaySpinnerPopup(
|
||||||
items: List<SpinnerEntry>,
|
items: List<SpinnerEntry>,
|
||||||
selectedIndex: Int,
|
selectedIndex: Int,
|
||||||
isDropdownExpanded: Boolean,
|
isDropdownExpanded: Boolean,
|
||||||
@@ -143,14 +148,13 @@ private fun SuperSpinnerPopup(
|
|||||||
spinnerColors: SpinnerColors,
|
spinnerColors: SpinnerColors,
|
||||||
renderInRootScaffold: Boolean,
|
renderInRootScaffold: Boolean,
|
||||||
onSelectedIndexChange: ((Int) -> Unit)?,
|
onSelectedIndexChange: ((Int) -> Unit)?,
|
||||||
forceNoSelectedState: Boolean,
|
|
||||||
) {
|
) {
|
||||||
val haptic = LocalHapticFeedback.current
|
|
||||||
val onSelectState = rememberUpdatedState(onSelectedIndexChange)
|
val onSelectState = rememberUpdatedState(onSelectedIndexChange)
|
||||||
val currentOnDismiss by rememberUpdatedState(onDismiss)
|
val currentOnDismiss by rememberUpdatedState(onDismiss)
|
||||||
|
val currentHapticFeedback by rememberUpdatedState(hapticFeedback)
|
||||||
val onItemSelected: (Int) -> Unit = remember {
|
val onItemSelected: (Int) -> Unit = remember {
|
||||||
{ selectedIdx ->
|
{ selectedIdx ->
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.Confirm)
|
currentHapticFeedback.performHapticFeedback(HapticFeedbackType.Confirm)
|
||||||
onSelectState.value?.invoke(selectedIdx)
|
onSelectState.value?.invoke(selectedIdx)
|
||||||
currentOnDismiss()
|
currentOnDismiss()
|
||||||
}
|
}
|
||||||
@@ -169,14 +173,15 @@ private fun SuperSpinnerPopup(
|
|||||||
SpinnerItemImpl(
|
SpinnerItemImpl(
|
||||||
entry = spinnerEntry,
|
entry = spinnerEntry,
|
||||||
entryCount = items.size,
|
entryCount = items.size,
|
||||||
isSelected = !forceNoSelectedState && selectedIndex == index,
|
isSelected = selectedIndex == index,
|
||||||
index = index,
|
index = index,
|
||||||
spinnerColors = spinnerColors,
|
spinnerColors = spinnerColors,
|
||||||
dialogMode = false,
|
dialogMode = false,
|
||||||
|
enabled = spinnerEntry.enabled,
|
||||||
onSelectedIndexChange = onItemSelected,
|
onSelectedIndexChange = onItemSelected,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.miuix
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class SpinnerEntry(
|
||||||
|
val icon: @Composable ((Modifier) -> Unit)? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
val summary: String? = null,
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
)
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.miuix
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.sizeIn
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import top.yukonga.miuix.kmp.basic.SpinnerColors
|
||||||
|
import top.yukonga.miuix.kmp.basic.Text
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SpinnerItemImpl(
|
||||||
|
entry: SpinnerEntry,
|
||||||
|
entryCount: Int,
|
||||||
|
isSelected: Boolean,
|
||||||
|
index: Int,
|
||||||
|
spinnerColors: SpinnerColors,
|
||||||
|
dialogMode: Boolean = false,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onSelectedIndexChange: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
val additionalTopPadding = if (!dialogMode && index == 0) 20.dp else 12.dp
|
||||||
|
val additionalBottomPadding = if (!dialogMode && index == entryCount - 1) 20.dp else 12.dp
|
||||||
|
|
||||||
|
val disabled = !enabled
|
||||||
|
val (titleColor, summaryColor, backgroundColor) = when {
|
||||||
|
disabled -> Triple(
|
||||||
|
MiuixTheme.colorScheme.disabledOnSecondaryVariant,
|
||||||
|
MiuixTheme.colorScheme.disabledOnSecondaryVariant,
|
||||||
|
spinnerColors.containerColor,
|
||||||
|
)
|
||||||
|
|
||||||
|
isSelected -> Triple(
|
||||||
|
spinnerColors.selectedContentColor,
|
||||||
|
spinnerColors.selectedSummaryColor,
|
||||||
|
spinnerColors.selectedContainerColor,
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> Triple(
|
||||||
|
spinnerColors.contentColor,
|
||||||
|
spinnerColors.summaryColor,
|
||||||
|
spinnerColors.containerColor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val selectColor = when {
|
||||||
|
disabled -> Color.Transparent
|
||||||
|
isSelected -> spinnerColors.selectedIndicatorColor
|
||||||
|
else -> Color.Transparent
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentOnSelectedIndexChange by rememberUpdatedState(onSelectedIndexChange)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier
|
||||||
|
.drawBehind { drawRect(backgroundColor) }
|
||||||
|
.clickable(enabled = !disabled) { currentOnSelectedIndexChange(index) }
|
||||||
|
.then(
|
||||||
|
if (dialogMode) {
|
||||||
|
Modifier
|
||||||
|
.heightIn(min = 56.dp)
|
||||||
|
.widthIn(min = 200.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 28.dp)
|
||||||
|
} else {
|
||||||
|
Modifier.padding(horizontal = 20.dp)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.padding(top = additionalTopPadding, bottom = additionalBottomPadding),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = if (dialogMode) Modifier else Modifier.widthIn(max = 216.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Start,
|
||||||
|
) {
|
||||||
|
entry.icon?.let {
|
||||||
|
it(
|
||||||
|
Modifier
|
||||||
|
.sizeIn(minWidth = 26.dp, minHeight = 26.dp)
|
||||||
|
.padding(end = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
entry.title?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
fontSize = MiuixTheme.textStyles.body1.fontSize,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = titleColor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
entry.summary?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
fontSize = MiuixTheme.textStyles.body2.fontSize,
|
||||||
|
color = summaryColor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!disabled) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.sizeIn(minWidth = 8.dp, minHeight = 8.dp)
|
||||||
|
.padding(
|
||||||
|
start = if (dialogMode) 20.dp else 12.dp,
|
||||||
|
top = 1.dp,
|
||||||
|
bottom = 1.dp,
|
||||||
|
)
|
||||||
|
.clip(RectangleShape)
|
||||||
|
.background(selectColor),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,7 +64,6 @@ import top.yukonga.miuix.kmp.blur.BlendColorEntry
|
|||||||
import top.yukonga.miuix.kmp.blur.BlurBlendMode
|
import top.yukonga.miuix.kmp.blur.BlurBlendMode
|
||||||
import top.yukonga.miuix.kmp.blur.BlurColors
|
import top.yukonga.miuix.kmp.blur.BlurColors
|
||||||
import top.yukonga.miuix.kmp.blur.BlurDefaults
|
import top.yukonga.miuix.kmp.blur.BlurDefaults
|
||||||
import top.yukonga.miuix.kmp.blur.LayerBackdrop
|
|
||||||
import top.yukonga.miuix.kmp.blur.isRenderEffectSupported
|
import top.yukonga.miuix.kmp.blur.isRenderEffectSupported
|
||||||
import top.yukonga.miuix.kmp.blur.layerBackdrop
|
import top.yukonga.miuix.kmp.blur.layerBackdrop
|
||||||
import top.yukonga.miuix.kmp.blur.rememberLayerBackdrop
|
import top.yukonga.miuix.kmp.blur.rememberLayerBackdrop
|
||||||
@@ -197,6 +196,31 @@ private fun AboutContent(
|
|||||||
.collect {}
|
.collect {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AboutCard(
|
||||||
|
modifier: Modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.textureBlur(
|
||||||
|
backdrop = backdrop,
|
||||||
|
shape = SmoothRoundedCornerShape(16.dp),
|
||||||
|
blurRadius = 60f,
|
||||||
|
noiseCoefficient = BlurDefaults.NoiseCoefficient,
|
||||||
|
colors = BlurColors(blendColors = cardBlendColors),
|
||||||
|
enabled = blurEnabled,
|
||||||
|
),
|
||||||
|
colors = CardDefaults.defaultColors(
|
||||||
|
color =
|
||||||
|
if (blurEnabled) Color.Transparent
|
||||||
|
else colorScheme.surfaceContainer,
|
||||||
|
contentColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
BgEffectBackground(
|
BgEffectBackground(
|
||||||
dynamicBackground = true,
|
dynamicBackground = true,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -348,12 +372,7 @@ private fun AboutContent(
|
|||||||
.padding(bottom = padding.calculateBottomPadding()),
|
.padding(bottom = padding.calculateBottomPadding()),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
AboutCard(
|
AboutCard {
|
||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
|
||||||
backdrop = backdrop,
|
|
||||||
blurEnabled = blurEnabled,
|
|
||||||
blendColors = cardBlendColors,
|
|
||||||
) {
|
|
||||||
ArrowPreference(
|
ArrowPreference(
|
||||||
title = "项目仓库",
|
title = "项目仓库",
|
||||||
endActions = {
|
endActions = {
|
||||||
@@ -382,17 +401,15 @@ private fun AboutContent(
|
|||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
context.startActivity(
|
context.startActivity(
|
||||||
Intent(Intent.ACTION_VIEW, releasesUrl.toUri())
|
Intent(
|
||||||
|
Intent.ACTION_VIEW,
|
||||||
|
releasesUrl.toUri(),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
AboutCard(
|
AboutCard {
|
||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
|
||||||
backdrop = backdrop,
|
|
||||||
blurEnabled = blurEnabled,
|
|
||||||
blendColors = cardBlendColors,
|
|
||||||
) {
|
|
||||||
ArrowPreference(
|
ArrowPreference(
|
||||||
title = "License",
|
title = "License",
|
||||||
endActions = {
|
endActions = {
|
||||||
@@ -406,46 +423,43 @@ private fun AboutContent(
|
|||||||
context.startActivity(
|
context.startActivity(
|
||||||
Intent(
|
Intent(
|
||||||
Intent.ACTION_VIEW,
|
Intent.ACTION_VIEW,
|
||||||
"https://www.apache.org/licenses/LICENSE-2.0.txt".toUri()
|
"https://www.apache.org/licenses/LICENSE-2.0.txt".toUri(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
AboutCard {
|
||||||
|
listOf(
|
||||||
|
Pair("Miuix", "https://github.com/compose-miuix-ui/miuix"),
|
||||||
|
Pair("scrcpy", "https://github.com/Genymobile/scrcpy"),
|
||||||
|
).forEach { (name, repo) ->
|
||||||
|
ArrowPreference(
|
||||||
|
title = name,
|
||||||
|
endActions = {
|
||||||
|
Text(
|
||||||
|
text = "GitHub",
|
||||||
|
fontSize = textStyles.body2.fontSize,
|
||||||
|
color = colorScheme.onSurfaceVariantActions,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
context.startActivity(
|
||||||
|
Intent(
|
||||||
|
Intent.ACTION_VIEW,
|
||||||
|
repo.toUri(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AboutCard(
|
|
||||||
modifier: Modifier,
|
|
||||||
backdrop: LayerBackdrop,
|
|
||||||
blurEnabled: Boolean,
|
|
||||||
blendColors: List<BlendColorEntry>,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier = modifier.textureBlur(
|
|
||||||
backdrop = backdrop,
|
|
||||||
shape = SmoothRoundedCornerShape(16.dp),
|
|
||||||
blurRadius = 60f,
|
|
||||||
noiseCoefficient = BlurDefaults.NoiseCoefficient,
|
|
||||||
colors = BlurColors(blendColors = blendColors),
|
|
||||||
enabled = blurEnabled,
|
|
||||||
),
|
|
||||||
colors = CardDefaults.defaultColors(
|
|
||||||
color =
|
|
||||||
if (blurEnabled) Color.Transparent
|
|
||||||
else colorScheme.surfaceContainer,
|
|
||||||
contentColor = Color.Transparent,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun heroBlendColors(isDark: Boolean): List<BlendColorEntry> =
|
private fun heroBlendColors(isDark: Boolean): List<BlendColorEntry> =
|
||||||
if (isDark) listOf(
|
if (isDark) listOf(
|
||||||
BlendColorEntry(Color(0xE6A1A1A1), BlurBlendMode.ColorDodge),
|
BlendColorEntry(Color(0xE6A1A1A1), BlurBlendMode.ColorDodge),
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid.pages
|
|
||||||
|
|
||||||
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
|
|
||||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
|
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
|
|
||||||
|
|
||||||
internal class DeviceTabAdbCallbacks(
|
|
||||||
private val runAdbConnect: (
|
|
||||||
label: String,
|
|
||||||
onStarted: (() -> Unit)?,
|
|
||||||
onFinished: (() -> Unit)?,
|
|
||||||
block: suspend () -> Unit,
|
|
||||||
) -> Unit,
|
|
||||||
private val runBusy: (
|
|
||||||
label: String,
|
|
||||||
onFinished: (() -> Unit)?,
|
|
||||||
block: suspend () -> Unit,
|
|
||||||
) -> Unit,
|
|
||||||
private val disconnectCurrentTargetBeforeConnecting: suspend (host: String, port: Int) -> Unit,
|
|
||||||
private val connectWithTimeout: suspend (host: String, port: Int) -> Unit,
|
|
||||||
private val handleAdbConnected: suspend (
|
|
||||||
host: String,
|
|
||||||
port: Int,
|
|
||||||
autoStartScrcpy: Boolean,
|
|
||||||
autoEnterFullScreen: Boolean,
|
|
||||||
scrcpyProfileId: String,
|
|
||||||
) -> Unit,
|
|
||||||
private val disconnectAdbConnection: suspend (
|
|
||||||
clearQuickOnlineForTarget: ConnectionTarget?,
|
|
||||||
logMessage: String?,
|
|
||||||
showSnackMessage: String?,
|
|
||||||
) -> Unit,
|
|
||||||
private val discoverPairingTarget: suspend () -> Pair<String, Int>?,
|
|
||||||
private val pairTarget: suspend (host: String, port: Int, code: String) -> Boolean,
|
|
||||||
private val isConnectedToTarget: (host: String, port: Int) -> Boolean,
|
|
||||||
private val onConnectionFailed: (Throwable) -> Unit,
|
|
||||||
private val onQuickConnectedChanged: (Boolean) -> Unit,
|
|
||||||
private val onBlackListHost: (String) -> Unit,
|
|
||||||
private val setActiveDeviceActionId: (String?) -> Unit,
|
|
||||||
) {
|
|
||||||
fun onDeviceAction(device: DeviceShortcut) {
|
|
||||||
val host = device.host
|
|
||||||
val port = device.port
|
|
||||||
val connected = isConnectedToTarget(host, port)
|
|
||||||
|
|
||||||
if (!connected) {
|
|
||||||
runAdbConnect(
|
|
||||||
"连接 ADB",
|
|
||||||
{ setActiveDeviceActionId(device.id) },
|
|
||||||
{ setActiveDeviceActionId(null) },
|
|
||||||
) {
|
|
||||||
disconnectCurrentTargetBeforeConnecting(host, port)
|
|
||||||
try {
|
|
||||||
connectWithTimeout(host, port)
|
|
||||||
handleAdbConnected(
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
device.startScrcpyOnConnect,
|
|
||||||
device.startScrcpyOnConnect && device.openFullscreenOnStart,
|
|
||||||
device.scrcpyProfileId,
|
|
||||||
)
|
|
||||||
onQuickConnectedChanged(false)
|
|
||||||
} catch (error: Exception) {
|
|
||||||
onConnectionFailed(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
runAdbConnect(
|
|
||||||
"断开 ADB",
|
|
||||||
{ setActiveDeviceActionId(device.id) },
|
|
||||||
{ setActiveDeviceActionId(null) },
|
|
||||||
) {
|
|
||||||
onBlackListHost(host)
|
|
||||||
disconnectAdbConnection(
|
|
||||||
ConnectionTarget(host, port),
|
|
||||||
"ADB 已断开: ${device.name}",
|
|
||||||
"ADB 已断开",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onQuickConnect(target: ConnectionTarget) {
|
|
||||||
runAdbConnect(
|
|
||||||
"连接 ADB",
|
|
||||||
{ setActiveDeviceActionId(target.toString()) },
|
|
||||||
{ setActiveDeviceActionId(null) },
|
|
||||||
) {
|
|
||||||
disconnectCurrentTargetBeforeConnecting(target.host, target.port)
|
|
||||||
try {
|
|
||||||
connectWithTimeout(target.host, target.port)
|
|
||||||
handleAdbConnected(
|
|
||||||
target.host,
|
|
||||||
target.port,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
ScrcpyOptions.GLOBAL_PROFILE_ID,
|
|
||||||
)
|
|
||||||
onQuickConnectedChanged(true)
|
|
||||||
} catch (error: Exception) {
|
|
||||||
onConnectionFailed(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onDisconnectCurrent(target: ConnectionTarget?) {
|
|
||||||
runAdbConnect(
|
|
||||||
"断开 ADB",
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
) {
|
|
||||||
target?.let {
|
|
||||||
onBlackListHost(it.host)
|
|
||||||
disconnectAdbConnection(
|
|
||||||
it,
|
|
||||||
"ADB 已断开",
|
|
||||||
"ADB 已断开",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onPair(host: String, port: String, code: String) {
|
|
||||||
runBusy("执行配对", null) {
|
|
||||||
val resolvedHost = host.trim()
|
|
||||||
val resolvedPort = port.trim().toIntOrNull() ?: return@runBusy
|
|
||||||
val resolvedCode = code.trim()
|
|
||||||
pairTarget(resolvedHost, resolvedPort, resolvedCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun onDiscoverPairingTarget(): Pair<String, Int>? {
|
|
||||||
return discoverPairingTarget()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,954 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.pages
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.StreamActivity
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.AppScreenOn
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.DisconnectCause
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
|
||||||
|
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.storage.Storage.scrcpyOptions
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyProfiles
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
private const val ADB_CONNECT_TIMEOUT_MS = 12_000L
|
||||||
|
private const val ADB_KEEPALIVE_INTERVAL_MS = 3_000L
|
||||||
|
private const val ADB_KEEPALIVE_TIMEOUT_MS = 1_500L
|
||||||
|
private const val ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS = 2_000L
|
||||||
|
private const val ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS = 2_000L
|
||||||
|
private const val ADB_TCP_PROBE_TIMEOUT_MS = 500
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
internal class DeviceTabViewModel(
|
||||||
|
internal val scrcpy: Scrcpy,
|
||||||
|
connectionServices: DeviceConnectionServices,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val scrcpyListings: Scrcpy.Listings get() = scrcpy.listings
|
||||||
|
|
||||||
|
private val adbCoordinator = connectionServices.adbCoordinator
|
||||||
|
private val connectionStateStore = connectionServices.connectionStateStore
|
||||||
|
private val connectionController = connectionServices.connectionController
|
||||||
|
private val autoReconnectManager = connectionServices.autoReconnectManager
|
||||||
|
|
||||||
|
private val _asBundle = MutableStateFlow(appSettings.bundleState.value)
|
||||||
|
val asBundle: StateFlow<AppSettings.Bundle> = _asBundle.asStateFlow()
|
||||||
|
|
||||||
|
private val _qdBundle = MutableStateFlow(quickDevices.bundleState.value)
|
||||||
|
|
||||||
|
val soBundle: StateFlow<ScrcpyOptions.Bundle> = scrcpyOptions.bundleState
|
||||||
|
val scrcpyProfilesState = scrcpyProfiles.state
|
||||||
|
val connectionState = connectionStateStore.state
|
||||||
|
val sessionInfo: StateFlow<Scrcpy.Session.SessionInfo?> = scrcpy.currentSessionState
|
||||||
|
val listingsRefreshBusy: StateFlow<Boolean> = scrcpy.listings.refreshBusyState
|
||||||
|
val listingsRefreshVersion: StateFlow<Int> = scrcpy.listings.refreshVersionState
|
||||||
|
|
||||||
|
private val _busy = MutableStateFlow(false)
|
||||||
|
val busy: StateFlow<Boolean> = _busy.asStateFlow()
|
||||||
|
|
||||||
|
private val _adbConnecting = MutableStateFlow(false)
|
||||||
|
val adbConnecting: StateFlow<Boolean> = _adbConnecting.asStateFlow()
|
||||||
|
|
||||||
|
private val _editingDeviceId = MutableStateFlow<String?>(null)
|
||||||
|
val editingDeviceId: StateFlow<String?> = _editingDeviceId.asStateFlow()
|
||||||
|
|
||||||
|
private val _activeDeviceActionId = MutableStateFlow<String?>(null)
|
||||||
|
val activeDeviceActionId: StateFlow<String?> = _activeDeviceActionId.asStateFlow()
|
||||||
|
|
||||||
|
private val _showRecentTasksSheet = MutableStateFlow(false)
|
||||||
|
val showRecentTasksSheet: StateFlow<Boolean> = _showRecentTasksSheet.asStateFlow()
|
||||||
|
|
||||||
|
private val _showAllAppsSheet = MutableStateFlow(false)
|
||||||
|
val showAllAppsSheet: StateFlow<Boolean> = _showAllAppsSheet.asStateFlow()
|
||||||
|
|
||||||
|
private val _imeRequestToken = MutableStateFlow(0)
|
||||||
|
val imeRequestToken: StateFlow<Int> = _imeRequestToken.asStateFlow()
|
||||||
|
|
||||||
|
private val _pendingScrollToPreview = MutableStateFlow(false)
|
||||||
|
val pendingScrollToPreview: StateFlow<Boolean> = _pendingScrollToPreview.asStateFlow()
|
||||||
|
|
||||||
|
private val _quickConnectInput = MutableStateFlow(_qdBundle.value.quickConnectInput)
|
||||||
|
val quickConnectInput: StateFlow<String> = _quickConnectInput.asStateFlow()
|
||||||
|
|
||||||
|
private val _savedShortcuts = MutableStateFlow(
|
||||||
|
DeviceShortcuts.unmarshalFrom(_qdBundle.value.quickDevicesList)
|
||||||
|
)
|
||||||
|
val savedShortcuts: StateFlow<DeviceShortcuts> = _savedShortcuts.asStateFlow()
|
||||||
|
|
||||||
|
private val _isAppInForeground = MutableStateFlow(true)
|
||||||
|
val isAppInForeground: StateFlow<Boolean> = _isAppInForeground.asStateFlow()
|
||||||
|
|
||||||
|
private val sessionReconnectBlacklistHosts = mutableSetOf<String>()
|
||||||
|
|
||||||
|
val adbConnected: StateFlow<Boolean> = connectionState
|
||||||
|
.map { it.adbSession.isConnected }
|
||||||
|
.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
connectionState.value.adbSession.isConnected,
|
||||||
|
)
|
||||||
|
|
||||||
|
val statusLine: StateFlow<String> = connectionState
|
||||||
|
.map { it.adbSession.statusLine }
|
||||||
|
.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
connectionState.value.adbSession.statusLine,
|
||||||
|
)
|
||||||
|
|
||||||
|
val isQuickConnected: StateFlow<Boolean> = connectionState
|
||||||
|
.map { it.adbSession.isQuickConnected }
|
||||||
|
.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
connectionState.value.adbSession.isQuickConnected,
|
||||||
|
)
|
||||||
|
|
||||||
|
val currentTarget: StateFlow<ConnectionTarget?> = connectionState
|
||||||
|
.map { it.adbSession.currentTarget }
|
||||||
|
.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
connectionState.value.adbSession.currentTarget,
|
||||||
|
)
|
||||||
|
|
||||||
|
val connectedDeviceLabel: StateFlow<String> = connectionState
|
||||||
|
.map { it.adbSession.connectedDeviceLabel }
|
||||||
|
.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
connectionState.value.adbSession.connectedDeviceLabel,
|
||||||
|
)
|
||||||
|
|
||||||
|
val connectedScrcpyProfileId: StateFlow<String> = combine(
|
||||||
|
connectionState.map { it.adbSession },
|
||||||
|
_savedShortcuts,
|
||||||
|
) { session, shortcuts ->
|
||||||
|
val target = session.currentTarget
|
||||||
|
if (session.isConnected && target != null)
|
||||||
|
shortcuts.get(target.host, target.port)?.scrcpyProfileId
|
||||||
|
?: session.connectedScrcpyProfileId
|
||||||
|
else
|
||||||
|
session.connectedScrcpyProfileId
|
||||||
|
}.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
connectionState.value.adbSession.connectedScrcpyProfileId,
|
||||||
|
)
|
||||||
|
|
||||||
|
val connectedScrcpyBundle: StateFlow<ScrcpyOptions.Bundle> = combine(
|
||||||
|
connectedScrcpyProfileId,
|
||||||
|
soBundle,
|
||||||
|
scrcpyProfilesState,
|
||||||
|
) { profileId, globalBundle, profiles ->
|
||||||
|
if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID) globalBundle
|
||||||
|
else profiles.profiles.firstOrNull { it.id == profileId }?.bundle ?: globalBundle
|
||||||
|
}.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
soBundle.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
val connectedVideoPlaybackEnabled: StateFlow<Boolean> = connectedScrcpyBundle
|
||||||
|
.map { it.video && it.videoPlayback }
|
||||||
|
.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
val connectedScrcpyProfileName: StateFlow<String> = combine(
|
||||||
|
connectedScrcpyProfileId,
|
||||||
|
scrcpyProfilesState,
|
||||||
|
) { profileId, profiles ->
|
||||||
|
profiles.profiles
|
||||||
|
.firstOrNull { it.id == profileId }
|
||||||
|
?.name
|
||||||
|
?: ScrcpyOptions.GLOBAL_PROFILE_NAME
|
||||||
|
}.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
ScrcpyOptions.GLOBAL_PROFILE_NAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
val canShowPreviewControls: StateFlow<Boolean> = combine(
|
||||||
|
adbConnected,
|
||||||
|
connectedVideoPlaybackEnabled,
|
||||||
|
sessionInfo,
|
||||||
|
) { connected, videoPlayback, info ->
|
||||||
|
connected && videoPlayback && info != null && info.width > 0 && info.height > 0
|
||||||
|
}.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
val virtualButtonLayout: StateFlow<Pair<List<VirtualButtonAction>, List<VirtualButtonAction>>> =
|
||||||
|
_asBundle.map {
|
||||||
|
VirtualButtonActions.splitLayout(
|
||||||
|
VirtualButtonActions.parseStoredLayout(it.virtualButtonsLayout)
|
||||||
|
)
|
||||||
|
}.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
VirtualButtonActions.splitLayout(
|
||||||
|
VirtualButtonActions.parseStoredLayout(_asBundle.value.virtualButtonsLayout)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _snackbarEvents = Channel<String>(Channel.BUFFERED)
|
||||||
|
val snackbarEvents: Flow<String> = _snackbarEvents.receiveAsFlow()
|
||||||
|
|
||||||
|
private val _fullscreenRequests = Channel<Unit>(Channel.BUFFERED)
|
||||||
|
val fullscreenRequests: Flow<Unit> = _fullscreenRequests.receiveAsFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Sync asBundle from storage -> local
|
||||||
|
viewModelScope.launch {
|
||||||
|
appSettings.bundleState.collectLatest { shared ->
|
||||||
|
if (_asBundle.value != shared) {
|
||||||
|
_asBundle.value = shared
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounced save asBundle local -> storage
|
||||||
|
viewModelScope.launch {
|
||||||
|
_asBundle.debounce(Settings.BUNDLE_SAVE_DELAY).collectLatest { bundle ->
|
||||||
|
if (bundle != appSettings.bundleState.value) {
|
||||||
|
appSettings.saveBundle(bundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync qdBundle from storage -> local
|
||||||
|
viewModelScope.launch {
|
||||||
|
quickDevices.bundleState.collectLatest { shared ->
|
||||||
|
if (_qdBundle.value != shared) {
|
||||||
|
_qdBundle.value = shared
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounced save qdBundle local -> storage
|
||||||
|
viewModelScope.launch {
|
||||||
|
_qdBundle.debounce(Settings.BUNDLE_SAVE_DELAY).collectLatest { bundle ->
|
||||||
|
if (bundle != quickDevices.bundleState.value) {
|
||||||
|
quickDevices.saveBundle(bundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync savedShortcuts from qdBundle serialized list
|
||||||
|
viewModelScope.launch {
|
||||||
|
_qdBundle.collectLatest { bundle ->
|
||||||
|
val parsed = DeviceShortcuts.unmarshalFrom(bundle.quickDevicesList)
|
||||||
|
if (parsed.marshalToString() != _savedShortcuts.value.marshalToString()) {
|
||||||
|
_savedShortcuts.value = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist savedShortcuts -> qdBundle (debounced)
|
||||||
|
viewModelScope.launch {
|
||||||
|
_savedShortcuts.debounce(Settings.BUNDLE_SAVE_DELAY).collectLatest { shortcuts ->
|
||||||
|
val serialized = shortcuts.marshalToString()
|
||||||
|
if (serialized != _qdBundle.value.quickDevicesList) {
|
||||||
|
_qdBundle.update { it.copy(quickDevicesList = serialized) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
runBlocking(Dispatchers.IO) {
|
||||||
|
appSettings.saveBundle(_asBundle.value)
|
||||||
|
quickDevices.saveBundle(_qdBundle.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resolveScrcpyBundle(profileId: String): ScrcpyOptions.Bundle {
|
||||||
|
if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID) return soBundle.value
|
||||||
|
return scrcpyProfilesState.value.profiles
|
||||||
|
.firstOrNull { it.id == profileId }
|
||||||
|
?.bundle
|
||||||
|
?: soBundle.value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setQuickConnectInput(value: String) {
|
||||||
|
_quickConnectInput.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveQuickConnectInput() {
|
||||||
|
_qdBundle.update { it.copy(quickConnectInput = _quickConnectInput.value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setEditingDeviceId(id: String?) {
|
||||||
|
_editingDeviceId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAppInForeground(foreground: Boolean) {
|
||||||
|
_isAppInForeground.value = foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showRecentTasks() {
|
||||||
|
_showRecentTasksSheet.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideRecentTasks() {
|
||||||
|
_showRecentTasksSheet.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showAllApps() {
|
||||||
|
_showAllAppsSheet.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideAllApps() {
|
||||||
|
_showAllAppsSheet.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleIme() {
|
||||||
|
_imeRequestToken.update { it + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateAsBundle(transform: (AppSettings.Bundle) -> AppSettings.Bundle) {
|
||||||
|
_asBundle.update(transform)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateShortcut(
|
||||||
|
id: String? = null,
|
||||||
|
host: String? = null,
|
||||||
|
port: Int? = null,
|
||||||
|
name: String? = null,
|
||||||
|
startScrcpyOnConnect: Boolean? = null,
|
||||||
|
openFullscreenOnStart: Boolean? = null,
|
||||||
|
scrcpyProfileId: String? = null,
|
||||||
|
newPort: Int? = null,
|
||||||
|
updateNameOnlyWhenEmpty: Boolean = false,
|
||||||
|
) {
|
||||||
|
_savedShortcuts.update {
|
||||||
|
it.update(
|
||||||
|
id,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
name,
|
||||||
|
startScrcpyOnConnect,
|
||||||
|
openFullscreenOnStart,
|
||||||
|
scrcpyProfileId,
|
||||||
|
newPort,
|
||||||
|
updateNameOnlyWhenEmpty,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun upsertShortcut(shortcut: DeviceShortcut) {
|
||||||
|
_savedShortcuts.update { it.upsert(shortcut) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeShortcut(id: String) {
|
||||||
|
_savedShortcuts.update { it.remove(id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleVirtualButtonAction(action: VirtualButtonAction) {
|
||||||
|
when (action) {
|
||||||
|
VirtualButtonAction.RECENT_TASKS -> _showRecentTasksSheet.value = true
|
||||||
|
VirtualButtonAction.ALL_APPS -> _showAllAppsSheet.value = true
|
||||||
|
VirtualButtonAction.TOGGLE_IME -> _imeRequestToken.update { it + 1 }
|
||||||
|
VirtualButtonAction.PASTE_LOCAL_CLIPBOARD -> { /* handled by Composable with context */
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val keycode = action.keycode ?: return
|
||||||
|
runBusy("发送 ${action.title}") {
|
||||||
|
scrcpy.injectKeycode(0, keycode)
|
||||||
|
scrcpy.injectKeycode(1, keycode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun snackbar(message: String) {
|
||||||
|
_snackbarEvents.trySend(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startScrcpy() = runBusy("启动 scrcpy") { startScrcpySession() }
|
||||||
|
|
||||||
|
fun stopScrcpy() = runBusy("停止 scrcpy") { stopScrcpySession() }
|
||||||
|
|
||||||
|
fun startScrcpy(packageName: String) = runBusy("启动 scrcpy") {
|
||||||
|
startScrcpySession(startAppOverride = packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun launchAppWithFallback(packageName: String) = runBusy("启动应用") {
|
||||||
|
runCatching { scrcpy.startApp(packageName) }
|
||||||
|
.onSuccess { logEvent("已在当前显示启动应用: $packageName") }
|
||||||
|
.onFailure { error ->
|
||||||
|
snackbar("通过 scrcpy 控制通道启动应用失败,回退 ADB")
|
||||||
|
logEvent(
|
||||||
|
"通过 scrcpy 控制通道启动应用失败,回退 ADB" +
|
||||||
|
": ${error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName}",
|
||||||
|
Log.WARN, error,
|
||||||
|
)
|
||||||
|
adbCoordinator.startApp(packageName = packageName)
|
||||||
|
logEvent("已通过 ADB 启动应用: $packageName")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runBusy(
|
||||||
|
label: String,
|
||||||
|
onFinished: (() -> Unit)? = null,
|
||||||
|
block: suspend () -> Unit
|
||||||
|
) {
|
||||||
|
if (_busy.value) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_busy.value = true
|
||||||
|
try {
|
||||||
|
block()
|
||||||
|
} catch (_: TimeoutCancellationException) {
|
||||||
|
logEvent("$label 超时", Log.WARN)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName
|
||||||
|
logEvent("$label 参数错误: $detail", Log.WARN, e)
|
||||||
|
snackbar("$label 参数错误: $detail")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName
|
||||||
|
logEvent("$label 失败: $detail", Log.ERROR, e)
|
||||||
|
} finally {
|
||||||
|
_busy.value = false
|
||||||
|
onFinished?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runAdbConnect(
|
||||||
|
label: String,
|
||||||
|
onStarted: (() -> Unit)? = null,
|
||||||
|
onFinished: (() -> Unit)? = null,
|
||||||
|
block: suspend () -> Unit,
|
||||||
|
) {
|
||||||
|
if (_adbConnecting.value) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
onStarted?.invoke()
|
||||||
|
_adbConnecting.value = true
|
||||||
|
try {
|
||||||
|
block()
|
||||||
|
} catch (_: TimeoutCancellationException) {
|
||||||
|
logEvent("$label 超时", Log.WARN)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName
|
||||||
|
logEvent("$label 参数错误: $detail", Log.WARN, e)
|
||||||
|
snackbar("$label 参数错误: $detail")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName
|
||||||
|
logEvent("$label 失败: $detail", Log.ERROR, e)
|
||||||
|
} finally {
|
||||||
|
_adbConnecting.value = false
|
||||||
|
onFinished?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun disconnectAdbConnection(
|
||||||
|
clearQuickOnlineForTarget: ConnectionTarget? = currentTarget.value,
|
||||||
|
logMessage: String? = null,
|
||||||
|
showSnackMessage: String? = null,
|
||||||
|
cause: DisconnectCause = DisconnectCause.User,
|
||||||
|
statusLine: String = "未连接",
|
||||||
|
) {
|
||||||
|
val result = connectionController.disconnectAdbConnection(
|
||||||
|
clearQuickOnlineForTarget,
|
||||||
|
cause,
|
||||||
|
statusLine
|
||||||
|
)
|
||||||
|
result.clearedTarget?.let { target ->
|
||||||
|
if (target.host.isNotBlank())
|
||||||
|
_savedShortcuts.update { it.update(host = target.host, port = target.port) }
|
||||||
|
}
|
||||||
|
logMessage?.let { logEvent(it) }
|
||||||
|
if (!showSnackMessage.isNullOrBlank()) snackbar(showSnackMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun disconnectCurrentTargetBeforeConnecting(newHost: String, newPort: Int) {
|
||||||
|
val disconnected =
|
||||||
|
connectionController.disconnectCurrentTargetBeforeConnecting(newHost, newPort) ?: return
|
||||||
|
sessionReconnectBlacklistHosts += disconnected.host
|
||||||
|
if (disconnected.host.isNotBlank())
|
||||||
|
_savedShortcuts.update { it.update(host = disconnected.host, port = disconnected.port) }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun connectWithTimeout(host: String, port: Int) {
|
||||||
|
connectionController.connectWithTimeout(host, port, ADB_CONNECT_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun applyConnectedDeviceCapabilities(sdkInt: Int) {
|
||||||
|
connectionController.applyConnectedDeviceCapabilities(sdkInt)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun handleAdbConnected(
|
||||||
|
host: String, port: Int,
|
||||||
|
autoStartScrcpy: Boolean = false,
|
||||||
|
autoEnterFullScreen: Boolean = false,
|
||||||
|
scrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID,
|
||||||
|
) {
|
||||||
|
val connected = connectionController.handleAdbConnected(host, port, scrcpyProfileId)
|
||||||
|
val info = connected.info
|
||||||
|
val fullLabel =
|
||||||
|
if (info.serial.isNotBlank()) "${info.model} (${info.serial})" else info.model
|
||||||
|
|
||||||
|
applyConnectedDeviceCapabilities(info.sdkInt)
|
||||||
|
_savedShortcuts.update {
|
||||||
|
it.update(
|
||||||
|
host = host,
|
||||||
|
port = port,
|
||||||
|
name = fullLabel,
|
||||||
|
updateNameOnlyWhenEmpty = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
logEvent(
|
||||||
|
"ADB 已连接: model=${info.model}, serial=${info.serial.ifBlank { "unknown" }}, " +
|
||||||
|
"manufacturer=${info.manufacturer.ifBlank { "unknown" }}, brand=${info.brand.ifBlank { "unknown" }}, " +
|
||||||
|
"device=${info.device.ifBlank { "unknown" }}, android=${info.androidRelease.ifBlank { "unknown" }}, sdk=${info.sdkInt}"
|
||||||
|
)
|
||||||
|
snackbar("ADB 已连接")
|
||||||
|
|
||||||
|
if (_asBundle.value.adbAutoLoadAppListOnConnect) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
runCatching { scrcpy.listings.getApps(forceRefresh = true) }
|
||||||
|
.onFailure { error ->
|
||||||
|
logEvent(
|
||||||
|
"获取应用列表失败: ${error.message}",
|
||||||
|
Log.WARN,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoStartScrcpy && sessionInfo.value == null) {
|
||||||
|
runBusy("启动 scrcpy") {
|
||||||
|
startScrcpySession(openFullscreen = autoStartScrcpy && autoEnterFullScreen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun startScrcpySession(
|
||||||
|
openFullscreen: Boolean = false,
|
||||||
|
startAppOverride: String? = null,
|
||||||
|
) {
|
||||||
|
val activeBundle = resolveScrcpyBundle(connectedScrcpyProfileId.value)
|
||||||
|
val options = scrcpyOptions.toClientOptions(activeBundle).fix()
|
||||||
|
val resolvedOptions = startAppOverride
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { options.copy(startApp = it) }
|
||||||
|
?: options
|
||||||
|
val session = scrcpy.start(resolvedOptions)
|
||||||
|
_pendingScrollToPreview.value = resolvedOptions.video && resolvedOptions.videoPlayback
|
||||||
|
|
||||||
|
if (resolvedOptions.startApp.isNotBlank() && resolvedOptions.control) {
|
||||||
|
runCatching { scrcpy.startApp(resolvedOptions.startApp) }
|
||||||
|
.onSuccess { logEvent("已请求 scrcpy 启动应用: ${resolvedOptions.startApp}") }
|
||||||
|
.onFailure { error ->
|
||||||
|
logEvent(
|
||||||
|
"通过 scrcpy 控制通道启动应用失败: ${error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName}",
|
||||||
|
Log.WARN, error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((resolvedOptions.fullscreen || openFullscreen) &&
|
||||||
|
resolvedOptions.video && resolvedOptions.videoPlayback
|
||||||
|
) {
|
||||||
|
_fullscreenRequests.trySend(Unit)
|
||||||
|
}
|
||||||
|
if (resolvedOptions.disableScreensaver) AppScreenOn.acquire()
|
||||||
|
connectionController.markScrcpyStarted()
|
||||||
|
|
||||||
|
@SuppressLint("DefaultLocale")
|
||||||
|
val videoDetail = if (!resolvedOptions.video) "off"
|
||||||
|
else if (activeBundle.videoBitRate <= 0) "${session.codec?.string ?: "null"} ${session.width}x${session.height} @default"
|
||||||
|
else "${session.codec?.string ?: "null"} ${session.width}x${session.height} @${
|
||||||
|
String.format(
|
||||||
|
"%.1f",
|
||||||
|
activeBundle.videoBitRate / 1_000_000f
|
||||||
|
)
|
||||||
|
}Mbps"
|
||||||
|
|
||||||
|
val audioDetail = if (!activeBundle.audio) "off"
|
||||||
|
else if (activeBundle.audioBitRate <= 0) "${resolvedOptions.audioCodec} default source=${resolvedOptions.audioSource}"
|
||||||
|
else "${resolvedOptions.audioCodec} ${activeBundle.audioBitRate / 1_000f}Kbps source=${resolvedOptions.audioSource}${if (!resolvedOptions.audioPlayback) "(no-playback)" else ""}"
|
||||||
|
|
||||||
|
logEvent(
|
||||||
|
"scrcpy 已启动: device=${session.deviceName}, video=$videoDetail, audio=$audioDetail, " +
|
||||||
|
"control=${resolvedOptions.control}, turnScreenOff=${resolvedOptions.turnScreenOff}, " +
|
||||||
|
"maxSize=${resolvedOptions.maxSize}, maxFps=${resolvedOptions.maxFps}"
|
||||||
|
)
|
||||||
|
snackbar("scrcpy 已启动" + if (resolvedOptions.recordFilename.isNotBlank()) "并开始录制" else "")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun stopScrcpySession() {
|
||||||
|
val activeBundle = resolveScrcpyBundle(connectedScrcpyProfileId.value)
|
||||||
|
val options = scrcpyOptions.toClientOptions(activeBundle).fix()
|
||||||
|
if (options.killAdbOnClose) {
|
||||||
|
currentTarget.value?.host?.let { sessionReconnectBlacklistHosts += it }
|
||||||
|
val result = connectionController.stopScrcpySession(killAdbOnClose = true)
|
||||||
|
result.clearedTarget?.let { target ->
|
||||||
|
if (target.host.isNotBlank())
|
||||||
|
_savedShortcuts.update { it.update(host = target.host, port = target.port) }
|
||||||
|
}
|
||||||
|
logEvent("scrcpy 已停止,ADB 已断开")
|
||||||
|
snackbar("scrcpy 已停止,ADB 已断开")
|
||||||
|
} else {
|
||||||
|
connectionController.stopScrcpySession(killAdbOnClose = false)
|
||||||
|
logEvent("scrcpy 已停止")
|
||||||
|
snackbar("scrcpy 已停止")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shouldOpenFullscreenCompat(): Boolean = _asBundle.value.fullscreenCompatibilityMode
|
||||||
|
|
||||||
|
fun openStreamActivity(context: Context) {
|
||||||
|
context.startActivity(StreamActivity.createIntent(context))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refreshApps() {
|
||||||
|
runCatching { scrcpy.listings.getApps(forceRefresh = true) }
|
||||||
|
.onFailure { error ->
|
||||||
|
logEvent("获取应用列表失败: ${error.message}", Log.WARN, error)
|
||||||
|
withContext(Dispatchers.Main) { snackbar("获取应用列表失败") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refreshRecentTasks() {
|
||||||
|
runCatching { scrcpy.listings.getRecentTasks(forceRefresh = true) }
|
||||||
|
.onFailure { error ->
|
||||||
|
logEvent("获取最近任务失败: ${error.message}", Log.WARN, error)
|
||||||
|
withContext(Dispatchers.Main) { snackbar("获取最近任务失败") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun pasteLocalClipboard(context: Context) {
|
||||||
|
val text =
|
||||||
|
io.github.miuzarte.scrcpyforandroid.services.LocalInputService.getClipboardText(context)
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
if (text == null) {
|
||||||
|
snackbar("本机剪贴板为空或不是文本")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val useLegacyPaste = connectedScrcpyBundle.value.legacyPaste
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (useLegacyPaste) scrcpy.injectText(text)
|
||||||
|
else scrcpy.setClipboard(text, paste = true)
|
||||||
|
}
|
||||||
|
logEvent(if (useLegacyPaste) "已使用 legacy paste 注入本机剪贴板文本" else "已同步本机剪贴板到设备并触发粘贴")
|
||||||
|
}.onFailure { error ->
|
||||||
|
logEvent("本机剪贴板粘贴失败: ${error.message}", Log.WARN, error)
|
||||||
|
snackbar(if (useLegacyPaste) "legacy 粘贴失败" else "剪贴板同步粘贴失败,可尝试开启 --legacy-paste")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun commitImeText(text: String) {
|
||||||
|
submitImeText(
|
||||||
|
scrcpy = scrcpy, text = text,
|
||||||
|
keyInjectMode = scrcpyOptions.toClientOptions(connectedScrcpyBundle.value).keyInjectMode,
|
||||||
|
) { error, useClipboardPaste ->
|
||||||
|
logEvent("输入法文本提交失败: ${error.message}", Log.WARN, error)
|
||||||
|
snackbar(if (useClipboardPaste) "非 ASCII 文本粘贴失败" else "文本输入失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDeviceAction(device: DeviceShortcut) {
|
||||||
|
val connected = adbConnected.value &&
|
||||||
|
currentTarget.value?.host == device.host && currentTarget.value?.port == device.port
|
||||||
|
|
||||||
|
if (!connected) {
|
||||||
|
runAdbConnect(
|
||||||
|
"连接 ADB",
|
||||||
|
{ _activeDeviceActionId.value = device.id },
|
||||||
|
{ _activeDeviceActionId.value = null }) {
|
||||||
|
disconnectCurrentTargetBeforeConnecting(device.host, device.port)
|
||||||
|
try {
|
||||||
|
connectWithTimeout(device.host, device.port)
|
||||||
|
handleAdbConnected(
|
||||||
|
device.host,
|
||||||
|
device.port,
|
||||||
|
device.startScrcpyOnConnect,
|
||||||
|
device.startScrcpyOnConnect && device.openFullscreenOnStart,
|
||||||
|
device.scrcpyProfileId
|
||||||
|
)
|
||||||
|
connectionController.updateQuickConnected(false)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
connectionController.markConnectionFailed(error)
|
||||||
|
logEvent("ADB 连接失败: ${error.message}", Log.ERROR)
|
||||||
|
snackbar("ADB 连接失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runAdbConnect(
|
||||||
|
"断开 ADB",
|
||||||
|
{ _activeDeviceActionId.value = device.id },
|
||||||
|
{ _activeDeviceActionId.value = null }) {
|
||||||
|
sessionReconnectBlacklistHosts += device.host
|
||||||
|
disconnectAdbConnection(
|
||||||
|
ConnectionTarget(device.host, device.port),
|
||||||
|
"ADB 已断开: ${device.name}",
|
||||||
|
"ADB 已断开"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onQuickConnect(target: ConnectionTarget) {
|
||||||
|
runAdbConnect(
|
||||||
|
"连接 ADB",
|
||||||
|
{ _activeDeviceActionId.value = target.toString() },
|
||||||
|
{ _activeDeviceActionId.value = null }) {
|
||||||
|
disconnectCurrentTargetBeforeConnecting(target.host, target.port)
|
||||||
|
try {
|
||||||
|
connectWithTimeout(target.host, target.port)
|
||||||
|
handleAdbConnected(
|
||||||
|
target.host, target.port,
|
||||||
|
autoStartScrcpy = false,
|
||||||
|
autoEnterFullScreen = false,
|
||||||
|
scrcpyProfileId = ScrcpyOptions.GLOBAL_PROFILE_ID,
|
||||||
|
)
|
||||||
|
connectionController.updateQuickConnected(true)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
connectionController.markConnectionFailed(error)
|
||||||
|
logEvent("ADB 连接失败: ${error.message}", Log.ERROR)
|
||||||
|
snackbar("ADB 连接失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDisconnectCurrent(target: ConnectionTarget?) {
|
||||||
|
runAdbConnect("断开 ADB") {
|
||||||
|
target?.let {
|
||||||
|
sessionReconnectBlacklistHosts += it.host
|
||||||
|
disconnectAdbConnection(it, "ADB 已断开", "ADB 已断开")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onPair(host: String, port: String, code: String) {
|
||||||
|
runBusy("执行配对") {
|
||||||
|
val h = host.trim()
|
||||||
|
val p = port.trim().toIntOrNull() ?: return@runBusy
|
||||||
|
val c = code.trim()
|
||||||
|
val ok = adbCoordinator.pair(h, p, c)
|
||||||
|
logEvent(if (ok) "配对成功" else "配对失败", if (ok) Log.INFO else Log.ERROR)
|
||||||
|
snackbar(if (ok) "配对成功" else "配对失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun onDiscoverPairingTarget(): Pair<String, Int>? {
|
||||||
|
return adbCoordinator.discoverPairingService(includeLanDevices = _asBundle.value.adbMdnsLanDiscovery)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun blacklistHost(host: String) {
|
||||||
|
sessionReconnectBlacklistHosts += host
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startKeepAliveLoop() {
|
||||||
|
if (_keepAliveLoopStarted) return
|
||||||
|
_keepAliveLoopStarted = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
autoReconnectManager.runKeepAliveLoop(
|
||||||
|
isForeground = { _isAppInForeground.value },
|
||||||
|
intervalMs = ADB_KEEPALIVE_INTERVAL_MS,
|
||||||
|
connectTimeoutMs = ADB_CONNECT_TIMEOUT_MS,
|
||||||
|
keepAliveTimeoutMs = ADB_KEEPALIVE_TIMEOUT_MS,
|
||||||
|
onReconnectSuccess = { host, port ->
|
||||||
|
logEvent("ADB 自动重连成功: $host:$port")
|
||||||
|
snackbar("ADB 自动重连成功")
|
||||||
|
},
|
||||||
|
onReconnectFailure = { error ->
|
||||||
|
viewModelScope.launch {
|
||||||
|
disconnectAdbConnection(
|
||||||
|
cause = DisconnectCause.KeepAliveFailed,
|
||||||
|
statusLine = "ADB 连接断开"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
logEvent("ADB 自动重连失败: $error", Log.ERROR)
|
||||||
|
snackbar("ADB 自动重连失败")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
_keepAliveLoopStarted = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _keepAliveLoopStarted = false
|
||||||
|
|
||||||
|
fun startAutoReconnectLoop() {
|
||||||
|
if (_autoReconnectLoopStarted) return
|
||||||
|
_autoReconnectLoopStarted = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
autoReconnectManager.runAutoReconnectLoop(
|
||||||
|
isForeground = { _isAppInForeground.value },
|
||||||
|
isAutoReconnectEnabled = { _asBundle.value.adbAutoReconnectPairedDevice },
|
||||||
|
isBusy = { _busy.value },
|
||||||
|
isAdbConnecting = { _adbConnecting.value },
|
||||||
|
hasActiveSession = { sessionInfo.value != null },
|
||||||
|
savedShortcuts = { _savedShortcuts.value.toList() },
|
||||||
|
isBlacklisted = { host -> sessionReconnectBlacklistHosts.contains(host) },
|
||||||
|
connectTimeoutMs = ADB_CONNECT_TIMEOUT_MS,
|
||||||
|
probeTimeoutMs = ADB_TCP_PROBE_TIMEOUT_MS,
|
||||||
|
discoverConnectService = {
|
||||||
|
adbCoordinator.discoverConnectService(
|
||||||
|
ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS,
|
||||||
|
_asBundle.value.adbMdnsLanDiscovery
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onMdnsPortChanged = { host, oldPort, newPort ->
|
||||||
|
_savedShortcuts.update {
|
||||||
|
it.update(
|
||||||
|
host = host,
|
||||||
|
port = oldPort,
|
||||||
|
newPort = newPort
|
||||||
|
)
|
||||||
|
}
|
||||||
|
logEvent("mDNS 发现新端口,已更新快速设备: $host:$oldPort -> $host:$newPort")
|
||||||
|
},
|
||||||
|
onKnownDeviceReconnected = { target ->
|
||||||
|
_savedShortcuts.update { it.update(host = target.host, port = target.port) }
|
||||||
|
logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}")
|
||||||
|
},
|
||||||
|
onDiscoveredDeviceReconnected = { host, port, _ ->
|
||||||
|
_savedShortcuts.update { it.update(host = host, port = port) }
|
||||||
|
logEvent("ADB 自动重连成功: $host:$port")
|
||||||
|
},
|
||||||
|
retryIntervalMs = ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _autoReconnectLoopStarted = false
|
||||||
|
|
||||||
|
fun startProfileIdSync() {
|
||||||
|
if (_profileIdSyncStarted) return
|
||||||
|
_profileIdSyncStarted = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
combine(adbConnected, currentTarget, _savedShortcuts) { connected, target, shortcuts ->
|
||||||
|
Triple(connected, target, shortcuts)
|
||||||
|
}.collect { (connected, target, shortcuts) ->
|
||||||
|
if (!connected || target == null) return@collect
|
||||||
|
val boundProfileId = shortcuts.get(target.host, target.port)
|
||||||
|
?.scrcpyProfileId ?: ScrcpyOptions.GLOBAL_PROFILE_ID
|
||||||
|
if (boundProfileId != connectionState.value.adbSession.connectedScrcpyProfileId) {
|
||||||
|
connectionController.syncConnectedScrcpyProfileId(boundProfileId)
|
||||||
|
logEvent("当前连接设备已切换为配置: $boundProfileId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _profileIdSyncStarted = false
|
||||||
|
|
||||||
|
fun startRecentTasksAutoRefresh() {
|
||||||
|
if (_recentTasksAutoRefreshStarted) return
|
||||||
|
_recentTasksAutoRefreshStarted = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
combine(adbConnected, currentTarget, _isAppInForeground) { connected, _, foreground ->
|
||||||
|
connected && foreground
|
||||||
|
}.collect { shouldRefresh ->
|
||||||
|
if (!shouldRefresh) return@collect
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
runCatching { scrcpy.listings.getRecentTasks(forceRefresh = true) }
|
||||||
|
.onFailure { error ->
|
||||||
|
logEvent(
|
||||||
|
"获取最近任务失败: ${error.message}",
|
||||||
|
Log.WARN,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _recentTasksAutoRefreshStarted = false
|
||||||
|
|
||||||
|
fun clearPendingScrollToPreview() {
|
||||||
|
_pendingScrollToPreview.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun injectTouch(
|
||||||
|
action: Int, pointerId: Long, x: Int, y: Int,
|
||||||
|
screenWidth: Int, screenHeight: Int, pressure: Float,
|
||||||
|
actionButton: Int = 0, buttons: Int = 0,
|
||||||
|
) {
|
||||||
|
scrcpy.injectTouch(
|
||||||
|
action,
|
||||||
|
pointerId,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
screenWidth,
|
||||||
|
screenHeight,
|
||||||
|
pressure,
|
||||||
|
actionButton,
|
||||||
|
buttons
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun pressBackOrTurnScreenOn(action: Int) {
|
||||||
|
scrcpy.pressBackOrTurnScreenOn(action)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findCachedApp(packageName: String): Scrcpy.AppInfo? =
|
||||||
|
scrcpy.listings.findCachedApp(packageName)
|
||||||
|
|
||||||
|
suspend fun startApp(packageName: String) {
|
||||||
|
scrcpy.startApp(packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun startAppViaAdb(packageName: String) {
|
||||||
|
adbCoordinator.startApp(packageName = packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Factory(
|
||||||
|
private val scrcpy: Scrcpy,
|
||||||
|
private val connectionServices: DeviceConnectionServices,
|
||||||
|
) : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
return DeviceTabViewModel(scrcpy, connectionServices) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,486 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.pages
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.DirectoryDownloadSnapshot
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.DirectorySnapshotSession
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.FileManagerService
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.RemoteFileEntry
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.RemoteFileKind
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.RemoteFileStat
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.BundleSyncDelegate
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
private const val ROOT_REMOTE_PATH = "/"
|
||||||
|
private const val INITIAL_REMOTE_PATH = "/storage/emulated/0"
|
||||||
|
|
||||||
|
internal enum class FileManagerSortField { NAME, SIZE, TIME, EXTENSION }
|
||||||
|
|
||||||
|
internal data class FileManagerScrollPosition(val index: Int, val offset: Int)
|
||||||
|
|
||||||
|
internal sealed interface PendingTreeDownload {
|
||||||
|
data class File(val remotePath: String, val fileName: String) : PendingTreeDownload
|
||||||
|
data class Directory(val snapshot: DirectoryDownloadSnapshot) : PendingTreeDownload
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class FileManagerViewModel : ViewModel() {
|
||||||
|
|
||||||
|
private val asBundleSync = BundleSyncDelegate(
|
||||||
|
sharedFlow = appSettings.bundleState,
|
||||||
|
save = { appSettings.saveBundle(it) },
|
||||||
|
scope = viewModelScope,
|
||||||
|
)
|
||||||
|
private val asBundle = asBundleSync.value
|
||||||
|
|
||||||
|
private val _pathStack = MutableStateFlow(buildPathStack(INITIAL_REMOTE_PATH))
|
||||||
|
val pathStack: StateFlow<List<String>> = _pathStack.asStateFlow()
|
||||||
|
val currentPath: StateFlow<String> = _pathStack
|
||||||
|
.map { it.lastOrNull() ?: INITIAL_REMOTE_PATH }
|
||||||
|
.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
INITIAL_REMOTE_PATH,
|
||||||
|
)
|
||||||
|
val canNavigateUp: StateFlow<Boolean> = _pathStack
|
||||||
|
.map { it.size > 1 }
|
||||||
|
.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _directoryCache = MutableStateFlow<Map<String, List<RemoteFileEntry>>>(emptyMap())
|
||||||
|
val directoryCache: StateFlow<Map<String, List<RemoteFileEntry>>> =
|
||||||
|
_directoryCache.asStateFlow()
|
||||||
|
|
||||||
|
val cachedEntries: StateFlow<List<RemoteFileEntry>?> = combine(
|
||||||
|
_directoryCache, currentPath,
|
||||||
|
) { cache, path -> cache[path] }
|
||||||
|
.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
|
val sortField: StateFlow<FileManagerSortField> = asBundle
|
||||||
|
.map {
|
||||||
|
runCatching { FileManagerSortField.valueOf(it.fileManagerSortBy) }.getOrDefault(
|
||||||
|
FileManagerSortField.NAME
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
FileManagerSortField.NAME,
|
||||||
|
)
|
||||||
|
val sortDescending: StateFlow<Boolean> = asBundle
|
||||||
|
.map { it.fileManagerSortDescending }
|
||||||
|
.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
val displayedEntries: StateFlow<List<RemoteFileEntry>> = combine(
|
||||||
|
cachedEntries, sortField, sortDescending,
|
||||||
|
) { entries, field, descending ->
|
||||||
|
sortEntries(entries.orEmpty(), field, descending)
|
||||||
|
}.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _directoryScrollCache =
|
||||||
|
MutableStateFlow(emptyMap<String, FileManagerScrollPosition>())
|
||||||
|
val directoryScrollCache: StateFlow<Map<String, FileManagerScrollPosition>> =
|
||||||
|
_directoryScrollCache.asStateFlow()
|
||||||
|
|
||||||
|
private val _loading = MutableStateFlow(false)
|
||||||
|
val loading: StateFlow<Boolean> = _loading.asStateFlow()
|
||||||
|
|
||||||
|
private val _isRefreshing = MutableStateFlow(false)
|
||||||
|
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
|
||||||
|
|
||||||
|
private val _errorText = MutableStateFlow<String?>(null)
|
||||||
|
val errorText: StateFlow<String?> = _errorText.asStateFlow()
|
||||||
|
|
||||||
|
private val _showDetailsSheet = MutableStateFlow(false)
|
||||||
|
val showDetailsSheet: StateFlow<Boolean> = _showDetailsSheet.asStateFlow()
|
||||||
|
|
||||||
|
private val _showRawDetails = MutableStateFlow(false)
|
||||||
|
val showRawDetails: StateFlow<Boolean> = _showRawDetails.asStateFlow()
|
||||||
|
|
||||||
|
private val _detailLoading = MutableStateFlow(false)
|
||||||
|
val detailLoading: StateFlow<Boolean> = _detailLoading.asStateFlow()
|
||||||
|
|
||||||
|
private val _selectedEntry = MutableStateFlow<RemoteFileEntry?>(null)
|
||||||
|
val selectedEntry: StateFlow<RemoteFileEntry?> = _selectedEntry.asStateFlow()
|
||||||
|
|
||||||
|
private val _selectedStat = MutableStateFlow<RemoteFileStat?>(null)
|
||||||
|
val selectedStat: StateFlow<RemoteFileStat?> = _selectedStat.asStateFlow()
|
||||||
|
|
||||||
|
private val _selectedTargetStat = MutableStateFlow<RemoteFileStat?>(null)
|
||||||
|
val selectedTargetStat: StateFlow<RemoteFileStat?> = _selectedTargetStat.asStateFlow()
|
||||||
|
|
||||||
|
private val _selectedSnapshot = MutableStateFlow<DirectoryDownloadSnapshot?>(null)
|
||||||
|
val selectedSnapshot: StateFlow<DirectoryDownloadSnapshot?> = _selectedSnapshot.asStateFlow()
|
||||||
|
|
||||||
|
private var activeSnapshotSession: DirectorySnapshotSession? = null
|
||||||
|
|
||||||
|
private val _pendingTreeDownload = MutableStateFlow<PendingTreeDownload?>(null)
|
||||||
|
val pendingTreeDownload: StateFlow<PendingTreeDownload?> = _pendingTreeDownload.asStateFlow()
|
||||||
|
|
||||||
|
private val _snackbarEvents = Channel<String>(Channel.BUFFERED)
|
||||||
|
val snackbarEvents: Flow<String> = _snackbarEvents.receiveAsFlow()
|
||||||
|
|
||||||
|
private fun snackbar(msg: String) {
|
||||||
|
_snackbarEvents.trySend(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
asBundleSync.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
clearDetailsInternal()
|
||||||
|
runBlocking(Dispatchers.IO) { asBundleSync.flush() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun navigateUp(): Boolean {
|
||||||
|
val stack = _pathStack.value
|
||||||
|
if (stack.size <= 1) return false
|
||||||
|
_pathStack.update { it.dropLast(1) }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun jumpToPath(rawPath: String) {
|
||||||
|
val normalized = normalizePath(rawPath)
|
||||||
|
_pathStack.value = buildPathStack(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openEntry(entry: RemoteFileEntry) {
|
||||||
|
when {
|
||||||
|
entry.isDirectory -> {
|
||||||
|
_pathStack.update { it + normalizePath(entry.fullPath) }
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.kind == RemoteFileKind.Link || entry.symlinkTarget != null -> {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val targetPath = resolveLinkTarget(entry)
|
||||||
|
if (targetPath == null) {
|
||||||
|
snackbar("链接目标不可用,长按查看信息")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val result = runCatching { FileManagerService.stat(targetPath) }
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
result.onSuccess { targetStat ->
|
||||||
|
if (isDirectoryStat(targetStat)) jumpToPath(targetPath)
|
||||||
|
else snackbar("链接目标不是文件夹,长按查看信息")
|
||||||
|
}.onFailure { error ->
|
||||||
|
snackbar("读取链接目标失败: ${error.message ?: error.javaClass.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> snackbar("长按可查看文件详情")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun reloadCurrentDirectory(force: Boolean) {
|
||||||
|
val path = currentPath.value
|
||||||
|
val cached = _directoryCache.value[path]
|
||||||
|
if (!force && cached != null) return
|
||||||
|
_loading.value = cached == null
|
||||||
|
_errorText.value = null
|
||||||
|
val result = runCatching { FileManagerService.listDirectory(path) }
|
||||||
|
_loading.value = false
|
||||||
|
_isRefreshing.value = false
|
||||||
|
result.onSuccess { entries -> _directoryCache.update { cache -> cache + (path to entries) } }
|
||||||
|
.onFailure { _errorText.value = it.message ?: it.javaClass.simpleName }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invalidateCacheForCurrentDirectory() {
|
||||||
|
_directoryCache.update { it - currentPath.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createFolder(folderName: String) {
|
||||||
|
val name = folderName.trim()
|
||||||
|
if (name.isBlank()) {
|
||||||
|
snackbar("文件夹名称不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = runCatching {
|
||||||
|
FileManagerService.createDirectory(currentPath.value, name)
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
result.onSuccess {
|
||||||
|
snackbar("已创建文件夹")
|
||||||
|
invalidateCacheForCurrentDirectory()
|
||||||
|
_isRefreshing.value = true
|
||||||
|
}.onFailure { error ->
|
||||||
|
snackbar("创建失败: ${error.message ?: error.javaClass.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun uploadFile(context: Context, uri: Uri) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = runCatching {
|
||||||
|
context.contentResolver
|
||||||
|
.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
FileManagerService.uploadFile(context, uri, currentPath.value)
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
result.onSuccess {
|
||||||
|
snackbar("已上传到 ${currentPath.value}")
|
||||||
|
invalidateCacheForCurrentDirectory()
|
||||||
|
_isRefreshing.value = true
|
||||||
|
}.onFailure { error ->
|
||||||
|
snackbar("上传失败: ${error.message ?: error.javaClass.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showEntryDetails(entry: RemoteFileEntry) {
|
||||||
|
clearDetailsInternal()
|
||||||
|
_selectedEntry.value = entry
|
||||||
|
_showDetailsSheet.value = true
|
||||||
|
_detailLoading.value = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
val statResult = runCatching { FileManagerService.stat(entry.fullPath) }
|
||||||
|
val linkTargetPath = resolveLinkTarget(entry)
|
||||||
|
val targetStatResult =
|
||||||
|
linkTargetPath?.let { runCatching { FileManagerService.stat(it) } }
|
||||||
|
val snapshotResult =
|
||||||
|
if (entry.isDirectory) runCatching {
|
||||||
|
val session = DirectorySnapshotSession.open()
|
||||||
|
activeSnapshotSession = session
|
||||||
|
session.load(entry.fullPath)
|
||||||
|
}
|
||||||
|
else null
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
_detailLoading.value = false
|
||||||
|
statResult
|
||||||
|
.onSuccess { _selectedStat.value = it }
|
||||||
|
.onFailure { error ->
|
||||||
|
snackbar("读取详情失败: ${error.message ?: error.javaClass.simpleName}")
|
||||||
|
if (_selectedEntry.value === entry) _selectedEntry.value = null
|
||||||
|
}
|
||||||
|
targetStatResult
|
||||||
|
?.onSuccess { _selectedTargetStat.value = it }
|
||||||
|
snapshotResult
|
||||||
|
?.onSuccess { _selectedSnapshot.value = it }
|
||||||
|
?.onFailure { error ->
|
||||||
|
snackbar("目录扫描失败: ${error.message ?: error.javaClass.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissDetails() {
|
||||||
|
_showDetailsSheet.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleRawDetails() {
|
||||||
|
_showRawDetails.update { !it }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearDetails() {
|
||||||
|
clearDetailsInternal()
|
||||||
|
_showDetailsSheet.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearDetailsInternal() {
|
||||||
|
_selectedEntry.value = null
|
||||||
|
_selectedStat.value = null
|
||||||
|
_selectedTargetStat.value = null
|
||||||
|
_selectedSnapshot.value = null
|
||||||
|
_detailLoading.value = false
|
||||||
|
_showRawDetails.value = false
|
||||||
|
val session = activeSnapshotSession
|
||||||
|
activeSnapshotSession = null
|
||||||
|
if (session != null) {
|
||||||
|
viewModelScope.launch { runCatching { session.interrupt() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestDownload(entry: RemoteFileEntry) {
|
||||||
|
val snapshot = _selectedSnapshot.value
|
||||||
|
dismissDetails()
|
||||||
|
snackbar("开始下载")
|
||||||
|
viewModelScope.launch {
|
||||||
|
if (entry.isDirectory) {
|
||||||
|
if (snapshot == null) {
|
||||||
|
snackbar("目录信息仍在加载,请稍后重试")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val directSaved = FileManagerService.downloadDirectoryToPublicDownloads(snapshot)
|
||||||
|
if (directSaved)
|
||||||
|
snackbar("已下载到 Download/Scrcpy")
|
||||||
|
else _pendingTreeDownload.value = PendingTreeDownload.Directory(snapshot)
|
||||||
|
} else {
|
||||||
|
val directSaved =
|
||||||
|
FileManagerService.downloadFileToPublicDownloads(entry.fullPath, entry.name)
|
||||||
|
if (directSaved) snackbar("已下载到 Download/Scrcpy")
|
||||||
|
else _pendingTreeDownload.value =
|
||||||
|
PendingTreeDownload.File(entry.fullPath, entry.name)
|
||||||
|
}
|
||||||
|
if (_pendingTreeDownload.value != null)
|
||||||
|
snackbar("无法直接写入 Download/Scrcpy,请选择保存目录")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun startDownloadToTree(context: Context, treeUri: Uri, request: PendingTreeDownload) {
|
||||||
|
when (request) {
|
||||||
|
is PendingTreeDownload.File -> FileManagerService.downloadFileToTree(
|
||||||
|
context = context,
|
||||||
|
treeUri = treeUri,
|
||||||
|
remotePath = request.remotePath,
|
||||||
|
fileName = request.fileName,
|
||||||
|
)
|
||||||
|
|
||||||
|
is PendingTreeDownload.Directory -> FileManagerService.downloadDirectoryToTree(
|
||||||
|
context = context, treeUri = treeUri, snapshot = request.snapshot,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consumePendingTreeDownload() {
|
||||||
|
val pending = _pendingTreeDownload.value
|
||||||
|
_pendingTreeDownload.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restorePendingTreeDownload(request: PendingTreeDownload) {
|
||||||
|
_pendingTreeDownload.value = request
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveScrollPosition(path: String, index: Int, offset: Int) {
|
||||||
|
_directoryScrollCache.update { it + (path to FileManagerScrollPosition(index, offset)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setRefreshing(value: Boolean) {
|
||||||
|
_isRefreshing.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun downloadToTree(context: Context, treeUri: Uri) {
|
||||||
|
val request = _pendingTreeDownload.value
|
||||||
|
_pendingTreeDownload.value = null
|
||||||
|
if (request == null) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = runCatching {
|
||||||
|
context.contentResolver
|
||||||
|
.takePersistableUriPermission(
|
||||||
|
treeUri,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||||
|
)
|
||||||
|
startDownloadToTree(context, treeUri, request)
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
result
|
||||||
|
.onSuccess { snackbar("下载完成") }
|
||||||
|
.onFailure { error -> snackbar("下载失败: ${error.message ?: error.javaClass.simpleName}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSort(sortBy: FileManagerSortField? = null, descending: Boolean? = null) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
appSettings.updateBundle {
|
||||||
|
it.copy(
|
||||||
|
fileManagerSortBy = (sortBy ?: sortField.value).name,
|
||||||
|
fileManagerSortDescending = descending ?: sortDescending.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun buildPathStack(path: String): List<String> {
|
||||||
|
val normalized = normalizePath(path)
|
||||||
|
if (normalized == ROOT_REMOTE_PATH) return listOf(ROOT_REMOTE_PATH)
|
||||||
|
val parts = normalized.trim('/').split('/').filter { it.isNotBlank() }
|
||||||
|
val stack = mutableListOf(ROOT_REMOTE_PATH)
|
||||||
|
var current = ""
|
||||||
|
parts.forEach { part ->
|
||||||
|
current += "/$part"
|
||||||
|
stack += current
|
||||||
|
}
|
||||||
|
return stack
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun normalizePath(path: String): String {
|
||||||
|
val trimmed = path.trim()
|
||||||
|
if (trimmed.isBlank()) return ROOT_REMOTE_PATH
|
||||||
|
return "/" + trimmed
|
||||||
|
.trim('/')
|
||||||
|
.split('/')
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.joinToString("/")
|
||||||
|
.ifBlank { ROOT_REMOTE_PATH.removePrefix("/") }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun sortEntries(
|
||||||
|
entries: List<RemoteFileEntry>,
|
||||||
|
field: FileManagerSortField,
|
||||||
|
descending: Boolean,
|
||||||
|
): List<RemoteFileEntry> {
|
||||||
|
val comparator: Comparator<RemoteFileEntry> = when (field) {
|
||||||
|
FileManagerSortField.NAME -> compareBy { it.name.lowercase() }
|
||||||
|
FileManagerSortField.SIZE -> compareBy<RemoteFileEntry> {
|
||||||
|
it.sizeBytes ?: -1L
|
||||||
|
}.thenBy { it.name.lowercase() }
|
||||||
|
|
||||||
|
FileManagerSortField.TIME -> compareBy<RemoteFileEntry> { it.modifiedAt }.thenBy { it.name.lowercase() }
|
||||||
|
FileManagerSortField.EXTENSION -> compareBy<RemoteFileEntry>(::extensionSortBucket)
|
||||||
|
.thenBy(::extensionSortKey).thenBy { it.name.lowercase() }
|
||||||
|
}
|
||||||
|
return entries.sortedWith(
|
||||||
|
compareByDescending<RemoteFileEntry> { it.isDirectory }
|
||||||
|
.then(if (descending) comparator.reversed() else comparator)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun resolveLinkTarget(entry: RemoteFileEntry): String? {
|
||||||
|
val target = entry.symlinkTarget?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
return if (target.startsWith("/")) normalizePath(target)
|
||||||
|
else normalizePath(entry.fullPath.substringBeforeLast('/', "") + "/" + target)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun isDirectoryStat(stat: RemoteFileStat): Boolean =
|
||||||
|
stat.permissions
|
||||||
|
?.startsWith("d") == true ||
|
||||||
|
stat.typeLabel
|
||||||
|
?.contains("directory", ignoreCase = true) == true
|
||||||
|
|
||||||
|
private fun extensionSortBucket(entry: RemoteFileEntry) =
|
||||||
|
if (entry.isDirectory || extensionSortKey(entry).isEmpty()) 0 else 1
|
||||||
|
|
||||||
|
private fun extensionSortKey(entry: RemoteFileEntry) =
|
||||||
|
if (entry.isDirectory) ""
|
||||||
|
else entry.name.substringAfterLast('.', "").lowercase()
|
||||||
|
|
||||||
@@ -56,6 +56,7 @@ import io.github.miuzarte.scrcpyforandroid.password.PasswordPickerPopupContent
|
|||||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
|
||||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.LocalInputService
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
|
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
|
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||||
@@ -267,21 +268,6 @@ fun FullscreenControlScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun sendKeycode(keycode: Int) {
|
|
||||||
runCatching {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
scrcpy.injectKeycode(0, keycode)
|
|
||||||
scrcpy.injectKeycode(1, keycode)
|
|
||||||
}
|
|
||||||
}.onFailure { e ->
|
|
||||||
Log.w(
|
|
||||||
"FullscreenControlPage",
|
|
||||||
"sendKeycode failed for keycode=$keycode",
|
|
||||||
e
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun sendBackOrTurnScreenOn() {
|
suspend fun sendBackOrTurnScreenOn() {
|
||||||
runCatching {
|
runCatching {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -295,9 +281,7 @@ fun FullscreenControlScreen(
|
|||||||
|
|
||||||
BackHandler(enabled = true) {
|
BackHandler(enabled = true) {
|
||||||
if (asBundle.fullscreenControlBackToDevice && currentSession != null)
|
if (asBundle.fullscreenControlBackToDevice && currentSession != null)
|
||||||
taskScope.launch {
|
taskScope.launch { sendBackOrTurnScreenOn() }
|
||||||
sendBackOrTurnScreenOn()
|
|
||||||
}
|
|
||||||
else onBack()
|
else onBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,34 +307,6 @@ fun FullscreenControlScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun pasteLocalClipboard() {
|
|
||||||
val session = currentSession ?: return
|
|
||||||
val text = io.github.miuzarte.scrcpyforandroid.services.LocalInputService.getClipboardText(
|
|
||||||
activity ?: return
|
|
||||||
)
|
|
||||||
?.takeIf { it.isNotBlank() }
|
|
||||||
if (text == null) {
|
|
||||||
snackbar.show("本机剪贴板为空或不是文本")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val useLegacyPaste = session.legacyPaste
|
|
||||||
runCatching {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
if (useLegacyPaste) {
|
|
||||||
scrcpy.injectText(text)
|
|
||||||
} else {
|
|
||||||
scrcpy.setClipboard(text, paste = true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.onFailure { error ->
|
|
||||||
Log.w("FullscreenControlPage", "pasteLocalClipboard failed", error)
|
|
||||||
snackbar.show(
|
|
||||||
if (useLegacyPaste) "legacy 粘贴失败"
|
|
||||||
else "剪贴板同步粘贴失败,可尝试开启 --legacy-paste"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun commitImeText(text: String) {
|
suspend fun commitImeText(text: String) {
|
||||||
submitImeText(
|
submitImeText(
|
||||||
scrcpy = scrcpy,
|
scrcpy = scrcpy,
|
||||||
@@ -365,6 +321,78 @@ fun FullscreenControlScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun handleButtonAction(action: VirtualButtonAction) {
|
||||||
|
when (action) {
|
||||||
|
VirtualButtonAction.RECENT_TASKS -> {
|
||||||
|
showRecentTasksSheet = true
|
||||||
|
if (recentTasks.isEmpty() && !listingsRefreshBusy) {
|
||||||
|
taskScope.launch {
|
||||||
|
refreshApps()
|
||||||
|
refreshRecentTasks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VirtualButtonAction.ALL_APPS -> {
|
||||||
|
showAllAppsSheet = true
|
||||||
|
if (apps.isEmpty() && !listingsRefreshBusy) {
|
||||||
|
taskScope.launch {
|
||||||
|
refreshApps()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VirtualButtonAction.TOGGLE_IME -> imeRequestToken++
|
||||||
|
|
||||||
|
VirtualButtonAction.PASTE_LOCAL_CLIPBOARD ->
|
||||||
|
taskScope.launch {
|
||||||
|
val session = currentSession ?: return@launch
|
||||||
|
val text = LocalInputService.getClipboardText(activity ?: return@launch)
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
if (text == null) {
|
||||||
|
snackbar.show("本机剪贴板为空或不是文本")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val useLegacyPaste = session.legacyPaste
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (useLegacyPaste) scrcpy.injectText(text)
|
||||||
|
else scrcpy.setClipboard(text, paste = true)
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
Log.w("FullscreenControl", "pasteLocalClipboard failed", error)
|
||||||
|
snackbar.show(if (useLegacyPaste) "legacy 粘贴失败" else "剪贴板同步粘贴失败,可尝试开启 --legacy-paste")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> action.keycode?.let {
|
||||||
|
taskScope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
scrcpy.injectKeycode(0, it)
|
||||||
|
scrcpy.injectKeycode(1, it)
|
||||||
|
}
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.w(
|
||||||
|
"FullscreenControlPage",
|
||||||
|
"sendKeycode failed for keycode=$it",
|
||||||
|
e
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun startApp(packageName: String) =
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
scrcpy.startApp(packageName)
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
snackbar.show("启动应用失败: ${error.message ?: error.javaClass.simpleName}")
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||||
snackbarHost = { SnackbarHost(snackbar.hostState) },
|
snackbarHost = { SnackbarHost(snackbar.hostState) },
|
||||||
@@ -402,9 +430,7 @@ fun FullscreenControlScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onBackOrScreenOn = { action ->
|
onBackOrScreenOn = { action ->
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) { scrcpy.pressBackOrTurnScreenOn(action) }
|
||||||
scrcpy.pressBackOrTurnScreenOn(action)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -421,44 +447,9 @@ fun FullscreenControlScreen(
|
|||||||
dock = fullscreenVirtualButtonDock,
|
dock = fullscreenVirtualButtonDock,
|
||||||
reverseOrder = fullscreenVirtualButtonReverseOrder,
|
reverseOrder = fullscreenVirtualButtonReverseOrder,
|
||||||
thickness = fullscreenVirtualButtonHeight,
|
thickness = fullscreenVirtualButtonHeight,
|
||||||
onAction = { action ->
|
onAction = ::handleButtonAction,
|
||||||
when (action) {
|
passwordPopupContent = fragmentActivity?.let {
|
||||||
VirtualButtonAction.RECENT_TASKS -> {
|
{ onDismissRequest -> PasswordPickerPopupContent(onDismissRequest = onDismissRequest) }
|
||||||
showRecentTasksSheet = true
|
|
||||||
if (recentTasks.isEmpty() && !listingsRefreshBusy) {
|
|
||||||
refreshApps()
|
|
||||||
refreshRecentTasks()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VirtualButtonAction.ALL_APPS -> {
|
|
||||||
showAllAppsSheet = true
|
|
||||||
if (apps.isEmpty() && !listingsRefreshBusy) {
|
|
||||||
refreshApps()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VirtualButtonAction.TOGGLE_IME -> {
|
|
||||||
imeRequestToken++
|
|
||||||
}
|
|
||||||
|
|
||||||
VirtualButtonAction.PASTE_LOCAL_CLIPBOARD -> {
|
|
||||||
pasteLocalClipboard()
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
action.keycode?.let { sendKeycode(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
passwordPopupContent = if (fragmentActivity == null) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
{ onDismissRequest ->
|
|
||||||
PasswordPickerPopupContent(
|
|
||||||
onDismissRequest = onDismissRequest,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -467,43 +458,10 @@ fun FullscreenControlScreen(
|
|||||||
bar.FloatingBall(
|
bar.FloatingBall(
|
||||||
actions = floatingActions,
|
actions = floatingActions,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
onAction = { action ->
|
onAction = ::handleButtonAction,
|
||||||
when (action) {
|
passwordPopupContent = fragmentActivity?.let {
|
||||||
VirtualButtonAction.RECENT_TASKS -> {
|
{ onDismissRequest -> PasswordPickerPopupContent(onDismissRequest = onDismissRequest) }
|
||||||
showRecentTasksSheet = true
|
|
||||||
if (recentTasks.isEmpty() && !listingsRefreshBusy) {
|
|
||||||
refreshApps()
|
|
||||||
refreshRecentTasks()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VirtualButtonAction.ALL_APPS -> {
|
|
||||||
showAllAppsSheet = true
|
|
||||||
if (apps.isEmpty() && !listingsRefreshBusy) {
|
|
||||||
refreshApps()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VirtualButtonAction.TOGGLE_IME -> {
|
|
||||||
imeRequestToken++
|
|
||||||
}
|
|
||||||
|
|
||||||
VirtualButtonAction.PASTE_LOCAL_CLIPBOARD -> {
|
|
||||||
pasteLocalClipboard()
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
action.keycode?.let { sendKeycode(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
passwordPopupContent =
|
|
||||||
if (fragmentActivity == null) null
|
|
||||||
else { onDismissRequest ->
|
|
||||||
PasswordPickerPopupContent(
|
|
||||||
onDismissRequest = onDismissRequest,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,15 +479,7 @@ fun FullscreenControlScreen(
|
|||||||
system = app?.system,
|
system = app?.system,
|
||||||
onClick = {
|
onClick = {
|
||||||
showRecentTasksSheet = false
|
showRecentTasksSheet = false
|
||||||
taskScope.launch {
|
taskScope.launch { startApp(task.packageName) }
|
||||||
runCatching {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
scrcpy.startApp(task.packageName)
|
|
||||||
}
|
|
||||||
}.onFailure { error ->
|
|
||||||
snackbar.show("启动应用失败: ${error.message ?: error.javaClass.simpleName}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -556,15 +506,7 @@ fun FullscreenControlScreen(
|
|||||||
system = app.system,
|
system = app.system,
|
||||||
onClick = {
|
onClick = {
|
||||||
showAllAppsSheet = false
|
showAllAppsSheet = false
|
||||||
taskScope.launch {
|
taskScope.launch { startApp(app.packageName) }
|
||||||
runCatching {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
scrcpy.startApp(app.packageName)
|
|
||||||
}
|
|
||||||
}.onFailure { error ->
|
|
||||||
snackbar.show("启动应用失败: ${error.message ?: error.javaClass.simpleName}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -576,7 +518,6 @@ fun FullscreenControlScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -676,22 +617,6 @@ private fun rotateDockDirection(
|
|||||||
else -> direction
|
else -> direction
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* FullscreenControlScreen
|
|
||||||
*
|
|
||||||
* Purpose:
|
|
||||||
* - Presents a fullscreen interactive touch surface that maps Compose touch events
|
|
||||||
* to device coordinates and injects them via [onInjectTouch].
|
|
||||||
* - Responsible for pointer tracking, multi-touch mapping, coordinate normalization,
|
|
||||||
* and lifetime of synthetic touch events sent to the device.
|
|
||||||
*
|
|
||||||
* Concurrency and side-effects:
|
|
||||||
* - All heavy computations are local to the UI thread; injection itself is a quick
|
|
||||||
* callback (`onInjectTouch`) which delegates to native code elsewhere — keep that
|
|
||||||
* callback lightweight.
|
|
||||||
* - Use `pointerInteropFilter` to receive raw MotionEvent instances for precise
|
|
||||||
* multi-touch handling and to map Android pointer IDs to device pointers.
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FullscreenControlPage(
|
fun FullscreenControlPage(
|
||||||
scrcpy: Scrcpy,
|
scrcpy: Scrcpy,
|
||||||
|
|||||||
@@ -314,6 +314,11 @@ fun MainScreen() {
|
|||||||
autoReconnectManager = autoReconnectManager,
|
autoReconnectManager = autoReconnectManager,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val deviceTabViewModelFactory = remember(scrcpy, deviceConnectionServices) {
|
||||||
|
DeviceTabViewModel.Factory(scrcpy, deviceConnectionServices)
|
||||||
|
}
|
||||||
|
|
||||||
DisposableEffect(deviceConnectionServices) {
|
DisposableEffect(deviceConnectionServices) {
|
||||||
onDispose {
|
onDispose {
|
||||||
deviceConnectionServices.autoReconnectManager.close()
|
deviceConnectionServices.autoReconnectManager.close()
|
||||||
@@ -568,9 +573,8 @@ fun MainScreen() {
|
|||||||
saveableStateHolder.SaveableStateProvider(tab.name) {
|
saveableStateHolder.SaveableStateProvider(tab.name) {
|
||||||
when (tab) {
|
when (tab) {
|
||||||
MainBottomTabDestination.Devices -> DeviceTabScreen(
|
MainBottomTabDestination.Devices -> DeviceTabScreen(
|
||||||
|
viewModelFactory = deviceTabViewModelFactory,
|
||||||
scrollBehavior = devicesPageScrollBehavior,
|
scrollBehavior = devicesPageScrollBehavior,
|
||||||
scrcpy = scrcpy,
|
|
||||||
connectionServices = deviceConnectionServices,
|
|
||||||
bottomInnerPadding = bottomInnerPadding,
|
bottomInnerPadding = bottomInnerPadding,
|
||||||
onOpenReorderDevices = { showReorderDevices = true },
|
onOpenReorderDevices = { showReorderDevices = true },
|
||||||
onPreviewGestureLockChanged = { locked ->
|
onPreviewGestureLockChanged = { locked ->
|
||||||
|
|||||||
@@ -46,13 +46,14 @@ import androidx.compose.ui.text.input.KeyboardType
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
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.miuix.SpinnerEntry
|
||||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
|
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
|
||||||
import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.Crop
|
import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.Crop
|
||||||
import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.NewDisplay
|
import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.NewDisplay
|
||||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scaffolds.OverlaySpinnerWithFallback
|
||||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.ReorderableList
|
import io.github.miuzarte.scrcpyforandroid.scaffolds.ReorderableList
|
||||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
|
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
|
||||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSpinner
|
|
||||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
|
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
|
||||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
|
||||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||||
@@ -92,7 +93,6 @@ import top.yukonga.miuix.kmp.basic.PopupPositionProvider
|
|||||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||||
import top.yukonga.miuix.kmp.basic.ScrollBehavior
|
import top.yukonga.miuix.kmp.basic.ScrollBehavior
|
||||||
import top.yukonga.miuix.kmp.basic.SnackbarHost
|
import top.yukonga.miuix.kmp.basic.SnackbarHost
|
||||||
import top.yukonga.miuix.kmp.basic.SpinnerEntry
|
|
||||||
import top.yukonga.miuix.kmp.basic.TabRow
|
import top.yukonga.miuix.kmp.basic.TabRow
|
||||||
import top.yukonga.miuix.kmp.basic.Text
|
import top.yukonga.miuix.kmp.basic.Text
|
||||||
import top.yukonga.miuix.kmp.basic.TextButton
|
import top.yukonga.miuix.kmp.basic.TextButton
|
||||||
@@ -105,7 +105,6 @@ import top.yukonga.miuix.kmp.icon.extended.Store
|
|||||||
import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet
|
import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet
|
||||||
import top.yukonga.miuix.kmp.overlay.OverlayDialog
|
import top.yukonga.miuix.kmp.overlay.OverlayDialog
|
||||||
import top.yukonga.miuix.kmp.overlay.OverlayListPopup
|
import top.yukonga.miuix.kmp.overlay.OverlayListPopup
|
||||||
import top.yukonga.miuix.kmp.preference.ArrowPreference
|
|
||||||
import top.yukonga.miuix.kmp.preference.OverlayDropdownPreference
|
import top.yukonga.miuix.kmp.preference.OverlayDropdownPreference
|
||||||
import top.yukonga.miuix.kmp.preference.SwitchPreference
|
import top.yukonga.miuix.kmp.preference.SwitchPreference
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||||
@@ -257,9 +256,10 @@ internal fun ScrcpyAllOptionsScreen(
|
|||||||
onDismissRequest = { showProfileMenu = false },
|
onDismissRequest = { showProfileMenu = false },
|
||||||
) {
|
) {
|
||||||
ListPopupColumn {
|
ListPopupColumn {
|
||||||
ProfileMenuPopupItem(
|
DropdownImpl(
|
||||||
text = "管理配置",
|
text = "管理配置",
|
||||||
optionSize = 1,
|
optionSize = 1,
|
||||||
|
isSelected = false,
|
||||||
index = 0,
|
index = 0,
|
||||||
onSelectedIndexChange = {
|
onSelectedIndexChange = {
|
||||||
showManageProfilesSheet = true
|
showManageProfilesSheet = true
|
||||||
@@ -516,9 +516,7 @@ internal fun ScrcpyAllOptionsPage(
|
|||||||
(displays.indexOfFirst { it.id == soBundle.displayId } + 1).coerceAtLeast(0)
|
(displays.indexOfFirst { it.id == soBundle.displayId } + 1).coerceAtLeast(0)
|
||||||
}
|
}
|
||||||
val displayOverrideEndActionValue = remember(
|
val displayOverrideEndActionValue = remember(
|
||||||
displays,
|
displays, listRefreshVersion, soBundle.displayId,
|
||||||
listRefreshVersion,
|
|
||||||
soBundle.displayId,
|
|
||||||
) {
|
) {
|
||||||
if (displays.isEmpty() && soBundle.displayId >= 0) soBundle.displayId.toString()
|
if (displays.isEmpty() && soBundle.displayId >= 0) soBundle.displayId.toString()
|
||||||
else null
|
else null
|
||||||
@@ -550,9 +548,7 @@ internal fun ScrcpyAllOptionsPage(
|
|||||||
(cameras.indexOfFirst { it.id == soBundle.cameraId } + 1).coerceAtLeast(0)
|
(cameras.indexOfFirst { it.id == soBundle.cameraId } + 1).coerceAtLeast(0)
|
||||||
}
|
}
|
||||||
val cameraOverrideEndActionValue = remember(
|
val cameraOverrideEndActionValue = remember(
|
||||||
cameras,
|
cameras, listRefreshVersion, soBundle.cameraId,
|
||||||
listRefreshVersion,
|
|
||||||
soBundle.cameraId,
|
|
||||||
) {
|
) {
|
||||||
if (cameras.isEmpty() && soBundle.cameraId.isNotBlank()) soBundle.cameraId
|
if (cameras.isEmpty() && soBundle.cameraId.isNotBlank()) soBundle.cameraId
|
||||||
else null
|
else null
|
||||||
@@ -601,11 +597,8 @@ internal fun ScrcpyAllOptionsPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val cameraSizeOverrideEndActionValue = remember(
|
val cameraSizeOverrideEndActionValue = remember(
|
||||||
cameraSizes,
|
cameraSizes, listRefreshVersion,
|
||||||
listRefreshVersion,
|
soBundle.cameraSize, soBundle.cameraSizeCustom, soBundle.cameraSizeUseCustom,
|
||||||
soBundle.cameraSize,
|
|
||||||
soBundle.cameraSizeCustom,
|
|
||||||
soBundle.cameraSizeUseCustom,
|
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
cameraSizes.isNotEmpty() -> null
|
cameraSizes.isNotEmpty() -> null
|
||||||
@@ -689,9 +682,7 @@ internal fun ScrcpyAllOptionsPage(
|
|||||||
.coerceAtLeast(0)
|
.coerceAtLeast(0)
|
||||||
}
|
}
|
||||||
val videoEncoderOverrideEndActionValue = remember(
|
val videoEncoderOverrideEndActionValue = remember(
|
||||||
videoEncoders,
|
videoEncoders, listRefreshVersion, soBundle.videoEncoder,
|
||||||
listRefreshVersion,
|
|
||||||
soBundle.videoEncoder,
|
|
||||||
) {
|
) {
|
||||||
if (videoEncoders.isEmpty() && soBundle.videoEncoder.isNotBlank()) soBundle.videoEncoder
|
if (videoEncoders.isEmpty() && soBundle.videoEncoder.isNotBlank()) soBundle.videoEncoder
|
||||||
else null
|
else null
|
||||||
@@ -722,9 +713,7 @@ internal fun ScrcpyAllOptionsPage(
|
|||||||
.coerceAtLeast(0)
|
.coerceAtLeast(0)
|
||||||
}
|
}
|
||||||
val audioEncoderOverrideEndActionValue = remember(
|
val audioEncoderOverrideEndActionValue = remember(
|
||||||
audioEncoders,
|
audioEncoders, listRefreshVersion, soBundle.audioEncoder,
|
||||||
listRefreshVersion,
|
|
||||||
soBundle.audioEncoder,
|
|
||||||
) {
|
) {
|
||||||
if (audioEncoders.isEmpty() && soBundle.audioEncoder.isNotBlank()) soBundle.audioEncoder
|
if (audioEncoders.isEmpty() && soBundle.audioEncoder.isNotBlank()) soBundle.audioEncoder
|
||||||
else null
|
else null
|
||||||
@@ -792,13 +781,12 @@ internal fun ScrcpyAllOptionsPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val startAppOverrideEndActionValue = remember(
|
val startAppOverrideEndActionValue = remember(
|
||||||
apps,
|
apps, listRefreshVersion, soBundle.startApp,
|
||||||
listRefreshVersion,
|
|
||||||
soBundle.startApp,
|
|
||||||
) {
|
) {
|
||||||
if (apps.isEmpty() && soBundle.startApp.isNotBlank()) soBundle.startApp
|
if (apps.isEmpty() && soBundle.startApp.isNotBlank()) soBundle.startApp
|
||||||
else null
|
else null
|
||||||
}
|
}
|
||||||
|
|
||||||
var startAppCustomInput by rememberSaveable(soBundle.startAppCustom) {
|
var startAppCustomInput by rememberSaveable(soBundle.startAppCustom) {
|
||||||
mutableStateOf(soBundle.startAppCustom)
|
mutableStateOf(soBundle.startAppCustom)
|
||||||
}
|
}
|
||||||
@@ -1204,30 +1192,29 @@ internal fun ScrcpyAllOptionsPage(
|
|||||||
)
|
)
|
||||||
AnimatedVisibility(soBundle.videoSource == "display") {
|
AnimatedVisibility(soBundle.videoSource == "display") {
|
||||||
Column {
|
Column {
|
||||||
ArrowPreference(
|
OverlaySpinnerWithFallback(
|
||||||
title = "获取 Displays",
|
|
||||||
summary = "--list-displays",
|
|
||||||
onClick = {
|
|
||||||
if (refreshBusy) return@ArrowPreference
|
|
||||||
scope.launch {
|
|
||||||
snackbar.show("获取中")
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
scrcpy.listings.getDisplays(forceRefresh = true)
|
|
||||||
}
|
|
||||||
snackbar.show("获取成功")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
snackbar.show("刷新失败: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
SuperSpinner(
|
|
||||||
title = "监视器 ID",
|
title = "监视器 ID",
|
||||||
summary = "--display-id",
|
summary = "--display-id",
|
||||||
items = displaySpinnerItems,
|
items = displaySpinnerItems,
|
||||||
selectedIndex = displayDropdownIndex,
|
selectedIndex = displayDropdownIndex,
|
||||||
|
dataLoaded = displays.isNotEmpty(),
|
||||||
|
dataLoading = refreshBusy,
|
||||||
overrideEndActionValue = displayOverrideEndActionValue,
|
overrideEndActionValue = displayOverrideEndActionValue,
|
||||||
|
onExpandedChange = { expanded ->
|
||||||
|
if (expanded && displays.isEmpty()) {
|
||||||
|
scope.launch {
|
||||||
|
snackbar.show("获取中")
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
scrcpy.listings.getDisplays(forceRefresh = false)
|
||||||
|
}
|
||||||
|
snackbar.show("获取成功")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
snackbar.show("刷新失败: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onSelectedIndexChange = {
|
onSelectedIndexChange = {
|
||||||
soBundle = soBundle.copy(
|
soBundle = soBundle.copy(
|
||||||
displayId =
|
displayId =
|
||||||
@@ -1301,30 +1288,29 @@ internal fun ScrcpyAllOptionsPage(
|
|||||||
}
|
}
|
||||||
AnimatedVisibility(soBundle.videoSource == "camera") {
|
AnimatedVisibility(soBundle.videoSource == "camera") {
|
||||||
Column {
|
Column {
|
||||||
ArrowPreference(
|
OverlaySpinnerWithFallback(
|
||||||
title = "获取 Cameras",
|
|
||||||
summary = "--list-cameras",
|
|
||||||
onClick = {
|
|
||||||
if (refreshBusy) return@ArrowPreference
|
|
||||||
scope.launch {
|
|
||||||
snackbar.show("获取中")
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
scrcpy.listings.getCameras(forceRefresh = true)
|
|
||||||
}
|
|
||||||
snackbar.show("获取成功")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
snackbar.show("刷新失败: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
SuperSpinner(
|
|
||||||
title = "摄像头 ID",
|
title = "摄像头 ID",
|
||||||
summary = "--camera-id",
|
summary = "--camera-id",
|
||||||
items = cameraSpinnerItems,
|
items = cameraSpinnerItems,
|
||||||
selectedIndex = cameraDropdownIndex,
|
selectedIndex = cameraDropdownIndex,
|
||||||
|
dataLoaded = cameras.isNotEmpty(),
|
||||||
|
dataLoading = refreshBusy,
|
||||||
overrideEndActionValue = cameraOverrideEndActionValue,
|
overrideEndActionValue = cameraOverrideEndActionValue,
|
||||||
|
onExpandedChange = { expanded ->
|
||||||
|
if (expanded && cameras.isEmpty()) {
|
||||||
|
scope.launch {
|
||||||
|
snackbar.show("获取中")
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
scrcpy.listings.getCameras(forceRefresh = false)
|
||||||
|
}
|
||||||
|
snackbar.show("获取成功")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
snackbar.show("刷新失败: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onSelectedIndexChange = {
|
onSelectedIndexChange = {
|
||||||
soBundle = soBundle.copy(
|
soBundle = soBundle.copy(
|
||||||
cameraId =
|
cameraId =
|
||||||
@@ -1346,30 +1332,29 @@ internal fun ScrcpyAllOptionsPage(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
ArrowPreference(
|
OverlaySpinnerWithFallback(
|
||||||
title = "获取 Camera Sizes",
|
|
||||||
summary = "--list-camera-sizes",
|
|
||||||
onClick = {
|
|
||||||
if (refreshBusy) return@ArrowPreference
|
|
||||||
scope.launch {
|
|
||||||
snackbar.show("获取中")
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
scrcpy.listings.getCameraSizes(forceRefresh = true)
|
|
||||||
}
|
|
||||||
snackbar.show("获取成功")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
snackbar.show("刷新失败: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
SuperSpinner(
|
|
||||||
title = "摄像头分辨率",
|
title = "摄像头分辨率",
|
||||||
summary = "--camera-size",
|
summary = "--camera-size",
|
||||||
items = cameraSizeSpinnerItems,
|
items = cameraSizeSpinnerItems,
|
||||||
selectedIndex = cameraSizeDropdownIndex,
|
selectedIndex = cameraSizeDropdownIndex,
|
||||||
|
dataLoaded = cameraSizes.isNotEmpty(),
|
||||||
|
dataLoading = refreshBusy,
|
||||||
overrideEndActionValue = cameraSizeOverrideEndActionValue,
|
overrideEndActionValue = cameraSizeOverrideEndActionValue,
|
||||||
|
onExpandedChange = { expanded ->
|
||||||
|
if (expanded && cameraSizes.isEmpty()) {
|
||||||
|
scope.launch {
|
||||||
|
snackbar.show("获取中")
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
scrcpy.listings.getCameraSizes(forceRefresh = false)
|
||||||
|
}
|
||||||
|
snackbar.show("获取成功")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
snackbar.show("刷新失败: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onSelectedIndexChange = {
|
onSelectedIndexChange = {
|
||||||
when (it) {
|
when (it) {
|
||||||
0 -> {
|
0 -> {
|
||||||
@@ -1524,31 +1509,29 @@ internal fun ScrcpyAllOptionsPage(
|
|||||||
|
|
||||||
item {
|
item {
|
||||||
Card {
|
Card {
|
||||||
ArrowPreference(
|
OverlaySpinnerWithFallback(
|
||||||
title = "获取编码器列表",
|
|
||||||
summary = "--list-encoders",
|
|
||||||
onClick = {
|
|
||||||
if (refreshBusy) return@ArrowPreference
|
|
||||||
scope.launch {
|
|
||||||
snackbar.show("获取中")
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
scrcpy.listings.getEncoders(forceRefresh = true)
|
|
||||||
}
|
|
||||||
snackbar.show("获取成功")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
snackbar.show("刷新失败: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
// TODO: 等 MIUIX 发版, 在 OverlaySpinnerPreference / OverlayDropdownPreference 支持展开状态回调后, 在展开时触发获取
|
|
||||||
SuperSpinner(
|
|
||||||
title = "视频编码器",
|
title = "视频编码器",
|
||||||
summary = "--video-encoder",
|
summary = "--video-encoder",
|
||||||
items = videoEncoderItems,
|
items = videoEncoderItems,
|
||||||
selectedIndex = videoEncoderIndex,
|
selectedIndex = videoEncoderIndex,
|
||||||
|
dataLoaded = videoEncoders.isNotEmpty(),
|
||||||
|
dataLoading = refreshBusy,
|
||||||
overrideEndActionValue = videoEncoderOverrideEndActionValue,
|
overrideEndActionValue = videoEncoderOverrideEndActionValue,
|
||||||
|
onExpandedChange = { expanded ->
|
||||||
|
if (expanded && videoEncoders.isEmpty()) {
|
||||||
|
scope.launch {
|
||||||
|
snackbar.show("获取中")
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
scrcpy.listings.getEncoders(forceRefresh = false)
|
||||||
|
}
|
||||||
|
snackbar.show("获取成功")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
snackbar.show("刷新失败: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onSelectedIndexChange = {
|
onSelectedIndexChange = {
|
||||||
soBundle = soBundle.copy(
|
soBundle = soBundle.copy(
|
||||||
videoEncoder =
|
videoEncoder =
|
||||||
@@ -1571,12 +1554,29 @@ internal fun ScrcpyAllOptionsPage(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(all = UiSpacing.Large),
|
.padding(all = UiSpacing.Large),
|
||||||
)
|
)
|
||||||
SuperSpinner(
|
OverlaySpinnerWithFallback(
|
||||||
title = "音频编码器",
|
title = "音频编码器",
|
||||||
summary = "--audio-encoder",
|
summary = "--audio-encoder",
|
||||||
items = audioEncoderItems,
|
items = audioEncoderItems,
|
||||||
selectedIndex = audioEncoderIndex,
|
selectedIndex = audioEncoderIndex,
|
||||||
|
dataLoaded = audioEncoders.isNotEmpty(),
|
||||||
|
dataLoading = refreshBusy,
|
||||||
overrideEndActionValue = audioEncoderOverrideEndActionValue,
|
overrideEndActionValue = audioEncoderOverrideEndActionValue,
|
||||||
|
onExpandedChange = { expanded ->
|
||||||
|
if (expanded && audioEncoders.isEmpty()) {
|
||||||
|
scope.launch {
|
||||||
|
snackbar.show("获取中")
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
scrcpy.listings.getEncoders(forceRefresh = false)
|
||||||
|
}
|
||||||
|
snackbar.show("获取成功")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
snackbar.show("刷新失败: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onSelectedIndexChange = {
|
onSelectedIndexChange = {
|
||||||
soBundle = soBundle.copy(
|
soBundle = soBundle.copy(
|
||||||
audioEncoder =
|
audioEncoder =
|
||||||
@@ -1734,30 +1734,29 @@ internal fun ScrcpyAllOptionsPage(
|
|||||||
|
|
||||||
if (soBundle.videoSource == "display") item {
|
if (soBundle.videoSource == "display") item {
|
||||||
Card {
|
Card {
|
||||||
ArrowPreference(
|
OverlaySpinnerWithFallback(
|
||||||
title = "获取应用列表",
|
|
||||||
summary = "--list-apps",
|
|
||||||
onClick = {
|
|
||||||
if (refreshBusy) return@ArrowPreference
|
|
||||||
scope.launch {
|
|
||||||
snackbar.show("获取中")
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
scrcpy.listings.getApps(forceRefresh = true)
|
|
||||||
}
|
|
||||||
snackbar.show("获取成功")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
snackbar.show("刷新失败: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
SuperSpinner(
|
|
||||||
title = "scrcpy 启动后打开应用",
|
title = "scrcpy 启动后打开应用",
|
||||||
summary = "--start-app",
|
summary = "--start-app",
|
||||||
items = appDropdownItems,
|
items = appDropdownItems,
|
||||||
selectedIndex = appDropdownIndex,
|
selectedIndex = appDropdownIndex,
|
||||||
|
dataLoaded = apps.isNotEmpty(),
|
||||||
|
dataLoading = refreshBusy,
|
||||||
overrideEndActionValue = startAppOverrideEndActionValue,
|
overrideEndActionValue = startAppOverrideEndActionValue,
|
||||||
|
onExpandedChange = { expanded ->
|
||||||
|
if (expanded && apps.isEmpty()) {
|
||||||
|
scope.launch {
|
||||||
|
snackbar.show("获取中")
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
scrcpy.listings.getApps(forceRefresh = false)
|
||||||
|
}
|
||||||
|
snackbar.show("获取成功")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
snackbar.show("刷新失败: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onSelectedIndexChange = {
|
onSelectedIndexChange = {
|
||||||
when (it) {
|
when (it) {
|
||||||
0 -> {
|
0 -> {
|
||||||
@@ -2112,39 +2111,6 @@ private enum class ProfileDialogMode {
|
|||||||
Rename,
|
Rename,
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ProfileMenuPopupItem(
|
|
||||||
text: String,
|
|
||||||
optionSize: Int,
|
|
||||||
index: Int,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
onSelectedIndexChange: (Int) -> Unit,
|
|
||||||
) {
|
|
||||||
if (enabled) {
|
|
||||||
DropdownImpl(
|
|
||||||
text = text,
|
|
||||||
optionSize = optionSize,
|
|
||||||
isSelected = false,
|
|
||||||
index = index,
|
|
||||||
onSelectedIndexChange = onSelectedIndexChange,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = UiSpacing.PopupHorizontal)
|
|
||||||
.padding(
|
|
||||||
top = if (index == 0) UiSpacing.PopupHorizontal else UiSpacing.PageItem,
|
|
||||||
bottom = if (index == optionSize - 1) UiSpacing.PopupHorizontal else UiSpacing.PageItem,
|
|
||||||
),
|
|
||||||
color = colorScheme.disabledOnSecondaryVariant,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ProfileNameDialog(
|
private fun ProfileNameDialog(
|
||||||
mode: ProfileDialogMode?,
|
mode: ProfileDialogMode?,
|
||||||
|
|||||||
@@ -51,8 +51,6 @@ 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.MonetKeyColorOptions
|
import io.github.miuzarte.scrcpyforandroid.ui.MonetKeyColorOptions
|
||||||
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
|
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.MultiGroupsDropdownGroup
|
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.MultiGroupsDropdownPreference
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -60,6 +58,8 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import top.yukonga.miuix.kmp.basic.Card
|
import top.yukonga.miuix.kmp.basic.Card
|
||||||
|
import top.yukonga.miuix.kmp.basic.DropdownEntry
|
||||||
|
import top.yukonga.miuix.kmp.basic.DropdownItem
|
||||||
import top.yukonga.miuix.kmp.basic.Icon
|
import top.yukonga.miuix.kmp.basic.Icon
|
||||||
import top.yukonga.miuix.kmp.basic.IconButton
|
import top.yukonga.miuix.kmp.basic.IconButton
|
||||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||||
@@ -481,12 +481,11 @@ fun SettingsPage(
|
|||||||
)
|
)
|
||||||
AnimatedVisibility(asBundle.showFullscreenVirtualButtons) {
|
AnimatedVisibility(asBundle.showFullscreenVirtualButtons) {
|
||||||
Column {
|
Column {
|
||||||
MultiGroupsDropdownPreference(
|
OverlayDropdownPreference(
|
||||||
title = "虚拟按钮方向",
|
entries = listOf(
|
||||||
summary = fullscreenVirtualButtonDock.summary,
|
DropdownEntry(
|
||||||
groups = listOf(
|
items = FullscreenVirtualButtonDock
|
||||||
MultiGroupsDropdownGroup(
|
.modeItems.map { DropdownItem(it) },
|
||||||
options = FullscreenVirtualButtonDock.modeItems,
|
|
||||||
selectedIndex = fullscreenVirtualButtonDock.modeIndex,
|
selectedIndex = fullscreenVirtualButtonDock.modeIndex,
|
||||||
onSelectedIndexChange = { modeIndex ->
|
onSelectedIndexChange = { modeIndex ->
|
||||||
asBundle = asBundle.copy(
|
asBundle = asBundle.copy(
|
||||||
@@ -499,8 +498,9 @@ fun SettingsPage(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
MultiGroupsDropdownGroup(
|
DropdownEntry(
|
||||||
options = FullscreenVirtualButtonDock.directionItems,
|
items = FullscreenVirtualButtonDock
|
||||||
|
.directionItems.map { DropdownItem(it) },
|
||||||
selectedIndex = fullscreenVirtualButtonDock.directionIndex,
|
selectedIndex = fullscreenVirtualButtonDock.directionIndex,
|
||||||
onSelectedIndexChange = { directionIndex ->
|
onSelectedIndexChange = { directionIndex ->
|
||||||
asBundle = asBundle.copy(
|
asBundle = asBundle.copy(
|
||||||
@@ -514,6 +514,8 @@ fun SettingsPage(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
title = "虚拟按钮方向",
|
||||||
|
summary = fullscreenVirtualButtonDock.summary,
|
||||||
)
|
)
|
||||||
SuperSlider(
|
SuperSlider(
|
||||||
title = "虚拟按钮高度",
|
title = "虚拟按钮高度",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
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.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -44,38 +43,24 @@ import androidx.compose.ui.unit.Dp
|
|||||||
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
|
||||||
import com.termux.terminal.KeyHandler
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.termux.terminal.TerminalSession
|
import com.termux.terminal.TerminalSession
|
||||||
import com.termux.terminal.TextStyle
|
|
||||||
import com.termux.view.TerminalView
|
import com.termux.view.TerminalView
|
||||||
import com.termux.view.TerminalViewClient
|
import com.termux.view.TerminalViewClient
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||||
import io.github.miuzarte.scrcpyforandroid.nativecore.AdbSocketStream
|
|
||||||
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
|
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.LocalInputService
|
import io.github.miuzarte.scrcpyforandroid.services.LocalInputService
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
|
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
|
||||||
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.contextClick
|
import io.github.miuzarte.scrcpyforandroid.ui.contextClick
|
||||||
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
|
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.PopupMenuItem
|
import top.yukonga.miuix.kmp.basic.DropdownImpl
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import top.yukonga.miuix.kmp.basic.Icon
|
import top.yukonga.miuix.kmp.basic.Icon
|
||||||
import top.yukonga.miuix.kmp.basic.IconButton
|
import top.yukonga.miuix.kmp.basic.IconButton
|
||||||
import top.yukonga.miuix.kmp.basic.ListPopupColumn
|
import top.yukonga.miuix.kmp.basic.ListPopupColumn
|
||||||
import top.yukonga.miuix.kmp.basic.ListPopupDefaults
|
import top.yukonga.miuix.kmp.basic.ListPopupDefaults
|
||||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||||
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
|
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
|
||||||
import top.yukonga.miuix.kmp.basic.SnackbarDuration
|
|
||||||
import top.yukonga.miuix.kmp.basic.SnackbarResult
|
|
||||||
import top.yukonga.miuix.kmp.basic.Text
|
import top.yukonga.miuix.kmp.basic.Text
|
||||||
import top.yukonga.miuix.kmp.basic.TextField
|
import top.yukonga.miuix.kmp.basic.TextField
|
||||||
import top.yukonga.miuix.kmp.blur.layerBackdrop
|
import top.yukonga.miuix.kmp.blur.layerBackdrop
|
||||||
@@ -85,21 +70,14 @@ import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet
|
|||||||
import top.yukonga.miuix.kmp.overlay.OverlayListPopup
|
import top.yukonga.miuix.kmp.overlay.OverlayListPopup
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.nio.charset.StandardCharsets
|
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
private const val DEFAULT_TERMINAL_FONT_SIZE_SP = 14f
|
|
||||||
private const val MIN_TERMINAL_FONT_SIZE_SP = 1f
|
|
||||||
private const val MAX_TERMINAL_FONT_SIZE_SP = 32f
|
|
||||||
private const val FONT_SCALE_STEP_THRESHOLD = 1.08f
|
private const val FONT_SCALE_STEP_THRESHOLD = 1.08f
|
||||||
private const val LOG_TAG = "TerminalScreen"
|
private const val LOG_TAG = "TerminalScreen"
|
||||||
private const val TERMINAL_FONT_RELATIVE_PATH = "terminal/font.ttf"
|
|
||||||
|
|
||||||
private fun terminalFontFile(context: android.content.Context): File {
|
private fun terminalFontFile(context: android.content.Context) =
|
||||||
return File(context.filesDir, TERMINAL_FONT_RELATIVE_PATH)
|
File(context.filesDir, "terminal/font.ttf")
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadTerminalTypeface(context: android.content.Context): Typeface? {
|
private fun loadTerminalTypeface(context: android.content.Context): Typeface? {
|
||||||
val file = terminalFontFile(context)
|
val file = terminalFontFile(context)
|
||||||
@@ -113,6 +91,7 @@ fun TerminalScreen(
|
|||||||
isActive: Boolean,
|
isActive: Boolean,
|
||||||
onTerminalGestureLockChanged: (Boolean) -> Unit,
|
onTerminalGestureLockChanged: (Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val viewModel: TerminalViewModel = viewModel()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbar = LocalSnackbarController.current
|
val snackbar = LocalSnackbarController.current
|
||||||
val blurBackdrop = rememberBlurBackdrop(LocalEnableBlur.current)
|
val blurBackdrop = rememberBlurBackdrop(LocalEnableBlur.current)
|
||||||
@@ -121,6 +100,10 @@ fun TerminalScreen(
|
|||||||
var showOutputSheet by rememberSaveable { mutableStateOf(false) }
|
var showOutputSheet by rememberSaveable { mutableStateOf(false) }
|
||||||
var output by rememberSaveable { mutableStateOf("") }
|
var output by rememberSaveable { mutableStateOf("") }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.snackbarEvents.collect { snackbar.show(it) }
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
BlurredBar(backdrop = blurBackdrop) {
|
BlurredBar(backdrop = blurBackdrop) {
|
||||||
@@ -144,9 +127,10 @@ fun TerminalScreen(
|
|||||||
onDismissRequest = { showMenu = false },
|
onDismissRequest = { showMenu = false },
|
||||||
) {
|
) {
|
||||||
ListPopupColumn {
|
ListPopupColumn {
|
||||||
PopupMenuItem(
|
DropdownImpl(
|
||||||
text = "自由复制",
|
text = "自由复制",
|
||||||
optionSize = 2,
|
optionSize = 2,
|
||||||
|
isSelected = false,
|
||||||
index = 0,
|
index = 0,
|
||||||
enabled = output.isNotBlank(),
|
enabled = output.isNotBlank(),
|
||||||
onSelectedIndexChange = {
|
onSelectedIndexChange = {
|
||||||
@@ -154,9 +138,10 @@ fun TerminalScreen(
|
|||||||
showOutputSheet = true
|
showOutputSheet = true
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
PopupMenuItem(
|
DropdownImpl(
|
||||||
text = "清屏",
|
text = "清屏",
|
||||||
optionSize = 2,
|
optionSize = 2,
|
||||||
|
isSelected = false,
|
||||||
index = 1,
|
index = 1,
|
||||||
onSelectedIndexChange = {
|
onSelectedIndexChange = {
|
||||||
showMenu = false
|
showMenu = false
|
||||||
@@ -174,9 +159,10 @@ fun TerminalScreen(
|
|||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
if (blurActive) Modifier.layerBackdrop(blurBackdrop)
|
if (blurActive) Modifier.layerBackdrop(blurBackdrop)
|
||||||
else Modifier,
|
else Modifier
|
||||||
) {
|
) {
|
||||||
TerminalPage(
|
TerminalPage(
|
||||||
|
viewModel = viewModel,
|
||||||
contentPadding = pagePadding,
|
contentPadding = pagePadding,
|
||||||
bottomInnerPadding = bottomInnerPadding,
|
bottomInnerPadding = bottomInnerPadding,
|
||||||
isActive = isActive,
|
isActive = isActive,
|
||||||
@@ -184,7 +170,6 @@ fun TerminalScreen(
|
|||||||
output = output,
|
output = output,
|
||||||
onOutputChange = { output = it },
|
onOutputChange = { output = it },
|
||||||
)
|
)
|
||||||
|
|
||||||
TerminalOutputBottomSheet(
|
TerminalOutputBottomSheet(
|
||||||
show = showOutputSheet,
|
show = showOutputSheet,
|
||||||
output = output,
|
output = output,
|
||||||
@@ -202,6 +187,7 @@ fun TerminalScreen(
|
|||||||
@SuppressLint("ClickableViewAccessibility")
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
@Composable
|
@Composable
|
||||||
private fun TerminalPage(
|
private fun TerminalPage(
|
||||||
|
viewModel: TerminalViewModel,
|
||||||
contentPadding: PaddingValues,
|
contentPadding: PaddingValues,
|
||||||
bottomInnerPadding: Dp,
|
bottomInnerPadding: Dp,
|
||||||
isActive: Boolean,
|
isActive: Boolean,
|
||||||
@@ -213,151 +199,81 @@ private fun TerminalPage(
|
|||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val snackbar = LocalSnackbarController.current
|
val snackbar = LocalSnackbarController.current
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
val asBundleShared by appSettings.bundleState.collectAsState()
|
|
||||||
val uiScope = rememberCoroutineScope()
|
val asBundle by viewModel.asBundle.collectAsState()
|
||||||
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
val terminalFontSizeSp by viewModel.terminalFontSizeSp.collectAsState()
|
||||||
val shellWriteMutex = remember { Mutex() }
|
|
||||||
var terminalFontSizeSp by rememberSaveable(asBundleShared.terminalFontSizeSp) {
|
|
||||||
mutableFloatStateOf(asBundleShared.terminalFontSizeSp)
|
|
||||||
}
|
|
||||||
var shellReady by remember { mutableStateOf(false) }
|
|
||||||
var shellConnecting by remember { mutableStateOf(false) }
|
|
||||||
var shellStream by remember { mutableStateOf<AdbSocketStream?>(null) }
|
|
||||||
var terminalView by remember { mutableStateOf<TerminalView?>(null) }
|
|
||||||
val imeBottomDp = with(density) { WindowInsets.ime.getBottom(this).toDp() }
|
val imeBottomDp = with(density) { WindowInsets.ime.getBottom(this).toDp() }
|
||||||
var ctrlLatched by rememberSaveable { mutableStateOf(false) }
|
|
||||||
var altLatched by rememberSaveable { mutableStateOf(false) }
|
|
||||||
var pendingLatchedConsume by remember { mutableStateOf(false) }
|
|
||||||
var pinchGestureLock by remember { mutableStateOf(false) }
|
var pinchGestureLock by remember { mutableStateOf(false) }
|
||||||
var terminalTouchStartX by remember { mutableFloatStateOf(0f) }
|
var terminalTouchStartX by remember { mutableFloatStateOf(0f) }
|
||||||
var terminalTouchStartY by remember { mutableFloatStateOf(0f) }
|
var terminalTouchStartY by remember { mutableFloatStateOf(0f) }
|
||||||
val terminalTouchSlop = remember(context) {
|
val terminalTouchSlop = remember(context) { ViewConfiguration.get(context).scaledTouchSlop }
|
||||||
ViewConfiguration.get(context).scaledTouchSlop
|
|
||||||
}
|
|
||||||
var customTypeface by remember { mutableStateOf(loadTerminalTypeface(context)) }
|
var customTypeface by remember { mutableStateOf(loadTerminalTypeface(context)) }
|
||||||
val sessionHolder = remember { arrayOfNulls<TerminalSession>(1) }
|
var terminalView by remember { mutableStateOf<TerminalView?>(null) }
|
||||||
|
|
||||||
val terminalSurfaceColorArgb = colorScheme.surface.toArgb()
|
val terminalSurfaceColorArgb = colorScheme.surface.toArgb()
|
||||||
val terminalOnSurfaceColorArgb = colorScheme.onSurface.toArgb()
|
val terminalOnSurfaceColorArgb = colorScheme.onSurface.toArgb()
|
||||||
val terminalCursorColorArgb =
|
val terminalCursorColorArgb =
|
||||||
if (colorScheme.surface.luminance() < 0.5f) 0xffffffff.toInt()
|
if (colorScheme.surface.luminance() < 0.5f) 0xffffffff.toInt()
|
||||||
else 0xff000000.toInt()
|
else 0xff000000.toInt()
|
||||||
|
|
||||||
LaunchedEffect(asBundleShared.terminalFontSizeSp) {
|
|
||||||
if (terminalFontSizeSp != asBundleShared.terminalFontSizeSp) {
|
|
||||||
terminalFontSizeSp = asBundleShared.terminalFontSizeSp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun extractTranscript(session: TerminalSession): String {
|
|
||||||
val screen = session.emulator.getScreen()
|
|
||||||
return screen.getSelectedText(
|
|
||||||
selX1 = 0,
|
|
||||||
selY1 = -screen.activeTranscriptRows,
|
|
||||||
selX2 = session.emulator.mColumns,
|
|
||||||
selY2 = session.emulator.mRows - 1,
|
|
||||||
joinBackLines = false,
|
|
||||||
joinFullLines = false,
|
|
||||||
).trim('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
fun syncOutput() {
|
|
||||||
val session = sessionHolder[0] ?: return
|
|
||||||
onOutputChange(extractTranscript(session))
|
|
||||||
terminalView?.onScreenUpdated()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun writeBytesToShell(
|
|
||||||
data: ByteArray,
|
|
||||||
offset: Int,
|
|
||||||
count: Int,
|
|
||||||
) {
|
|
||||||
val stream = shellStream
|
|
||||||
if (stream == null || !shellReady || stream.closed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val payload = data.copyOfRange(offset, offset + count)
|
|
||||||
taskScope.launch {
|
|
||||||
val result = runCatching {
|
|
||||||
shellWriteMutex.withLock {
|
|
||||||
stream.outputStream.write(payload)
|
|
||||||
stream.outputStream.flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
result.onFailure { error ->
|
|
||||||
snackbar.show("终端输入失败: ${error.message ?: error.javaClass.simpleName}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun writeClipboardToShell() {
|
|
||||||
val text = LocalInputService.getClipboardText(context)
|
|
||||||
if (!text.isNullOrBlank()) {
|
|
||||||
val bytes = text.toByteArray(StandardCharsets.UTF_8)
|
|
||||||
writeBytesToShell(bytes, 0, bytes.size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val terminalSession = remember {
|
val terminalSession = remember {
|
||||||
TerminalSession(
|
TerminalSession(
|
||||||
shellWriter = ::writeBytesToShell,
|
shellWriter = { data, offset, count ->
|
||||||
onScreenUpdated = ::syncOutput,
|
viewModel.writeBytesToShell(data, offset, count)
|
||||||
|
},
|
||||||
|
onScreenUpdated = {
|
||||||
|
viewModel.syncOutput { onOutputChange(it) }
|
||||||
|
terminalView?.onScreenUpdated()
|
||||||
|
},
|
||||||
onCopyTextToClipboardRequested = { text ->
|
onCopyTextToClipboardRequested = { text ->
|
||||||
LocalInputService.setClipboardText(context, text)
|
LocalInputService.setClipboardText(context, text)
|
||||||
uiScope.launch {
|
snackbar.show("已复制到剪贴板")
|
||||||
snackbar.show("已复制到剪贴板")
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onPasteTextFromClipboardRequested = ::writeClipboardToShell,
|
onPasteTextFromClipboardRequested = { viewModel.writeClipboardToShell(context) },
|
||||||
onBellRequested = {},
|
onBellRequested = {},
|
||||||
)
|
).also { viewModel.sessionHolder[0] = it }
|
||||||
}
|
|
||||||
sessionHolder[0] = terminalSession
|
|
||||||
|
|
||||||
fun applyTerminalThemeColors() {
|
|
||||||
val colors = terminalSession.emulator.mColors.mCurrentColors
|
|
||||||
colors[TextStyle.COLOR_INDEX_BACKGROUND] = terminalSurfaceColorArgb
|
|
||||||
colors[TextStyle.COLOR_INDEX_FOREGROUND] = terminalOnSurfaceColorArgb
|
|
||||||
colors[TextStyle.COLOR_INDEX_CURSOR] = terminalCursorColorArgb
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateTerminalFontSize(newValue: Float) {
|
fun requestTerminalFocus() {
|
||||||
val clamped = newValue.coerceIn(MIN_TERMINAL_FONT_SIZE_SP, MAX_TERMINAL_FONT_SIZE_SP)
|
terminalView?.requestFocusFromTouch()
|
||||||
if (clamped == terminalFontSizeSp) {
|
terminalView?.requestFocus()
|
||||||
return
|
terminalView?.post {
|
||||||
|
terminalView?.requestFocusFromTouch()
|
||||||
|
terminalView?.requestFocus()
|
||||||
|
terminalView?.let(LocalInputService::showSoftKeyboard)
|
||||||
}
|
}
|
||||||
terminalFontSizeSp = clamped
|
}
|
||||||
terminalView?.setTextSize(with(density) { clamped.sp.roundToPx() })
|
|
||||||
uiScope.launch {
|
fun openShellSession(showKeyboardAfterConnect: Boolean) {
|
||||||
val latest = appSettings.bundleState.value
|
viewModel.openShellSession(showKeyboardAfterConnect, ::requestTerminalFocus)
|
||||||
if (latest.terminalFontSizeSp != clamped) {
|
}
|
||||||
appSettings.updateBundle {
|
|
||||||
it.copy(terminalFontSizeSp = clamped)
|
fun updateFontSize(newValue: Float) {
|
||||||
}
|
viewModel.updateTerminalFontSize(newValue) { clamped ->
|
||||||
}
|
terminalView?.setTextSize(with(density) { clamped.sp.roundToPx() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showFontSizeSnackbar() {
|
fun showFontSizeSnackbar() {
|
||||||
uiScope.launch {
|
viewModel.launchFontSizeSnackbar(
|
||||||
snackbar.hostState.newestSnackbarData()?.dismiss()
|
fontSizeSp = terminalFontSizeSp,
|
||||||
val result = snackbar.hostState.showSnackbar(
|
hostState = snackbar.hostState,
|
||||||
message = "终端字号 ${terminalFontSizeSp.roundToInt()}sp",
|
onReset = { clamped ->
|
||||||
actionLabel = "恢复默认",
|
terminalView?.setTextSize(with(density) { clamped.sp.roundToPx() })
|
||||||
withDismissAction = true,
|
},
|
||||||
duration = SnackbarDuration.Short,
|
)
|
||||||
)
|
|
||||||
if (result == SnackbarResult.ActionPerformed) {
|
|
||||||
updateTerminalFontSize(DEFAULT_TERMINAL_FONT_SIZE_SP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleTerminalTouchInterception(
|
fun applyTheme() {
|
||||||
view: TerminalView,
|
viewModel.applyTerminalThemeColors(
|
||||||
event: MotionEvent,
|
terminalSurfaceColorArgb,
|
||||||
): Boolean {
|
terminalOnSurfaceColorArgb,
|
||||||
|
terminalCursorColorArgb,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleTerminalTouchInterception(view: TerminalView, event: MotionEvent): Boolean {
|
||||||
when (event.actionMasked) {
|
when (event.actionMasked) {
|
||||||
MotionEvent.ACTION_DOWN -> {
|
MotionEvent.ACTION_DOWN -> {
|
||||||
terminalTouchStartX = event.x
|
terminalTouchStartX = event.x
|
||||||
@@ -384,7 +300,6 @@ private fun TerminalPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val shouldUnlock = event.actionMasked == MotionEvent.ACTION_UP ||
|
val shouldUnlock = event.actionMasked == MotionEvent.ACTION_UP ||
|
||||||
event.actionMasked == MotionEvent.ACTION_CANCEL ||
|
event.actionMasked == MotionEvent.ACTION_CANCEL ||
|
||||||
(event.actionMasked == MotionEvent.ACTION_POINTER_UP && event.pointerCount <= 2)
|
(event.actionMasked == MotionEvent.ACTION_POINTER_UP && event.pointerCount <= 2)
|
||||||
@@ -398,143 +313,18 @@ private fun TerminalPage(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun openShellSession(showKeyboardAfterConnect: Boolean) {
|
|
||||||
if (shellStream != null || shellConnecting) {
|
|
||||||
if (shellReady && showKeyboardAfterConnect) {
|
|
||||||
terminalView?.requestFocusFromTouch()
|
|
||||||
terminalView?.requestFocus()
|
|
||||||
terminalView?.post {
|
|
||||||
terminalView?.requestFocusFromTouch()
|
|
||||||
terminalView?.requestFocus()
|
|
||||||
terminalView?.let(LocalInputService::showSoftKeyboard)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
shellConnecting = true
|
|
||||||
taskScope.launch {
|
|
||||||
val streamResult = runCatching {
|
|
||||||
NativeAdbService.openShellStream("")
|
|
||||||
}
|
|
||||||
val stream = streamResult.getOrElse { error ->
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
shellConnecting = false
|
|
||||||
shellReady = false
|
|
||||||
snackbar.show("终端会话创建失败: ${error.message ?: error.javaClass.simpleName}")
|
|
||||||
}
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
shellStream = stream
|
|
||||||
shellReady = true
|
|
||||||
shellConnecting = false
|
|
||||||
if (showKeyboardAfterConnect) {
|
|
||||||
terminalView?.requestFocusFromTouch()
|
|
||||||
terminalView?.requestFocus()
|
|
||||||
terminalView?.post {
|
|
||||||
terminalView?.requestFocusFromTouch()
|
|
||||||
terminalView?.requestFocus()
|
|
||||||
terminalView?.let(LocalInputService::showSoftKeyboard)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val buffer = ByteArray(4096)
|
|
||||||
try {
|
|
||||||
while (!stream.closed) {
|
|
||||||
val count = stream.inputStream.read(buffer)
|
|
||||||
if (count <= 0) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
terminalSession.append(buffer, count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
snackbar.show("终端输出失败: ${error.message ?: error.javaClass.simpleName}")
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
runCatching { stream.close() }
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
if (shellStream === stream) {
|
|
||||||
shellStream = null
|
|
||||||
}
|
|
||||||
shellReady = false
|
|
||||||
shellConnecting = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun consumeLatchedModifiers(): Int {
|
|
||||||
var modifiers = 0
|
|
||||||
if (ctrlLatched) modifiers = modifiers or KeyHandler.KEYMOD_CTRL
|
|
||||||
if (altLatched) modifiers = modifiers or KeyHandler.KEYMOD_ALT
|
|
||||||
ctrlLatched = false
|
|
||||||
altLatched = false
|
|
||||||
pendingLatchedConsume = false
|
|
||||||
return modifiers
|
|
||||||
}
|
|
||||||
|
|
||||||
fun consumeLatchedVisualState() {
|
|
||||||
ctrlLatched = false
|
|
||||||
altLatched = false
|
|
||||||
pendingLatchedConsume = false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun applyCtrlModifier(ch: Char): Char {
|
|
||||||
return when (ch) {
|
|
||||||
in 'a'..'z' -> (ch.code - 'a'.code + 1).toChar()
|
|
||||||
in 'A'..'Z' -> (ch.code - 'A'.code + 1).toChar()
|
|
||||||
' ' -> 0.toChar()
|
|
||||||
'[' -> 27.toChar()
|
|
||||||
'\\' -> 28.toChar()
|
|
||||||
']' -> 29.toChar()
|
|
||||||
'^' -> 30.toChar()
|
|
||||||
'_', '/' -> 31.toChar()
|
|
||||||
else -> ch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun writeLiteralKey(text: String) {
|
|
||||||
var payload = text
|
|
||||||
val modifiers = consumeLatchedModifiers()
|
|
||||||
if ((modifiers and KeyHandler.KEYMOD_CTRL) != 0) {
|
|
||||||
payload = payload.map(::applyCtrlModifier).joinToString(separator = "")
|
|
||||||
}
|
|
||||||
if ((modifiers and KeyHandler.KEYMOD_ALT) != 0) {
|
|
||||||
payload = "\u001B$payload"
|
|
||||||
}
|
|
||||||
val bytes = payload.toByteArray(StandardCharsets.UTF_8)
|
|
||||||
writeBytesToShell(bytes, 0, bytes.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun writeSpecialKey(keyCode: Int) {
|
|
||||||
val modifiers = consumeLatchedModifiers()
|
|
||||||
val sequence = KeyHandler.getCode(
|
|
||||||
keyCode,
|
|
||||||
modifiers,
|
|
||||||
terminalSession.emulator.isCursorKeysApplicationMode(),
|
|
||||||
terminalSession.emulator.isKeypadApplicationMode(),
|
|
||||||
) ?: return
|
|
||||||
val bytes = sequence.toByteArray(StandardCharsets.UTF_8)
|
|
||||||
writeBytesToShell(bytes, 0, bytes.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
val terminalViewClient = remember {
|
val terminalViewClient = remember {
|
||||||
object : TerminalViewClient {
|
object : TerminalViewClient {
|
||||||
override fun onScale(scale: Float): Float {
|
override fun onScale(scale: Float): Float {
|
||||||
when {
|
when {
|
||||||
scale >= FONT_SCALE_STEP_THRESHOLD -> {
|
scale >= FONT_SCALE_STEP_THRESHOLD -> {
|
||||||
updateTerminalFontSize(terminalFontSizeSp + 1f)
|
updateFontSize(terminalFontSizeSp + 1f)
|
||||||
showFontSizeSnackbar()
|
showFontSizeSnackbar()
|
||||||
return 1f
|
return 1f
|
||||||
}
|
}
|
||||||
|
|
||||||
scale <= 1f / FONT_SCALE_STEP_THRESHOLD -> {
|
scale <= 1f / FONT_SCALE_STEP_THRESHOLD -> {
|
||||||
updateTerminalFontSize(terminalFontSizeSp - 1f)
|
updateFontSize(terminalFontSizeSp - 1f)
|
||||||
showFontSizeSnackbar()
|
showFontSizeSnackbar()
|
||||||
return 1f
|
return 1f
|
||||||
}
|
}
|
||||||
@@ -543,55 +333,42 @@ private fun TerminalPage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onSingleTapUp(e: MotionEvent) {
|
override fun onSingleTapUp(e: MotionEvent) {
|
||||||
openShellSession(showKeyboardAfterConnect = true)
|
openShellSession(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun shouldBackButtonBeMappedToEscape(): Boolean = false
|
override fun shouldBackButtonBeMappedToEscape() = false
|
||||||
|
override fun shouldEnforceCharBasedInput() = false
|
||||||
override fun shouldEnforceCharBasedInput(): Boolean = false
|
override fun shouldUseCtrlSpaceWorkaround() = false
|
||||||
|
override fun isTerminalViewSelected() = true
|
||||||
override fun shouldUseCtrlSpaceWorkaround(): Boolean = false
|
|
||||||
|
|
||||||
override fun isTerminalViewSelected(): Boolean = true
|
|
||||||
|
|
||||||
override fun copyModeChanged(copyMode: Boolean) = Unit
|
override fun copyModeChanged(copyMode: Boolean) = Unit
|
||||||
|
|
||||||
override fun onKeyDown(
|
override fun onKeyDown(
|
||||||
keyCode: Int,
|
keyCode: Int,
|
||||||
e: KeyEvent,
|
e: KeyEvent,
|
||||||
session: TerminalSession,
|
session: TerminalSession,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (e.action == KeyEvent.ACTION_DOWN && (ctrlLatched || altLatched)) {
|
if (e.action == KeyEvent.ACTION_DOWN && (viewModel.ctrlLatched || viewModel.altLatched))
|
||||||
pendingLatchedConsume = true
|
viewModel.pendingLatchedConsume = true
|
||||||
}
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onKeyUp(keyCode: Int, e: KeyEvent): Boolean {
|
override fun onKeyUp(keyCode: Int, e: KeyEvent): Boolean {
|
||||||
if (e.action == KeyEvent.ACTION_UP && pendingLatchedConsume) {
|
if (e.action == KeyEvent.ACTION_UP && viewModel.pendingLatchedConsume)
|
||||||
consumeLatchedVisualState()
|
viewModel.consumeLatchedVisualState()
|
||||||
}
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLongPress(event: MotionEvent): Boolean = false
|
override fun onLongPress(event: MotionEvent) = false
|
||||||
|
override fun readControlKey() = viewModel.ctrlLatched
|
||||||
override fun readControlKey(): Boolean = ctrlLatched
|
override fun readAltKey() = viewModel.altLatched
|
||||||
|
override fun readShiftKey() = false
|
||||||
override fun readAltKey(): Boolean = altLatched
|
override fun readFnKey() = false
|
||||||
|
|
||||||
override fun readShiftKey(): Boolean = false
|
|
||||||
|
|
||||||
override fun readFnKey(): Boolean = false
|
|
||||||
|
|
||||||
override fun onCodePoint(
|
override fun onCodePoint(
|
||||||
codePoint: Int,
|
codePoint: Int,
|
||||||
ctrlDown: Boolean,
|
ctrlDown: Boolean,
|
||||||
session: TerminalSession,
|
session: TerminalSession,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (ctrlLatched || altLatched || pendingLatchedConsume) {
|
if (viewModel.ctrlLatched || viewModel.altLatched || viewModel.pendingLatchedConsume)
|
||||||
consumeLatchedVisualState()
|
viewModel.consumeLatchedVisualState()
|
||||||
}
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,7 +378,7 @@ private fun TerminalPage(
|
|||||||
start = true,
|
start = true,
|
||||||
startOnlyIfCursorEnabled = true
|
startOnlyIfCursorEnabled = true
|
||||||
)
|
)
|
||||||
syncOutput()
|
viewModel.syncOutput { onOutputChange(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun logError(tag: String?, message: String?) {
|
override fun logError(tag: String?, message: String?) {
|
||||||
@@ -624,11 +401,7 @@ private fun TerminalPage(
|
|||||||
Log.v(tag ?: LOG_TAG, message.orEmpty())
|
Log.v(tag ?: LOG_TAG, message.orEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun logStackTraceWithMessage(
|
override fun logStackTraceWithMessage(tag: String?, message: String?, e: Exception?) {
|
||||||
tag: String?,
|
|
||||||
message: String?,
|
|
||||||
e: Exception?,
|
|
||||||
) {
|
|
||||||
Log.e(tag ?: LOG_TAG, message, e)
|
Log.e(tag ?: LOG_TAG, message, e)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,50 +411,48 @@ private fun TerminalPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reset on clear output
|
||||||
LaunchedEffect(output) {
|
LaunchedEffect(output) {
|
||||||
if (output.isEmpty() && extractTranscript(terminalSession).isNotEmpty()) {
|
if (output.isEmpty() && viewModel.extractTranscript(terminalSession).isNotEmpty()) {
|
||||||
terminalSession.reset()
|
terminalSession.reset()
|
||||||
applyTerminalThemeColors()
|
applyTheme()
|
||||||
terminalView?.mEmulator = terminalSession.emulator
|
terminalView?.mEmulator = terminalSession.emulator
|
||||||
terminalView?.onScreenUpdated()
|
terminalView?.onScreenUpdated()
|
||||||
syncOutput()
|
viewModel.syncOutput { onOutputChange(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// auto-connect when tab becomes active
|
||||||
LaunchedEffect(isActive) {
|
LaunchedEffect(isActive) {
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
onTerminalGestureLockChanged(false)
|
onTerminalGestureLockChanged(false)
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
customTypeface = loadTerminalTypeface(context)
|
customTypeface = loadTerminalTypeface(context)
|
||||||
if (shellStream == null && !shellConnecting) {
|
viewModel.autoConnectIfNeeded(::requestTerminalFocus)
|
||||||
val connected = runCatching { NativeAdbService.isConnected() }.getOrDefault(false)
|
|
||||||
if (connected) {
|
|
||||||
openShellSession(showKeyboardAfterConnect = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(colorScheme.surface, colorScheme.onSurface) {
|
// theme changes
|
||||||
applyTerminalThemeColors()
|
LaunchedEffect(
|
||||||
|
colorScheme.surface,
|
||||||
|
colorScheme.onSurface
|
||||||
|
) {
|
||||||
|
applyTheme()
|
||||||
terminalView?.onScreenUpdated()
|
terminalView?.onScreenUpdated()
|
||||||
syncOutput()
|
viewModel.syncOutput { onOutputChange(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// font size sync from storage
|
||||||
|
LaunchedEffect(asBundle.terminalFontSizeSp) {
|
||||||
|
if (terminalFontSizeSp != asBundle.terminalFontSizeSp) {
|
||||||
|
updateFontSize(asBundle.terminalFontSizeSp)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
val latestFontSize = terminalFontSizeSp
|
|
||||||
onTerminalGestureLockChanged(false)
|
onTerminalGestureLockChanged(false)
|
||||||
taskScope.launch {
|
viewModel.closeShell()
|
||||||
val latest = appSettings.bundleState.value
|
|
||||||
if (latest.terminalFontSizeSp != latestFontSize) {
|
|
||||||
appSettings.updateBundle {
|
|
||||||
it.copy(terminalFontSizeSp = latestFontSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
runCatching { shellStream?.close() }
|
|
||||||
taskScope.cancel()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,15 +479,10 @@ private fun TerminalPage(
|
|||||||
setTextSize(with(density) { terminalFontSizeSp.sp.roundToPx() })
|
setTextSize(with(density) { terminalFontSizeSp.sp.roundToPx() })
|
||||||
setTypeface(customTypeface ?: Typeface.MONOSPACE)
|
setTypeface(customTypeface ?: Typeface.MONOSPACE)
|
||||||
attachSession(terminalSession)
|
attachSession(terminalSession)
|
||||||
applyTerminalThemeColors()
|
applyTheme()
|
||||||
setTerminalCursorBlinkerRate(500)
|
setTerminalCursorBlinkerRate(500)
|
||||||
setTerminalCursorBlinkerState(
|
setTerminalCursorBlinkerState(start = true, startOnlyIfCursorEnabled = true)
|
||||||
start = true,
|
setOnTouchListener { _, event -> handleTerminalTouchInterception(this, event) }
|
||||||
startOnlyIfCursorEnabled = true,
|
|
||||||
)
|
|
||||||
setOnTouchListener { _, event ->
|
|
||||||
handleTerminalTouchInterception(this, event)
|
|
||||||
}
|
|
||||||
terminalView = this
|
terminalView = this
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -724,126 +490,118 @@ private fun TerminalPage(
|
|||||||
terminalView = it
|
terminalView = it
|
||||||
it.setTextSize(with(density) { terminalFontSizeSp.sp.roundToPx() })
|
it.setTextSize(with(density) { terminalFontSizeSp.sp.roundToPx() })
|
||||||
it.setTypeface(customTypeface ?: Typeface.MONOSPACE)
|
it.setTypeface(customTypeface ?: Typeface.MONOSPACE)
|
||||||
if (it.currentSession !== terminalSession) {
|
if (it.currentSession !== terminalSession) it.attachSession(terminalSession)
|
||||||
it.attachSession(terminalSession)
|
it.setOnTouchListener { _, event -> handleTerminalTouchInterception(it, event) }
|
||||||
}
|
applyTheme()
|
||||||
it.setOnTouchListener { _, event ->
|
|
||||||
handleTerminalTouchInterception(it, event)
|
|
||||||
}
|
|
||||||
applyTerminalThemeColors()
|
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f),
|
.weight(1f),
|
||||||
)
|
)
|
||||||
|
|
||||||
Row(
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
TerminalExtraKeyButton(
|
TerminalExtraKeyButton(
|
||||||
label = "ESC",
|
"ESC",
|
||||||
modifier = Modifier.weight(1f),
|
Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
haptic.contextClick()
|
haptic.contextClick()
|
||||||
writeLiteralKey("\u001B")
|
viewModel.writeLiteralKey("")
|
||||||
}
|
}
|
||||||
TerminalExtraKeyButton(
|
TerminalExtraKeyButton(
|
||||||
label = "/",
|
"/",
|
||||||
modifier = Modifier.weight(1f),
|
Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
haptic.contextClick()
|
haptic.contextClick()
|
||||||
writeLiteralKey("/")
|
viewModel.writeLiteralKey("/")
|
||||||
}
|
}
|
||||||
TerminalExtraKeyButton(
|
TerminalExtraKeyButton(
|
||||||
label = "-",
|
"-",
|
||||||
modifier = Modifier.weight(1f),
|
Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
haptic.contextClick()
|
haptic.contextClick()
|
||||||
writeLiteralKey("-")
|
viewModel.writeLiteralKey("-")
|
||||||
}
|
}
|
||||||
TerminalExtraKeyButton(
|
TerminalExtraKeyButton(
|
||||||
label = "HOME",
|
"HOME",
|
||||||
modifier = Modifier.weight(1f),
|
Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
haptic.contextClick()
|
haptic.contextClick()
|
||||||
writeSpecialKey(KeyEvent.KEYCODE_MOVE_HOME)
|
viewModel.writeSpecialKey(KeyEvent.KEYCODE_MOVE_HOME)
|
||||||
}
|
}
|
||||||
TerminalExtraKeyButton(
|
TerminalExtraKeyButton(
|
||||||
label = "↑",
|
"↑",
|
||||||
modifier = Modifier.weight(1f),
|
Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
haptic.contextClick()
|
haptic.contextClick()
|
||||||
writeSpecialKey(KeyEvent.KEYCODE_DPAD_UP)
|
viewModel.writeSpecialKey(KeyEvent.KEYCODE_DPAD_UP)
|
||||||
}
|
}
|
||||||
TerminalExtraKeyButton(
|
TerminalExtraKeyButton(
|
||||||
label = "END",
|
"END",
|
||||||
modifier = Modifier.weight(1f),
|
Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
haptic.contextClick()
|
haptic.contextClick()
|
||||||
writeSpecialKey(KeyEvent.KEYCODE_MOVE_END)
|
viewModel.writeSpecialKey(KeyEvent.KEYCODE_MOVE_END)
|
||||||
}
|
}
|
||||||
TerminalExtraKeyButton(
|
TerminalExtraKeyButton(
|
||||||
label = "PGUP",
|
"PGUP",
|
||||||
modifier = Modifier.weight(1f),
|
Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
haptic.contextClick()
|
haptic.contextClick()
|
||||||
writeSpecialKey(KeyEvent.KEYCODE_PAGE_UP)
|
viewModel.writeSpecialKey(KeyEvent.KEYCODE_PAGE_UP)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
TerminalExtraKeyButton(
|
TerminalExtraKeyButton(
|
||||||
label = "TAB",
|
"TAB",
|
||||||
modifier = Modifier.weight(1f),
|
Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
haptic.contextClick()
|
haptic.contextClick()
|
||||||
writeSpecialKey(KeyEvent.KEYCODE_TAB)
|
viewModel.writeSpecialKey(KeyEvent.KEYCODE_TAB)
|
||||||
}
|
}
|
||||||
TerminalExtraKeyButton(
|
TerminalExtraKeyButton(
|
||||||
label = "CTRL",
|
"CTRL",
|
||||||
active = ctrlLatched,
|
Modifier.weight(1f),
|
||||||
modifier = Modifier.weight(1f),
|
active = viewModel.ctrlLatched
|
||||||
) {
|
) {
|
||||||
haptic.contextClick()
|
haptic.contextClick()
|
||||||
ctrlLatched = !ctrlLatched
|
viewModel.ctrlLatched = !viewModel.ctrlLatched
|
||||||
}
|
}
|
||||||
TerminalExtraKeyButton(
|
TerminalExtraKeyButton(
|
||||||
label = "ALT",
|
"ALT",
|
||||||
active = altLatched,
|
Modifier.weight(1f),
|
||||||
modifier = Modifier.weight(1f),
|
active = viewModel.altLatched
|
||||||
) {
|
) {
|
||||||
haptic.contextClick()
|
haptic.contextClick()
|
||||||
altLatched = !altLatched
|
viewModel.altLatched = !viewModel.altLatched
|
||||||
}
|
}
|
||||||
TerminalExtraKeyButton(
|
TerminalExtraKeyButton(
|
||||||
label = "←",
|
"←",
|
||||||
modifier = Modifier.weight(1f),
|
Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
haptic.contextClick()
|
haptic.contextClick()
|
||||||
writeSpecialKey(KeyEvent.KEYCODE_DPAD_LEFT)
|
viewModel.writeSpecialKey(KeyEvent.KEYCODE_DPAD_LEFT)
|
||||||
}
|
}
|
||||||
TerminalExtraKeyButton(
|
TerminalExtraKeyButton(
|
||||||
label = "↓",
|
"↓",
|
||||||
modifier = Modifier.weight(1f),
|
Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
haptic.contextClick()
|
haptic.contextClick()
|
||||||
writeSpecialKey(KeyEvent.KEYCODE_DPAD_DOWN)
|
viewModel.writeSpecialKey(KeyEvent.KEYCODE_DPAD_DOWN)
|
||||||
}
|
}
|
||||||
TerminalExtraKeyButton(
|
TerminalExtraKeyButton(
|
||||||
label = "→",
|
"→",
|
||||||
modifier = Modifier.weight(1f),
|
Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
haptic.contextClick()
|
haptic.contextClick()
|
||||||
writeSpecialKey(KeyEvent.KEYCODE_DPAD_RIGHT)
|
viewModel.writeSpecialKey(KeyEvent.KEYCODE_DPAD_RIGHT)
|
||||||
}
|
}
|
||||||
TerminalExtraKeyButton(
|
TerminalExtraKeyButton(
|
||||||
label = "PGDN",
|
"PGDN",
|
||||||
modifier = Modifier.weight(1f),
|
Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
haptic.contextClick()
|
haptic.contextClick()
|
||||||
writeSpecialKey(KeyEvent.KEYCODE_PAGE_DOWN)
|
viewModel.writeSpecialKey(KeyEvent.KEYCODE_PAGE_DOWN)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -859,7 +617,6 @@ private fun TerminalExtraKeyButton(
|
|||||||
val content =
|
val content =
|
||||||
if (active) colorScheme.primary
|
if (active) colorScheme.primary
|
||||||
else colorScheme.onSurface
|
else colorScheme.onSurface
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.height(32.dp)
|
.height(32.dp)
|
||||||
@@ -898,7 +655,7 @@ private fun TerminalOutputBottomSheet(
|
|||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.fillMaxHeight(2f / 3f),
|
.fillMaxHeight(2f / 3f)
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
TextField(
|
TextField(
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.pages
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.termux.terminal.KeyHandler
|
||||||
|
import com.termux.terminal.TerminalSession
|
||||||
|
import com.termux.terminal.TextStyle
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.nativecore.AdbSocketStream
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.LocalInputService
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.BundleSyncDelegate
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import top.yukonga.miuix.kmp.basic.SnackbarHostState
|
||||||
|
import top.yukonga.miuix.kmp.basic.SnackbarResult
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private const val DEFAULT_TERMINAL_FONT_SIZE_SP = 14f
|
||||||
|
private const val LOG_TAG = "TerminalScreen"
|
||||||
|
|
||||||
|
internal class TerminalViewModel : ViewModel() {
|
||||||
|
|
||||||
|
private val asBundleSync = BundleSyncDelegate(
|
||||||
|
sharedFlow = appSettings.bundleState,
|
||||||
|
save = { appSettings.saveBundle(it) },
|
||||||
|
scope = viewModelScope,
|
||||||
|
)
|
||||||
|
val asBundle = asBundleSync.value
|
||||||
|
|
||||||
|
private val _terminalFontSizeSp = MutableStateFlow(asBundle.value.terminalFontSizeSp)
|
||||||
|
val terminalFontSizeSp: StateFlow<Float> = _terminalFontSizeSp.asStateFlow()
|
||||||
|
|
||||||
|
fun updateTerminalFontSize(newValue: Float, onApplied: (Float) -> Unit) {
|
||||||
|
val clamped = newValue.coerceIn(1f, 32f)
|
||||||
|
if (clamped == _terminalFontSizeSp.value) return
|
||||||
|
_terminalFontSizeSp.value = clamped
|
||||||
|
asBundleSync.update { it.copy(terminalFontSizeSp = clamped) }
|
||||||
|
onApplied(clamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetFontSizeToDefault(onApplied: (Float) -> Unit) {
|
||||||
|
updateTerminalFontSize(DEFAULT_TERMINAL_FONT_SIZE_SP, onApplied)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _shellReady = MutableStateFlow(false)
|
||||||
|
val shellReady: StateFlow<Boolean> = _shellReady.asStateFlow()
|
||||||
|
|
||||||
|
private val _shellConnecting = MutableStateFlow(false)
|
||||||
|
val shellConnecting: StateFlow<Boolean> = _shellConnecting.asStateFlow()
|
||||||
|
|
||||||
|
private val shellWriteMutex = Mutex()
|
||||||
|
private var shellStream: AdbSocketStream? = null
|
||||||
|
|
||||||
|
val sessionHolder = arrayOfNulls<TerminalSession>(1)
|
||||||
|
|
||||||
|
fun writeBytesToShell(data: ByteArray, offset: Int, count: Int) {
|
||||||
|
val stream = shellStream
|
||||||
|
if (stream == null || !_shellReady.value || stream.closed) return
|
||||||
|
val payload = data.copyOfRange(offset, offset + count)
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val result = runCatching {
|
||||||
|
shellWriteMutex.withLock {
|
||||||
|
stream.outputStream.write(payload)
|
||||||
|
stream.outputStream.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
result.onFailure { error ->
|
||||||
|
snackbar("终端输入失败: ${error.message ?: error.javaClass.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeClipboardToShell(context: Context) {
|
||||||
|
val text = LocalInputService.getClipboardText(context)
|
||||||
|
if (!text.isNullOrBlank()) {
|
||||||
|
val bytes = text.toByteArray(StandardCharsets.UTF_8)
|
||||||
|
writeBytesToShell(bytes, 0, bytes.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ctrlLatched by mutableStateOf(false)
|
||||||
|
var altLatched by mutableStateOf(false)
|
||||||
|
var pendingLatchedConsume by mutableStateOf(false)
|
||||||
|
|
||||||
|
fun consumeLatchedVisualState() {
|
||||||
|
ctrlLatched = false
|
||||||
|
altLatched = false
|
||||||
|
pendingLatchedConsume = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeLiteralKey(text: String) {
|
||||||
|
var payload = text
|
||||||
|
if (ctrlLatched) {
|
||||||
|
payload = payload.map { applyCtrlModifier(it) }.joinToString(separator = "")
|
||||||
|
}
|
||||||
|
if (altLatched) {
|
||||||
|
payload = "$payload"
|
||||||
|
}
|
||||||
|
ctrlLatched = false
|
||||||
|
altLatched = false
|
||||||
|
pendingLatchedConsume = false
|
||||||
|
val bytes = payload.toByteArray(StandardCharsets.UTF_8)
|
||||||
|
writeBytesToShell(bytes, 0, bytes.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeSpecialKey(keyCode: Int) {
|
||||||
|
val session = sessionHolder[0] ?: return
|
||||||
|
var modifiers = 0
|
||||||
|
if (ctrlLatched) modifiers = modifiers or KeyHandler.KEYMOD_CTRL
|
||||||
|
if (altLatched) modifiers = modifiers or KeyHandler.KEYMOD_ALT
|
||||||
|
ctrlLatched = false
|
||||||
|
altLatched = false
|
||||||
|
pendingLatchedConsume = false
|
||||||
|
val sequence = KeyHandler.getCode(
|
||||||
|
keyCode, modifiers,
|
||||||
|
session.emulator.isCursorKeysApplicationMode(),
|
||||||
|
session.emulator.isKeypadApplicationMode(),
|
||||||
|
) ?: return
|
||||||
|
val bytes = sequence.toByteArray(StandardCharsets.UTF_8)
|
||||||
|
writeBytesToShell(bytes, 0, bytes.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun extractTranscript(session: TerminalSession): String {
|
||||||
|
val screen = session.emulator.getScreen()
|
||||||
|
return screen.getSelectedText(
|
||||||
|
selX1 = 0, selY1 = -screen.activeTranscriptRows,
|
||||||
|
selX2 = session.emulator.mColumns, selY2 = session.emulator.mRows - 1,
|
||||||
|
joinBackLines = false, joinFullLines = false,
|
||||||
|
).trim('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
fun syncOutput(onOutputChange: (String) -> Unit) {
|
||||||
|
val session = sessionHolder[0] ?: return
|
||||||
|
onOutputChange(extractTranscript(session))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun applyTerminalThemeColors(surfaceArgb: Int, onSurfaceArgb: Int, cursorArgb: Int) {
|
||||||
|
val session = sessionHolder[0] ?: return
|
||||||
|
val colors = session.emulator.mColors.mCurrentColors
|
||||||
|
colors[TextStyle.COLOR_INDEX_BACKGROUND] = surfaceArgb
|
||||||
|
colors[TextStyle.COLOR_INDEX_FOREGROUND] = onSurfaceArgb
|
||||||
|
colors[TextStyle.COLOR_INDEX_CURSOR] = cursorArgb
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openShellSession(showKeyboardAfterConnect: Boolean, requestFocus: () -> Unit) {
|
||||||
|
if (shellStream != null || _shellConnecting.value) {
|
||||||
|
if (_shellReady.value && showKeyboardAfterConnect) requestFocus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_shellConnecting.value = true
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val streamResult = runCatching {
|
||||||
|
NativeAdbService.openShellStream("")
|
||||||
|
}
|
||||||
|
val stream = streamResult.getOrElse { error ->
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
_shellConnecting.value = false
|
||||||
|
_shellReady.value = false
|
||||||
|
snackbar("终端会话创建失败: ${error.message ?: error.javaClass.simpleName}")
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
shellStream = stream
|
||||||
|
_shellReady.value = true
|
||||||
|
_shellConnecting.value = false
|
||||||
|
if (showKeyboardAfterConnect) requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
val buffer = ByteArray(4096)
|
||||||
|
try {
|
||||||
|
while (!stream.closed) {
|
||||||
|
val count = stream.inputStream.read(buffer)
|
||||||
|
if (count <= 0) break
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
sessionHolder[0]?.append(buffer, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
snackbar("终端输出失败: ${error.message ?: error.javaClass.simpleName}")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
runCatching { stream.close() }
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
if (shellStream === stream) shellStream = null
|
||||||
|
_shellReady.value = false
|
||||||
|
_shellConnecting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun autoConnectIfNeeded(onFocus: () -> Unit) {
|
||||||
|
if (_shellReady.value || _shellConnecting.value) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val connected = runCatching {
|
||||||
|
withContext(Dispatchers.IO) { NativeAdbService.isConnected() }
|
||||||
|
}.getOrDefault(false)
|
||||||
|
if (connected) openShellSession(false, onFocus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeShell() {
|
||||||
|
runCatching { shellStream?.close() }
|
||||||
|
shellStream = null
|
||||||
|
_shellReady.value = false
|
||||||
|
_shellConnecting.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _snackbarEvents = Channel<String>(Channel.BUFFERED)
|
||||||
|
val snackbarEvents: Flow<String> = _snackbarEvents.receiveAsFlow()
|
||||||
|
|
||||||
|
private fun snackbar(message: String) {
|
||||||
|
_snackbarEvents.trySend(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun launchFontSizeSnackbar(
|
||||||
|
fontSizeSp: Float,
|
||||||
|
hostState: SnackbarHostState,
|
||||||
|
onReset: (Float) -> Unit,
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
hostState.newestSnackbarData()?.dismiss()
|
||||||
|
val result = hostState.showSnackbar(
|
||||||
|
message = "终端字号 ${fontSizeSp.roundToInt()}sp",
|
||||||
|
actionLabel = "恢复默认",
|
||||||
|
withDismissAction = true,
|
||||||
|
)
|
||||||
|
if (result == SnackbarResult.ActionPerformed) {
|
||||||
|
resetFontSizeToDefault(onReset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
asBundleSync.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
closeShell()
|
||||||
|
runBlocking(Dispatchers.IO) { asBundleSync.flush() }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private fun applyCtrlModifier(ch: Char): Char = when (ch) {
|
||||||
|
in 'a'..'z' -> (ch.code - 'a'.code + 1).toChar()
|
||||||
|
in 'A'..'Z' -> (ch.code - 'A'.code + 1).toChar()
|
||||||
|
' ' -> 0.toChar()
|
||||||
|
'[' -> 27.toChar()
|
||||||
|
'\\' -> 28.toChar()
|
||||||
|
']' -> 29.toChar()
|
||||||
|
'^' -> 30.toChar()
|
||||||
|
'_', '/' -> 31.toChar()
|
||||||
|
else -> ch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.scaffolds
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.miuix.OverlaySpinnerPreference
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.miuix.SpinnerEntry
|
||||||
|
import top.yukonga.miuix.kmp.basic.BasicComponentColors
|
||||||
|
import top.yukonga.miuix.kmp.basic.BasicComponentDefaults
|
||||||
|
import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator
|
||||||
|
import top.yukonga.miuix.kmp.basic.SpinnerColors
|
||||||
|
import top.yukonga.miuix.kmp.basic.SpinnerDefaults
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [OverlaySpinnerPreference] wrapper with fallback value display and loading state.
|
||||||
|
*
|
||||||
|
* When [dataLoaded] is false and [overrideEndActionValue] is set, the saved value is
|
||||||
|
* appended as a synthetic item so it remains visible before the list is fetched.
|
||||||
|
* When [dataLoading] is true, a "加载中..." entry with an [InfiniteProgressIndicator]
|
||||||
|
* icon is appended at the end.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun OverlaySpinnerWithFallback(
|
||||||
|
items: List<SpinnerEntry>,
|
||||||
|
selectedIndex: Int,
|
||||||
|
title: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
titleColor: BasicComponentColors = BasicComponentDefaults.titleColor(),
|
||||||
|
summary: String? = null,
|
||||||
|
summaryColor: BasicComponentColors = BasicComponentDefaults.summaryColor(),
|
||||||
|
spinnerColors: SpinnerColors = SpinnerDefaults.spinnerColors(),
|
||||||
|
startAction: @Composable (() -> Unit)? = null,
|
||||||
|
bottomAction: (@Composable () -> Unit)? = null,
|
||||||
|
insideMargin: PaddingValues = BasicComponentDefaults.InsideMargin,
|
||||||
|
maxHeight: Dp? = null,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
showValue: Boolean = true,
|
||||||
|
renderInRootScaffold: Boolean = true,
|
||||||
|
onExpandedChange: ((Boolean) -> Unit)? = null,
|
||||||
|
onSelectedIndexChange: ((Int) -> Unit)? = null,
|
||||||
|
dataLoaded: Boolean = true,
|
||||||
|
dataLoading: Boolean = false,
|
||||||
|
overrideEndActionValue: String? = null,
|
||||||
|
) {
|
||||||
|
val fallbackActive = !dataLoaded && !overrideEndActionValue.isNullOrBlank()
|
||||||
|
val fallbackIdx = items.size
|
||||||
|
val effectiveItems = remember(items, dataLoading, fallbackActive, overrideEndActionValue) {
|
||||||
|
buildList {
|
||||||
|
addAll(items)
|
||||||
|
if (fallbackActive) {
|
||||||
|
add(
|
||||||
|
SpinnerEntry(
|
||||||
|
title = overrideEndActionValue,
|
||||||
|
enabled = true, // 保持启用以显示蓝色而非灰色
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (dataLoading) {
|
||||||
|
add(
|
||||||
|
SpinnerEntry(
|
||||||
|
icon = { mod -> InfiniteProgressIndicator(mod) },
|
||||||
|
title = "加载中...",
|
||||||
|
enabled = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val effectiveIndex = if (fallbackActive) fallbackIdx else selectedIndex
|
||||||
|
|
||||||
|
OverlaySpinnerPreference(
|
||||||
|
items = effectiveItems,
|
||||||
|
selectedIndex = effectiveIndex,
|
||||||
|
title = title,
|
||||||
|
modifier = modifier,
|
||||||
|
titleColor = titleColor,
|
||||||
|
summary = summary,
|
||||||
|
summaryColor = summaryColor,
|
||||||
|
spinnerColors = spinnerColors,
|
||||||
|
startAction = startAction,
|
||||||
|
bottomAction = bottomAction,
|
||||||
|
insideMargin = insideMargin,
|
||||||
|
maxHeight = maxHeight,
|
||||||
|
enabled = enabled,
|
||||||
|
showValue = showValue,
|
||||||
|
renderInRootScaffold = renderInRootScaffold,
|
||||||
|
onExpandedChange = onExpandedChange,
|
||||||
|
onSelectedIndexChange = { idx ->
|
||||||
|
if (dataLoading) return@OverlaySpinnerPreference
|
||||||
|
if (fallbackActive && idx >= fallbackIdx) return@OverlaySpinnerPreference
|
||||||
|
onSelectedIndexChange?.invoke(idx)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,7 +18,6 @@ data class ClientOptions(
|
|||||||
// --crop=width:height:x:y
|
// --crop=width:height:x:y
|
||||||
var crop: String = "", // to server
|
var crop: String = "", // to server
|
||||||
|
|
||||||
// TODO: implement
|
|
||||||
// --record
|
// --record
|
||||||
var recordFilename: String = "",
|
var recordFilename: String = "",
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.storage
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages a local copy of a bundle from persistent storage with debounced save.
|
||||||
|
*
|
||||||
|
* Usage in a ViewModel:
|
||||||
|
* ```
|
||||||
|
* val asBundle = BundleSyncDelegate(
|
||||||
|
* sharedFlow = appSettings.bundleState,
|
||||||
|
* save = { appSettings.saveBundle(it) },
|
||||||
|
* scope = viewModelScope,
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* init { asBundle.start() }
|
||||||
|
*
|
||||||
|
* override fun onCleared() {
|
||||||
|
* runBlocking(Dispatchers.IO) { asBundle.flush() }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
class BundleSyncDelegate<T>(
|
||||||
|
private val sharedFlow: StateFlow<T>,
|
||||||
|
private val save: suspend (T) -> Unit,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val delayMs: Long = 600L,
|
||||||
|
) {
|
||||||
|
private val _value = MutableStateFlow(sharedFlow.value)
|
||||||
|
val value: StateFlow<T> = _value.asStateFlow()
|
||||||
|
|
||||||
|
fun update(transform: (T) -> T) {
|
||||||
|
_value.update(transform)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
scope.launch {
|
||||||
|
sharedFlow.collectLatest { shared ->
|
||||||
|
if (_value.value != shared) _value.value = shared
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
_value.debounce(delayMs).collectLatest { v ->
|
||||||
|
if (v != sharedFlow.value) save(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun flush() {
|
||||||
|
val latest = _value.value
|
||||||
|
if (latest != sharedFlow.value) save(latest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable equivalent of [BundleSyncDelegate] for files not yet migrated to
|
||||||
|
* ViewModel. Returns a mutable state backed by [sharedFlow] with debounced save.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```
|
||||||
|
* var asBundle by rememberBundleState(
|
||||||
|
* sharedFlow = appSettings.bundleState,
|
||||||
|
* save = { appSettings.saveBundle(it) },
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun <T> rememberBundleState(
|
||||||
|
sharedFlow: StateFlow<T>,
|
||||||
|
save: suspend (T) -> Unit,
|
||||||
|
): MutableState<T> {
|
||||||
|
val shared by sharedFlow.collectAsState()
|
||||||
|
val sharedLatest by rememberUpdatedState(shared)
|
||||||
|
val state = rememberSaveable(shared) { mutableStateOf(shared) }
|
||||||
|
var local by state
|
||||||
|
val localLatest by rememberUpdatedState(local)
|
||||||
|
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||||
|
|
||||||
|
LaunchedEffect(shared) {
|
||||||
|
if (local != shared) local = shared
|
||||||
|
}
|
||||||
|
LaunchedEffect(local) {
|
||||||
|
delay(Settings.BUNDLE_SAVE_DELAY)
|
||||||
|
if (local != sharedLatest) save(local)
|
||||||
|
}
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
kotlinx.coroutines.runBlocking(Dispatchers.IO) {
|
||||||
|
if (localLatest != sharedFlow.value) save(localLatest)
|
||||||
|
}
|
||||||
|
taskScope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
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.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
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.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
|
|
||||||
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
|
|
||||||
import top.yukonga.miuix.kmp.basic.Text
|
|
||||||
import top.yukonga.miuix.kmp.overlay.OverlayListPopup
|
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
|
||||||
|
|
||||||
data class MultiGroupsDropdownGroup(
|
|
||||||
val options: List<String>,
|
|
||||||
val selectedIndex: Int,
|
|
||||||
val onSelectedIndexChange: (Int) -> Unit,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MultiGroupsDropdownPreference(
|
|
||||||
title: String,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
summary: String? = null,
|
|
||||||
groups: List<MultiGroupsDropdownGroup>,
|
|
||||||
titleColor: BasicComponentColors = BasicComponentDefaults.titleColor(),
|
|
||||||
summaryColor: BasicComponentColors = BasicComponentDefaults.summaryColor(),
|
|
||||||
insideMargin: PaddingValues = BasicComponentDefaults.InsideMargin,
|
|
||||||
dropdownColors: DropdownColors = DropdownDefaults.dropdownColors(),
|
|
||||||
enabled: Boolean = true,
|
|
||||||
showValue: Boolean = true,
|
|
||||||
) {
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
|
||||||
val isExpanded = remember { mutableStateOf(false) }
|
|
||||||
val isHoldDown = remember { mutableStateOf(false) }
|
|
||||||
val haptic = LocalHapticFeedback.current
|
|
||||||
val hapticLatest by rememberUpdatedState(haptic)
|
|
||||||
|
|
||||||
val hasOptions = groups.any { it.options.isNotEmpty() }
|
|
||||||
val actualEnabled = enabled && hasOptions
|
|
||||||
val actionColor = if (actualEnabled) {
|
|
||||||
MiuixTheme.colorScheme.onSurfaceVariantActions
|
|
||||||
} else {
|
|
||||||
MiuixTheme.colorScheme.disabledOnSecondaryVariant
|
|
||||||
}
|
|
||||||
val selectedValueText = groups.joinToString("\n") { group ->
|
|
||||||
group.options.getOrNull(group.selectedIndex).orEmpty()
|
|
||||||
}.ifBlank { null }
|
|
||||||
|
|
||||||
BasicComponent(
|
|
||||||
modifier = modifier,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
insideMargin = insideMargin,
|
|
||||||
title = title,
|
|
||||||
titleColor = titleColor,
|
|
||||||
summary = summary,
|
|
||||||
summaryColor = summaryColor,
|
|
||||||
endActions = {
|
|
||||||
if (showValue && !selectedValueText.isNullOrBlank()) {
|
|
||||||
Text(
|
|
||||||
text = selectedValueText,
|
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
|
||||||
fontSize = MiuixTheme.textStyles.body2.fontSize,
|
|
||||||
color = actionColor,
|
|
||||||
textAlign = TextAlign.End,
|
|
||||||
lineHeight = MiuixTheme.textStyles.body2.lineHeight,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DropdownArrowEndAction(actionColor = actionColor)
|
|
||||||
if (hasOptions) {
|
|
||||||
OverlayListPopup(
|
|
||||||
show = isExpanded.value,
|
|
||||||
alignment = PopupPositionProvider.Align.End,
|
|
||||||
onDismissRequest = { isExpanded.value = false },
|
|
||||||
onDismissFinished = { isHoldDown.value = false },
|
|
||||||
) {
|
|
||||||
ListPopupColumn {
|
|
||||||
MultiGroupsDropdown(
|
|
||||||
groups = groups,
|
|
||||||
dropdownColors = dropdownColors,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
if (actualEnabled) {
|
|
||||||
isExpanded.value = !isExpanded.value
|
|
||||||
if (isExpanded.value) {
|
|
||||||
isHoldDown.value = true
|
|
||||||
hapticLatest.performHapticFeedback(HapticFeedbackType.ContextClick)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
holdDownState = isHoldDown.value,
|
|
||||||
enabled = actualEnabled,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MultiGroupsDropdown(
|
|
||||||
groups: List<MultiGroupsDropdownGroup>,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
dropdownColors: DropdownColors = DropdownDefaults.dropdownColors(),
|
|
||||||
) {
|
|
||||||
groups.forEachIndexed { groupIndex, group ->
|
|
||||||
group.options.forEachIndexed { optionIndex, option ->
|
|
||||||
DropdownImpl(
|
|
||||||
text = option,
|
|
||||||
optionSize = group.options.size,
|
|
||||||
isSelected = optionIndex == group.selectedIndex,
|
|
||||||
dropdownColors = dropdownColors,
|
|
||||||
index = optionIndex,
|
|
||||||
onSelectedIndexChange = group.onSelectedIndexChange,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (groupIndex != groups.lastIndex) {
|
|
||||||
HorizontalDivider(modifier = modifier.padding(horizontal = 20.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid.widgets
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
|
||||||
import top.yukonga.miuix.kmp.basic.DropdownImpl
|
|
||||||
import top.yukonga.miuix.kmp.basic.Text
|
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.textStyles
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PopupMenuItem(
|
|
||||||
text: String,
|
|
||||||
optionSize: Int,
|
|
||||||
index: Int,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
onSelectedIndexChange: (Int) -> Unit,
|
|
||||||
) {
|
|
||||||
if (enabled) {
|
|
||||||
DropdownImpl(
|
|
||||||
text = text,
|
|
||||||
optionSize = optionSize,
|
|
||||||
isSelected = false,
|
|
||||||
index = index,
|
|
||||||
onSelectedIndexChange = onSelectedIndexChange,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val additionalTopPadding = if (index == 0) UiSpacing.PopupHorizontal else UiSpacing.PageItem
|
|
||||||
val additionalBottomPadding =
|
|
||||||
if (index == optionSize - 1) UiSpacing.PopupHorizontal else UiSpacing.PageItem
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
fontSize = textStyles.body1.fontSize,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = colorScheme.disabledOnSecondaryVariant,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = UiSpacing.PopupHorizontal)
|
|
||||||
.padding(top = additionalTopPadding, bottom = additionalBottomPadding),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -27,6 +27,7 @@ androidxSecurityCrypto = "1.1.0"
|
|||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
|
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
|
||||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
|||||||
@@ -29,4 +29,5 @@ dependencyResolutionManagement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = "Scrcpy"
|
rootProject.name = "Scrcpy"
|
||||||
|
includeBuild("submodule/miuix")
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
1
submodule/miuix
Submodule
1
submodule/miuix
Submodule
Submodule submodule/miuix added at 81ad71b1c6
Reference in New Issue
Block a user