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

NowPlayingBar.kt

331 lines · 15.8 KB

Changed in: 0.5.0

1package dev.stave.ui.nowplaying
2
3import androidx.compose.foundation.background
4import androidx.compose.foundation.border
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.layout.Column
10import androidx.compose.foundation.layout.Row
11import androidx.compose.foundation.layout.Spacer
12import androidx.compose.foundation.layout.fillMaxHeight
13import androidx.compose.foundation.layout.fillMaxWidth
14import androidx.compose.foundation.layout.windowInsetsPadding
15import androidx.compose.foundation.layout.safeDrawing
16import androidx.compose.foundation.layout.only
17import androidx.compose.foundation.layout.WindowInsetsSides
18import androidx.compose.foundation.layout.WindowInsets
19import androidx.compose.foundation.layout.height
20import androidx.compose.foundation.layout.padding
21import androidx.compose.foundation.layout.size
22import androidx.compose.foundation.shape.RoundedCornerShape
23import androidx.compose.material.icons.Icons
24import androidx.compose.material.icons.filled.Fullscreen
25import androidx.compose.material.icons.filled.Pause
26import androidx.compose.material.icons.filled.PlayArrow
27import androidx.compose.material.icons.filled.QueueMusic
28import androidx.compose.material.icons.filled.SkipNext
29import androidx.compose.material.icons.filled.SkipPrevious
30import androidx.compose.material.icons.filled.Stop
31import androidx.compose.material3.Icon
32import androidx.compose.runtime.Composable
33import androidx.compose.runtime.remember
34import androidx.compose.ui.Alignment
35import androidx.compose.ui.Modifier
36import androidx.compose.ui.draw.clip
37import androidx.compose.ui.focus.FocusRequester
38import androidx.compose.ui.focus.focusRequester
39import androidx.compose.ui.graphics.vector.ImageVector
40import androidx.compose.ui.input.key.Key
41import androidx.compose.ui.input.key.KeyEventType
42import androidx.compose.ui.input.key.key
43import androidx.compose.ui.input.key.onPreviewKeyEvent
44import androidx.compose.ui.input.key.type
45import androidx.compose.ui.text.style.TextOverflow
46import androidx.compose.ui.unit.dp
47import androidx.tv.material3.MaterialTheme
48import androidx.tv.material3.Text
49import dev.stave.domain.renderer.PlaybackState
50import dev.stave.ui.components.AlbumArt
51import dev.stave.ui.components.LocalMarqueeEnabled
52import dev.stave.ui.components.staveFocusable
53import dev.stave.ui.components.staveMarquee
54import dev.stave.ui.theme.AccentAmber
55import dev.stave.ui.theme.OnMuted
56import dev.stave.ui.theme.OnPrimary
57import dev.stave.ui.theme.SurfaceDivider
58import dev.stave.ui.theme.SurfaceRaised
59import dev.stave.ui.theme.staveInterFamily
60import dev.stave.ui.theme.withInterDigits
61import 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
72fun 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
287private 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
326internal 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}