Skip to content

Commit 6506d62

Browse files
committed
Make LongPressMenuEditor work with DPAD / Android TV
1 parent 8075a43 commit 6506d62

2 files changed

Lines changed: 199 additions & 60 deletions

File tree

app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenu.kt

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import android.content.Context
77
import android.content.res.Configuration
88
import android.view.ViewGroup
99
import android.view.ViewGroup.LayoutParams
10-
import androidx.activity.compose.BackHandler
1110
import androidx.compose.foundation.clickable
1211
import androidx.compose.foundation.isSystemInDarkTheme
1312
import androidx.compose.foundation.layout.Arrangement
@@ -69,6 +68,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
6968
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
7069
import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
7170
import androidx.compose.ui.unit.dp
71+
import androidx.compose.ui.window.Dialog
72+
import androidx.compose.ui.window.DialogProperties
7273
import coil3.compose.AsyncImage
7374
import org.schabi.newpipe.R
7475
import org.schabi.newpipe.extractor.stream.StreamType
@@ -126,14 +127,16 @@ fun LongPressMenu(
126127

127128
if (showEditor) {
128129
// we can't put the editor in a bottom sheet, because it relies on dragging gestures
129-
ScaffoldWithToolbar(
130-
title = stringResource(R.string.long_press_menu_actions_editor),
131-
onBackClick = { showEditor = false },
132-
) { paddingValues ->
133-
Box(modifier = Modifier.padding(paddingValues)) {
134-
LongPressMenuEditor()
130+
Dialog(
131+
onDismissRequest = { showEditor = false },
132+
properties = DialogProperties(usePlatformDefaultWidth = false)
133+
) {
134+
ScaffoldWithToolbar(
135+
title = stringResource(R.string.long_press_menu_actions_editor),
136+
onBackClick = { showEditor = false },
137+
) { paddingValues ->
138+
LongPressMenuEditor(modifier = Modifier.padding(paddingValues))
135139
}
136-
BackHandler { showEditor = false }
137140
}
138141
} else {
139142
ModalBottomSheet(

app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt

Lines changed: 188 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ package org.schabi.newpipe.ui.components.menu
2121
import androidx.annotation.StringRes
2222
import androidx.compose.foundation.BorderStroke
2323
import androidx.compose.foundation.border
24+
import androidx.compose.foundation.layout.BoxWithConstraints
2425
import androidx.compose.foundation.layout.Column
2526
import androidx.compose.foundation.layout.fillMaxWidth
2627
import androidx.compose.foundation.layout.offset
@@ -54,9 +55,15 @@ import androidx.compose.runtime.setValue
5455
import androidx.compose.runtime.toMutableStateList
5556
import androidx.compose.ui.Alignment
5657
import androidx.compose.ui.Modifier
58+
import androidx.compose.ui.focus.focusTarget
5759
import androidx.compose.ui.geometry.Offset
5860
import androidx.compose.ui.graphics.Color
5961
import androidx.compose.ui.graphics.vector.ImageVector
62+
import androidx.compose.ui.input.key.Key
63+
import androidx.compose.ui.input.key.KeyEventType
64+
import androidx.compose.ui.input.key.key
65+
import androidx.compose.ui.input.key.onKeyEvent
66+
import androidx.compose.ui.input.key.type
6067
import androidx.compose.ui.platform.LocalDensity
6168
import androidx.compose.ui.res.stringResource
6269
import androidx.compose.ui.text.font.FontStyle
@@ -69,18 +76,19 @@ import androidx.compose.ui.unit.IntSize
6976
import androidx.compose.ui.unit.dp
7077
import androidx.compose.ui.unit.toSize
7178
import org.schabi.newpipe.R
79+
import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Companion.DefaultEnabledActions
7280
import org.schabi.newpipe.ui.detectDragGestures
7381
import org.schabi.newpipe.ui.theme.AppTheme
7482
import org.schabi.newpipe.util.text.FixedHeightCenteredText
83+
import kotlin.math.floor
7584
import kotlin.math.min
7685

77-
// TODO implement accessibility for this, to allow using this with a DPAD (e.g. Android TV)
7886
@Composable
79-
fun LongPressMenuEditor() {
87+
fun LongPressMenuEditor(modifier: Modifier = Modifier) {
8088
// We get the current arrangement once and do not observe on purpose
8189
// TODO load from settings
82-
val headerEnabled = remember { false } // true }
83-
val actionArrangement = remember { LongPressAction.Type.entries } // DefaultEnabledActions }
90+
val headerEnabled = remember { true }
91+
val actionArrangement = remember { DefaultEnabledActions }
8492
val items = remember(headerEnabled, actionArrangement) {
8593
sequence {
8694
yield(ItemInList.EnabledCaption)
@@ -127,8 +135,8 @@ fun LongPressMenuEditor() {
127135
return closestItemInRow
128136
}
129137

130-
fun beginDragGesture(pos: IntOffset) {
131-
val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return
138+
fun beginDragGesture(pos: IntOffset, rawItem: LazyGridItemInfo) {
139+
if (activeDragItem != null) return
132140
val item = items.getOrNull(rawItem.index) ?: return
133141
if (item.isDraggable) {
134142
items[rawItem.index] = ItemInList.DragMarker(item.columnSpan)
@@ -138,34 +146,41 @@ fun LongPressMenuEditor() {
138146
}
139147
}
140148

141-
fun handleDragGestureChange(pos: IntOffset, posChange: Offset) {
142-
val dragItem = activeDragItem
143-
if (dragItem == null) {
144-
// when the user clicks outside of any draggable item, let the list be scrolled
145-
gridState.dispatchRawDelta(-posChange.y)
146-
return
147-
}
148-
activeDragPosition = pos
149+
fun beginDragGesture(pos: IntOffset) {
149150
val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return
151+
beginDragGesture(pos, rawItem)
152+
}
153+
154+
fun handleDragGestureChange(dragItem: ItemInList, rawItem: LazyGridItemInfo) {
155+
val prevDragMarkerIndex = items.indexOfFirst { it is ItemInList.DragMarker }
156+
.takeIf { it >= 0 } ?: return // impossible situation, DragMarker is always in the list
150157

151158
// compute where the DragMarker will go (we need to do special logic to make sure the
152159
// HeaderBox always sticks right after EnabledCaption or HiddenCaption)
153160
val nextDragMarkerIndex = if (dragItem == ItemInList.HeaderBox) {
154161
val hiddenCaptionIndex = items.indexOf(ItemInList.HiddenCaption)
155162
if (rawItem.index < hiddenCaptionIndex)
156163
1 // i.e. right after the EnabledCaption
164+
else if (prevDragMarkerIndex < hiddenCaptionIndex)
165+
hiddenCaptionIndex // i.e. right after the HiddenCaption
157166
else
158167
hiddenCaptionIndex + 1 // i.e. right after the HiddenCaption
159168
} else {
160169
var i = rawItem.index
161170
// make sure it is not possible to move items in between a *Caption and a HeaderBox
162-
if (!items[i].isDraggable) i += 1
163-
if (items[i] == ItemInList.HeaderBox) i += 1
171+
val offsetForRemovingPrev = if (prevDragMarkerIndex < rawItem.index) 1 else 0
172+
if (!items[i - offsetForRemovingPrev].isDraggable) i += 1
173+
if (items[i - offsetForRemovingPrev] == ItemInList.HeaderBox) i += 1
164174
i
165175
}
166176

177+
// no need to do anything if the DragMarker is already at the right place
178+
if (prevDragMarkerIndex == nextDragMarkerIndex) {
179+
return
180+
}
181+
167182
// adjust the position of the DragMarker
168-
items.removeIf { it is ItemInList.DragMarker }
183+
items.removeAt(prevDragMarkerIndex)
169184
items.add(min(nextDragMarkerIndex, items.size), ItemInList.DragMarker(dragItem.columnSpan))
170185

171186
// add or remove NoneMarkers as needed
@@ -178,6 +193,18 @@ fun LongPressMenuEditor() {
178193
}
179194
}
180195

196+
fun handleDragGestureChange(pos: IntOffset, posChangeForScrolling: Offset) {
197+
val dragItem = activeDragItem
198+
if (dragItem == null) {
199+
// when the user clicks outside of any draggable item, let the list be scrolled
200+
gridState.dispatchRawDelta(-posChangeForScrolling.y)
201+
return
202+
}
203+
activeDragPosition = pos
204+
val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return
205+
handleDragGestureChange(dragItem, rawItem)
206+
}
207+
181208
fun completeDragGestureAndCleanUp() {
182209
val dragItem = activeDragItem
183210
if (dragItem != null) {
@@ -198,44 +225,153 @@ fun LongPressMenuEditor() {
198225
}
199226
}
200227

201-
LazyVerticalGrid(
202-
modifier = Modifier
203-
.safeDrawingPadding()
204-
.detectDragGestures(
205-
beginDragGesture = ::beginDragGesture,
206-
handleDragGestureChange = ::handleDragGestureChange,
207-
endDragGesture = ::completeDragGestureAndCleanUp,
208-
),
209-
// same width as the LongPressMenu
210-
columns = GridCells.Adaptive(MinButtonWidth),
211-
userScrollEnabled = false,
212-
state = gridState,
213-
) {
214-
itemsIndexed(
215-
items,
216-
key = { _, item -> item.stableUniqueKey() },
217-
span = { _, item -> GridItemSpan(item.columnSpan ?: maxLineSpan) },
218-
) { i, item ->
228+
BoxWithConstraints(modifier) {
229+
// otherwise we wouldn't know the amount of columns to handle the Up/Down key events
230+
val columns = maxOf(1, floor(this.maxWidth / MinButtonWidth).toInt())
231+
LazyVerticalGrid(
232+
modifier = Modifier
233+
.safeDrawingPadding()
234+
.detectDragGestures(
235+
beginDragGesture = ::beginDragGesture,
236+
handleDragGestureChange = ::handleDragGestureChange,
237+
endDragGesture = ::completeDragGestureAndCleanUp,
238+
)
239+
.focusTarget()
240+
.onKeyEvent { event ->
241+
if (event.type != KeyEventType.KeyDown) {
242+
if (event.type == KeyEventType.KeyUp &&
243+
event.key == Key.DirectionDown &&
244+
currentlyFocusedItem < 0
245+
) {
246+
//
247+
currentlyFocusedItem = 0
248+
}
249+
return@onKeyEvent false
250+
}
251+
var focusedItem = currentlyFocusedItem
252+
when (event.key) {
253+
Key.DirectionUp -> {
254+
if (focusedItem < 0) {
255+
return@onKeyEvent false
256+
} else if (items[focusedItem].columnSpan == null) {
257+
focusedItem -= 1
258+
} else {
259+
var remaining = columns
260+
while (true) {
261+
focusedItem -= 1
262+
if (focusedItem < 0) {
263+
break
264+
}
265+
remaining -= items[focusedItem].columnSpan ?: columns
266+
if (remaining <= 0) {
267+
break
268+
}
269+
}
270+
}
271+
}
272+
273+
Key.DirectionDown -> {
274+
if (focusedItem >= items.size - 1) {
275+
return@onKeyEvent false
276+
} else if (items[focusedItem].columnSpan == null) {
277+
focusedItem += 1
278+
} else {
279+
var remaining = columns
280+
while (true) {
281+
focusedItem += 1
282+
if (focusedItem >= items.size - 1) {
283+
break
284+
}
285+
remaining -= items[focusedItem].columnSpan ?: columns
286+
if (remaining <= 0) {
287+
break
288+
}
289+
}
290+
}
291+
}
292+
293+
Key.DirectionLeft -> {
294+
if (focusedItem < 0) {
295+
return@onKeyEvent false
296+
} else {
297+
focusedItem -= 1
298+
}
299+
}
300+
301+
Key.DirectionRight -> {
302+
if (focusedItem >= items.size - 1) {
303+
return@onKeyEvent false
304+
} else {
305+
focusedItem += 1
306+
}
307+
}
308+
309+
Key.Enter, Key.NumPadEnter, Key.DirectionCenter -> if (activeDragItem == null) {
310+
val rawItem = gridState.layoutInfo.visibleItemsInfo
311+
.firstOrNull { it.index == focusedItem }
312+
?: return@onKeyEvent false
313+
beginDragGesture(rawItem.offset, rawItem)
314+
return@onKeyEvent true
315+
} else {
316+
completeDragGestureAndCleanUp()
317+
return@onKeyEvent true
318+
}
319+
320+
else -> return@onKeyEvent false
321+
}
322+
323+
currentlyFocusedItem = focusedItem
324+
if (focusedItem < 0) {
325+
// not checking for focusedItem>=items.size because it's impossible for it
326+
// to reach that value, and that's because we assume that there is nothing
327+
// else focusable *after* this view. This way we don't need to cleanup the
328+
// drag gestures when the user reaches the end, which would be confusing as
329+
// then there would be no indication of the current cursor position at all.
330+
completeDragGestureAndCleanUp()
331+
return@onKeyEvent false
332+
}
333+
334+
val dragItem = activeDragItem
335+
if (dragItem != null) {
336+
val rawItem = gridState.layoutInfo.visibleItemsInfo
337+
.firstOrNull { it.index == focusedItem }
338+
?: return@onKeyEvent false
339+
activeDragPosition = rawItem.offset
340+
handleDragGestureChange(dragItem, rawItem)
341+
}
342+
return@onKeyEvent true
343+
},
344+
// same width as the LongPressMenu
345+
columns = GridCells.Adaptive(MinButtonWidth),
346+
userScrollEnabled = false,
347+
state = gridState,
348+
) {
349+
itemsIndexed(
350+
items,
351+
key = { _, item -> item.stableUniqueKey() },
352+
span = { _, item -> GridItemSpan(item.columnSpan ?: maxLineSpan) },
353+
) { i, item ->
354+
ItemInListUi(
355+
item = item,
356+
selected = currentlyFocusedItem == i,
357+
modifier = Modifier.animateItem()
358+
)
359+
}
360+
}
361+
if (activeDragItem != null) {
362+
val size = with(LocalDensity.current) {
363+
remember(activeDragSize) { activeDragSize.toSize().toDpSize() }
364+
}
219365
ItemInListUi(
220-
item = item,
221-
selected = currentlyFocusedItem == i,
222-
modifier = Modifier.animateItem()
366+
item = activeDragItem!!,
367+
selected = false,
368+
modifier = Modifier
369+
.size(size)
370+
.offset { activeDragPosition }
371+
.offset(-size.width / 2, -size.height / 2),
223372
)
224373
}
225374
}
226-
if (activeDragItem != null) {
227-
val size = with(LocalDensity.current) {
228-
remember(activeDragSize) { activeDragSize.toSize().toDpSize() }
229-
}
230-
ItemInListUi(
231-
item = activeDragItem!!,
232-
selected = true,
233-
modifier = Modifier
234-
.size(size)
235-
.offset { activeDragPosition }
236-
.offset(-size.width / 2, -size.height / 2),
237-
)
238-
}
239375
}
240376

241377
sealed class ItemInList(val isDraggable: Boolean, open val columnSpan: Int? = 1) {
@@ -305,7 +441,7 @@ private fun ActionOrHeaderBox(
305441
color = backgroundColor,
306442
contentColor = contentColor,
307443
shape = MaterialTheme.shapes.large,
308-
border = BorderStroke(2.dp, contentColor).takeIf { selected },
444+
border = BorderStroke(2.dp, contentColor.copy(alpha = 1f)).takeIf { selected },
309445
modifier = modifier.padding(
310446
horizontal = horizontalPadding,
311447
vertical = 5.dp,

0 commit comments

Comments
 (0)