feat: shortcut of recent tasks
This commit is contained in:
5
TODO.md
5
TODO.md
@@ -1,7 +1,11 @@
|
|||||||
|
<!-- markdownlint-disable MD024 -->
|
||||||
|
|
||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
## PARAMS
|
## PARAMS
|
||||||
|
|
||||||
|
### LOWER PRIORITY
|
||||||
|
|
||||||
- orientation locking
|
- orientation locking
|
||||||
- `-r, --record=file.mp4` 投屏时录制到文件 Record screen to file. The format is determined by the --record-format option if set, or by the file extension.
|
- `-r, --record=file.mp4` 投屏时录制到文件 Record screen to file. The format is determined by the --record-format option if set, or by the file extension.
|
||||||
- `--record-format` 录制格式 Force recording format (mp4, mkv, m4a, mka, opus, aac, flac or wav).
|
- `--record-format` 录制格式 Force recording format (mp4, mkv, m4a, mka, opus, aac, flac or wav).
|
||||||
@@ -12,7 +16,6 @@
|
|||||||
|
|
||||||
顺序无关
|
顺序无关
|
||||||
|
|
||||||
- 多配置切换
|
|
||||||
- [音频延迟](https://developer.android.com/ndk/guides/audio/audio-latency)
|
- [音频延迟](https://developer.android.com/ndk/guides/audio/audio-latency)
|
||||||
- 横屏布局
|
- 横屏布局
|
||||||
- I18N
|
- I18N
|
||||||
|
|||||||
@@ -2,14 +2,22 @@ package io.github.miuzarte.scrcpyforandroid.pages
|
|||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.sizeIn
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.rounded.MoreVert
|
import androidx.compose.material.icons.rounded.MoreVert
|
||||||
|
import androidx.compose.material.icons.rounded.Refresh
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -22,14 +30,19 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
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.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.graphics.BlendMode
|
||||||
|
import androidx.compose.ui.graphics.BlendModeColorFilter
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import io.github.miuzarte.scrcpyforandroid.StreamActivity
|
import io.github.miuzarte.scrcpyforandroid.StreamActivity
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
|
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||||
import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics
|
import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics
|
||||||
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
|
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
|
||||||
@@ -60,7 +73,6 @@ import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard
|
|||||||
import io.github.miuzarte.scrcpyforandroid.widgets.QuickConnectCard
|
import io.github.miuzarte.scrcpyforandroid.widgets.QuickConnectCard
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
|
import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.StatusCard
|
import io.github.miuzarte.scrcpyforandroid.widgets.StatusCard
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
|
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
|
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonCard
|
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonCard
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -79,11 +91,20 @@ import top.yukonga.miuix.kmp.basic.ListPopupDefaults
|
|||||||
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
|
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.SpinnerColors
|
||||||
|
import top.yukonga.miuix.kmp.basic.SpinnerDefaults
|
||||||
|
import top.yukonga.miuix.kmp.basic.SpinnerEntry
|
||||||
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.basic.TopAppBar
|
import top.yukonga.miuix.kmp.basic.TopAppBar
|
||||||
|
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||||
|
import top.yukonga.miuix.kmp.icon.basic.Check
|
||||||
|
import top.yukonga.miuix.kmp.icon.extended.Store
|
||||||
|
import top.yukonga.miuix.kmp.icon.extended.Tune
|
||||||
|
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
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn as FoundationLazyColumn
|
||||||
|
|
||||||
private const val ADB_CONNECT_TIMEOUT_MS = 12_000L
|
private const val ADB_CONNECT_TIMEOUT_MS = 12_000L
|
||||||
private const val ADB_KEEPALIVE_INTERVAL_MS = 3_000L
|
private const val ADB_KEEPALIVE_INTERVAL_MS = 3_000L
|
||||||
@@ -256,10 +277,12 @@ fun DeviceTabPage(
|
|||||||
var busy by rememberSaveable { mutableStateOf(false) }
|
var busy by rememberSaveable { mutableStateOf(false) }
|
||||||
var adbSession by rememberSaveable { mutableStateOf(DeviceAdbSessionState()) }
|
var adbSession by rememberSaveable { mutableStateOf(DeviceAdbSessionState()) }
|
||||||
val sessionInfo by scrcpy.currentSessionState.collectAsState()
|
val sessionInfo by scrcpy.currentSessionState.collectAsState()
|
||||||
|
val listingsRefreshBusy by scrcpy.listings.refreshBusyState.collectAsState()
|
||||||
var editingDeviceId by rememberSaveable { mutableStateOf<String?>(null) }
|
var editingDeviceId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(null) }
|
var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var adbConnecting by rememberSaveable { mutableStateOf(false) }
|
var adbConnecting by rememberSaveable { mutableStateOf(false) }
|
||||||
var pendingScrollToPreview by rememberSaveable { mutableStateOf(false) }
|
var pendingScrollToPreview by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showRecentTasksSheet by rememberSaveable { mutableStateOf(false) }
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val isPreviewCardVisible by remember(listState) {
|
val isPreviewCardVisible by remember(listState) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
@@ -275,6 +298,7 @@ fun DeviceTabPage(
|
|||||||
val connectedScrcpyProfileId = adbSession.connectedScrcpyProfileId
|
val connectedScrcpyProfileId = adbSession.connectedScrcpyProfileId
|
||||||
val audioForwardingSupported = adbSession.audioForwardingSupported
|
val audioForwardingSupported = adbSession.audioForwardingSupported
|
||||||
val cameraMirroringSupported = adbSession.cameraMirroringSupported
|
val cameraMirroringSupported = adbSession.cameraMirroringSupported
|
||||||
|
val recentTasks = scrcpy.listings.recentTasks
|
||||||
|
|
||||||
fun resolveScrcpyBundle(profileId: String): ScrcpyOptions.Bundle {
|
fun resolveScrcpyBundle(profileId: String): ScrcpyOptions.Bundle {
|
||||||
if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID) {
|
if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID) {
|
||||||
@@ -611,13 +635,20 @@ fun DeviceTabPage(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun startScrcpySession(openFullscreen: Boolean = false) {
|
suspend fun startScrcpySession(
|
||||||
|
openFullscreen: Boolean = false,
|
||||||
|
startAppOverride: String? = null,
|
||||||
|
) {
|
||||||
val activeBundle = resolveScrcpyBundle(connectedScrcpyProfileId)
|
val activeBundle = resolveScrcpyBundle(connectedScrcpyProfileId)
|
||||||
val options = scrcpyOptions.toClientOptions(activeBundle).fix()
|
val options = scrcpyOptions.toClientOptions(activeBundle).fix()
|
||||||
val session = scrcpy.start(options)
|
val resolvedOptions = startAppOverride
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { options.copy(startApp = it) }
|
||||||
|
?: options
|
||||||
|
val session = scrcpy.start(resolvedOptions)
|
||||||
pendingScrollToPreview = true
|
pendingScrollToPreview = true
|
||||||
val startAppRequest = runCatching {
|
val startAppRequest = runCatching {
|
||||||
resolveStartAppRequest(scrcpy, options)
|
resolveStartAppRequest(scrcpy, resolvedOptions)
|
||||||
}.getOrElse { error ->
|
}.getOrElse { error ->
|
||||||
logEvent(
|
logEvent(
|
||||||
"启动应用请求无效: ${
|
"启动应用请求无效: ${
|
||||||
@@ -629,7 +660,7 @@ fun DeviceTabPage(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
startAppRequest?.let { request ->
|
startAppRequest?.let { request ->
|
||||||
if (options.newDisplay.isNotBlank() && request.displayId == null) {
|
if (resolvedOptions.newDisplay.isNotBlank() && request.displayId == null) {
|
||||||
logEvent(
|
logEvent(
|
||||||
"当前实现无法获取 new display 的真实 displayId,应用会启动到默认显示",
|
"当前实现无法获取 new display 的真实 displayId,应用会启动到默认显示",
|
||||||
Log.WARN,
|
Log.WARN,
|
||||||
@@ -657,30 +688,30 @@ fun DeviceTabPage(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (options.fullscreen || openFullscreen) withContext(Dispatchers.Main) {
|
if (resolvedOptions.fullscreen || openFullscreen) withContext(Dispatchers.Main) {
|
||||||
context.startActivity(StreamActivity.createIntent(context))
|
context.startActivity(StreamActivity.createIntent(context))
|
||||||
}
|
}
|
||||||
if (options.disableScreensaver)
|
if (resolvedOptions.disableScreensaver)
|
||||||
AppWakeLocks.acquire()
|
AppWakeLocks.acquire()
|
||||||
|
|
||||||
adbSession = adbSession.copy(statusLine = "scrcpy 运行中")
|
adbSession = adbSession.copy(statusLine = "scrcpy 运行中")
|
||||||
@SuppressLint("DefaultLocale")
|
@SuppressLint("DefaultLocale")
|
||||||
val videoDetail =
|
val videoDetail =
|
||||||
if (!options.video) "off"
|
if (!resolvedOptions.video) "off"
|
||||||
else if (activeBundle.videoBitRate <= 0) "${session.codec?.string ?: "null"} ${session.width}x${session.height} @default"
|
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} " +
|
else "${session.codec?.string ?: "null"} ${session.width}x${session.height} " +
|
||||||
"@${String.format("%.1f", activeBundle.videoBitRate / 1_000_000f)}Mbps"
|
"@${String.format("%.1f", activeBundle.videoBitRate / 1_000_000f)}Mbps"
|
||||||
|
|
||||||
val audioDetail =
|
val audioDetail =
|
||||||
if (!activeBundle.audio) "off"
|
if (!activeBundle.audio) "off"
|
||||||
else if (activeBundle.audioBitRate <= 0) "${options.audioCodec} default source=${options.audioSource}"
|
else if (activeBundle.audioBitRate <= 0) "${resolvedOptions.audioCodec} default source=${resolvedOptions.audioSource}"
|
||||||
else "${options.audioCodec} ${activeBundle.audioBitRate / 1_000f}Kbps source=${options.audioSource}${if (!options.audioPlayback) "(no-playback)" else ""}"
|
else "${resolvedOptions.audioCodec} ${activeBundle.audioBitRate / 1_000f}Kbps source=${resolvedOptions.audioSource}${if (!resolvedOptions.audioPlayback) "(no-playback)" else ""}"
|
||||||
|
|
||||||
logEvent(
|
logEvent(
|
||||||
"scrcpy 已启动: device=${session.deviceName}" +
|
"scrcpy 已启动: device=${session.deviceName}" +
|
||||||
", video=$videoDetail, audio=$audioDetail" +
|
", video=$videoDetail, audio=$audioDetail" +
|
||||||
", control=${options.control}, turnScreenOff=${options.turnScreenOff}" +
|
", control=${resolvedOptions.control}, turnScreenOff=${resolvedOptions.turnScreenOff}" +
|
||||||
", maxSize=${options.maxSize}, maxFps=${options.maxFps}"
|
", maxSize=${resolvedOptions.maxSize}, maxFps=${resolvedOptions.maxFps}"
|
||||||
)
|
)
|
||||||
snackbar.show("scrcpy 已启动")
|
snackbar.show("scrcpy 已启动")
|
||||||
}
|
}
|
||||||
@@ -742,12 +773,35 @@ fun DeviceTabPage(
|
|||||||
"sdk=${info.sdkInt}"
|
"sdk=${info.sdkInt}"
|
||||||
)
|
)
|
||||||
snackbar.show("ADB 已连接")
|
snackbar.show("ADB 已连接")
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
scrcpy.listings.getApps(forceRefresh = true)
|
||||||
|
}.onFailure { error ->
|
||||||
|
logEvent("获取应用列表失败: ${error.message}", Log.WARN, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (autoStartScrcpy && sessionInfo == null) runBusy("启动 scrcpy") {
|
if (autoStartScrcpy && sessionInfo == null) runBusy("启动 scrcpy") {
|
||||||
startScrcpySession(openFullscreen = autoStartScrcpy && autoEnterFullScreen)
|
startScrcpySession(openFullscreen = autoStartScrcpy && autoEnterFullScreen)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(
|
||||||
|
adbConnected,
|
||||||
|
currentTarget?.host,
|
||||||
|
currentTarget?.port,
|
||||||
|
isAppInForeground,
|
||||||
|
) {
|
||||||
|
if (!adbConnected || !isAppInForeground) return@LaunchedEffect
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
scrcpy.listings.getRecentTasks(forceRefresh = true)
|
||||||
|
}.onFailure { error ->
|
||||||
|
logEvent("获取最近任务失败: ${error.message}", Log.WARN, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
adbConnected,
|
adbConnected,
|
||||||
asBundle.adbAutoReconnectPairedDevice,
|
asBundle.adbAutoReconnectPairedDevice,
|
||||||
@@ -992,6 +1046,33 @@ fun DeviceTabPage(
|
|||||||
cameraMirroringSupported = cameraMirroringSupported,
|
cameraMirroringSupported = cameraMirroringSupported,
|
||||||
adbConnecting = adbConnecting,
|
adbConnecting = adbConnecting,
|
||||||
isQuickConnected = isQuickConnected,
|
isQuickConnected = isQuickConnected,
|
||||||
|
recentTasksEndAction = {
|
||||||
|
Text(
|
||||||
|
text = when {
|
||||||
|
listingsRefreshBusy -> "..."
|
||||||
|
recentTasks.isNotEmpty() -> recentTasks.size.toString()
|
||||||
|
else -> "空"
|
||||||
|
},
|
||||||
|
color = MiuixTheme.colorScheme.onSurfaceVariantActions,
|
||||||
|
fontSize = MiuixTheme.textStyles.body2.fontSize,
|
||||||
|
modifier = Modifier.padding(end = UiSpacing.ContentVertical),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onOpenRecentTasks = {
|
||||||
|
showRecentTasksSheet = true
|
||||||
|
if (recentTasks.isEmpty() && !listingsRefreshBusy) {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
scrcpy.listings.getRecentTasks(forceRefresh = true)
|
||||||
|
}.onFailure { error ->
|
||||||
|
logEvent("获取最近任务失败: ${error.message}", Log.WARN, error)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
snackbar.show("获取最近任务失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onOpenAdvanced = { navigator.push(RootScreen.Advanced) },
|
onOpenAdvanced = { navigator.push(RootScreen.Advanced) },
|
||||||
onStartStopHaptic = { haptics.contextClick() },
|
onStartStopHaptic = { haptics.contextClick() },
|
||||||
onStart = {
|
onStart = {
|
||||||
@@ -1067,6 +1148,32 @@ fun DeviceTabPage(
|
|||||||
|
|
||||||
item { Spacer(Modifier.height(UiSpacing.PageBottom)) }
|
item { Spacer(Modifier.height(UiSpacing.PageBottom)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RecentTasksBottomSheet(
|
||||||
|
show = showRecentTasksSheet,
|
||||||
|
tasks = recentTasks,
|
||||||
|
scrcpy = scrcpy,
|
||||||
|
refreshBusy = listingsRefreshBusy,
|
||||||
|
onDismissRequest = { showRecentTasksSheet = false },
|
||||||
|
onRefresh = {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
scrcpy.listings.getRecentTasks(forceRefresh = true)
|
||||||
|
}.onFailure { error ->
|
||||||
|
logEvent("获取最近任务失败: ${error.message}", Log.WARN, error)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
snackbar.show("获取最近任务失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLaunchTask = { task ->
|
||||||
|
showRecentTasksSheet = false
|
||||||
|
runBusy("启动 scrcpy") {
|
||||||
|
startScrcpySession(startAppOverride = task.packageName)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -1109,6 +1216,153 @@ private fun DeviceMenuPopup(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecentTasksBottomSheet(
|
||||||
|
show: Boolean,
|
||||||
|
tasks: List<Scrcpy.RecentTaskInfo>,
|
||||||
|
scrcpy: Scrcpy,
|
||||||
|
refreshBusy: Boolean,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
onLaunchTask: (Scrcpy.RecentTaskInfo) -> Unit,
|
||||||
|
) {
|
||||||
|
val spinnerColors = SpinnerDefaults.spinnerColors()
|
||||||
|
OverlayBottomSheet(
|
||||||
|
show = show,
|
||||||
|
title = "最近任务",
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
endAction = {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
if (!refreshBusy) {
|
||||||
|
onRefresh()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.Refresh,
|
||||||
|
contentDescription = "刷新最近任务",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
tasks.isEmpty() && refreshBusy -> {
|
||||||
|
Text(
|
||||||
|
text = "最近任务加载中",
|
||||||
|
modifier = Modifier.padding(horizontal = UiSpacing.Large),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.isEmpty() -> {
|
||||||
|
Text(
|
||||||
|
text = "没有可用的最近任务",
|
||||||
|
modifier = Modifier.padding(horizontal = UiSpacing.Large),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
FoundationLazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 420.dp),
|
||||||
|
) {
|
||||||
|
items(tasks.size) { index ->
|
||||||
|
val task = tasks[index]
|
||||||
|
val app = scrcpy.listings.findCachedApp(task.packageName)
|
||||||
|
val entry = SpinnerEntry(
|
||||||
|
icon = { modifier ->
|
||||||
|
Icon(
|
||||||
|
imageVector = if (app?.system == true) MiuixIcons.Tune else MiuixIcons.Store,
|
||||||
|
contentDescription = task.packageName,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = app?.label?.takeIf { it.isNotBlank() } ?: task.packageName,
|
||||||
|
summary = task.packageName,
|
||||||
|
)
|
||||||
|
RecentTaskSheetItem(
|
||||||
|
entry = entry,
|
||||||
|
entryCount = tasks.size,
|
||||||
|
index = index,
|
||||||
|
spinnerColors = spinnerColors,
|
||||||
|
onClick = { onLaunchTask(task) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(UiSpacing.SheetBottom))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecentTaskSheetItem(
|
||||||
|
entry: SpinnerEntry,
|
||||||
|
entryCount: Int,
|
||||||
|
index: Int,
|
||||||
|
spinnerColors: SpinnerColors,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val additionalTopPadding = if (index == 0) 20.dp else 12.dp
|
||||||
|
val additionalBottomPadding = if (index == entryCount - 1) 20.dp else 12.dp
|
||||||
|
val checkColorFilter = remember {
|
||||||
|
BlendModeColorFilter(Color.Transparent, BlendMode.SrcIn)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.drawBehind { drawRect(spinnerColors.containerColor) }
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 20.dp)
|
||||||
|
.padding(top = additionalTopPadding, bottom = additionalBottomPadding),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Start,
|
||||||
|
) {
|
||||||
|
entry.icon?.invoke(
|
||||||
|
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 = spinnerColors.contentColor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
entry.summary?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
fontSize = MiuixTheme.textStyles.body2.fontSize,
|
||||||
|
color = spinnerColors.summaryColor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Image(
|
||||||
|
imageVector = MiuixIcons.Basic.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 12.dp)
|
||||||
|
.sizeIn(minWidth = 20.dp, minHeight = 20.dp),
|
||||||
|
colorFilter = checkColorFilter,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DeviceMenuPopupItem(
|
private fun DeviceMenuPopupItem(
|
||||||
text: String,
|
text: String,
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import androidx.compose.runtime.MutableState
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -401,8 +400,8 @@ internal fun ScrcpyAllOptionsPage(
|
|||||||
val taskScope = remember { CoroutineScope(Dispatchers.IO + SupervisorJob()) }
|
val taskScope = remember { CoroutineScope(Dispatchers.IO + SupervisorJob()) }
|
||||||
val snackbar = LocalSnackbarController.current
|
val snackbar = LocalSnackbarController.current
|
||||||
|
|
||||||
var refreshBusy by rememberSaveable { mutableStateOf(false) }
|
val refreshBusy by scrcpy.listings.refreshBusyState.collectAsState()
|
||||||
var listRefreshVersion by rememberSaveable { mutableIntStateOf(0) }
|
val listRefreshVersion by scrcpy.listings.refreshVersionState.collectAsState()
|
||||||
|
|
||||||
var selectedProfileId by selectedProfileIdState
|
var selectedProfileId by selectedProfileIdState
|
||||||
val selectedProfileIdLatest by rememberUpdatedState(selectedProfileId)
|
val selectedProfileIdLatest by rememberUpdatedState(selectedProfileId)
|
||||||
@@ -1058,18 +1057,14 @@ internal fun ScrcpyAllOptionsPage(
|
|||||||
onClick = {
|
onClick = {
|
||||||
if (refreshBusy) return@ArrowPreference
|
if (refreshBusy) return@ArrowPreference
|
||||||
scope.launch {
|
scope.launch {
|
||||||
refreshBusy = true
|
|
||||||
snackbar.show("获取中")
|
snackbar.show("获取中")
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
scrcpy.listings.getDisplays(forceRefresh = true)
|
scrcpy.listings.getDisplays(forceRefresh = true)
|
||||||
}
|
}
|
||||||
listRefreshVersion += 1
|
|
||||||
snackbar.show("获取成功")
|
snackbar.show("获取成功")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
snackbar.show("刷新失败: ${e.message}")
|
snackbar.show("刷新失败: ${e.message}")
|
||||||
} finally {
|
|
||||||
refreshBusy = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1158,18 +1153,14 @@ internal fun ScrcpyAllOptionsPage(
|
|||||||
onClick = {
|
onClick = {
|
||||||
if (refreshBusy) return@ArrowPreference
|
if (refreshBusy) return@ArrowPreference
|
||||||
scope.launch {
|
scope.launch {
|
||||||
refreshBusy = true
|
|
||||||
snackbar.show("获取中")
|
snackbar.show("获取中")
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
scrcpy.listings.getCameras(forceRefresh = true)
|
scrcpy.listings.getCameras(forceRefresh = true)
|
||||||
}
|
}
|
||||||
listRefreshVersion += 1
|
|
||||||
snackbar.show("获取成功")
|
snackbar.show("获取成功")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
snackbar.show("刷新失败: ${e.message}")
|
snackbar.show("刷新失败: ${e.message}")
|
||||||
} finally {
|
|
||||||
refreshBusy = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1206,18 +1197,14 @@ internal fun ScrcpyAllOptionsPage(
|
|||||||
onClick = {
|
onClick = {
|
||||||
if (refreshBusy) return@ArrowPreference
|
if (refreshBusy) return@ArrowPreference
|
||||||
scope.launch {
|
scope.launch {
|
||||||
refreshBusy = true
|
|
||||||
snackbar.show("获取中")
|
snackbar.show("获取中")
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
scrcpy.listings.getCameraSizes(forceRefresh = true)
|
scrcpy.listings.getCameraSizes(forceRefresh = true)
|
||||||
}
|
}
|
||||||
listRefreshVersion += 1
|
|
||||||
snackbar.show("获取成功")
|
snackbar.show("获取成功")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
snackbar.show("刷新失败: ${e.message}")
|
snackbar.show("刷新失败: ${e.message}")
|
||||||
} finally {
|
|
||||||
refreshBusy = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1397,18 +1384,14 @@ internal fun ScrcpyAllOptionsPage(
|
|||||||
onClick = {
|
onClick = {
|
||||||
if (refreshBusy) return@ArrowPreference
|
if (refreshBusy) return@ArrowPreference
|
||||||
scope.launch {
|
scope.launch {
|
||||||
refreshBusy = true
|
|
||||||
snackbar.show("获取中")
|
snackbar.show("获取中")
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
scrcpy.listings.getEncoders(forceRefresh = true)
|
scrcpy.listings.getEncoders(forceRefresh = true)
|
||||||
}
|
}
|
||||||
listRefreshVersion += 1
|
|
||||||
snackbar.show("获取成功")
|
snackbar.show("获取成功")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
snackbar.show("刷新失败: ${e.message}")
|
snackbar.show("刷新失败: ${e.message}")
|
||||||
} finally {
|
|
||||||
refreshBusy = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1547,18 +1530,14 @@ internal fun ScrcpyAllOptionsPage(
|
|||||||
onClick = {
|
onClick = {
|
||||||
if (refreshBusy) return@ArrowPreference
|
if (refreshBusy) return@ArrowPreference
|
||||||
scope.launch {
|
scope.launch {
|
||||||
refreshBusy = true
|
|
||||||
snackbar.show("获取中")
|
snackbar.show("获取中")
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
scrcpy.listings.getApps(forceRefresh = true)
|
scrcpy.listings.getApps(forceRefresh = true)
|
||||||
}
|
}
|
||||||
listRefreshVersion += 1
|
|
||||||
snackbar.show("获取成功")
|
snackbar.show("获取成功")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
snackbar.show("刷新失败: ${e.message}")
|
snackbar.show("刷新失败: ${e.message}")
|
||||||
} finally {
|
|
||||||
refreshBusy = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -93,6 +94,8 @@ class Scrcpy(
|
|||||||
Regex("""--camera-id=(\S+)\s+\(([^,]+),\s*([0-9]+x[0-9]+),\s*fps=\[([0-9,\s]+)\]\)""")
|
Regex("""--camera-id=(\S+)\s+\(([^,]+),\s*([0-9]+x[0-9]+),\s*fps=\[([0-9,\s]+)\]\)""")
|
||||||
private val APP_REGEX =
|
private val APP_REGEX =
|
||||||
Regex("""^\s*([*-])\s+(.+?)\s{2,}([A-Za-z0-9._]+)\s*$""", RegexOption.MULTILINE)
|
Regex("""^\s*([*-])\s+(.+?)\s{2,}([A-Za-z0-9._]+)\s*$""", RegexOption.MULTILINE)
|
||||||
|
private val RECENT_TASK_PACKAGE_REGEX =
|
||||||
|
Regex("""\bcmp=([A-Za-z0-9._]+)/""")
|
||||||
|
|
||||||
fun generateScid(): UInt {
|
fun generateScid(): UInt {
|
||||||
// Only use 31 bits to avoid issues with signed values on the Java-side
|
// Only use 31 bits to avoid issues with signed values on the Java-side
|
||||||
@@ -308,6 +311,11 @@ class Scrcpy(
|
|||||||
private val camerasMutex = Mutex()
|
private val camerasMutex = Mutex()
|
||||||
private val cameraSizesMutex = Mutex()
|
private val cameraSizesMutex = Mutex()
|
||||||
private val appsMutex = Mutex()
|
private val appsMutex = Mutex()
|
||||||
|
private val recentTasksMutex = Mutex()
|
||||||
|
|
||||||
|
private val _refreshBusy = MutableStateFlow(false)
|
||||||
|
private val _refreshVersion = MutableStateFlow(0)
|
||||||
|
private val _refreshCounter = MutableStateFlow(0)
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var cachedVideoEncoders: List<EncoderInfo>? = null
|
private var cachedVideoEncoders: List<EncoderInfo>? = null
|
||||||
@@ -327,12 +335,23 @@ class Scrcpy(
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var cachedApps: List<AppInfo>? = null
|
private var cachedApps: List<AppInfo>? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var cachedAppsByPackage: Map<String, AppInfo> = emptyMap()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var cachedRecentTasks: List<RecentTaskInfo>? = null
|
||||||
|
|
||||||
|
val refreshBusyState: StateFlow<Boolean> = _refreshBusy.asStateFlow()
|
||||||
|
val refreshVersionState: StateFlow<Int> = _refreshVersion.asStateFlow()
|
||||||
val videoEncoders: List<EncoderInfo> get() = cachedVideoEncoders.orEmpty()
|
val videoEncoders: List<EncoderInfo> get() = cachedVideoEncoders.orEmpty()
|
||||||
val audioEncoders: List<EncoderInfo> get() = cachedAudioEncoders.orEmpty()
|
val audioEncoders: List<EncoderInfo> get() = cachedAudioEncoders.orEmpty()
|
||||||
val displays: List<DisplayInfo> get() = cachedDisplays.orEmpty()
|
val displays: List<DisplayInfo> get() = cachedDisplays.orEmpty()
|
||||||
val cameras: List<CameraInfo> get() = cachedCameras.orEmpty()
|
val cameras: List<CameraInfo> get() = cachedCameras.orEmpty()
|
||||||
val cameraSizes: List<String> get() = cachedCameraSizes.orEmpty()
|
val cameraSizes: List<String> get() = cachedCameraSizes.orEmpty()
|
||||||
val apps: List<AppInfo> get() = cachedApps.orEmpty()
|
val apps: List<AppInfo> get() = cachedApps.orEmpty()
|
||||||
|
val recentTasks: List<RecentTaskInfo> get() = cachedRecentTasks.orEmpty()
|
||||||
|
|
||||||
|
fun findCachedApp(packageName: String): AppInfo? = cachedAppsByPackage[packageName]
|
||||||
|
|
||||||
suspend fun getVideoEncoders(forceRefresh: Boolean = false): List<EncoderInfo> {
|
suspend fun getVideoEncoders(forceRefresh: Boolean = false): List<EncoderInfo> {
|
||||||
cachedVideoEncoders?.takeUnless { forceRefresh }?.let { return it }
|
cachedVideoEncoders?.takeUnless { forceRefresh }?.let { return it }
|
||||||
@@ -353,16 +372,18 @@ class Scrcpy(
|
|||||||
if (!forceRefresh && cachedVideoEncoders != null && cachedAudioEncoders != null)
|
if (!forceRefresh && cachedVideoEncoders != null && cachedAudioEncoders != null)
|
||||||
return@withLock cachedVideoEncoders.orEmpty() to cachedAudioEncoders.orEmpty()
|
return@withLock cachedVideoEncoders.orEmpty() to cachedAudioEncoders.orEmpty()
|
||||||
|
|
||||||
val output = executeList(ListOptions.ENCODERS)
|
runTrackedFetch {
|
||||||
val (video, audio) = parseEncoders(output)
|
val output = executeList(ListOptions.ENCODERS)
|
||||||
cachedVideoEncoders = video
|
val (video, audio) = parseEncoders(output)
|
||||||
cachedAudioEncoders = audio
|
cachedVideoEncoders = video
|
||||||
logListPreview(
|
cachedAudioEncoders = audio
|
||||||
list = ListOptions.ENCODERS,
|
logListPreview(
|
||||||
countSummary = "video=${video.size} audio=${audio.size}",
|
list = ListOptions.ENCODERS,
|
||||||
output = output,
|
countSummary = "video=${video.size} audio=${audio.size}",
|
||||||
)
|
output = output,
|
||||||
video to audio
|
)
|
||||||
|
video to audio
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,15 +391,17 @@ class Scrcpy(
|
|||||||
cachedDisplays?.takeUnless { forceRefresh }?.let { return it }
|
cachedDisplays?.takeUnless { forceRefresh }?.let { return it }
|
||||||
return displaysMutex.withLock {
|
return displaysMutex.withLock {
|
||||||
cachedDisplays?.takeUnless { forceRefresh } ?: run {
|
cachedDisplays?.takeUnless { forceRefresh } ?: run {
|
||||||
val output = executeList(ListOptions.DISPLAYS)
|
runTrackedFetch {
|
||||||
val parsed = parseDisplays(output)
|
val output = executeList(ListOptions.DISPLAYS)
|
||||||
cachedDisplays = parsed
|
val parsed = parseDisplays(output)
|
||||||
logListPreview(
|
cachedDisplays = parsed
|
||||||
list = ListOptions.DISPLAYS,
|
logListPreview(
|
||||||
countSummary = "displays=${parsed.size}",
|
list = ListOptions.DISPLAYS,
|
||||||
output = output,
|
countSummary = "displays=${parsed.size}",
|
||||||
)
|
output = output,
|
||||||
parsed
|
)
|
||||||
|
parsed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -387,15 +410,17 @@ class Scrcpy(
|
|||||||
cachedCameras?.takeUnless { forceRefresh }?.let { return it }
|
cachedCameras?.takeUnless { forceRefresh }?.let { return it }
|
||||||
return camerasMutex.withLock {
|
return camerasMutex.withLock {
|
||||||
cachedCameras?.takeUnless { forceRefresh } ?: run {
|
cachedCameras?.takeUnless { forceRefresh } ?: run {
|
||||||
val output = executeList(ListOptions.CAMERAS)
|
runTrackedFetch {
|
||||||
val parsed = parseCameras(output)
|
val output = executeList(ListOptions.CAMERAS)
|
||||||
cachedCameras = parsed
|
val parsed = parseCameras(output)
|
||||||
logListPreview(
|
cachedCameras = parsed
|
||||||
list = ListOptions.CAMERAS,
|
logListPreview(
|
||||||
countSummary = "cameras=${parsed.size}",
|
list = ListOptions.CAMERAS,
|
||||||
output = output,
|
countSummary = "cameras=${parsed.size}",
|
||||||
)
|
output = output,
|
||||||
parsed
|
)
|
||||||
|
parsed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -404,18 +429,20 @@ class Scrcpy(
|
|||||||
cachedCameraSizes?.takeUnless { forceRefresh }?.let { return it }
|
cachedCameraSizes?.takeUnless { forceRefresh }?.let { return it }
|
||||||
return cameraSizesMutex.withLock {
|
return cameraSizesMutex.withLock {
|
||||||
cachedCameraSizes?.takeUnless { forceRefresh } ?: run {
|
cachedCameraSizes?.takeUnless { forceRefresh } ?: run {
|
||||||
val output = executeList(ListOptions.CAMERA_SIZES)
|
runTrackedFetch {
|
||||||
val parsed = parseCameraSizes(output)
|
val output = executeList(ListOptions.CAMERA_SIZES)
|
||||||
.sortedWith(compareByDescending { size ->
|
val parsed = parseCameraSizes(output)
|
||||||
size.substringBefore('x').toIntOrNull() ?: 0
|
.sortedWith(compareByDescending { size ->
|
||||||
})
|
size.substringBefore('x').toIntOrNull() ?: 0
|
||||||
cachedCameraSizes = parsed
|
})
|
||||||
logListPreview(
|
cachedCameraSizes = parsed
|
||||||
list = ListOptions.CAMERA_SIZES,
|
logListPreview(
|
||||||
countSummary = "sizes=${parsed.size}",
|
list = ListOptions.CAMERA_SIZES,
|
||||||
output = output,
|
countSummary = "sizes=${parsed.size}",
|
||||||
)
|
output = output,
|
||||||
parsed
|
)
|
||||||
|
parsed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -424,19 +451,55 @@ class Scrcpy(
|
|||||||
cachedApps?.takeUnless { forceRefresh }?.let { return it }
|
cachedApps?.takeUnless { forceRefresh }?.let { return it }
|
||||||
return appsMutex.withLock {
|
return appsMutex.withLock {
|
||||||
cachedApps?.takeUnless { forceRefresh } ?: run {
|
cachedApps?.takeUnless { forceRefresh } ?: run {
|
||||||
val output = executeList(ListOptions.APPS)
|
runTrackedFetch {
|
||||||
val parsed = parseApps(output)
|
val output = executeList(ListOptions.APPS)
|
||||||
cachedApps = parsed
|
val parsed = parseApps(output)
|
||||||
logListPreview(
|
cachedApps = parsed
|
||||||
list = ListOptions.APPS,
|
cachedAppsByPackage = parsed.associateBy { it.packageName }
|
||||||
countSummary = "apps=${parsed.size}",
|
cachedRecentTasks = cachedRecentTasks?.map { task ->
|
||||||
output = output,
|
task.copy(appLabel = cachedAppsByPackage[task.packageName]?.label)
|
||||||
)
|
}
|
||||||
parsed
|
logListPreview(
|
||||||
|
list = ListOptions.APPS,
|
||||||
|
countSummary = "apps=${parsed.size}",
|
||||||
|
output = output,
|
||||||
|
)
|
||||||
|
parsed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getRecentTasks(forceRefresh: Boolean = false): List<RecentTaskInfo> {
|
||||||
|
cachedRecentTasks?.takeUnless { forceRefresh }?.let { return it }
|
||||||
|
return recentTasksMutex.withLock {
|
||||||
|
cachedRecentTasks?.takeUnless { forceRefresh } ?: run {
|
||||||
|
runTrackedFetch {
|
||||||
|
val output = NativeAdbService.shell("dumpsys activity recents")
|
||||||
|
val parsed = parseRecentTasks(output).map { task ->
|
||||||
|
task.copy(appLabel = cachedAppsByPackage[task.packageName]?.label)
|
||||||
|
}
|
||||||
|
cachedRecentTasks = parsed
|
||||||
|
Log.i(TAG, "recentTasks(): parsed count=${parsed.size}")
|
||||||
|
parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> runTrackedFetch(block: suspend () -> T): T {
|
||||||
|
_refreshCounter.update { it + 1 }
|
||||||
|
_refreshBusy.value = true
|
||||||
|
return try {
|
||||||
|
block().also {
|
||||||
|
_refreshVersion.update { it + 1 }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
val remaining = (_refreshCounter.value - 1).coerceAtLeast(0)
|
||||||
|
_refreshCounter.value = remaining
|
||||||
|
_refreshBusy.value = remaining > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun executeList(list: ListOptions): String = withContext(Dispatchers.IO) {
|
private suspend fun executeList(list: ListOptions): String = withContext(Dispatchers.IO) {
|
||||||
@@ -570,6 +633,21 @@ class Scrcpy(
|
|||||||
return apps.toList()
|
return apps.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parseRecentTasks(output: String): List<RecentTaskInfo> {
|
||||||
|
val packages = LinkedHashSet<String>()
|
||||||
|
RECENT_TASK_PACKAGE_REGEX.findAll(output).forEach { match ->
|
||||||
|
val packageName = match.groupValues[1].trim()
|
||||||
|
if (packageName.isNotBlank()) {
|
||||||
|
packages += packageName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return packages.map { packageName ->
|
||||||
|
RecentTaskInfo(
|
||||||
|
packageName = packageName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class EncoderInfo(
|
data class EncoderInfo(
|
||||||
val codec: Codec,
|
val codec: Codec,
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -597,6 +675,11 @@ class Scrcpy(
|
|||||||
val packageName: String,
|
val packageName: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class RecentTaskInfo(
|
||||||
|
val packageName: String,
|
||||||
|
val appLabel: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
private suspend fun executeServer(
|
private suspend fun executeServer(
|
||||||
serverJar: File,
|
serverJar: File,
|
||||||
options: ClientOptions,
|
options: ClientOptions,
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ package io.github.miuzarte.scrcpyforandroid.services
|
|||||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
|
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExecutorCoroutineDispatcher
|
import kotlinx.coroutines.ExecutorCoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.asCoroutineDispatcher
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.Closeable
|
import java.io.Closeable
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import kotlinx.coroutines.asCoroutineDispatcher
|
|
||||||
|
|
||||||
internal class DeviceAdbBackgroundRunner : Closeable {
|
internal class DeviceAdbBackgroundRunner : Closeable {
|
||||||
private val executor = Executors.newSingleThreadExecutor { runnable ->
|
private val executor = Executors.newSingleThreadExecutor { runnable ->
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.BoxWithConstraints
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
@@ -399,6 +400,8 @@ internal fun ConfigPanel(
|
|||||||
cameraMirroringSupported: Boolean,
|
cameraMirroringSupported: Boolean,
|
||||||
adbConnecting: Boolean,
|
adbConnecting: Boolean,
|
||||||
isQuickConnected: Boolean,
|
isQuickConnected: Boolean,
|
||||||
|
recentTasksEndAction: @Composable (RowScope.() -> Unit)? = null,
|
||||||
|
onOpenRecentTasks: (() -> Unit)? = null,
|
||||||
onOpenAdvanced: () -> Unit,
|
onOpenAdvanced: () -> Unit,
|
||||||
onStartStopHaptic: (() -> Unit)? = null,
|
onStartStopHaptic: (() -> Unit)? = null,
|
||||||
onStart: () -> Unit,
|
onStart: () -> Unit,
|
||||||
@@ -575,6 +578,16 @@ internal fun ConfigPanel(
|
|||||||
onClick = onOpenAdvanced,
|
onClick = onOpenAdvanced,
|
||||||
enabled = !sessionStarted,
|
enabled = !sessionStarted,
|
||||||
)
|
)
|
||||||
|
if (onOpenRecentTasks != null) {
|
||||||
|
ArrowPreference(
|
||||||
|
title = "最近任务",
|
||||||
|
endActions = {
|
||||||
|
recentTasksEndAction?.invoke(this)
|
||||||
|
},
|
||||||
|
onClick = onOpenRecentTasks,
|
||||||
|
enabled = !busy && !adbConnecting,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
Reference in New Issue
Block a user