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

View File

@@ -259,6 +259,14 @@ fun SettingsPage(
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 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),

View File

@@ -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<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,
moreActions: List<VirtualButtonAction>,
actions: List<VirtualButtonAction>,
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])
}
},
)