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

View File

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

View File

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

View File

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

View File

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

View File

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