From e734891eb37e772c573674dd2baf964cc478d0ba Mon Sep 17 00:00:00 2001 From: Miuzarte <982809597@qq.com> Date: Fri, 10 Apr 2026 18:16:40 +0800 Subject: [PATCH] feat: floating "quick ball" in fullscreen --- .../pages/FullscreenControlScreen.kt | 16 ++ .../scrcpyforandroid/pages/SettingsScreen.kt | 8 + .../scrcpyforandroid/storage/AppSettings.kt | 25 +++ .../widgets/VirtualButtons.kt | 162 +++++++++++++++++- 4 files changed, 202 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt index 221fa0d..581d7f5 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt @@ -86,8 +86,12 @@ fun FullscreenControlScreen( VirtualButtonActions.parseStoredLayout(asBundle.virtualButtonsLayout) ) } + val floatingActions = remember(buttonItems) { + (buttonItems.first + buttonItems.second).filter { it != io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction.MORE } + } val fullscreenDebugInfo = asBundle.fullscreenDebugInfo val showFullscreenVirtualButtons = asBundle.showFullscreenVirtualButtons + val showFullscreenFloatingButton = asBundle.showFullscreenFloatingButton val bar = remember(buttonItems) { VirtualButtonBar( @@ -185,6 +189,18 @@ fun FullscreenControlScreen( }, ) } + + if (showFullscreenFloatingButton) { + bar.FloatingBall( + actions = floatingActions, + modifier = Modifier.fillMaxSize(), + onAction = { action -> + action.keycode?.let { + sendKeycode(it) + } + }, + ) + } } } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt index 1b2969e..751b552 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt @@ -259,6 +259,14 @@ fun SettingsPage( asBundle = asBundle.copy(showFullscreenVirtualButtons = it) }, ) + SwitchPreference( + title = "全屏显示悬浮球", + summary = "在全屏控制页显示可拖动的悬浮球,点击后弹出完整虚拟按键菜单", + checked = asBundle.showFullscreenFloatingButton, + onCheckedChange = { + asBundle = asBundle.copy(showFullscreenFloatingButton = it) + }, + ) } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt index 92887cb..bd9271d 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt @@ -4,6 +4,7 @@ import android.content.Context import android.os.Parcelable import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey @@ -27,8 +28,20 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { ) val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = Pair( booleanPreferencesKey("show_fullscreen_virtual_buttons"), + false + ) + val SHOW_FULLSCREEN_FLOATING_BUTTON = Pair( + booleanPreferencesKey("show_fullscreen_floating_button"), true ) + val FULLSCREEN_FLOATING_BUTTON_X_FRACTION = Pair( + floatPreferencesKey("fullscreen_floating_button_x_fraction"), + 0.84f + ) + val FULLSCREEN_FLOATING_BUTTON_Y_FRACTION = Pair( + floatPreferencesKey("fullscreen_floating_button_y_fraction"), + 0.72f + ) val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair( intPreferencesKey("device_preview_card_height_dp"), 320 @@ -82,6 +95,9 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { // Scrcpy Settings val fullscreenDebugInfo by setting(FULLSCREEN_DEBUG_INFO) val showFullscreenVirtualButtons by setting(SHOW_FULLSCREEN_VIRTUAL_BUTTONS) + val showFullscreenFloatingButton by setting(SHOW_FULLSCREEN_FLOATING_BUTTON) + val fullscreenFloatingButtonXFraction by setting(FULLSCREEN_FLOATING_BUTTON_X_FRACTION) + val fullscreenFloatingButtonYFraction by setting(FULLSCREEN_FLOATING_BUTTON_Y_FRACTION) val devicePreviewCardHeightDp by setting(DEVICE_PREVIEW_CARD_HEIGHT_DP) val previewVirtualButtonShowText by setting(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) val virtualButtonsLayout by setting(VIRTUAL_BUTTONS_LAYOUT) @@ -104,6 +120,9 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { val monet: Boolean, val fullscreenDebugInfo: Boolean, val showFullscreenVirtualButtons: Boolean, + val showFullscreenFloatingButton: Boolean, + val fullscreenFloatingButtonXFraction: Float, + val fullscreenFloatingButtonYFraction: Float, val devicePreviewCardHeightDp: Int, val previewVirtualButtonShowText: Boolean, val virtualButtonsLayout: String, @@ -123,6 +142,9 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { bundleField(MONET) { bundle: Bundle -> bundle.monet }, bundleField(FULLSCREEN_DEBUG_INFO) { bundle: Bundle -> bundle.fullscreenDebugInfo }, bundleField(SHOW_FULLSCREEN_VIRTUAL_BUTTONS) { bundle: Bundle -> bundle.showFullscreenVirtualButtons }, + bundleField(SHOW_FULLSCREEN_FLOATING_BUTTON) { bundle: Bundle -> bundle.showFullscreenFloatingButton }, + bundleField(FULLSCREEN_FLOATING_BUTTON_X_FRACTION) { bundle: Bundle -> bundle.fullscreenFloatingButtonXFraction }, + bundleField(FULLSCREEN_FLOATING_BUTTON_Y_FRACTION) { bundle: Bundle -> bundle.fullscreenFloatingButtonYFraction }, bundleField(DEVICE_PREVIEW_CARD_HEIGHT_DP) { bundle: Bundle -> bundle.devicePreviewCardHeightDp }, bundleField(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) { bundle: Bundle -> bundle.previewVirtualButtonShowText }, bundleField(VIRTUAL_BUTTONS_LAYOUT) { bundle: Bundle -> bundle.virtualButtonsLayout }, @@ -143,6 +165,9 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { monet = preferences.read(MONET), fullscreenDebugInfo = preferences.read(FULLSCREEN_DEBUG_INFO), showFullscreenVirtualButtons = preferences.read(SHOW_FULLSCREEN_VIRTUAL_BUTTONS), + showFullscreenFloatingButton = preferences.read(SHOW_FULLSCREEN_FLOATING_BUTTON), + fullscreenFloatingButtonXFraction = preferences.read(FULLSCREEN_FLOATING_BUTTON_X_FRACTION), + fullscreenFloatingButtonYFraction = preferences.read(FULLSCREEN_FLOATING_BUTTON_Y_FRACTION), devicePreviewCardHeightDp = preferences.read(DEVICE_PREVIEW_CARD_HEIGHT_DP), previewVirtualButtonShowText = preferences.read(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT), virtualButtonsLayout = preferences.read(VIRTUAL_BUTTONS_LAYOUT), diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt index 1129273..d2c3a8e 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt @@ -1,14 +1,21 @@ package io.github.miuzarte.scrcpyforandroid.widgets +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.VolumeDown @@ -22,19 +29,31 @@ import androidx.compose.material.icons.rounded.Notifications import androidx.compose.material.icons.rounded.PowerSettingsNew import androidx.compose.material.icons.rounded.Screenshot import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import io.github.miuzarte.scrcpyforandroid.constants.UiAndroidKeycodes import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics import io.github.miuzarte.scrcpyforandroid.storage.AppSettings +import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import top.yukonga.miuix.kmp.basic.Button import top.yukonga.miuix.kmp.basic.ButtonDefaults @@ -214,9 +233,9 @@ class VirtualButtonBar( } } if (action == VirtualButtonAction.MORE) { - MorePopup( + ActionPopup( show = showMorePopup, - moreActions = moreActions, + actions = moreActions, onDismiss = { showMorePopup = false }, onAction = { onAction(it) @@ -268,9 +287,9 @@ class VirtualButtonBar( } if (action == VirtualButtonAction.MORE) { - MorePopup( + ActionPopup( show = showMorePopup, - moreActions = moreActions, + actions = moreActions, onDismiss = { showMorePopup = false }, onAction = { onAction(it) @@ -285,17 +304,142 @@ class VirtualButtonBar( } @Composable - private fun MorePopup( + fun FloatingBall( + actions: List, + onAction: suspend (VirtualButtonAction) -> Unit, + modifier: Modifier = Modifier, + ) { + val scope = rememberCoroutineScope() + val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } + val haptics = LocalAppHaptics.current + var showActions by remember { mutableStateOf(false) } + val asBundleShared by appSettings.bundleState.collectAsState() + val asBundleSharedLatest by rememberUpdatedState(asBundleShared) + var offsetXFraction by rememberSaveable(asBundleShared.fullscreenFloatingButtonXFraction) { + mutableFloatStateOf(asBundleShared.fullscreenFloatingButtonXFraction) + } + var offsetYFraction by rememberSaveable(asBundleShared.fullscreenFloatingButtonYFraction) { + mutableFloatStateOf(asBundleShared.fullscreenFloatingButtonYFraction) + } + DisposableEffect(Unit) { + onDispose { + taskScope.launch { + val latest = asBundleSharedLatest + if ( + offsetXFraction != latest.fullscreenFloatingButtonXFraction || + offsetYFraction != latest.fullscreenFloatingButtonYFraction + ) { + appSettings.saveBundle( + latest.copy( + fullscreenFloatingButtonXFraction = offsetXFraction, + fullscreenFloatingButtonYFraction = offsetYFraction, + ) + ) + } + } + } + } + + BoxWithConstraints( + modifier = modifier.fillMaxSize(), + ) { + val ballSize = 48.dp + val ringSize = 24.dp + val ringWidth = 2.dp + val maxX = (maxWidth - ballSize).coerceAtLeast(0.dp) + val maxY = (maxHeight - ballSize).coerceAtLeast(0.dp) + val currentX = + maxX * offsetXFraction.coerceIn(0f, 1f) + val currentY = + maxY * offsetYFraction.coerceIn(0f, 1f) + + Box( + modifier = Modifier + .offset { + IntOffset( + currentX.roundToPx(), + currentY.roundToPx(), + ) + } + .size(ballSize) + .pointerInput(maxX, maxY) { + var dragStartXFraction = offsetXFraction + var dragStartYFraction = offsetYFraction + detectDragGestures( + onDragStart = { + dragStartXFraction = offsetXFraction + dragStartYFraction = offsetYFraction + }, + ) { change, dragAmount -> + change.consume() + val nextX = + (maxX.toPx() * dragStartXFraction + dragAmount.x).coerceIn( + 0f, + maxX.toPx() + ) + val nextY = + (maxY.toPx() * dragStartYFraction + dragAmount.y).coerceIn( + 0f, + maxY.toPx() + ) + val nextXFraction = if (maxX > 0.dp) nextX / maxX.toPx() else 0f + val nextYFraction = if (maxY > 0.dp) nextY / maxY.toPx() else 0f + dragStartXFraction = nextXFraction + dragStartYFraction = nextYFraction + offsetXFraction = nextXFraction + offsetYFraction = nextYFraction + } + }, + ) { + Button( + modifier = Modifier.fillMaxSize(), + onClick = { + haptics.contextClick() + showActions = true + }, + cornerRadius = ballSize / 2, + minHeight = ballSize, + insideMargin = PaddingValues(0.dp), + colors = ButtonDefaults.buttonColors( + color = Color.Black.copy(alpha = 0.24f), + ), + ) { + Box( + modifier = Modifier + .size(ringSize) + .clip(CircleShape) + .border(ringWidth, Color.White, CircleShape), + ) + } + + ActionPopup( + show = showActions, + actions = actions, + onDismiss = { showActions = false }, + onAction = { + scope.launch { + onAction(it) + } + showActions = false + }, + renderInRootScaffold = true, + ) + } + } + } + + @Composable + private fun ActionPopup( show: Boolean, - moreActions: List, + actions: List, onDismiss: () -> Unit, onAction: suspend (VirtualButtonAction) -> Unit, renderInRootScaffold: Boolean, ) { val scope = rememberCoroutineScope() val haptics = LocalAppHaptics.current - val spinnerItems = remember(moreActions) { - moreActions.map { action -> + val spinnerItems = remember(actions) { + actions.map { action -> SpinnerEntry( icon = { Icon( @@ -330,7 +474,7 @@ class VirtualButtonBar( onSelectedIndexChange = { selectedIdx -> haptics.confirm() scope.launch { - onAction(moreActions[selectedIdx]) + onAction(actions[selectedIdx]) } }, )