NowPlayingBar.kt
Each line, with the release that last changed it.
| 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 | } |


