diff --git a/TODO.md b/TODO.md index 3995ed4..7737f9e 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,11 @@ + + # 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 diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt index 977741b..dba063f 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt @@ -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(null) } var activeDeviceActionId by rememberSaveable { mutableStateOf(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: 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, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt index 26d4af9..2a93e6a 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt @@ -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 } } }, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt index 26651f6..a39d170 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt @@ -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? = null @@ -327,12 +335,23 @@ class Scrcpy( @Volatile private var cachedApps: List? = null + @Volatile + private var cachedAppsByPackage: Map = emptyMap() + + @Volatile + private var cachedRecentTasks: List? = null + + val refreshBusyState: StateFlow = _refreshBusy.asStateFlow() + val refreshVersionState: StateFlow = _refreshVersion.asStateFlow() val videoEncoders: List get() = cachedVideoEncoders.orEmpty() val audioEncoders: List get() = cachedAudioEncoders.orEmpty() val displays: List get() = cachedDisplays.orEmpty() val cameras: List get() = cachedCameras.orEmpty() val cameraSizes: List get() = cachedCameraSizes.orEmpty() val apps: List get() = cachedApps.orEmpty() + val recentTasks: List get() = cachedRecentTasks.orEmpty() + + fun findCachedApp(packageName: String): AppInfo? = cachedAppsByPackage[packageName] suspend fun getVideoEncoders(forceRefresh: Boolean = false): List { 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 { + 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 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 { + val packages = LinkedHashSet() + 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, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbBackgroundRunner.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbBackgroundRunner.kt index 5756e70..398021e 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbBackgroundRunner.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbBackgroundRunner.kt @@ -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 -> diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt index e6623ee..6ea87d1 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt @@ -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