feat: floating "quick ball" in fullscreen

This commit is contained in:
Miuzarte
2026-04-10 18:16:40 +08:00
parent c4003826ff
commit e734891eb3
4 changed files with 202 additions and 9 deletions

View File

@@ -86,8 +86,12 @@ fun FullscreenControlScreen(
VirtualButtonActions.parseStoredLayout(asBundle.virtualButtonsLayout) 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 fullscreenDebugInfo = asBundle.fullscreenDebugInfo
val showFullscreenVirtualButtons = asBundle.showFullscreenVirtualButtons val showFullscreenVirtualButtons = asBundle.showFullscreenVirtualButtons
val showFullscreenFloatingButton = asBundle.showFullscreenFloatingButton
val bar = remember(buttonItems) { val bar = remember(buttonItems) {
VirtualButtonBar( VirtualButtonBar(
@@ -185,6 +189,18 @@ fun FullscreenControlScreen(
}, },
) )
} }
if (showFullscreenFloatingButton) {
bar.FloatingBall(
actions = floatingActions,
modifier = Modifier.fillMaxSize(),
onAction = { action ->
action.keycode?.let {
sendKeycode(it)
}
},
)
}
} }
} }
} }

View File

@@ -259,6 +259,14 @@ fun SettingsPage(
asBundle = asBundle.copy(showFullscreenVirtualButtons = it) asBundle = asBundle.copy(showFullscreenVirtualButtons = it)
}, },
) )
SwitchPreference(
title = "全屏显示悬浮球",
summary = "在全屏控制页显示可拖动的悬浮球,点击后弹出完整虚拟按键菜单",
checked = asBundle.showFullscreenFloatingButton,
onCheckedChange = {
asBundle = asBundle.copy(showFullscreenFloatingButton = it)
},
)
} }
} }

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.os.Parcelable import android.os.Parcelable
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
@@ -27,8 +28,20 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
) )
val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = Pair( val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = Pair(
booleanPreferencesKey("show_fullscreen_virtual_buttons"), booleanPreferencesKey("show_fullscreen_virtual_buttons"),
false
)
val SHOW_FULLSCREEN_FLOATING_BUTTON = Pair(
booleanPreferencesKey("show_fullscreen_floating_button"),
true 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( val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair(
intPreferencesKey("device_preview_card_height_dp"), intPreferencesKey("device_preview_card_height_dp"),
320 320
@@ -82,6 +95,9 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
// Scrcpy Settings // Scrcpy Settings
val fullscreenDebugInfo by setting(FULLSCREEN_DEBUG_INFO) val fullscreenDebugInfo by setting(FULLSCREEN_DEBUG_INFO)
val showFullscreenVirtualButtons by setting(SHOW_FULLSCREEN_VIRTUAL_BUTTONS) 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 devicePreviewCardHeightDp by setting(DEVICE_PREVIEW_CARD_HEIGHT_DP)
val previewVirtualButtonShowText by setting(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) val previewVirtualButtonShowText by setting(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT)
val virtualButtonsLayout by setting(VIRTUAL_BUTTONS_LAYOUT) val virtualButtonsLayout by setting(VIRTUAL_BUTTONS_LAYOUT)
@@ -104,6 +120,9 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
val monet: Boolean, val monet: Boolean,
val fullscreenDebugInfo: Boolean, val fullscreenDebugInfo: Boolean,
val showFullscreenVirtualButtons: Boolean, val showFullscreenVirtualButtons: Boolean,
val showFullscreenFloatingButton: Boolean,
val fullscreenFloatingButtonXFraction: Float,
val fullscreenFloatingButtonYFraction: Float,
val devicePreviewCardHeightDp: Int, val devicePreviewCardHeightDp: Int,
val previewVirtualButtonShowText: Boolean, val previewVirtualButtonShowText: Boolean,
val virtualButtonsLayout: String, val virtualButtonsLayout: String,
@@ -123,6 +142,9 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
bundleField(MONET) { bundle: Bundle -> bundle.monet }, bundleField(MONET) { bundle: Bundle -> bundle.monet },
bundleField(FULLSCREEN_DEBUG_INFO) { bundle: Bundle -> bundle.fullscreenDebugInfo }, bundleField(FULLSCREEN_DEBUG_INFO) { bundle: Bundle -> bundle.fullscreenDebugInfo },
bundleField(SHOW_FULLSCREEN_VIRTUAL_BUTTONS) { bundle: Bundle -> bundle.showFullscreenVirtualButtons }, 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(DEVICE_PREVIEW_CARD_HEIGHT_DP) { bundle: Bundle -> bundle.devicePreviewCardHeightDp },
bundleField(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) { bundle: Bundle -> bundle.previewVirtualButtonShowText }, bundleField(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) { bundle: Bundle -> bundle.previewVirtualButtonShowText },
bundleField(VIRTUAL_BUTTONS_LAYOUT) { bundle: Bundle -> bundle.virtualButtonsLayout }, bundleField(VIRTUAL_BUTTONS_LAYOUT) { bundle: Bundle -> bundle.virtualButtonsLayout },
@@ -143,6 +165,9 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
monet = preferences.read(MONET), monet = preferences.read(MONET),
fullscreenDebugInfo = preferences.read(FULLSCREEN_DEBUG_INFO), fullscreenDebugInfo = preferences.read(FULLSCREEN_DEBUG_INFO),
showFullscreenVirtualButtons = preferences.read(SHOW_FULLSCREEN_VIRTUAL_BUTTONS), 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), devicePreviewCardHeightDp = preferences.read(DEVICE_PREVIEW_CARD_HEIGHT_DP),
previewVirtualButtonShowText = preferences.read(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT), previewVirtualButtonShowText = preferences.read(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT),
virtualButtonsLayout = preferences.read(VIRTUAL_BUTTONS_LAYOUT), virtualButtonsLayout = preferences.read(VIRTUAL_BUTTONS_LAYOUT),

View File

@@ -1,14 +1,21 @@
package io.github.miuzarte.scrcpyforandroid.widgets 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
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.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.VolumeDown 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.PowerSettingsNew
import androidx.compose.material.icons.rounded.Screenshot import androidx.compose.material.icons.rounded.Screenshot
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector 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 androidx.compose.ui.unit.dp
import io.github.miuzarte.scrcpyforandroid.constants.UiAndroidKeycodes import io.github.miuzarte.scrcpyforandroid.constants.UiAndroidKeycodes
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.storage.AppSettings 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 kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.Button import top.yukonga.miuix.kmp.basic.Button
import top.yukonga.miuix.kmp.basic.ButtonDefaults import top.yukonga.miuix.kmp.basic.ButtonDefaults
@@ -214,9 +233,9 @@ class VirtualButtonBar(
} }
} }
if (action == VirtualButtonAction.MORE) { if (action == VirtualButtonAction.MORE) {
MorePopup( ActionPopup(
show = showMorePopup, show = showMorePopup,
moreActions = moreActions, actions = moreActions,
onDismiss = { showMorePopup = false }, onDismiss = { showMorePopup = false },
onAction = { onAction = {
onAction(it) onAction(it)
@@ -268,9 +287,9 @@ class VirtualButtonBar(
} }
if (action == VirtualButtonAction.MORE) { if (action == VirtualButtonAction.MORE) {
MorePopup( ActionPopup(
show = showMorePopup, show = showMorePopup,
moreActions = moreActions, actions = moreActions,
onDismiss = { showMorePopup = false }, onDismiss = { showMorePopup = false },
onAction = { onAction = {
onAction(it) onAction(it)
@@ -285,17 +304,142 @@ class VirtualButtonBar(
} }
@Composable @Composable
private fun MorePopup( fun FloatingBall(
actions: List<VirtualButtonAction>,
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, show: Boolean,
moreActions: List<VirtualButtonAction>, actions: List<VirtualButtonAction>,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onAction: suspend (VirtualButtonAction) -> Unit, onAction: suspend (VirtualButtonAction) -> Unit,
renderInRootScaffold: Boolean, renderInRootScaffold: Boolean,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val haptics = LocalAppHaptics.current val haptics = LocalAppHaptics.current
val spinnerItems = remember(moreActions) { val spinnerItems = remember(actions) {
moreActions.map { action -> actions.map { action ->
SpinnerEntry( SpinnerEntry(
icon = { icon = {
Icon( Icon(
@@ -330,7 +474,7 @@ class VirtualButtonBar(
onSelectedIndexChange = { selectedIdx -> onSelectedIndexChange = { selectedIdx ->
haptics.confirm() haptics.confirm()
scope.launch { scope.launch {
onAction(moreActions[selectedIdx]) onAction(actions[selectedIdx])
} }
}, },
) )