Make LongPressMenuEditor work with DPAD / Android TV

This commit is contained in:
Stypox 2025-10-23 03:11:14 +02:00
parent 6396c97c9a
commit e350b10b14
No known key found for this signature in database
GPG Key ID: 4BDF1B40A49FDD23
2 changed files with 201 additions and 60 deletions

View File

@ -7,7 +7,6 @@ import android.content.Context
import android.content.res.Configuration
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
@ -69,6 +68,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil3.compose.AsyncImage
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.stream.StreamType
@ -126,14 +127,16 @@ fun LongPressMenu(
if (showEditor) {
// we can't put the editor in a bottom sheet, because it relies on dragging gestures
ScaffoldWithToolbar(
title = stringResource(R.string.long_press_menu_actions_editor),
onBackClick = { showEditor = false },
) { paddingValues ->
Box(modifier = Modifier.padding(paddingValues)) {
LongPressMenuEditor()
Dialog(
onDismissRequest = { showEditor = false },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
ScaffoldWithToolbar(
title = stringResource(R.string.long_press_menu_actions_editor),
onBackClick = { showEditor = false },
) { paddingValues ->
LongPressMenuEditor(modifier = Modifier.padding(paddingValues))
}
BackHandler { showEditor = false }
}
} else {
ModalBottomSheet(

View File

@ -21,6 +21,7 @@ package org.schabi.newpipe.ui.components.menu
import androidx.annotation.StringRes
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
@ -54,9 +55,15 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusTarget
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
@ -69,18 +76,21 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import org.schabi.newpipe.R
import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Companion.DefaultEnabledActions
import org.schabi.newpipe.ui.detectDragGestures
import org.schabi.newpipe.ui.theme.AppTheme
import org.schabi.newpipe.util.text.FixedHeightCenteredText
import kotlin.math.floor
import kotlin.math.min
// TODO implement accessibility for this, to allow using this with a DPAD (e.g. Android TV)
// TODO padding doesn't seem to work as expected when the list becomes scrollable?
// TODO does Android TV auto-scroll to the selected item when the list becomes scrollable?
@Composable
fun LongPressMenuEditor() {
fun LongPressMenuEditor(modifier: Modifier = Modifier) {
// We get the current arrangement once and do not observe on purpose
// TODO load from settings
val headerEnabled = remember { false } // true }
val actionArrangement = remember { LongPressAction.Type.entries } // DefaultEnabledActions }
val headerEnabled = remember { true }
val actionArrangement = remember { DefaultEnabledActions }
val items = remember(headerEnabled, actionArrangement) {
sequence {
yield(ItemInList.EnabledCaption)
@ -127,8 +137,8 @@ fun LongPressMenuEditor() {
return closestItemInRow
}
fun beginDragGesture(pos: IntOffset) {
val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return
fun beginDragGesture(pos: IntOffset, rawItem: LazyGridItemInfo) {
if (activeDragItem != null) return
val item = items.getOrNull(rawItem.index) ?: return
if (item.isDraggable) {
items[rawItem.index] = ItemInList.DragMarker(item.columnSpan)
@ -138,15 +148,14 @@ fun LongPressMenuEditor() {
}
}
fun handleDragGestureChange(pos: IntOffset, posChange: Offset) {
val dragItem = activeDragItem
if (dragItem == null) {
// when the user clicks outside of any draggable item, let the list be scrolled
gridState.dispatchRawDelta(-posChange.y)
return
}
activeDragPosition = pos
fun beginDragGesture(pos: IntOffset) {
val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return
beginDragGesture(pos, rawItem)
}
fun handleDragGestureChange(dragItem: ItemInList, rawItem: LazyGridItemInfo) {
val prevDragMarkerIndex = items.indexOfFirst { it is ItemInList.DragMarker }
.takeIf { it >= 0 } ?: return // impossible situation, DragMarker is always in the list
// compute where the DragMarker will go (we need to do special logic to make sure the
// HeaderBox always sticks right after EnabledCaption or HiddenCaption)
@ -154,19 +163,27 @@ fun LongPressMenuEditor() {
val hiddenCaptionIndex = items.indexOf(ItemInList.HiddenCaption)
if (rawItem.index < hiddenCaptionIndex) {
1 // i.e. right after the EnabledCaption
} else if (prevDragMarkerIndex < hiddenCaptionIndex) {
hiddenCaptionIndex // i.e. right after the HiddenCaption
} else {
hiddenCaptionIndex + 1 // i.e. right after the HiddenCaption
}
} else {
var i = rawItem.index
// make sure it is not possible to move items in between a *Caption and a HeaderBox
if (!items[i].isDraggable) i += 1
if (items[i] == ItemInList.HeaderBox) i += 1
val offsetForRemovingPrev = if (prevDragMarkerIndex < rawItem.index) 1 else 0
if (!items[i - offsetForRemovingPrev].isDraggable) i += 1
if (items[i - offsetForRemovingPrev] == ItemInList.HeaderBox) i += 1
i
}
// no need to do anything if the DragMarker is already at the right place
if (prevDragMarkerIndex == nextDragMarkerIndex) {
return
}
// adjust the position of the DragMarker
items.removeIf { it is ItemInList.DragMarker }
items.removeAt(prevDragMarkerIndex)
items.add(min(nextDragMarkerIndex, items.size), ItemInList.DragMarker(dragItem.columnSpan))
// add or remove NoneMarkers as needed
@ -179,6 +196,18 @@ fun LongPressMenuEditor() {
}
}
fun handleDragGestureChange(pos: IntOffset, posChangeForScrolling: Offset) {
val dragItem = activeDragItem
if (dragItem == null) {
// when the user clicks outside of any draggable item, let the list be scrolled
gridState.dispatchRawDelta(-posChangeForScrolling.y)
return
}
activeDragPosition = pos
val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return
handleDragGestureChange(dragItem, rawItem)
}
fun completeDragGestureAndCleanUp() {
val dragItem = activeDragItem
if (dragItem != null) {
@ -199,44 +228,153 @@ fun LongPressMenuEditor() {
}
}
LazyVerticalGrid(
modifier = Modifier
.safeDrawingPadding()
.detectDragGestures(
beginDragGesture = ::beginDragGesture,
handleDragGestureChange = ::handleDragGestureChange,
endDragGesture = ::completeDragGestureAndCleanUp,
),
// same width as the LongPressMenu
columns = GridCells.Adaptive(MinButtonWidth),
userScrollEnabled = false,
state = gridState,
) {
itemsIndexed(
items,
key = { _, item -> item.stableUniqueKey() },
span = { _, item -> GridItemSpan(item.columnSpan ?: maxLineSpan) },
) { i, item ->
BoxWithConstraints(modifier) {
// otherwise we wouldn't know the amount of columns to handle the Up/Down key events
val columns = maxOf(1, floor(this.maxWidth / MinButtonWidth).toInt())
LazyVerticalGrid(
modifier = Modifier
.safeDrawingPadding()
.detectDragGestures(
beginDragGesture = ::beginDragGesture,
handleDragGestureChange = ::handleDragGestureChange,
endDragGesture = ::completeDragGestureAndCleanUp,
)
.focusTarget()
.onKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) {
if (event.type == KeyEventType.KeyUp &&
event.key == Key.DirectionDown &&
currentlyFocusedItem < 0
) {
//
currentlyFocusedItem = 0
}
return@onKeyEvent false
}
var focusedItem = currentlyFocusedItem
when (event.key) {
Key.DirectionUp -> {
if (focusedItem < 0) {
return@onKeyEvent false
} else if (items[focusedItem].columnSpan == null) {
focusedItem -= 1
} else {
var remaining = columns
while (true) {
focusedItem -= 1
if (focusedItem < 0) {
break
}
remaining -= items[focusedItem].columnSpan ?: columns
if (remaining <= 0) {
break
}
}
}
}
Key.DirectionDown -> {
if (focusedItem >= items.size - 1) {
return@onKeyEvent false
} else if (items[focusedItem].columnSpan == null) {
focusedItem += 1
} else {
var remaining = columns
while (true) {
focusedItem += 1
if (focusedItem >= items.size - 1) {
break
}
remaining -= items[focusedItem].columnSpan ?: columns
if (remaining <= 0) {
break
}
}
}
}
Key.DirectionLeft -> {
if (focusedItem < 0) {
return@onKeyEvent false
} else {
focusedItem -= 1
}
}
Key.DirectionRight -> {
if (focusedItem >= items.size - 1) {
return@onKeyEvent false
} else {
focusedItem += 1
}
}
Key.Enter, Key.NumPadEnter, Key.DirectionCenter -> if (activeDragItem == null) {
val rawItem = gridState.layoutInfo.visibleItemsInfo
.firstOrNull { it.index == focusedItem }
?: return@onKeyEvent false
beginDragGesture(rawItem.offset, rawItem)
return@onKeyEvent true
} else {
completeDragGestureAndCleanUp()
return@onKeyEvent true
}
else -> return@onKeyEvent false
}
currentlyFocusedItem = focusedItem
if (focusedItem < 0) {
// not checking for focusedItem>=items.size because it's impossible for it
// to reach that value, and that's because we assume that there is nothing
// else focusable *after* this view. This way we don't need to cleanup the
// drag gestures when the user reaches the end, which would be confusing as
// then there would be no indication of the current cursor position at all.
completeDragGestureAndCleanUp()
return@onKeyEvent false
}
val dragItem = activeDragItem
if (dragItem != null) {
val rawItem = gridState.layoutInfo.visibleItemsInfo
.firstOrNull { it.index == focusedItem }
?: return@onKeyEvent false
activeDragPosition = rawItem.offset
handleDragGestureChange(dragItem, rawItem)
}
return@onKeyEvent true
},
// same width as the LongPressMenu
columns = GridCells.Adaptive(MinButtonWidth),
userScrollEnabled = false,
state = gridState,
) {
itemsIndexed(
items,
key = { _, item -> item.stableUniqueKey() },
span = { _, item -> GridItemSpan(item.columnSpan ?: maxLineSpan) },
) { i, item ->
ItemInListUi(
item = item,
selected = currentlyFocusedItem == i,
modifier = Modifier.animateItem()
)
}
}
if (activeDragItem != null) {
val size = with(LocalDensity.current) {
remember(activeDragSize) { activeDragSize.toSize().toDpSize() }
}
ItemInListUi(
item = item,
selected = currentlyFocusedItem == i,
modifier = Modifier.animateItem()
item = activeDragItem!!,
selected = false,
modifier = Modifier
.size(size)
.offset { activeDragPosition }
.offset(-size.width / 2, -size.height / 2),
)
}
}
if (activeDragItem != null) {
val size = with(LocalDensity.current) {
remember(activeDragSize) { activeDragSize.toSize().toDpSize() }
}
ItemInListUi(
item = activeDragItem!!,
selected = true,
modifier = Modifier
.size(size)
.offset { activeDragPosition }
.offset(-size.width / 2, -size.height / 2),
)
}
}
sealed class ItemInList(val isDraggable: Boolean, open val columnSpan: Int? = 1) {
@ -306,7 +444,7 @@ private fun ActionOrHeaderBox(
color = backgroundColor,
contentColor = contentColor,
shape = MaterialTheme.shapes.large,
border = BorderStroke(2.dp, contentColor).takeIf { selected },
border = BorderStroke(2.dp, contentColor.copy(alpha = 1f)).takeIf { selected },
modifier = modifier.padding(
horizontal = horizontalPadding,
vertical = 5.dp,