feat: shortcut of recent tasks

This commit is contained in:
Miuzarte
2026-04-13 00:35:53 +08:00
parent 28543d0b69
commit e33c5f801b
6 changed files with 419 additions and 87 deletions

View File

@@ -1,7 +1,11 @@
<!-- markdownlint-disable MD024 -->
# TODO
## PARAMS
### LOWER PRIORITY
- 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.
- `--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)
- 横屏布局
- I18N

View File

@@ -2,14 +2,22 @@ package io.github.miuzarte.scrcpyforandroid.pages
import android.annotation.SuppressLint
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -22,14 +30,19 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import io.github.miuzarte.scrcpyforandroid.StreamActivity
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics
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.SectionSmallTitle
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.VirtualButtonCard
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.Scaffold
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.TextField
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.theme.MiuixTheme
import androidx.compose.foundation.lazy.LazyColumn as FoundationLazyColumn
private const val ADB_CONNECT_TIMEOUT_MS = 12_000L
private const val ADB_KEEPALIVE_INTERVAL_MS = 3_000L
@@ -256,10 +277,12 @@ fun DeviceTabPage(
var busy by rememberSaveable { mutableStateOf(false) }
var adbSession by rememberSaveable { mutableStateOf(DeviceAdbSessionState()) }
val sessionInfo by scrcpy.currentSessionState.collectAsState()
val listingsRefreshBusy by scrcpy.listings.refreshBusyState.collectAsState()
var editingDeviceId by rememberSaveable { mutableStateOf<String?>(null) }
var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(null) }
var adbConnecting by rememberSaveable { mutableStateOf(false) }
var pendingScrollToPreview by rememberSaveable { mutableStateOf(false) }
var showRecentTasksSheet by rememberSaveable { mutableStateOf(false) }
val listState = rememberLazyListState()
val isPreviewCardVisible by remember(listState) {
derivedStateOf {
@@ -275,6 +298,7 @@ fun DeviceTabPage(
val connectedScrcpyProfileId = adbSession.connectedScrcpyProfileId
val audioForwardingSupported = adbSession.audioForwardingSupported
val cameraMirroringSupported = adbSession.cameraMirroringSupported
val recentTasks = scrcpy.listings.recentTasks
fun resolveScrcpyBundle(profileId: String): ScrcpyOptions.Bundle {
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 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
val startAppRequest = runCatching {
resolveStartAppRequest(scrcpy, options)
resolveStartAppRequest(scrcpy, resolvedOptions)
}.getOrElse { error ->
logEvent(
"启动应用请求无效: ${
@@ -629,7 +660,7 @@ fun DeviceTabPage(
null
}
startAppRequest?.let { request ->
if (options.newDisplay.isNotBlank() && request.displayId == null) {
if (resolvedOptions.newDisplay.isNotBlank() && request.displayId == null) {
logEvent(
"当前实现无法获取 new display 的真实 displayId应用会启动到默认显示",
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))
}
if (options.disableScreensaver)
if (resolvedOptions.disableScreensaver)
AppWakeLocks.acquire()
adbSession = adbSession.copy(statusLine = "scrcpy 运行中")
@SuppressLint("DefaultLocale")
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 "${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) "${options.audioCodec} default source=${options.audioSource}"
else "${options.audioCodec} ${activeBundle.audioBitRate / 1_000f}Kbps source=${options.audioSource}${if (!options.audioPlayback) "(no-playback)" else ""}"
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=${options.control}, turnScreenOff=${options.turnScreenOff}" +
", maxSize=${options.maxSize}, maxFps=${options.maxFps}"
", control=${resolvedOptions.control}, turnScreenOff=${resolvedOptions.turnScreenOff}" +
", maxSize=${resolvedOptions.maxSize}, maxFps=${resolvedOptions.maxFps}"
)
snackbar.show("scrcpy 已启动")
}
@@ -742,12 +773,35 @@ fun DeviceTabPage(
"sdk=${info.sdkInt}"
)
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") {
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(
adbConnected,
asBundle.adbAutoReconnectPairedDevice,
@@ -992,6 +1046,33 @@ fun DeviceTabPage(
cameraMirroringSupported = cameraMirroringSupported,
adbConnecting = adbConnecting,
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) },
onStartStopHaptic = { haptics.contextClick() },
onStart = {
@@ -1067,6 +1148,32 @@ fun DeviceTabPage(
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
@@ -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
private fun DeviceMenuPopupItem(
text: String,

View File

@@ -25,7 +25,6 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -401,8 +400,8 @@ internal fun ScrcpyAllOptionsPage(
val taskScope = remember { CoroutineScope(Dispatchers.IO + SupervisorJob()) }
val snackbar = LocalSnackbarController.current
var refreshBusy by rememberSaveable { mutableStateOf(false) }
var listRefreshVersion by rememberSaveable { mutableIntStateOf(0) }
val refreshBusy by scrcpy.listings.refreshBusyState.collectAsState()
val listRefreshVersion by scrcpy.listings.refreshVersionState.collectAsState()
var selectedProfileId by selectedProfileIdState
val selectedProfileIdLatest by rememberUpdatedState(selectedProfileId)
@@ -1058,18 +1057,14 @@ internal fun ScrcpyAllOptionsPage(
onClick = {
if (refreshBusy) return@ArrowPreference
scope.launch {
refreshBusy = true
snackbar.show("获取中")
try {
withContext(Dispatchers.IO) {
scrcpy.listings.getDisplays(forceRefresh = true)
}
listRefreshVersion += 1
snackbar.show("获取成功")
} catch (e: Exception) {
snackbar.show("刷新失败: ${e.message}")
} finally {
refreshBusy = false
}
}
},
@@ -1158,18 +1153,14 @@ internal fun ScrcpyAllOptionsPage(
onClick = {
if (refreshBusy) return@ArrowPreference
scope.launch {
refreshBusy = true
snackbar.show("获取中")
try {
withContext(Dispatchers.IO) {
scrcpy.listings.getCameras(forceRefresh = true)
}
listRefreshVersion += 1
snackbar.show("获取成功")
} catch (e: Exception) {
snackbar.show("刷新失败: ${e.message}")
} finally {
refreshBusy = false
}
}
},
@@ -1206,18 +1197,14 @@ internal fun ScrcpyAllOptionsPage(
onClick = {
if (refreshBusy) return@ArrowPreference
scope.launch {
refreshBusy = true
snackbar.show("获取中")
try {
withContext(Dispatchers.IO) {
scrcpy.listings.getCameraSizes(forceRefresh = true)
}
listRefreshVersion += 1
snackbar.show("获取成功")
} catch (e: Exception) {
snackbar.show("刷新失败: ${e.message}")
} finally {
refreshBusy = false
}
}
},
@@ -1397,18 +1384,14 @@ internal fun ScrcpyAllOptionsPage(
onClick = {
if (refreshBusy) return@ArrowPreference
scope.launch {
refreshBusy = true
snackbar.show("获取中")
try {
withContext(Dispatchers.IO) {
scrcpy.listings.getEncoders(forceRefresh = true)
}
listRefreshVersion += 1
snackbar.show("获取成功")
} catch (e: Exception) {
snackbar.show("刷新失败: ${e.message}")
} finally {
refreshBusy = false
}
}
},
@@ -1547,18 +1530,14 @@ internal fun ScrcpyAllOptionsPage(
onClick = {
if (refreshBusy) return@ArrowPreference
scope.launch {
refreshBusy = true
snackbar.show("获取中")
try {
withContext(Dispatchers.IO) {
scrcpy.listings.getApps(forceRefresh = true)
}
listRefreshVersion += 1
snackbar.show("获取成功")
} catch (e: Exception) {
snackbar.show("刷新失败: ${e.message}")
} finally {
refreshBusy = false
}
}
},

View File

@@ -19,6 +19,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
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]+)\]\)""")
private val APP_REGEX =
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 {
// 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 cameraSizesMutex = 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
private var cachedVideoEncoders: List<EncoderInfo>? = null
@@ -327,12 +335,23 @@ class Scrcpy(
@Volatile
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 audioEncoders: List<EncoderInfo> get() = cachedAudioEncoders.orEmpty()
val displays: List<DisplayInfo> get() = cachedDisplays.orEmpty()
val cameras: List<CameraInfo> get() = cachedCameras.orEmpty()
val cameraSizes: List<String> get() = cachedCameraSizes.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> {
cachedVideoEncoders?.takeUnless { forceRefresh }?.let { return it }
@@ -353,16 +372,18 @@ class Scrcpy(
if (!forceRefresh && cachedVideoEncoders != null && cachedAudioEncoders != null)
return@withLock cachedVideoEncoders.orEmpty() to cachedAudioEncoders.orEmpty()
val output = executeList(ListOptions.ENCODERS)
val (video, audio) = parseEncoders(output)
cachedVideoEncoders = video
cachedAudioEncoders = audio
logListPreview(
list = ListOptions.ENCODERS,
countSummary = "video=${video.size} audio=${audio.size}",
output = output,
)
video to audio
runTrackedFetch {
val output = executeList(ListOptions.ENCODERS)
val (video, audio) = parseEncoders(output)
cachedVideoEncoders = video
cachedAudioEncoders = audio
logListPreview(
list = ListOptions.ENCODERS,
countSummary = "video=${video.size} audio=${audio.size}",
output = output,
)
video to audio
}
}
}
@@ -370,15 +391,17 @@ class Scrcpy(
cachedDisplays?.takeUnless { forceRefresh }?.let { return it }
return displaysMutex.withLock {
cachedDisplays?.takeUnless { forceRefresh } ?: run {
val output = executeList(ListOptions.DISPLAYS)
val parsed = parseDisplays(output)
cachedDisplays = parsed
logListPreview(
list = ListOptions.DISPLAYS,
countSummary = "displays=${parsed.size}",
output = output,
)
parsed
runTrackedFetch {
val output = executeList(ListOptions.DISPLAYS)
val parsed = parseDisplays(output)
cachedDisplays = parsed
logListPreview(
list = ListOptions.DISPLAYS,
countSummary = "displays=${parsed.size}",
output = output,
)
parsed
}
}
}
}
@@ -387,15 +410,17 @@ class Scrcpy(
cachedCameras?.takeUnless { forceRefresh }?.let { return it }
return camerasMutex.withLock {
cachedCameras?.takeUnless { forceRefresh } ?: run {
val output = executeList(ListOptions.CAMERAS)
val parsed = parseCameras(output)
cachedCameras = parsed
logListPreview(
list = ListOptions.CAMERAS,
countSummary = "cameras=${parsed.size}",
output = output,
)
parsed
runTrackedFetch {
val output = executeList(ListOptions.CAMERAS)
val parsed = parseCameras(output)
cachedCameras = parsed
logListPreview(
list = ListOptions.CAMERAS,
countSummary = "cameras=${parsed.size}",
output = output,
)
parsed
}
}
}
}
@@ -404,18 +429,20 @@ class Scrcpy(
cachedCameraSizes?.takeUnless { forceRefresh }?.let { return it }
return cameraSizesMutex.withLock {
cachedCameraSizes?.takeUnless { forceRefresh } ?: run {
val output = executeList(ListOptions.CAMERA_SIZES)
val parsed = parseCameraSizes(output)
.sortedWith(compareByDescending { size ->
size.substringBefore('x').toIntOrNull() ?: 0
})
cachedCameraSizes = parsed
logListPreview(
list = ListOptions.CAMERA_SIZES,
countSummary = "sizes=${parsed.size}",
output = output,
)
parsed
runTrackedFetch {
val output = executeList(ListOptions.CAMERA_SIZES)
val parsed = parseCameraSizes(output)
.sortedWith(compareByDescending { size ->
size.substringBefore('x').toIntOrNull() ?: 0
})
cachedCameraSizes = parsed
logListPreview(
list = ListOptions.CAMERA_SIZES,
countSummary = "sizes=${parsed.size}",
output = output,
)
parsed
}
}
}
}
@@ -424,19 +451,55 @@ class Scrcpy(
cachedApps?.takeUnless { forceRefresh }?.let { return it }
return appsMutex.withLock {
cachedApps?.takeUnless { forceRefresh } ?: run {
val output = executeList(ListOptions.APPS)
val parsed = parseApps(output)
cachedApps = parsed
logListPreview(
list = ListOptions.APPS,
countSummary = "apps=${parsed.size}",
output = output,
)
parsed
runTrackedFetch {
val output = executeList(ListOptions.APPS)
val parsed = parseApps(output)
cachedApps = parsed
cachedAppsByPackage = parsed.associateBy { it.packageName }
cachedRecentTasks = cachedRecentTasks?.map { task ->
task.copy(appLabel = cachedAppsByPackage[task.packageName]?.label)
}
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) {
@@ -570,6 +633,21 @@ class Scrcpy(
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(
val codec: Codec,
val id: String,
@@ -597,6 +675,11 @@ class Scrcpy(
val packageName: String,
)
data class RecentTaskInfo(
val packageName: String,
val appLabel: String? = null,
)
private suspend fun executeServer(
serverJar: File,
options: ClientOptions,

View File

@@ -3,11 +3,11 @@ package io.github.miuzarte.scrcpyforandroid.services
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExecutorCoroutineDispatcher
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.io.Closeable
import java.util.concurrent.Executors
import kotlinx.coroutines.asCoroutineDispatcher
internal class DeviceAdbBackgroundRunner : Closeable {
private val executor = Executors.newSingleThreadExecutor { runnable ->

View File

@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
@@ -399,6 +400,8 @@ internal fun ConfigPanel(
cameraMirroringSupported: Boolean,
adbConnecting: Boolean,
isQuickConnected: Boolean,
recentTasksEndAction: @Composable (RowScope.() -> Unit)? = null,
onOpenRecentTasks: (() -> Unit)? = null,
onOpenAdvanced: () -> Unit,
onStartStopHaptic: (() -> Unit)? = null,
onStart: () -> Unit,
@@ -575,6 +578,16 @@ internal fun ConfigPanel(
onClick = onOpenAdvanced,
enabled = !sessionStarted,
)
if (onOpenRecentTasks != null) {
ArrowPreference(
title = "最近任务",
endActions = {
recentTasksEndAction?.invoke(this)
},
onClick = onOpenRecentTasks,
enabled = !busy && !adbConnecting,
)
}
Row(
modifier = Modifier