package dev.stave.ui.guide
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.animateScrollBy
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
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.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import dev.stave.R
import dev.stave.ui.components.staveFocusable
import dev.stave.ui.theme.AccentAmber
import dev.stave.ui.theme.OnMuted
import dev.stave.ui.theme.OnPrimary
import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
import dev.stave.ui.theme.adaptiveScreenPaddingVertical
import dev.stave.ui.theme.isCompactHeight
import dev.stave.ui.theme.isCompactWidth
import dev.stave.ui.theme.SurfaceDivider
import dev.stave.ui.theme.withInterDigits
import kotlinx.coroutines.launch
/**
* User guide — two-pane TV-native layout. Left pane: navigable table of
* contents (Introduction + 9 Acts). Right pane: scrollable content for the
* selected chapter. D-pad Up/Down navigates TOC entries on the left or
* scrolls content on the right. Left/Right switches between panes.
*
* This replaces the single-column LazyColumn design that produced jerky
* focus-driven scrolling. Splitting navigation from reading solves it:
* the TOC has small fixed-height items (no jumps), and the content pane
* scrolls smoothly via a scroll-state rather than focus-to-item hops.
*/
@Composable
fun GuideScreen(onBack: () -> Unit) {
BackHandler(onBack = onBack)
val blocks = remember { buildGuideBlocks() }
val chapters = remember(blocks) { groupIntoChapters(blocks) }
var selectedIndex by rememberSaveable { mutableStateOf(0) }
val contentScrollState = rememberScrollState()
val coroutineScope = rememberCoroutineScope()
// Reset scroll to top when the user picks a different chapter.
LaunchedEffect(selectedIndex) {
contentScrollState.animateScrollTo(0)
}
// Right-pane focus target. When user presses Right from the TOC,
// focus lands here and D-pad Up/Down scroll the content.
val contentFocus = remember { FocusRequester() }
// One FocusRequester per chapter so Left from the right pane returns
// to the SELECTED entry, not whichever entry spatial focus picks.
val chapterFocusRequesters = remember(chapters) {
List(chapters.size) { FocusRequester() }
}
// Initial focus on the first TOC entry.
LaunchedEffect(Unit) {
runCatching { chapterFocusRequesters.firstOrNull()?.requestFocus() }
}
// Compact phone (portrait): the TV two-pane Row puts a 280dp left LazyColumn
// next to a weight(1f) right Column. On a 411dp portrait phone that left
// pane consumes 280 + 24dp gap = ~304dp of width and the right pane is left
// with ~107dp — too narrow for body text, so Compose wraps the chapter text
// one character per line ("F-i-r-s-t s-t-e-p-s" reading vertically). Fix:
// collapse to a single-pane layout where the user picks a chapter from a
// full-width TOC, taps in, reads the content, and presses back to return
// to the TOC. Uses rememberSaveable so config changes (rotation, theme
// switch) preserve which view the user was on.
if (isCompactWidth()) {
var compactShowingContent by rememberSaveable { mutableStateOf(false) }
// Override BackHandler: when content is showing, Back goes back to the
// TOC rather than exiting the User Guide entirely. Two-step exit
// matches the user's mental model of "I'm reading a chapter, going
// back means returning to the chapter list."
BackHandler(enabled = compactShowingContent) {
compactShowingContent = false
}
Column(
Modifier
.fillMaxSize()
.padding(
horizontal = adaptiveScreenPaddingHorizontal(),
vertical = adaptiveScreenPaddingVertical(),
),
) {
if (!compactShowingContent) {
// ── Compact: TOC view ────────────────────────────────────
LazyColumn(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
item(key = "masthead-compact") {
Column(
Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(R.drawable.earhorn_badge),
contentDescription = null,
modifier = Modifier.size(72.dp),
)
Spacer(Modifier.height(4.dp))
Text(
"User Guide",
style = MaterialTheme.typography.titleMedium,
color = OnPrimary,
)
}
}
itemsIndexed(chapters, key = { _, ch -> ch.numeral.ifEmpty { "intro" } }) { idx, ch ->
ChapterNavItem(
numeral = ch.numeral,
title = ch.title,
isSelected = idx == selectedIndex,
onClick = {
selectedIndex = idx
compactShowingContent = true
},
// No spatial focus management on compact — touch
// is the primary input mode and there's no right
// pane to navigate to.
focusRequester = null,
onRight = { },
)
}
}
} else {
// ── Compact: content view ─────────────────────────────────
val chapter = chapters.getOrNull(selectedIndex)
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Back-to-chapters affordance — full-width tappable row.
// Amber chevron + label read as "back" rather than as a
// heading. Vertical padding kept tight (4dp) so the
// back row doesn't waste headroom above the title;
// the labelLarge intrinsic height already gives a
// ≥48dp tap target with the row width.
Row(
Modifier
.fillMaxWidth()
.clickable { compactShowingContent = false }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"‹ Chapters",
style = MaterialTheme.typography.labelLarge,
color = AccentAmber,
)
}
if (chapter != null) {
if (chapter.numeral.isEmpty()) {
// Introduction: vertical badge + body, since
// there's no horizontal room for the side-by-side
// arrangement the TV layout uses.
Image(
painter = painterResource(R.drawable.earhorn_badge),
contentDescription = null,
modifier = Modifier
.size(140.dp)
.align(Alignment.CenterHorizontally),
)
Text(
"A User's Guide",
style = MaterialTheme.typography.headlineMedium,
color = OnPrimary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Text(
"to the careful operation of Earhorn",
style = MaterialTheme.typography.titleSmall,
color = OnMuted,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
chapter.sections.forEach { section ->
Text(
withInterDigits(section.body),
style = MaterialTheme.typography.bodyLarge,
color = OnMuted,
)
}
} else {
// Numeral + title on a SINGLE line. The prior
// stacked layout (displaySmall numeral on its
// own line, headlineMedium title on its own
// line, plus spacedBy-12dp gaps between every
// sibling) ate ~120dp before the chapter text
// even started. Inlining them at titleMedium
// / titleLarge gives the same hierarchy
// (numeral amber, title bright) in a single
// ~36dp row.
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
chapter.numeral,
style = MaterialTheme.typography.titleMedium,
color = AccentAmber,
)
Text(
chapter.title,
style = MaterialTheme.typography.titleLarge,
color = OnPrimary,
)
}
Box(
Modifier
.width(72.dp)
.height(2.dp)
.background(AccentAmber),
)
chapter.sections.forEach { section ->
SectionBlock(section.heading, section.body)
}
}
Spacer(Modifier.height(24.dp))
}
}
}
}
return
}
Row(
Modifier
.fillMaxSize()
.padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical()),
horizontalArrangement = Arrangement.spacedBy(24.dp),
) {
// ── Left pane: chapter list ──────────────────────────────────
LazyColumn(
Modifier.width(280.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
// Masthead at the very top of the left pane.
item(key = "masthead") {
Column(
Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(R.drawable.earhorn_badge),
contentDescription = null,
modifier = Modifier.size(80.dp),
)
Spacer(Modifier.height(4.dp))
Text(
"User Guide",
style = MaterialTheme.typography.titleMedium,
color = OnPrimary,
)
}
}
itemsIndexed(chapters, key = { _, ch -> ch.numeral.ifEmpty { "intro" } }) { idx, ch ->
ChapterNavItem(
numeral = ch.numeral,
title = ch.title,
isSelected = idx == selectedIndex,
onClick = {
selectedIndex = idx
// Focus the right pane so the user can immediately
// D-pad Down to scroll through the content.
runCatching { contentFocus.requestFocus() }
},
focusRequester = chapterFocusRequesters.getOrNull(idx),
onRight = { runCatching { contentFocus.requestFocus() } },
)
}
}
// ── Right pane: content for the selected chapter ─────────────
// Focusable scroll container. D-pad Up/Down scroll by a page-ish
// increment; when at the scroll ceiling, Up falls through to spatial
// focus (which lands on the left-pane TOC entry). Left always
// returns to the TOC via default spatial focus.
Column(
Modifier
.weight(1f)
.focusRequester(contentFocus)
.focusable()
.onPreviewKeyEvent { ev ->
if (ev.type != KeyEventType.KeyDown)
return@onPreviewKeyEvent false
when (ev.key) {
Key.DirectionLeft -> {
// Return to the SELECTED TOC entry, not
// whichever one spatial focus would pick.
runCatching {
chapterFocusRequesters.getOrNull(selectedIndex)
?.requestFocus()
}
true
}
Key.DirectionDown -> {
if (contentScrollState.value < contentScrollState.maxValue) {
coroutineScope.launch {
contentScrollState.animateScrollBy(400f)
}
true
} else false
}
Key.DirectionUp -> {
if (contentScrollState.value > 0) {
coroutineScope.launch {
contentScrollState.animateScrollBy(-400f)
}
true
} else {
// At top → return to selected TOC entry
runCatching {
chapterFocusRequesters.getOrNull(selectedIndex)
?.requestFocus()
}
true
}
}
else -> false
}
}
.verticalScroll(contentScrollState)
.padding(end = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
val chapter = chapters.getOrNull(selectedIndex)
if (chapter != null) {
// Scroll hint — subtle amber caret tells the user D-pad
// Down scrolls through the content in this pane.
Text(
"▼",
style = MaterialTheme.typography.labelSmall,
color = AccentAmber,
modifier = Modifier.align(Alignment.End),
)
if (chapter.numeral.isEmpty()) {
// Introduction: badge + body. On a TV / tall tablet, render
// side-by-side (badge LEFT, text RIGHT). On a short
// landscape-phone viewport, stack the badge on TOP so it
// doesn't dominate the limited vertical space and crowd the
// article text into a sliver column.
if (isCompactHeight()) {
// Hero stack — badge centered above title block.
// Size halves vs the side-by-side variant so the badge
// doesn't eat the whole landscape viewport before the
// user reaches a single word of body text.
Image(
painter = painterResource(R.drawable.earhorn_badge),
contentDescription = null,
modifier = Modifier
.size(96.dp)
.align(Alignment.CenterHorizontally),
)
Spacer(Modifier.height(8.dp))
Text(
"A User's Guide",
style = MaterialTheme.typography.displaySmall,
color = OnPrimary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Text(
"to the careful operation of Earhorn",
style = MaterialTheme.typography.titleSmall,
color = OnMuted,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
chapter.sections.forEach { section ->
Text(
withInterDigits(section.body),
style = MaterialTheme.typography.bodyLarge,
color = OnMuted,
)
}
} else {
// TV / tall tablet — badge left, text right (matches About).
Row(
Modifier.fillMaxWidth().padding(vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(32.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(R.drawable.earhorn_badge),
contentDescription = null,
modifier = Modifier.size(180.dp),
)
Column(Modifier.weight(1f)) {
Text(
"A User's Guide",
style = MaterialTheme.typography.displayMedium,
color = OnPrimary,
)
Text(
"to the careful operation of Earhorn",
style = MaterialTheme.typography.titleMedium,
color = OnMuted,
)
Spacer(Modifier.height(12.dp))
chapter.sections.forEach { section ->
Text(
withInterDigits(section.body),
style = MaterialTheme.typography.bodyLarge,
color = OnMuted,
)
}
}
}
}
} else {
// Act chapter: heading + sections
Spacer(Modifier.height(8.dp))
Text(
chapter.numeral,
style = MaterialTheme.typography.displaySmall,
color = AccentAmber,
)
Text(
chapter.title,
style = MaterialTheme.typography.headlineLarge,
color = OnPrimary,
textAlign = TextAlign.Center,
)
Box(
Modifier
.width(96.dp)
.height(2.dp)
.background(AccentAmber),
)
Spacer(Modifier.height(8.dp))
chapter.sections.forEach { section ->
SectionBlock(section.heading, section.body)
}
}
Spacer(Modifier.height(24.dp)) // bottom breathing room
}
}
}
}
// ---------------------------------------------------------------------------
// Two-pane data model
// ---------------------------------------------------------------------------
private data class GuideChapter(
val numeral: String, // "" for Introduction
val title: String,
val sections: List<GuideBlock.Section>,
)
/** Walk the flat block list and group into chapters: one Introduction
* (holding any IntroPara blocks) plus one chapter per Act (holding its
* trailing Section blocks). Skips decorative blocks (Divider, Ornament,
* TOC, BackToContents) — those only made sense in the single-column
* scroll layout. */
private fun groupIntoChapters(blocks: List<GuideBlock>): List<GuideChapter> {
val chapters = mutableListOf<GuideChapter>()
// Introduction chapter
val introSections = blocks.filterIsInstance<GuideBlock.IntroPara>()
.map { GuideBlock.Section("", it.text) }
chapters.add(GuideChapter("", "Introduction", introSections))
// Acts
var currentAct: GuideBlock.Act? = null
var currentSections = mutableListOf<GuideBlock.Section>()
for (block in blocks) {
when (block) {
is GuideBlock.Act -> {
currentAct?.let {
chapters.add(GuideChapter(it.numeral, it.title, currentSections.toList()))
}
currentAct = block
currentSections = mutableListOf()
}
is GuideBlock.Section -> currentSections.add(block)
else -> { /* skip decorative blocks */ }
}
}
currentAct?.let {
chapters.add(GuideChapter(it.numeral, it.title, currentSections.toList()))
}
return chapters
}
/** Single entry in the left-pane TOC. Amber highlight when focused;
* selected entry gets a subtle background to show "this is what you're
* reading." */
@Composable
private fun ChapterNavItem(
numeral: String,
title: String,
isSelected: Boolean,
onClick: () -> Unit,
focusRequester: FocusRequester?,
onRight: () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
Row(
Modifier
.fillMaxWidth()
.background(
if (isSelected) SurfaceDivider
else Color.Transparent
)
.then(
if (focusRequester != null) Modifier.focusRequester(focusRequester)
else Modifier
)
.onPreviewKeyEvent { ev ->
if (ev.type == KeyEventType.KeyDown &&
ev.key == Key.DirectionRight
) {
onRight()
true
} else false
}
.staveFocusable(interaction, cornerRadiusDp = 4)
.clickable(interaction, indication = null, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (numeral.isNotEmpty()) {
// 48dp fixed-width slot for the numeral. The prior 36dp was
// wide enough for I–VII at labelLarge but VIII overflowed and
// wrapped to "VII / I"; IX fits but XI/XII would also overflow
// 36dp. softWrap=false guarantees the numeral stays on one
// line even if a future chapter pushes the chapter count past
// what 48dp can hold (at which point we'd just bump this).
Text(
numeral,
style = MaterialTheme.typography.labelLarge,
color = AccentAmber,
maxLines = 1,
softWrap = false,
modifier = Modifier.width(48.dp),
)
}
Text(
title,
style = MaterialTheme.typography.bodyMedium,
color = OnPrimary,
)
}
}
/** Scroll-and-focus target. Written by TOC/BackToContents clicks,
* consumed by a LaunchedEffect that coordinates the two operations. */
private sealed class JumpTarget {
data class Act(val numeral: String) : JumpTarget()
data object Contents : JumpTarget()
}
// ---------------------------------------------------------------------------
// Content model + data
// ---------------------------------------------------------------------------
private sealed class GuideBlock {
data object Masthead : GuideBlock()
data class IntroPara(val text: String) : GuideBlock()
data object TableOfContents : GuideBlock()
data object Divider : GuideBlock()
data class Act(val numeral: String, val title: String) : GuideBlock()
data class Section(val heading: String, val body: String) : GuideBlock()
/** A focusable button that jumps back to the Table of Contents. Placed
* at the end of each Act so the user never has to D-pad up through
* many paragraphs. */
data object BackToContents : GuideBlock()
data object Ornament : GuideBlock()
}
/** Fixed content list. Any change to this list shifts indices automatically
* because the TOC computes them from the list itself. */
private fun buildGuideBlocks(): List<GuideBlock> = buildList {
add(GuideBlock.Masthead)
add(
GuideBlock.IntroPara(
"Earhorn is a quiet player for the music you already own. It connects to a UPnP " +
"server running somewhere on your network — a NAS, a computer, a Raspberry Pi — " +
"and lets you browse that library, queue tracks, and listen through your TV. " +
"Every control is designed for a D-pad remote; nothing requires a touchscreen " +
"and nothing requires an internet connection beyond your local network."
)
)
add(GuideBlock.TableOfContents)
add(GuideBlock.Divider)
add(GuideBlock.Act("I", "First steps"))
add(
GuideBlock.Section(
heading = "Finding a server",
body = "When Earhorn first opens, it scans your local network for UPnP media servers " +
"— MinimServer, Twonky, Plex (with DLNA enabled), Kodi, Emby, Jellyfin, and " +
"many others all work. Any servers it discovers appear on the first screen. " +
"Click the one you want and Earhorn will remember your choice for next time. " +
"Press the Rescan button in the header if you've just brought a server online " +
"and it hasn't appeared yet.",
)
)
add(
GuideBlock.Section(
heading = "If nothing appears",
body = "Most commonly this means your server isn't broadcasting UPnP, or it's on a " +
"different network segment than the TV. Make sure the server advertises as a " +
"DLNA/UPnP media server (not just a music-only protocol like Subsonic), and " +
"that both devices are on the same subnet. Guest Wi-Fi networks often isolate " +
"clients from each other — that's usually the culprit.",
)
)
add(GuideBlock.BackToContents)
add(GuideBlock.Divider)
add(GuideBlock.Act("II", "Browsing your library"))
add(
GuideBlock.Section(
heading = "Folders, albums, tracks",
body = "The top level of a server is usually organised by category — Artists, " +
"Albums, Genres, Composers, etc. Click into a category to drill in. Albums " +
"open into a dedicated album view with artwork; generic folders open into a " +
"flat list. There's no distinction between 'library' and 'playlists' — " +
"whatever structure your server provides is what you navigate through.",
)
)
add(
GuideBlock.Section(
heading = "Getting around",
body = "D-pad up and down move through lists. Left and right cross between panels — " +
"for instance, inside an album view, Right steps into the track list and Left " +
"walks back out through the album's action buttons. Pressing Left on the " +
"leftmost control of any screen jumps straight to the Now Playing bar when " +
"music is playing, so you never need to scroll down through a long list to " +
"reach transport controls.",
)
)
add(
GuideBlock.Section(
heading = "The Back button",
body = "Back always steps up one level in the hierarchy, exactly as you'd expect. " +
"Holding it doesn't do anything special — Earhorn doesn't have modal screens " +
"that trap you, so a single press is always enough to retreat.",
)
)
add(GuideBlock.BackToContents)
add(GuideBlock.Divider)
add(GuideBlock.Act("III", "Playback and the queue"))
add(
GuideBlock.Section(
heading = "The rule of one press",
body = "A single click on any song queues it. A single click on an album's title or " +
"cover opens the album (not playback). A long-press — holding select for " +
"about half a second — opens the full action menu for that item. Earhorn never " +
"asks you a question on a single click; clicking always acts.",
)
)
add(
GuideBlock.Section(
heading = "Queue versus play",
body = "When the queue is empty and you click a song, playback begins immediately. " +
"When the queue has tracks in it already, clicking a song appends that track " +
"to the end of the queue without disturbing what's playing. This is the safe " +
"default: one click will never wipe out a carefully-built queue.",
)
)
add(
GuideBlock.Section(
heading = "Replacing the queue",
body = "To replace the whole queue with an album, use the large play button on the " +
"album's page. If the queue isn't empty, a dialog will appear asking whether " +
"you really mean to clear it. Replacing is always deliberate; there's no way " +
"to clobber the queue by accident.",
)
)
add(
GuideBlock.Section(
heading = "Long-press on a song",
body = "Hold select on any song to open its action menu. From there you can queue " +
"the track, insert it next (play immediately after whatever's playing), or — " +
"in the context of an album — queue every track from this one through the end. " +
"None of these options replace the queue.",
)
)
add(
GuideBlock.Section(
heading = "Stop versus pause",
body = "Pause holds your place; press play again and the track resumes where you " +
"left it. Stop ends playback and hides the Now Playing bar, but it keeps the " +
"queue intact — press play once the bar reappears and the track restarts from " +
"the beginning, rather than resuming mid-song.",
)
)
add(GuideBlock.BackToContents)
add(GuideBlock.Divider)
add(GuideBlock.Act("IV", "The Now Playing screen"))
add(
GuideBlock.Section(
heading = "Opening the full view",
body = "The Now Playing bar docked at the bottom of every screen is a compact " +
"transport. Click its artwork or title to open the full Now Playing view — " +
"larger artwork, full metadata, a progress bar you can scrub, and the More " +
"actions row.",
)
)
add(
GuideBlock.Section(
heading = "Scrubbing the timeline",
body = "On the Now Playing view, press D-pad Up from the transport row to focus " +
"the progress bar. You'll see it thicken, an amber outline appear, and a " +
"small amber knob mark the playhead. From there, Left and Right seek by ten " +
"seconds each press. Hold the key and auto-repeat gives you smooth " +
"continuous scrubbing. Press Down to return to the transport.",
)
)
add(
GuideBlock.Section(
heading = "The transport row",
body = "Previous, Stop, Play/Pause, Next. Ordering matches the buttons on every " +
"classical hi-fi remote: skip back on the far left, play/pause central. " +
"Focus lands on Play/Pause when the screen first opens.",
)
)
add(
GuideBlock.Section(
heading = "The more-actions row",
body = "Below the transport: Queue (opens the queue list), More by Artist (every " +
"other album by the current track's artist), and More in Genre (every album " +
"tagged with this track's genre). If the current track has multiple genres, " +
"the genre action opens a picker so you can choose which one to browse.",
)
)
add(GuideBlock.BackToContents)
add(GuideBlock.Divider)
add(GuideBlock.Act("V", "The Now Playing bar"))
add(
GuideBlock.Section(
heading = "What it shows",
body = "When anything is queued and not stopped, a thin strip pins itself to the " +
"bottom of the screen with artwork, track title, elapsed and total time, " +
"and an inline mini-transport: Previous, Stop, Play/Pause, Next, Queue, " +
"Expand.",
)
)
add(
GuideBlock.Section(
heading = "Reaching it in one press",
body = "From any list screen, press Left past the leftmost focusable item to jump " +
"straight to the bar's play/pause button. You never need to scroll down " +
"through a list to hit pause — one press, from anywhere. Once focus is on " +
"the bar, Left and Right move between bar controls normally.",
)
)
add(
GuideBlock.Section(
heading = "Leaving the bar",
body = "Either Up or Down from any bar control returns focus to the content " +
"above. Down wraps around to the top of the page — useful because the " +
"bar is already at the screen's bottom and Down would otherwise do " +
"nothing. Up and Down both land on whichever focusable sits nearest " +
"above the bar, so behavior stays predictable across screens.",
)
)
add(GuideBlock.BackToContents)
add(GuideBlock.Divider)
add(GuideBlock.Act("VI", "Searching your library"))
add(
GuideBlock.Section(
heading = "Opening search",
body = "Two ways in, both from the Browse screen. Press D-pad Left on the Earhorn " +
"button in the header — the keyboard slides in on the left half of the " +
"screen. Or navigate to the search pill (the text field between the " +
"\"Browse\" title and the Earhorn button) and press Enter. Either way the " +
"keyboard belongs to Earhorn. No system IME. No Google TV search overlay. " +
"No external app takes over.",
)
)
add(
GuideBlock.Section(
heading = "The 4×12 grid",
body = "Four rows of twelve keys each, uniform. Top row: QWERTY letters plus " +
"apostrophe and quote. Second row: more QWERTY plus hyphen, underscore, " +
"period. Third row: the last letter row plus question mark, exclamation, " +
"comma, slash, colon. Bottom row: digits zero through nine plus " +
"ampersand and at-sign. Every character a search query needs is one " +
"press from a letter. Search is case-insensitive, so there is no Shift " +
"and no Caps — saves two modes and two function-row slots.",
)
)
add(
GuideBlock.Section(
heading = "The function row",
body = "Space, Backspace, Clear, Mic (if the device has a speech recognizer), " +
"Done. Clear wipes the entire query in one press — useful when you want " +
"to change search terms without backspacing through the old one. " +
"Done saves your query to recent searches and closes the keyboard, leaving " +
"the bucket summary visible.",
)
)
add(
GuideBlock.Section(
heading = "D-pad flow on the keyboard",
body = "Left and Right move within a row; Left on the first key wraps to the " +
"last, Right on the last key wraps to the first. Up and Down move " +
"between rows, column-aligned: Up from \"f\" lands on \"r\", Up from " +
"\"r\" lands on the \"4\" above it, and so on. Up from the top row " +
"closes the keyboard. Down from the bottom letter row lands on the " +
"function key directly below — Space if you're on the left side, " +
"Done if you're on the right.",
)
)
add(
GuideBlock.Section(
heading = "Recent searches",
body = "When the keyboard opens with an empty query, the right pane shows your " +
"ten most recent searches with the newest on top. Initial focus lands on " +
"the top entry, so picking up where you left off is one Enter press. " +
"D-pad Left from any recent jumps focus back to the keyboard's Q key.",
)
)
add(
GuideBlock.Section(
heading = "The five buckets",
body = "Once you start typing, the server returns five category counts: " +
"Albums whose title matches, Albums whose artist matches, Artists whose " +
"name matches, Tracks whose title matches, and Tracks whose artist " +
"matches. Empty buckets hide themselves. Click any bucket row to expand " +
"into the full matching list. This mirrors how BubbleUPnP organizes " +
"results — it scales: a search for a common word returns hundreds of " +
"tracks but still shows at a glance that there are only two albums.",
)
)
add(
GuideBlock.Section(
heading = "Relevance ranking inside a bucket",
body = "Inside a title-matched bucket, matches are sorted by relevance: items " +
"whose title starts with your query first (articles like \"The\" and \"A\" " +
"ignored for the prefix check), then items where the query appears at a " +
"word boundary, then substring matches, then everything else. " +
"Alphabetical within each rank. Inside an artist-matched bucket, items " +
"are grouped by artist — one artist's albums or tracks sit together.",
)
)
add(
GuideBlock.Section(
heading = "Back cycles focus",
body = "With the keyboard open and focus on a key, Back moves focus into the " +
"bucket summary (keyboard stays visible). Back from inside the pane " +
"returns focus to the Q key. Back with empty query and no recents " +
"closes the keyboard entirely. If you've drilled into a bucket and come " +
"back to Browse, Back reopens the keyboard with your previous query " +
"preserved, ready to refine.",
)
)
add(
GuideBlock.Section(
heading = "Remote shortcuts",
body = "On a remote with media keys: Play/Pause inserts a space, and Rewind " +
"acts as Backspace. Saves the trip to the function row for the two " +
"most common edits.",
)
)
add(
GuideBlock.Section(
heading = "Voice search",
body = "The mic key opens a modal voice prompt. Earhorn uses the " +
"system speech recognizer with the \"prefer offline\" hint, " +
"so when an on-device model is installed (common on modern " +
"Android TVs) your speech stays on the device; otherwise it " +
"falls back to the cloud path. An amber halo pulses while " +
"it listens; text appears as you speak. Press Stop to " +
"commit what was heard, Cancel to bail. First use requests " +
"microphone permission; Earhorn never uses the mic otherwise.",
)
)
add(GuideBlock.BackToContents)
add(GuideBlock.Divider)
add(GuideBlock.Act("VII", "Random & discovery"))
add(
GuideBlock.Section(
heading = "More by Artist, More in Genre",
body = "On the Now Playing screen, the library icon jumps to every other album " +
"by the current track's artist. The tag icon jumps to every album in the " +
"current track's genre. On an album view, the action row has the same " +
"shortcuts — labelled with the artist and genre name when known.",
)
)
add(
GuideBlock.Section(
heading = "Random",
body = "Click Random in the Browse header and pick a count and a type. Random " +
"Albums builds a shelf of scattered albums from across the whole library " +
"for you to browse. Random Tracks queues them up and starts playing " +
"immediately — good for an unfamiliar shuffle session. Each pick " +
"produces genuinely scattered results, not a contiguous slice.",
)
)
add(
GuideBlock.Section(
heading = "The random list persists",
body = "Once you've generated a random list, stepping into an album and back " +
"out keeps the same list so you can peruse it properly. You'll only get " +
"a fresh random set when you explicitly re-enter the Random flow from " +
"the header.",
)
)
add(GuideBlock.BackToContents)
add(GuideBlock.Divider)
add(GuideBlock.Act("VIII", "The queue in depth"))
add(
GuideBlock.Section(
heading = "Opening the queue",
body = "Every screen's header has a Queue button, and so does the Now Playing view. " +
"The list shows what's coming, with the current track highlighted in amber " +
"and marked with a triangle.",
)
)
add(
GuideBlock.Section(
heading = "Acting on queue items",
body = "A single click on any queued track jumps playback to that track. Long-press " +
"for a menu: Play from here (jump), Remove from queue, Move up, Move down. " +
"The header has a Clear button that empties the whole queue and stops " +
"playback — useful when you want to start fresh.",
)
)
add(GuideBlock.BackToContents)
add(GuideBlock.Divider)
add(GuideBlock.Act("IX", "Making it your own"))
add(
GuideBlock.Section(
heading = "Marquee long titles",
body = "Long classical titles — \"Symphony No. 9 in D minor, Op. 125 'Choral': " +
"IV. Presto\" — get truncated with an ellipsis by default. Turn on Marquee " +
"in Settings and text that would otherwise be cut off instead scrolls " +
"horizontally on a single line. Affects the Now Playing screen, the Now " +
"Playing bar, and queue rows.",
)
)
add(
GuideBlock.Section(
heading = "Metadata fields",
body = "The album view shows a block of metadata — artist, genre, year, audio " +
"format, sample rate, bit depth, and so on — pulled from the tags on your " +
"files. Scroll down in Settings and you'll find a toggle for each. Turn off " +
"anything you don't want to see.",
)
)
add(
GuideBlock.Section(
heading = "Changing servers",
body = "Earhorn remembers your chosen server and jumps directly to it on launch. To " +
"pick a different one, press Back to the servers list, or Rescan if you want " +
"to refresh discovery. Pressing Rescan also forgets your preference so the " +
"next server you click becomes the new default.",
)
)
add(GuideBlock.BackToContents)
add(GuideBlock.Ornament)
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
@Composable
private fun SectionBlock(heading: String, body: String) {
if (isCompactWidth()) {
// Compact phone: left-aligned, full-width body. The TV layout
// centers the heading and caps body width to 70% — elegant on a
// wide TV viewport but on a 411dp portrait phone that's ~250dp of
// usable text column, which forces 3-word-per-line wraps that
// make the guide unreadable. Compact uses the screen width with
// left-alignment for normal reading flow.
Column(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
) {
Text(
heading,
style = MaterialTheme.typography.titleMedium,
color = OnPrimary,
)
Spacer(Modifier.height(4.dp))
Box(
Modifier
.width(40.dp)
.height(1.dp)
.background(SurfaceDivider),
)
Spacer(Modifier.height(8.dp))
Text(
withInterDigits(body),
style = MaterialTheme.typography.bodyMedium,
color = OnMuted,
)
}
return
}
Column(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
heading,
style = MaterialTheme.typography.headlineSmall,
color = OnPrimary,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(6.dp))
Box(
Modifier
.width(40.dp)
.height(1.dp)
.background(SurfaceDivider),
)
Spacer(Modifier.height(12.dp))
Text(
withInterDigits(body),
style = MaterialTheme.typography.bodyLarge,
color = OnMuted,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(0.70f),
)
}
}