NowPlayingBar.kt
331 lines · 15.8 KB
Changed in: 0.5.0
| 1 | package dev.stave.ui.nowplaying |
| 2 | |
| 3 | import androidx.compose.foundation.background |
| 4 | import androidx.compose.foundation.border |
| 5 | import androidx.compose.foundation.clickable |
| 6 | import androidx.compose.foundation.interaction.MutableInteractionSource |
| 7 | import androidx.compose.foundation.layout.Arrangement |
| 8 | import androidx.compose.foundation.layout.Box |
| 9 | import androidx.compose.foundation.layout.Column |
| 10 | import androidx.compose.foundation.layout.Row |
| 11 | import androidx.compose.foundation.layout.Spacer |
| 12 | import androidx.compose.foundation.layout.fillMaxHeight |
| 13 | import androidx.compose.foundation.layout.fillMaxWidth |
| 14 | import androidx.compose.foundation.layout.windowInsetsPadding |
| 15 | import androidx.compose.foundation.layout.safeDrawing |
| 16 | import androidx.compose.foundation.layout.only |
| 17 | import androidx.compose.foundation.layout.WindowInsetsSides |
| 18 | import androidx.compose.foundation.layout.WindowInsets |
| 19 | import androidx.compose.foundation.layout.height |
| 20 | import androidx.compose.foundation.layout.padding |
| 21 | import androidx.compose.foundation.layout.size |
| 22 | import androidx.compose.foundation.shape.RoundedCornerShape |
| 23 | import androidx.compose.material.icons.Icons |
| 24 | import androidx.compose.material.icons.filled.Fullscreen |
| 25 | import androidx.compose.material.icons.filled.Pause |
| 26 | import androidx.compose.material.icons.filled.PlayArrow |
| 27 | import androidx.compose.material.icons.filled.QueueMusic |
| 28 | import androidx.compose.material.icons.filled.SkipNext |
| 29 | import androidx.compose.material.icons.filled.SkipPrevious |
| 30 | import androidx.compose.material.icons.filled.Stop |
| 31 | import androidx.compose.material3.Icon |
| 32 | import androidx.compose.runtime.Composable |
| 33 | import androidx.compose.runtime.remember |
| 34 | import androidx.compose.ui.Alignment |
| 35 | import androidx.compose.ui.Modifier |
| 36 | import androidx.compose.ui.draw.clip |
| 37 | import androidx.compose.ui.focus.FocusRequester |
| 38 | import androidx.compose.ui.focus.focusRequester |
| 39 | import androidx.compose.ui.graphics.vector.ImageVector |
| 40 | import androidx.compose.ui.input.key.Key |
| 41 | import androidx.compose.ui.input.key.KeyEventType |
| 42 | import androidx.compose.ui.input.key.key |
| 43 | import androidx.compose.ui.input.key.onPreviewKeyEvent |
| 44 | import androidx.compose.ui.input.key.type |
| 45 | import androidx.compose.ui.text.style.TextOverflow |
| 46 | import androidx.compose.ui.unit.dp |
| 47 | import androidx.tv.material3.MaterialTheme |
| 48 | import androidx.tv.material3.Text |
| 49 | import dev.stave.domain.renderer.PlaybackState |
| 50 | import dev.stave.ui.components.AlbumArt |
| 51 | import dev.stave.ui.components.LocalMarqueeEnabled |
| 52 | import dev.stave.ui.components.staveFocusable |
| 53 | import dev.stave.ui.components.staveMarquee |
| 54 | import dev.stave.ui.theme.AccentAmber |
| 55 | import dev.stave.ui.theme.OnMuted |
| 56 | import dev.stave.ui.theme.OnPrimary |
| 57 | import dev.stave.ui.theme.SurfaceDivider |
| 58 | import dev.stave.ui.theme.SurfaceRaised |
| 59 | import dev.stave.ui.theme.staveInterFamily |
| 60 | import dev.stave.ui.theme.withInterDigits |
| 61 | import kotlin.time.Duration |
| 62 | |
| 63 | /** |
| 64 | * The persistent mini-player at the bottom of the main content surfaces. Carries inline |
| 65 | * transport controls (Prev, Stop, Play/Pause, Next) plus a Queue shortcut — critical because |
| 66 | * reaching this bar from a 100-item browse list used to require paging through every row. |
| 67 | * |
| 68 | * [playPauseFocusRequester] is the target the top-level D-pad-Left shortcut in EarhornNavHost |
| 69 | * uses to jump directly here. |
| 70 | */ |
| 71 | @Composable |
| 72 | fun NowPlayingBar( |
| 73 | state: PlaybackState, |
| 74 | onExpand: () -> Unit, |
| 75 | onTogglePlay: () -> Unit, |
| 76 | onStop: () -> Unit, |
| 77 | onPrev: () -> Unit, |
| 78 | onNext: () -> Unit, |
| 79 | onOpenQueue: () -> Unit, |
| 80 | playPauseFocusRequester: FocusRequester, |
| 81 | /** Focus target for the leftmost element of the bar — the art + metadata |
| 82 | * expand zone. Distinct from [playPauseFocusRequester] because some |
| 83 | * gestures (like Left-from-search-field) explicitly want the leftmost |
| 84 | * bar element, while others (Left-past-leftmost from list screens) want |
| 85 | * play/pause as the high-value action. */ |
| 86 | expandFocusRequester: FocusRequester? = null, |
| 87 | /** Invoked on D-pad Up from any bar element. The bar sits at screen bottom; |
| 88 | * letting spatial focus pick a landing target leads to unpredictable |
| 89 | * results (often the header Earhorn button, whose Left gesture pops the |
| 90 | * keyboard — a surprise for users just trying to return to the list). |
| 91 | * Callers wire this to a predictable focus target such as the Queue |
| 92 | * header button. */ |
| 93 | onUpFromBar: (() -> Unit)? = null, |
| 94 | /** Invoked on D-pad Down from any bar element. The bar sits at screen |
| 95 | * bottom; Down has nowhere to go physically, so we wrap focus back to |
| 96 | * a predictable target above — typically the same one [onUpFromBar] |
| 97 | * lands on. User explicitly asked for this behavior: "when you press |
| 98 | * down on the now playing bar, it should take you back up." Routing |
| 99 | * away from search (which would pop the keyboard) is the caller's |
| 100 | * responsibility. */ |
| 101 | onDownFromBar: (() -> Unit)? = null, |
| 102 | modifier: Modifier = Modifier, |
| 103 | ) { |
| 104 | val track = state.current ?: return |
| 105 | val expandInteraction = remember { MutableInteractionSource() } |
| 106 | // Focus target for the rightmost control (Fullscreen). Lets Left-from |
| 107 | // -leftmost wrap around to it for fast cross-bar navigation on D-pad. |
| 108 | val fullscreenFocusRequester = remember { FocusRequester() } |
| 109 | // Maintainer, 2026-10-29: phones (<600dp) had no room for the track. Old |
| 110 | // fixed width: 2*20 padding + 6*48 buttons + 6*12 gaps = 400dp, plus the |
| 111 | // info area's own 2*8 padding + 48 art + 2*16 gaps + ~70dp time label = |
| 112 | // ~566dp on a 360–411dp screen, so the title got 0. Compact only, all |
| 113 | // six buttons kept: 2*8 + 6*40 + 6*2 = 268dp fixed; info area 92dp (360) |
| 114 | // / 143dp (411); inside it 2*4 padding + 40 art + 1*8 gap = 56dp, leaving |
| 115 | // the title 36dp (360) / 87dp (411); the time label moves under the |
| 116 | // subtitle instead of taking width. Wide/TV unchanged. |
| 117 | val compact = dev.stave.ui.theme.isCompactWidth() |
| 118 | |
| 119 | Column( |
| 120 | modifier |
| 121 | .fillMaxWidth() |
| 122 | // Horizontal safe-drawing insets: on tablet landscape the |
| 123 | // side navigation bar overlapped the bar's edge controls |
| 124 | // (field-reported as the bar being cut off). Vertical is |
| 125 | // deliberately excluded — the host scaffold owns bottom |
| 126 | // insets on phones, and double-padding would float the bar. |
| 127 | .windowInsetsPadding( |
| 128 | WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal), |
| 129 | ) |
| 130 | .background(SurfaceRaised) |
| 131 | .border(0.5.dp, SurfaceDivider), |
| 132 | ) { |
| 133 | // Progress indicator sits ABOVE the interactive row so the row's focus |
| 134 | // outline can never overlap it. 2dp (was 1dp) so it reads as a deliberate |
| 135 | // UI element visible from across the room. Track-progress fraction fills |
| 136 | // from the left; the unfilled remainder is the bar's own SurfaceRaised |
| 137 | // background, which reads as a subtle track behind the amber fill. |
| 138 | if (state.duration != Duration.ZERO) { |
| 139 | val frac = (state.position.inWholeMilliseconds.toFloat() / |
| 140 | state.duration.inWholeMilliseconds.toFloat()).coerceIn(0f, 1f) |
| 141 | Box( |
| 142 | Modifier |
| 143 | .fillMaxWidth(frac) |
| 144 | .height(2.dp) |
| 145 | .background(AccentAmber) |
| 146 | ) |
| 147 | } else { |
| 148 | // Preserve vertical layout when no duration (brief window at track |
| 149 | // load) so the row content doesn't shift up 2dp and then back down. |
| 150 | Spacer(Modifier.height(2.dp)) |
| 151 | } |
| 152 | |
| 153 | Row( |
| 154 | Modifier |
| 155 | .fillMaxWidth() |
| 156 | .height(72.dp) |
| 157 | .padding(horizontal = if (compact) 8.dp else 20.dp) // Maintainer, 2026-10-29: see `compact`. |
| 158 | .then( |
| 159 | if (onUpFromBar != null || onDownFromBar != null) { |
| 160 | Modifier.onPreviewKeyEvent { ev -> |
| 161 | if (ev.type == KeyEventType.KeyDown) { |
| 162 | when (ev.key) { |
| 163 | Key.DirectionUp -> { |
| 164 | onUpFromBar?.invoke() |
| 165 | onUpFromBar != null |
| 166 | } |
| 167 | Key.DirectionDown -> { |
| 168 | onDownFromBar?.invoke() |
| 169 | onDownFromBar != null |
| 170 | } |
| 171 | else -> false |
| 172 | } |
| 173 | } else false |
| 174 | } |
| 175 | } else Modifier |
| 176 | ), |
| 177 | verticalAlignment = Alignment.CenterVertically, |
| 178 | horizontalArrangement = Arrangement.spacedBy(if (compact) 2.dp else 12.dp) // Maintainer, 2026-10-29: see `compact`. |
| 179 | ) { |
| 180 | // Art + metadata: tapping this region expands to the full Now Playing screen. |
| 181 | // Isolating the click target here (rather than making the whole bar clickable) |
| 182 | // means the transport buttons don't accidentally trigger expand. Left-from-here |
| 183 | // wraps to the rightmost control (Fullscreen) — symmetric "edge-to-edge" nav. |
| 184 | Row( |
| 185 | Modifier |
| 186 | .weight(1f) |
| 187 | .fillMaxHeight() |
| 188 | .then( |
| 189 | if (expandFocusRequester != null) |
| 190 | Modifier.focusRequester(expandFocusRequester) |
| 191 | else Modifier |
| 192 | ) |
| 193 | .onPreviewKeyEvent { ev -> |
| 194 | if (ev.type == KeyEventType.KeyDown && |
| 195 | ev.key == Key.DirectionLeft |
| 196 | ) { |
| 197 | runCatching { fullscreenFocusRequester.requestFocus() } |
| 198 | true |
| 199 | } else false |
| 200 | } |
| 201 | .staveFocusable(expandInteraction, cornerRadiusDp = 6) |
| 202 | .clickable(expandInteraction, indication = null) { onExpand() } |
| 203 | // Maintainer, 2026-10-29: compact padding/gap/art per `compact` above. |
| 204 | .padding(horizontal = if (compact) 4.dp else 8.dp), |
| 205 | verticalAlignment = Alignment.CenterVertically, |
| 206 | horizontalArrangement = Arrangement.spacedBy(if (compact) 8.dp else 16.dp), |
| 207 | ) { |
| 208 | AlbumArt( |
| 209 | model = track.albumArtUri, |
| 210 | modifier = Modifier |
| 211 | .size(if (compact) 40.dp else 48.dp) |
| 212 | .clip(RoundedCornerShape(2.dp)), |
| 213 | ) |
| 214 | Column(Modifier.weight(1f)) { |
| 215 | val marqueeOn = LocalMarqueeEnabled.current |
| 216 | Text( |
| 217 | withInterDigits(track.title), |
| 218 | style = MaterialTheme.typography.titleMedium, |
| 219 | color = OnPrimary, |
| 220 | maxLines = 1, |
| 221 | overflow = TextOverflow.Ellipsis, |
| 222 | modifier = Modifier.staveMarquee(marqueeOn), |
| 223 | ) |
| 224 | val subtitle = listOfNotNull(track.artist ?: track.albumArtist, track.album) |
| 225 | .joinToString(" \u00b7 ") |
| 226 | Text( |
| 227 | withInterDigits(subtitle), |
| 228 | style = MaterialTheme.typography.bodySmall, |
| 229 | color = OnMuted, |
| 230 | maxLines = 1, |
| 231 | overflow = TextOverflow.Ellipsis, |
| 232 | modifier = Modifier.staveMarquee(marqueeOn), |
| 233 | ) |
| 234 | // Maintainer, 2026-10-29: on compact width the time label |
| 235 | // rides under the subtitle (kept, not removed) so it does |
| 236 | // not eat the title's width. |
| 237 | if (compact) { |
| 238 | Text( |
| 239 | text = "${state.position.formatMmSs()} / ${state.duration.formatMmSs()}", |
| 240 | style = MaterialTheme.typography.labelSmall.copy(fontFamily = staveInterFamily()), |
| 241 | color = OnMuted, |
| 242 | maxLines = 1, |
| 243 | ) |
| 244 | } |
| 245 | } |
| 246 | if (!compact) { |
| 247 | Text( |
| 248 | text = "${state.position.formatMmSs()} / ${state.duration.formatMmSs()}", |
| 249 | style = MaterialTheme.typography.labelSmall.copy(fontFamily = staveInterFamily()), |
| 250 | color = OnMuted |
| 251 | ) |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | // Transport cluster. Ordering matches both the full Now Playing screen and classic |
| 256 | // hi-fi remotes: |◀◀ ■ ▶/❚❚ ▶▶| plus a Queue shortcut. |
| 257 | BarButton(Icons.Filled.SkipPrevious, "Previous", onPrev) |
| 258 | BarButton(Icons.Filled.Stop, "Stop", onStop) |
| 259 | BarButton( |
| 260 | icon = if (state.isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, |
| 261 | description = if (state.isPlaying) "Pause" else "Play", |
| 262 | onClick = onTogglePlay, |
| 263 | focusRequester = playPauseFocusRequester, |
| 264 | accent = true, |
| 265 | ) |
| 266 | BarButton(Icons.Filled.SkipNext, "Next", onNext) |
| 267 | BarButton(Icons.Filled.QueueMusic, "Queue", onOpenQueue) |
| 268 | // Explicit expand-to-Now-Playing button. The art+metadata click zone on the left |
| 269 | // also expands, but icons-at-end is the pattern users scan for; leaving expand |
| 270 | // only reachable via a weirdly-shaped click target was a discoverability hole. |
| 271 | BarButton( |
| 272 | icon = Icons.Filled.Fullscreen, |
| 273 | description = "Open Now Playing", |
| 274 | onClick = onExpand, |
| 275 | focusRequester = fullscreenFocusRequester, |
| 276 | ) |
| 277 | } |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | /** |
| 282 | * Small transport button sized for the bar. [accent] tints the icon amber (used for play/pause |
| 283 | * to highlight it as the primary action). [focusRequester], when non-null, lets the containing |
| 284 | * screen target this button directly — used by the D-pad-Left shortcut. |
| 285 | */ |
| 286 | @Composable |
| 287 | private fun BarButton( |
| 288 | icon: ImageVector, |
| 289 | description: String, |
| 290 | onClick: () -> Unit, |
| 291 | focusRequester: FocusRequester? = null, |
| 292 | accent: Boolean = false, |
| 293 | ) { |
| 294 | // Size differs by form factor: TV keeps the original 40dp (auto-sized |
| 295 | // icon) so the bar's appearance is byte-identical to the prior build; |
| 296 | // phone gets an explicit 28dp icon. |
| 297 | // Maintainer, 2026-10-29: phone box 48dp -> 40dp (same as TV now) so six |
| 298 | // buttons leave the track title room; arithmetic in NowPlayingBar. |
| 299 | val compact = dev.stave.ui.theme.isCompactWidth() |
| 300 | val interaction = remember { MutableInteractionSource() } |
| 301 | Box( |
| 302 | Modifier |
| 303 | .size(40.dp) |
| 304 | .let { if (focusRequester != null) it.focusRequester(focusRequester) else it } |
| 305 | .staveFocusable(interaction, cornerRadiusDp = 8) |
| 306 | .clickable(interaction, indication = null) { onClick() }, |
| 307 | contentAlignment = Alignment.Center |
| 308 | ) { |
| 309 | if (compact) { |
| 310 | Icon( |
| 311 | imageVector = icon, |
| 312 | contentDescription = description, |
| 313 | tint = if (accent) AccentAmber else OnPrimary, |
| 314 | modifier = Modifier.size(28.dp), |
| 315 | ) |
| 316 | } else { |
| 317 | Icon( |
| 318 | imageVector = icon, |
| 319 | contentDescription = description, |
| 320 | tint = if (accent) AccentAmber else OnPrimary, |
| 321 | ) |
| 322 | } |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | internal fun Duration.formatMmSs(): String { |
| 327 | val total = inWholeSeconds |
| 328 | val m = total / 60 |
| 329 | val s = total % 60 |
| 330 | return "%d:%02d".format(m, s) |
| 331 | } |


