package dev.stave.ui.nowplaying
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.QueueMusic
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import dev.stave.domain.renderer.PlaybackState
import dev.stave.ui.components.AlbumArt
import dev.stave.ui.components.LocalMarqueeEnabled
import dev.stave.ui.components.staveFocusable
import dev.stave.ui.components.staveMarquee
import dev.stave.ui.theme.AccentAmber
import dev.stave.ui.theme.OnMuted
import dev.stave.ui.theme.OnPrimary
import dev.stave.ui.theme.SurfaceDivider
import dev.stave.ui.theme.SurfaceRaised
import dev.stave.ui.theme.staveInterFamily
import dev.stave.ui.theme.withInterDigits
import kotlin.time.Duration
/**
* The persistent mini-player at the bottom of the main content surfaces. Carries inline
* transport controls (Prev, Stop, Play/Pause, Next) plus a Queue shortcut — critical because
* reaching this bar from a 100-item browse list used to require paging through every row.
*
* [playPauseFocusRequester] is the target the top-level D-pad-Left shortcut in EarhornNavHost
* uses to jump directly here.
*/
@Composable
fun NowPlayingBar(
state: PlaybackState,
onExpand: () -> Unit,
onTogglePlay: () -> Unit,
onStop: () -> Unit,
onPrev: () -> Unit,
onNext: () -> Unit,
onOpenQueue: () -> Unit,
playPauseFocusRequester: FocusRequester,
/** Focus target for the leftmost element of the bar — the art + metadata
* expand zone. Distinct from [playPauseFocusRequester] because some
* gestures (like Left-from-search-field) explicitly want the leftmost
* bar element, while others (Left-past-leftmost from list screens) want
* play/pause as the high-value action. */
expandFocusRequester: FocusRequester? = null,
/** Invoked on D-pad Up from any bar element. The bar sits at screen bottom;
* letting spatial focus pick a landing target leads to unpredictable
* results (often the header Earhorn button, whose Left gesture pops the
* keyboard — a surprise for users just trying to return to the list).
* Callers wire this to a predictable focus target such as the Queue
* header button. */
onUpFromBar: (() -> Unit)? = null,
/** Invoked on D-pad Down from any bar element. The bar sits at screen
* bottom; Down has nowhere to go physically, so we wrap focus back to
* a predictable target above — typically the same one [onUpFromBar]
* lands on. User explicitly asked for this behavior: "when you press
* down on the now playing bar, it should take you back up." Routing
* away from search (which would pop the keyboard) is the caller's
* responsibility. */
onDownFromBar: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val track = state.current ?: return
val expandInteraction = remember { MutableInteractionSource() }
// Focus target for the rightmost control (Fullscreen). Lets Left-from
// -leftmost wrap around to it for fast cross-bar navigation on D-pad.
val fullscreenFocusRequester = remember { FocusRequester() }
// Maintainer, 2026-10-29: phones (<600dp) had no room for the track. Old
// fixed width: 2*20 padding + 6*48 buttons + 6*12 gaps = 400dp, plus the
// info area's own 2*8 padding + 48 art + 2*16 gaps + ~70dp time label =
// ~566dp on a 360–411dp screen, so the title got 0. Compact only, all
// six buttons kept: 2*8 + 6*40 + 6*2 = 268dp fixed; info area 92dp (360)
// / 143dp (411); inside it 2*4 padding + 40 art + 1*8 gap = 56dp, leaving
// the title 36dp (360) / 87dp (411); the time label moves under the
// subtitle instead of taking width. Wide/TV unchanged.
val compact = dev.stave.ui.theme.isCompactWidth()
Column(
modifier
.fillMaxWidth()
// Horizontal safe-drawing insets: on tablet landscape the
// side navigation bar overlapped the bar's edge controls
// (field-reported as the bar being cut off). Vertical is
// deliberately excluded — the host scaffold owns bottom
// insets on phones, and double-padding would float the bar.
.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal),
)
.background(SurfaceRaised)
.border(0.5.dp, SurfaceDivider),
) {
// Progress indicator sits ABOVE the interactive row so the row's focus
// outline can never overlap it. 2dp (was 1dp) so it reads as a deliberate
// UI element visible from across the room. Track-progress fraction fills
// from the left; the unfilled remainder is the bar's own SurfaceRaised
// background, which reads as a subtle track behind the amber fill.
if (state.duration != Duration.ZERO) {
val frac = (state.position.inWholeMilliseconds.toFloat() /
state.duration.inWholeMilliseconds.toFloat()).coerceIn(0f, 1f)
Box(
Modifier
.fillMaxWidth(frac)
.height(2.dp)
.background(AccentAmber)
)
} else {
// Preserve vertical layout when no duration (brief window at track
// load) so the row content doesn't shift up 2dp and then back down.
Spacer(Modifier.height(2.dp))
}
Row(
Modifier
.fillMaxWidth()
.height(72.dp)
.padding(horizontal = if (compact) 8.dp else 20.dp) // Maintainer, 2026-10-29: see `compact`.
.then(
if (onUpFromBar != null || onDownFromBar != null) {
Modifier.onPreviewKeyEvent { ev ->
if (ev.type == KeyEventType.KeyDown) {
when (ev.key) {
Key.DirectionUp -> {
onUpFromBar?.invoke()
onUpFromBar != null
}
Key.DirectionDown -> {
onDownFromBar?.invoke()
onDownFromBar != null
}
else -> false
}
} else false
}
} else Modifier
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(if (compact) 2.dp else 12.dp) // Maintainer, 2026-10-29: see `compact`.
) {
// Art + metadata: tapping this region expands to the full Now Playing screen.
// Isolating the click target here (rather than making the whole bar clickable)
// means the transport buttons don't accidentally trigger expand. Left-from-here
// wraps to the rightmost control (Fullscreen) — symmetric "edge-to-edge" nav.
Row(
Modifier
.weight(1f)
.fillMaxHeight()
.then(
if (expandFocusRequester != null)
Modifier.focusRequester(expandFocusRequester)
else Modifier
)
.onPreviewKeyEvent { ev ->
if (ev.type == KeyEventType.KeyDown &&
ev.key == Key.DirectionLeft
) {
runCatching { fullscreenFocusRequester.requestFocus() }
true
} else false
}
.staveFocusable(expandInteraction, cornerRadiusDp = 6)
.clickable(expandInteraction, indication = null) { onExpand() }
// Maintainer, 2026-10-29: compact padding/gap/art per `compact` above.
.padding(horizontal = if (compact) 4.dp else 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(if (compact) 8.dp else 16.dp),
) {
AlbumArt(
model = track.albumArtUri,
modifier = Modifier
.size(if (compact) 40.dp else 48.dp)
.clip(RoundedCornerShape(2.dp)),
)
Column(Modifier.weight(1f)) {
val marqueeOn = LocalMarqueeEnabled.current
Text(
withInterDigits(track.title),
style = MaterialTheme.typography.titleMedium,
color = OnPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.staveMarquee(marqueeOn),
)
val subtitle = listOfNotNull(track.artist ?: track.albumArtist, track.album)
.joinToString(" \u00b7 ")
Text(
withInterDigits(subtitle),
style = MaterialTheme.typography.bodySmall,
color = OnMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.staveMarquee(marqueeOn),
)
// Maintainer, 2026-10-29: on compact width the time label
// rides under the subtitle (kept, not removed) so it does
// not eat the title's width.
if (compact) {
Text(
text = "${state.position.formatMmSs()} / ${state.duration.formatMmSs()}",
style = MaterialTheme.typography.labelSmall.copy(fontFamily = staveInterFamily()),
color = OnMuted,
maxLines = 1,
)
}
}
if (!compact) {
Text(
text = "${state.position.formatMmSs()} / ${state.duration.formatMmSs()}",
style = MaterialTheme.typography.labelSmall.copy(fontFamily = staveInterFamily()),
color = OnMuted
)
}
}
// Transport cluster. Ordering matches both the full Now Playing screen and classic
// hi-fi remotes: |◀◀ ■ ▶/❚❚ ▶▶| plus a Queue shortcut.
BarButton(Icons.Filled.SkipPrevious, "Previous", onPrev)
BarButton(Icons.Filled.Stop, "Stop", onStop)
BarButton(
icon = if (state.isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
description = if (state.isPlaying) "Pause" else "Play",
onClick = onTogglePlay,
focusRequester = playPauseFocusRequester,
accent = true,
)
BarButton(Icons.Filled.SkipNext, "Next", onNext)
BarButton(Icons.Filled.QueueMusic, "Queue", onOpenQueue)
// Explicit expand-to-Now-Playing button. The art+metadata click zone on the left
// also expands, but icons-at-end is the pattern users scan for; leaving expand
// only reachable via a weirdly-shaped click target was a discoverability hole.
BarButton(
icon = Icons.Filled.Fullscreen,
description = "Open Now Playing",
onClick = onExpand,
focusRequester = fullscreenFocusRequester,
)
}
}
}
/**
* Small transport button sized for the bar. [accent] tints the icon amber (used for play/pause
* to highlight it as the primary action). [focusRequester], when non-null, lets the containing
* screen target this button directly — used by the D-pad-Left shortcut.
*/
@Composable
private fun BarButton(
icon: ImageVector,
description: String,
onClick: () -> Unit,
focusRequester: FocusRequester? = null,
accent: Boolean = false,
) {
// Size differs by form factor: TV keeps the original 40dp (auto-sized
// icon) so the bar's appearance is byte-identical to the prior build;
// phone gets an explicit 28dp icon.
// Maintainer, 2026-10-29: phone box 48dp -> 40dp (same as TV now) so six
// buttons leave the track title room; arithmetic in NowPlayingBar.
val compact = dev.stave.ui.theme.isCompactWidth()
val interaction = remember { MutableInteractionSource() }
Box(
Modifier
.size(40.dp)
.let { if (focusRequester != null) it.focusRequester(focusRequester) else it }
.staveFocusable(interaction, cornerRadiusDp = 8)
.clickable(interaction, indication = null) { onClick() },
contentAlignment = Alignment.Center
) {
if (compact) {
Icon(
imageVector = icon,
contentDescription = description,
tint = if (accent) AccentAmber else OnPrimary,
modifier = Modifier.size(28.dp),
)
} else {
Icon(
imageVector = icon,
contentDescription = description,
tint = if (accent) AccentAmber else OnPrimary,
)
}
}
}
internal fun Duration.formatMmSs(): String {
val total = inWholeSeconds
val m = total / 60
val s = total % 60
return "%d:%02d".format(m, s)
}