EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

ActionSheet.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.ui.components
2
3import androidx.activity.compose.BackHandler
4import androidx.compose.foundation.background
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.interaction.MutableInteractionSource
7import androidx.compose.foundation.layout.Arrangement
8import androidx.compose.foundation.layout.Box
9import androidx.compose.foundation.lazy.LazyColumn
10import androidx.compose.foundation.lazy.itemsIndexed
11import androidx.compose.foundation.layout.Column
12import androidx.compose.foundation.layout.Row
13import androidx.compose.foundation.layout.Spacer
14import androidx.compose.foundation.layout.fillMaxHeight
15import androidx.compose.foundation.layout.fillMaxSize
16import androidx.compose.foundation.layout.height
17import androidx.compose.foundation.layout.padding
18import androidx.compose.foundation.layout.fillMaxWidth
19import androidx.compose.foundation.layout.width
20import androidx.compose.foundation.layout.widthIn
21import androidx.compose.runtime.Composable
22import androidx.compose.runtime.LaunchedEffect
23import androidx.compose.runtime.getValue
24import androidx.compose.runtime.mutableStateOf
25import androidx.compose.runtime.remember
26import androidx.compose.runtime.rememberUpdatedState
27import androidx.compose.ui.Alignment
28import androidx.compose.ui.Modifier
29import androidx.compose.ui.focus.FocusRequester
30import androidx.compose.ui.focus.focusProperties
31import androidx.compose.foundation.focusGroup
32// Maintainer, 2026-10-29: tap handling for the scrim and panel (taps fell through).
33import androidx.compose.foundation.gestures.detectTapGestures
34import androidx.compose.ui.input.pointer.pointerInput
35import androidx.compose.ui.focus.focusRequester
36import androidx.compose.ui.graphics.Color
37import androidx.compose.ui.input.key.Key
38import androidx.compose.ui.input.key.KeyEventType
39import androidx.compose.ui.input.key.key
40import androidx.compose.ui.input.key.onPreviewKeyEvent
41import androidx.compose.ui.input.key.type
42import androidx.compose.ui.text.style.TextOverflow
43import androidx.compose.ui.unit.dp
44import androidx.tv.material3.MaterialTheme
45import androidx.tv.material3.Text
46import dev.stave.ui.theme.OnMuted
47import dev.stave.ui.theme.OnPrimary
48import dev.stave.ui.theme.OnQuiet
49import dev.stave.ui.theme.SurfaceBase
50import dev.stave.ui.theme.SurfaceRaised
51import dev.stave.ui.theme.isCompactHeight
52import dev.stave.ui.theme.withInterDigits
53
54/**
55 * A single tappable action within an [ActionSheet].
56 * [glyph] is a short leading character (▶, +, ⤵ etc.) — deliberately unicode rather than
57 * vector icons to keep the sheet dependency-free and consistent with action labels elsewhere.
58 */
59data class SheetAction(
60 val glyph: String,
61 val label: String,
62 val onClick: () -> Unit,
63)
64
65/**
66 * Right-anchored focusable action sheet. Shown as a full-screen overlay with a dim scrim over
67 * the underlying content and a 480dp panel on the right. Dismissable via BackHandler or the
68 * Cancel row.
69 *
70 * Design notes:
71 * - **Focus is trapped horizontally**: D-pad Left/Right are swallowed at the root so the user
72 * cannot spatially escape the sheet into the dimmed background content. Up/Down navigate
73 * between sheet rows as expected.
74 * - **No stuck-in-sheet risk**: every sheet auto-appends a Cancel row at the bottom, and the
75 * remote Back button always dismisses. So the user always has at least two escape routes.
76 * Trapping horizontal focus is safe because of this.
77 * - Initial focus lands on the first action (usually "Play"), matching §4.5.
78 * - Scrim color is a 55% black veil — intent is "the world behind is dimmed," not "a new
79 * surface is layered on top."
80 */
81@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
82@Composable
83fun ActionSheet(
84 title: String,
85 subtitle: String? = null,
86 actions: List<SheetAction>,
87 onDismiss: () -> Unit,
88 bottomContent: @Composable (() -> Unit)? = null,
89) {
90 BackHandler(onBack = onDismiss)
91
92 val firstFocus = remember { FocusRequester() }
93 // Long-press dismiss guard. When the sheet is opened by a long-press
94 // (DpadCenter held ≥500ms), the user is still holding the select key
95 // when the sheet appears. If we don't swallow the first release (and
96 // any auto-repeat KeyDowns before it), the focused action item fires
97 // immediately — the user never gets to see the menu.
98 //
99 // Strategy: assume long-press if a select KeyDown arrives within 80ms
100 // of composition (i.e. the key was already held). If so, consume
101 // everything until the first KeyUp. If no select KeyDown arrives in
102 // that window, the sheet was opened by a normal click and the guard
103 // is disarmed immediately — no double-press needed.
104 val guardActive = remember { mutableStateOf(true) }
105 val guardArmed = remember { mutableStateOf(false) }
106 LaunchedEffect(Unit) {
107 // Give 80ms for a held-key KeyDown event to arrive. If it does,
108 // guardArmed is set true by the key handler below. If not, the
109 // sheet was opened by a normal click and we disarm the guard.
110 kotlinx.coroutines.delay(80)
111 if (!guardArmed.value) {
112 guardActive.value = false
113 }
114 runCatching { firstFocus.requestFocus() }
115 }
116
117 // Maintainer, 2026-10-29: phone landscape (~360dp tall) left ~36dp for the
118 // action list under 48dp padding + 2-line title + 3-line subtitle. Short
119 // viewports get tighter padding/spacing and one-line headers.
120 val compactHeight = isCompactHeight()
121 // Maintainer, 2026-10-29: latest dismiss callback for the long-lived scrim
122 // tap detector below (pointerInput(Unit) would otherwise keep the first one).
123 val currentOnDismiss by rememberUpdatedState(onDismiss)
124
125 Box(
126 Modifier
127 .fillMaxSize()
128 .background(Color.Black.copy(alpha = 0.55f))
129 // Maintainer, 2026-10-29: the scrim had no pointer handler, so taps on
130 // the dimmed area fell through to the screen underneath and
131 // tap-outside never dismissed. A tap detector (not clickable) is
132 // used deliberately: clickable adds a focus target that is
133 // focusable in non-touch mode (TV) plus Enter-key handling, which
134 // would change D-pad behaviour; pointerInput adds neither, so TV
135 // focus is exactly as before.
136 .pointerInput(Unit) { detectTapGestures { currentOnDismiss() } }
137 .onPreviewKeyEvent { event ->
138 // Swallow horizontal D-pad to trap focus inside the sheet. Without
139 // this, pressing Left moves focus into the dimmed content behind
140 // the sheet, which reads as "the popup just disappeared" even
141 // though it's still technically there. Cancel row + Back button
142 // are the legitimate escapes.
143 if (event.key == Key.DirectionLeft || event.key == Key.DirectionRight) {
144 return@onPreviewKeyEvent true
145 }
146 val isSelect = event.key == Key.DirectionCenter ||
147 event.key == Key.Enter ||
148 event.key == Key.NumPadEnter
149 if (!isSelect) return@onPreviewKeyEvent false
150 // Guard: if the select key is already held when the sheet
151 // mounts (long-press open), swallow everything until the
152 // first KeyUp. A KeyDown arriving within 80ms of mount
153 // arms the guard; otherwise it's already disarmed.
154 if (guardActive.value) {
155 if (event.type == KeyEventType.KeyDown) {
156 guardArmed.value = true
157 }
158 if (event.type == KeyEventType.KeyUp && guardArmed.value) {
159 guardActive.value = false
160 }
161 return@onPreviewKeyEvent true
162 }
163 false
164 }
165 // Focus trap (same pattern as the Cast picker sheets): the
166 // Left/Right swallow above only covers horizontal escapes —
167 // Up past the first action row or Down past Cancel could
168 // still spatially land focus on an invisible element behind
169 // the scrim, "vanishing" the user's cursor. Cancelling exit
170 // on the sheet's focus group pins ALL directional navigation
171 // inside; Back and the Cancel row remain the escapes.
172 .focusProperties { exit = { FocusRequester.Cancel } }
173 .focusGroup(),
174 contentAlignment = Alignment.CenterEnd,
175 ) {
176 Column(
177 Modifier
178 .fillMaxHeight()
179 // 480dp was 380dp. The extra width is for long labels — genre
180 // names with "/" separators, playlist descriptions, etc. that
181 // previously got cut off at a too-narrow single line.
182 // Audit 2026-09-02: a fixed 480dp overflowed every phone
183 // (~411dp portrait) — the sheet's left edge and its glyph
184 // column sat off-screen. Cap at 480, never wider than the
185 // screen; TV is unchanged (its screens exceed 480).
186 .fillMaxWidth()
187 .widthIn(max = 480.dp)
188 .background(SurfaceBase)
189 // Maintainer, 2026-10-29: the panel consumes its own taps (title,
190 // padding, gaps) so they neither reach the scrim's dismiss nor
191 // the screen underneath. pointerInput rather than a no-op
192 // clickable for the same reason as the scrim: no new focus
193 // target on TV. Rows' own clickables still win (they are
194 // children and consume the tap first).
195 .pointerInput(Unit) { detectTapGestures { } }
196 // Maintainer, 2026-10-29: 16dp vertical padding on short (phone
197 // landscape) viewports; 48dp otherwise, as before.
198 .padding(horizontal = 28.dp, vertical = if (compactHeight) 16.dp else 48.dp),
199 ) {
200 Text(
201 title,
202 style = MaterialTheme.typography.titleLarge,
203 color = OnPrimary,
204 // Maintainer, 2026-10-29: one line on short viewports.
205 maxLines = if (compactHeight) 1 else 2,
206 overflow = TextOverflow.Ellipsis,
207 )
208 if (subtitle != null) {
209 Spacer(Modifier.height(4.dp))
210 Text(
211 withInterDigits(subtitle),
212 style = MaterialTheme.typography.bodyMedium,
213 color = OnMuted,
214 // Was maxLines=1 — caused "This replaces your current queue
215 // with 42 random tracks." and similar subtitles to elipsize
216 // in the middle. 3 lines is plenty for any real subtitle.
217 // Maintainer, 2026-10-29: one line on short viewports.
218 maxLines = if (compactHeight) 1 else 3,
219 softWrap = true,
220 overflow = TextOverflow.Ellipsis,
221 )
222 }
223 // Maintainer, 2026-10-29: 12dp on short viewports, 28dp otherwise.
224 Spacer(Modifier.height(if (compactHeight) 12.dp else 28.dp))
225
226 // LazyColumn (not Column) so the list scrolls when actions + the
227 // trailing Cancel row would exceed the sheet's vertical space. On
228 // Android TV this is driven by focus: D-pad Down onto an off-screen
229 // row causes LazyColumn to scroll it into view automatically. This
230 // is what makes the Cancel row reachable when the genre picker has
231 // 5+ options — previously it was drawn below the viewport with no
232 // way to scroll to it.
233 LazyColumn(
234 verticalArrangement = Arrangement.spacedBy(6.dp),
235 ) {
236 itemsIndexed(actions) { index, action ->
237 SheetRow(
238 action = action,
239 onClick = {
240 action.onClick()
241 onDismiss()
242 },
243 modifier = if (index == 0) {
244 Modifier.focusRequester(firstFocus)
245 } else {
246 Modifier
247 },
248 )
249 }
250 item {
251 Spacer(Modifier.height(12.dp))
252 SheetRow(
253 action = SheetAction("✕", "Cancel") { /* no-op, dismiss handles it */ },
254 onClick = onDismiss,
255 quiet = true,
256 )
257 }
258 }
259 if (bottomContent != null) {
260 Spacer(Modifier.height(32.dp))
261 bottomContent()
262 }
263 }
264 }
265}
266
267@Composable
268private fun SheetRow(
269 action: SheetAction,
270 onClick: () -> Unit,
271 modifier: Modifier = Modifier,
272 quiet: Boolean = false,
273) {
274 val interaction = remember { MutableInteractionSource() }
275 Box(
276 modifier
277 .fillMaxHeightRowSurface()
278 .background(SurfaceRaised)
279 .staveFocusable(interaction, cornerRadiusDp = 6)
280 .clickable(interaction, indication = null) { onClick() }
281 .padding(horizontal = 18.dp, vertical = 14.dp),
282 ) {
283 Row(verticalAlignment = Alignment.CenterVertically) {
284 Text(
285 action.glyph,
286 style = MaterialTheme.typography.titleMedium,
287 color = if (quiet) OnQuiet else OnPrimary,
288 modifier = Modifier.width(28.dp),
289 )
290 Text(
291 withInterDigits(action.label),
292 style = MaterialTheme.typography.bodyLarge,
293 color = if (quiet) OnQuiet else OnPrimary,
294 maxLines = 2,
295 softWrap = true,
296 overflow = TextOverflow.Ellipsis,
297 modifier = Modifier.weight(1f),
298 )
299 }
300 }
301}
302
303/** Keeps each row a consistent width inside the sheet — fills the sheet's
304 * interior. Previously this was a fixed 324dp; too narrow for long labels
305 * on the new 480dp sheet. */
306private fun Modifier.fillMaxHeightRowSurface(): Modifier = this.fillMaxWidth()