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

GuideScreen.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.ui.guide
2
3import androidx.activity.compose.BackHandler
4import androidx.compose.foundation.Image
5import androidx.compose.foundation.background
6import androidx.compose.foundation.clickable
7import androidx.compose.foundation.focusable
8import androidx.compose.foundation.gestures.animateScrollBy
9import androidx.compose.foundation.interaction.MutableInteractionSource
10import androidx.compose.foundation.layout.Arrangement
11import androidx.compose.foundation.layout.Box
12import androidx.compose.foundation.layout.Column
13import androidx.compose.foundation.layout.Row
14import androidx.compose.foundation.layout.Spacer
15import androidx.compose.foundation.layout.fillMaxSize
16import androidx.compose.foundation.layout.fillMaxWidth
17import androidx.compose.foundation.layout.height
18import androidx.compose.foundation.layout.padding
19import androidx.compose.foundation.layout.size
20import androidx.compose.foundation.layout.width
21import androidx.compose.foundation.lazy.LazyColumn
22import androidx.compose.foundation.lazy.itemsIndexed
23import androidx.compose.foundation.rememberScrollState
24import androidx.compose.foundation.verticalScroll
25import androidx.compose.runtime.Composable
26import androidx.compose.runtime.LaunchedEffect
27import androidx.compose.runtime.getValue
28import androidx.compose.runtime.mutableStateOf
29import androidx.compose.runtime.remember
30import androidx.compose.runtime.rememberCoroutineScope
31import androidx.compose.runtime.saveable.rememberSaveable
32import androidx.compose.runtime.setValue
33import androidx.compose.ui.Alignment
34import androidx.compose.ui.Modifier
35import androidx.compose.ui.focus.FocusRequester
36import androidx.compose.ui.focus.focusRequester
37import androidx.compose.ui.graphics.Color
38import androidx.compose.ui.input.key.Key
39import androidx.compose.ui.input.key.KeyEventType
40import androidx.compose.ui.input.key.key
41import androidx.compose.ui.input.key.onPreviewKeyEvent
42import androidx.compose.ui.input.key.type
43import androidx.compose.ui.res.painterResource
44import androidx.compose.ui.text.style.TextAlign
45import androidx.compose.ui.unit.dp
46import androidx.tv.material3.MaterialTheme
47import androidx.tv.material3.Text
48import dev.stave.R
49import dev.stave.ui.components.staveFocusable
50import dev.stave.ui.theme.AccentAmber
51import dev.stave.ui.theme.OnMuted
52import dev.stave.ui.theme.OnPrimary
53import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
54import dev.stave.ui.theme.adaptiveScreenPaddingVertical
55import dev.stave.ui.theme.isCompactHeight
56import dev.stave.ui.theme.isCompactWidth
57import dev.stave.ui.theme.SurfaceDivider
58import dev.stave.ui.theme.withInterDigits
59import kotlinx.coroutines.launch
60
61/**
62 * User guide — two-pane TV-native layout. Left pane: navigable table of
63 * contents (Introduction + 9 Acts). Right pane: scrollable content for the
64 * selected chapter. D-pad Up/Down navigates TOC entries on the left or
65 * scrolls content on the right. Left/Right switches between panes.
66 *
67 * This replaces the single-column LazyColumn design that produced jerky
68 * focus-driven scrolling. Splitting navigation from reading solves it:
69 * the TOC has small fixed-height items (no jumps), and the content pane
70 * scrolls smoothly via a scroll-state rather than focus-to-item hops.
71 */
72@Composable
73fun GuideScreen(onBack: () -> Unit) {
74 BackHandler(onBack = onBack)
75
76 val blocks = remember { buildGuideBlocks() }
77 val chapters = remember(blocks) { groupIntoChapters(blocks) }
78
79 var selectedIndex by rememberSaveable { mutableStateOf(0) }
80 val contentScrollState = rememberScrollState()
81 val coroutineScope = rememberCoroutineScope()
82
83 // Reset scroll to top when the user picks a different chapter.
84 LaunchedEffect(selectedIndex) {
85 contentScrollState.animateScrollTo(0)
86 }
87
88 // Right-pane focus target. When user presses Right from the TOC,
89 // focus lands here and D-pad Up/Down scroll the content.
90 val contentFocus = remember { FocusRequester() }
91 // One FocusRequester per chapter so Left from the right pane returns
92 // to the SELECTED entry, not whichever entry spatial focus picks.
93 val chapterFocusRequesters = remember(chapters) {
94 List(chapters.size) { FocusRequester() }
95 }
96
97 // Initial focus on the first TOC entry.
98 LaunchedEffect(Unit) {
99 runCatching { chapterFocusRequesters.firstOrNull()?.requestFocus() }
100 }
101
102 // Compact phone (portrait): the TV two-pane Row puts a 280dp left LazyColumn
103 // next to a weight(1f) right Column. On a 411dp portrait phone that left
104 // pane consumes 280 + 24dp gap = ~304dp of width and the right pane is left
105 // with ~107dp — too narrow for body text, so Compose wraps the chapter text
106 // one character per line ("F-i-r-s-t s-t-e-p-s" reading vertically). Fix:
107 // collapse to a single-pane layout where the user picks a chapter from a
108 // full-width TOC, taps in, reads the content, and presses back to return
109 // to the TOC. Uses rememberSaveable so config changes (rotation, theme
110 // switch) preserve which view the user was on.
111 if (isCompactWidth()) {
112 var compactShowingContent by rememberSaveable { mutableStateOf(false) }
113 // Override BackHandler: when content is showing, Back goes back to the
114 // TOC rather than exiting the User Guide entirely. Two-step exit
115 // matches the user's mental model of "I'm reading a chapter, going
116 // back means returning to the chapter list."
117 BackHandler(enabled = compactShowingContent) {
118 compactShowingContent = false
119 }
120 Column(
121 Modifier
122 .fillMaxSize()
123 .padding(
124 horizontal = adaptiveScreenPaddingHorizontal(),
125 vertical = adaptiveScreenPaddingVertical(),
126 ),
127 ) {
128 if (!compactShowingContent) {
129 // ── Compact: TOC view ────────────────────────────────────
130 LazyColumn(
131 Modifier.fillMaxSize(),
132 verticalArrangement = Arrangement.spacedBy(4.dp),
133 ) {
134 item(key = "masthead-compact") {
135 Column(
136 Modifier
137 .fillMaxWidth()
138 .padding(bottom = 16.dp),
139 horizontalAlignment = Alignment.CenterHorizontally,
140 ) {
141 Image(
142 painter = painterResource(R.drawable.earhorn_badge),
143 contentDescription = null,
144 modifier = Modifier.size(72.dp),
145 )
146 Spacer(Modifier.height(4.dp))
147 Text(
148 "User Guide",
149 style = MaterialTheme.typography.titleMedium,
150 color = OnPrimary,
151 )
152 }
153 }
154 itemsIndexed(chapters, key = { _, ch -> ch.numeral.ifEmpty { "intro" } }) { idx, ch ->
155 ChapterNavItem(
156 numeral = ch.numeral,
157 title = ch.title,
158 isSelected = idx == selectedIndex,
159 onClick = {
160 selectedIndex = idx
161 compactShowingContent = true
162 },
163 // No spatial focus management on compact — touch
164 // is the primary input mode and there's no right
165 // pane to navigate to.
166 focusRequester = null,
167 onRight = { },
168 )
169 }
170 }
171 } else {
172 // ── Compact: content view ─────────────────────────────────
173 val chapter = chapters.getOrNull(selectedIndex)
174 Column(
175 Modifier
176 .fillMaxSize()
177 .verticalScroll(rememberScrollState()),
178 verticalArrangement = Arrangement.spacedBy(8.dp),
179 ) {
180 // Back-to-chapters affordance — full-width tappable row.
181 // Amber chevron + label read as "back" rather than as a
182 // heading. Vertical padding kept tight (4dp) so the
183 // back row doesn't waste headroom above the title;
184 // the labelLarge intrinsic height already gives a
185 // ≥48dp tap target with the row width.
186 Row(
187 Modifier
188 .fillMaxWidth()
189 .clickable { compactShowingContent = false }
190 .padding(vertical = 4.dp),
191 verticalAlignment = Alignment.CenterVertically,
192 ) {
193 Text(
194 "‹ Chapters",
195 style = MaterialTheme.typography.labelLarge,
196 color = AccentAmber,
197 )
198 }
199 if (chapter != null) {
200 if (chapter.numeral.isEmpty()) {
201 // Introduction: vertical badge + body, since
202 // there's no horizontal room for the side-by-side
203 // arrangement the TV layout uses.
204 Image(
205 painter = painterResource(R.drawable.earhorn_badge),
206 contentDescription = null,
207 modifier = Modifier
208 .size(140.dp)
209 .align(Alignment.CenterHorizontally),
210 )
211 Text(
212 "A User's Guide",
213 style = MaterialTheme.typography.headlineMedium,
214 color = OnPrimary,
215 textAlign = TextAlign.Center,
216 modifier = Modifier.fillMaxWidth(),
217 )
218 Text(
219 "to the careful operation of Earhorn",
220 style = MaterialTheme.typography.titleSmall,
221 color = OnMuted,
222 textAlign = TextAlign.Center,
223 modifier = Modifier.fillMaxWidth(),
224 )
225 Spacer(Modifier.height(8.dp))
226 chapter.sections.forEach { section ->
227 Text(
228 withInterDigits(section.body),
229 style = MaterialTheme.typography.bodyLarge,
230 color = OnMuted,
231 )
232 }
233 } else {
234 // Numeral + title on a SINGLE line. The prior
235 // stacked layout (displaySmall numeral on its
236 // own line, headlineMedium title on its own
237 // line, plus spacedBy-12dp gaps between every
238 // sibling) ate ~120dp before the chapter text
239 // even started. Inlining them at titleMedium
240 // / titleLarge gives the same hierarchy
241 // (numeral amber, title bright) in a single
242 // ~36dp row.
243 Row(
244 verticalAlignment = Alignment.Bottom,
245 horizontalArrangement = Arrangement.spacedBy(12.dp),
246 ) {
247 Text(
248 chapter.numeral,
249 style = MaterialTheme.typography.titleMedium,
250 color = AccentAmber,
251 )
252 Text(
253 chapter.title,
254 style = MaterialTheme.typography.titleLarge,
255 color = OnPrimary,
256 )
257 }
258 Box(
259 Modifier
260 .width(72.dp)
261 .height(2.dp)
262 .background(AccentAmber),
263 )
264 chapter.sections.forEach { section ->
265 SectionBlock(section.heading, section.body)
266 }
267 }
268 Spacer(Modifier.height(24.dp))
269 }
270 }
271 }
272 }
273 return
274 }
275
276 Row(
277 Modifier
278 .fillMaxSize()
279 .padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical()),
280 horizontalArrangement = Arrangement.spacedBy(24.dp),
281 ) {
282 // ── Left pane: chapter list ──────────────────────────────────
283 LazyColumn(
284 Modifier.width(280.dp),
285 verticalArrangement = Arrangement.spacedBy(4.dp),
286 ) {
287 // Masthead at the very top of the left pane.
288 item(key = "masthead") {
289 Column(
290 Modifier
291 .fillMaxWidth()
292 .padding(bottom = 16.dp),
293 horizontalAlignment = Alignment.CenterHorizontally,
294 ) {
295 Image(
296 painter = painterResource(R.drawable.earhorn_badge),
297 contentDescription = null,
298 modifier = Modifier.size(80.dp),
299 )
300 Spacer(Modifier.height(4.dp))
301 Text(
302 "User Guide",
303 style = MaterialTheme.typography.titleMedium,
304 color = OnPrimary,
305 )
306 }
307 }
308 itemsIndexed(chapters, key = { _, ch -> ch.numeral.ifEmpty { "intro" } }) { idx, ch ->
309 ChapterNavItem(
310 numeral = ch.numeral,
311 title = ch.title,
312 isSelected = idx == selectedIndex,
313 onClick = {
314 selectedIndex = idx
315 // Focus the right pane so the user can immediately
316 // D-pad Down to scroll through the content.
317 runCatching { contentFocus.requestFocus() }
318 },
319 focusRequester = chapterFocusRequesters.getOrNull(idx),
320 onRight = { runCatching { contentFocus.requestFocus() } },
321 )
322 }
323 }
324
325 // ── Right pane: content for the selected chapter ─────────────
326 // Focusable scroll container. D-pad Up/Down scroll by a page-ish
327 // increment; when at the scroll ceiling, Up falls through to spatial
328 // focus (which lands on the left-pane TOC entry). Left always
329 // returns to the TOC via default spatial focus.
330 Column(
331 Modifier
332 .weight(1f)
333 .focusRequester(contentFocus)
334 .focusable()
335 .onPreviewKeyEvent { ev ->
336 if (ev.type != KeyEventType.KeyDown)
337 return@onPreviewKeyEvent false
338 when (ev.key) {
339 Key.DirectionLeft -> {
340 // Return to the SELECTED TOC entry, not
341 // whichever one spatial focus would pick.
342 runCatching {
343 chapterFocusRequesters.getOrNull(selectedIndex)
344 ?.requestFocus()
345 }
346 true
347 }
348 Key.DirectionDown -> {
349 if (contentScrollState.value < contentScrollState.maxValue) {
350 coroutineScope.launch {
351 contentScrollState.animateScrollBy(400f)
352 }
353 true
354 } else false
355 }
356 Key.DirectionUp -> {
357 if (contentScrollState.value > 0) {
358 coroutineScope.launch {
359 contentScrollState.animateScrollBy(-400f)
360 }
361 true
362 } else {
363 // At top → return to selected TOC entry
364 runCatching {
365 chapterFocusRequesters.getOrNull(selectedIndex)
366 ?.requestFocus()
367 }
368 true
369 }
370 }
371 else -> false
372 }
373 }
374 .verticalScroll(contentScrollState)
375 .padding(end = 16.dp),
376 horizontalAlignment = Alignment.CenterHorizontally,
377 verticalArrangement = Arrangement.spacedBy(12.dp),
378 ) {
379 val chapter = chapters.getOrNull(selectedIndex)
380 if (chapter != null) {
381 // Scroll hint — subtle amber caret tells the user D-pad
382 // Down scrolls through the content in this pane.
383 Text(
384 "▼",
385 style = MaterialTheme.typography.labelSmall,
386 color = AccentAmber,
387 modifier = Modifier.align(Alignment.End),
388 )
389 if (chapter.numeral.isEmpty()) {
390 // Introduction: badge + body. On a TV / tall tablet, render
391 // side-by-side (badge LEFT, text RIGHT). On a short
392 // landscape-phone viewport, stack the badge on TOP so it
393 // doesn't dominate the limited vertical space and crowd the
394 // article text into a sliver column.
395 if (isCompactHeight()) {
396 // Hero stack — badge centered above title block.
397 // Size halves vs the side-by-side variant so the badge
398 // doesn't eat the whole landscape viewport before the
399 // user reaches a single word of body text.
400 Image(
401 painter = painterResource(R.drawable.earhorn_badge),
402 contentDescription = null,
403 modifier = Modifier
404 .size(96.dp)
405 .align(Alignment.CenterHorizontally),
406 )
407 Spacer(Modifier.height(8.dp))
408 Text(
409 "A User's Guide",
410 style = MaterialTheme.typography.displaySmall,
411 color = OnPrimary,
412 textAlign = TextAlign.Center,
413 modifier = Modifier.fillMaxWidth(),
414 )
415 Text(
416 "to the careful operation of Earhorn",
417 style = MaterialTheme.typography.titleSmall,
418 color = OnMuted,
419 textAlign = TextAlign.Center,
420 modifier = Modifier.fillMaxWidth(),
421 )
422 Spacer(Modifier.height(12.dp))
423 chapter.sections.forEach { section ->
424 Text(
425 withInterDigits(section.body),
426 style = MaterialTheme.typography.bodyLarge,
427 color = OnMuted,
428 )
429 }
430 } else {
431 // TV / tall tablet — badge left, text right (matches About).
432 Row(
433 Modifier.fillMaxWidth().padding(vertical = 16.dp),
434 horizontalArrangement = Arrangement.spacedBy(32.dp),
435 verticalAlignment = Alignment.CenterVertically,
436 ) {
437 Image(
438 painter = painterResource(R.drawable.earhorn_badge),
439 contentDescription = null,
440 modifier = Modifier.size(180.dp),
441 )
442 Column(Modifier.weight(1f)) {
443 Text(
444 "A User's Guide",
445 style = MaterialTheme.typography.displayMedium,
446 color = OnPrimary,
447 )
448 Text(
449 "to the careful operation of Earhorn",
450 style = MaterialTheme.typography.titleMedium,
451 color = OnMuted,
452 )
453 Spacer(Modifier.height(12.dp))
454 chapter.sections.forEach { section ->
455 Text(
456 withInterDigits(section.body),
457 style = MaterialTheme.typography.bodyLarge,
458 color = OnMuted,
459 )
460 }
461 }
462 }
463 }
464 } else {
465 // Act chapter: heading + sections
466 Spacer(Modifier.height(8.dp))
467 Text(
468 chapter.numeral,
469 style = MaterialTheme.typography.displaySmall,
470 color = AccentAmber,
471 )
472 Text(
473 chapter.title,
474 style = MaterialTheme.typography.headlineLarge,
475 color = OnPrimary,
476 textAlign = TextAlign.Center,
477 )
478 Box(
479 Modifier
480 .width(96.dp)
481 .height(2.dp)
482 .background(AccentAmber),
483 )
484 Spacer(Modifier.height(8.dp))
485 chapter.sections.forEach { section ->
486 SectionBlock(section.heading, section.body)
487 }
488 }
489 Spacer(Modifier.height(24.dp)) // bottom breathing room
490 }
491 }
492 }
493}
494
495// ---------------------------------------------------------------------------
496// Two-pane data model
497// ---------------------------------------------------------------------------
498
499private data class GuideChapter(
500 val numeral: String, // "" for Introduction
501 val title: String,
502 val sections: List<GuideBlock.Section>,
503)
504
505/** Walk the flat block list and group into chapters: one Introduction
506 * (holding any IntroPara blocks) plus one chapter per Act (holding its
507 * trailing Section blocks). Skips decorative blocks (Divider, Ornament,
508 * TOC, BackToContents) — those only made sense in the single-column
509 * scroll layout. */
510private fun groupIntoChapters(blocks: List<GuideBlock>): List<GuideChapter> {
511 val chapters = mutableListOf<GuideChapter>()
512
513 // Introduction chapter
514 val introSections = blocks.filterIsInstance<GuideBlock.IntroPara>()
515 .map { GuideBlock.Section("", it.text) }
516 chapters.add(GuideChapter("", "Introduction", introSections))
517
518 // Acts
519 var currentAct: GuideBlock.Act? = null
520 var currentSections = mutableListOf<GuideBlock.Section>()
521
522 for (block in blocks) {
523 when (block) {
524 is GuideBlock.Act -> {
525 currentAct?.let {
526 chapters.add(GuideChapter(it.numeral, it.title, currentSections.toList()))
527 }
528 currentAct = block
529 currentSections = mutableListOf()
530 }
531 is GuideBlock.Section -> currentSections.add(block)
532 else -> { /* skip decorative blocks */ }
533 }
534 }
535 currentAct?.let {
536 chapters.add(GuideChapter(it.numeral, it.title, currentSections.toList()))
537 }
538
539 return chapters
540}
541
542/** Single entry in the left-pane TOC. Amber highlight when focused;
543 * selected entry gets a subtle background to show "this is what you're
544 * reading." */
545@Composable
546private fun ChapterNavItem(
547 numeral: String,
548 title: String,
549 isSelected: Boolean,
550 onClick: () -> Unit,
551 focusRequester: FocusRequester?,
552 onRight: () -> Unit,
553) {
554 val interaction = remember { MutableInteractionSource() }
555 Row(
556 Modifier
557 .fillMaxWidth()
558 .background(
559 if (isSelected) SurfaceDivider
560 else Color.Transparent
561 )
562 .then(
563 if (focusRequester != null) Modifier.focusRequester(focusRequester)
564 else Modifier
565 )
566 .onPreviewKeyEvent { ev ->
567 if (ev.type == KeyEventType.KeyDown &&
568 ev.key == Key.DirectionRight
569 ) {
570 onRight()
571 true
572 } else false
573 }
574 .staveFocusable(interaction, cornerRadiusDp = 4)
575 .clickable(interaction, indication = null, onClick = onClick)
576 .padding(horizontal = 12.dp, vertical = 10.dp),
577 verticalAlignment = Alignment.CenterVertically,
578 ) {
579 if (numeral.isNotEmpty()) {
580 // 48dp fixed-width slot for the numeral. The prior 36dp was
581 // wide enough for I–VII at labelLarge but VIII overflowed and
582 // wrapped to "VII / I"; IX fits but XI/XII would also overflow
583 // 36dp. softWrap=false guarantees the numeral stays on one
584 // line even if a future chapter pushes the chapter count past
585 // what 48dp can hold (at which point we'd just bump this).
586 Text(
587 numeral,
588 style = MaterialTheme.typography.labelLarge,
589 color = AccentAmber,
590 maxLines = 1,
591 softWrap = false,
592 modifier = Modifier.width(48.dp),
593 )
594 }
595 Text(
596 title,
597 style = MaterialTheme.typography.bodyMedium,
598 color = OnPrimary,
599 )
600 }
601}
602
603/** Scroll-and-focus target. Written by TOC/BackToContents clicks,
604 * consumed by a LaunchedEffect that coordinates the two operations. */
605private sealed class JumpTarget {
606 data class Act(val numeral: String) : JumpTarget()
607 data object Contents : JumpTarget()
608}
609
610// ---------------------------------------------------------------------------
611// Content model + data
612// ---------------------------------------------------------------------------
613
614private sealed class GuideBlock {
615 data object Masthead : GuideBlock()
616 data class IntroPara(val text: String) : GuideBlock()
617 data object TableOfContents : GuideBlock()
618 data object Divider : GuideBlock()
619 data class Act(val numeral: String, val title: String) : GuideBlock()
620 data class Section(val heading: String, val body: String) : GuideBlock()
621 /** A focusable button that jumps back to the Table of Contents. Placed
622 * at the end of each Act so the user never has to D-pad up through
623 * many paragraphs. */
624 data object BackToContents : GuideBlock()
625 data object Ornament : GuideBlock()
626}
627
628/** Fixed content list. Any change to this list shifts indices automatically
629 * because the TOC computes them from the list itself. */
630private fun buildGuideBlocks(): List<GuideBlock> = buildList {
631 add(GuideBlock.Masthead)
632 add(
633 GuideBlock.IntroPara(
634 "Earhorn is a quiet player for the music you already own. It connects to a UPnP " +
635 "server running somewhere on your network — a NAS, a computer, a Raspberry Pi — " +
636 "and lets you browse that library, queue tracks, and listen through your TV. " +
637 "Every control is designed for a D-pad remote; nothing requires a touchscreen " +
638 "and nothing requires an internet connection beyond your local network."
639 )
640 )
641 add(GuideBlock.TableOfContents)
642 add(GuideBlock.Divider)
643
644 add(GuideBlock.Act("I", "First steps"))
645 add(
646 GuideBlock.Section(
647 heading = "Finding a server",
648 body = "When Earhorn first opens, it scans your local network for UPnP media servers " +
649 "— MinimServer, Twonky, Plex (with DLNA enabled), Kodi, Emby, Jellyfin, and " +
650 "many others all work. Any servers it discovers appear on the first screen. " +
651 "Click the one you want and Earhorn will remember your choice for next time. " +
652 "Press the Rescan button in the header if you've just brought a server online " +
653 "and it hasn't appeared yet.",
654 )
655 )
656 add(
657 GuideBlock.Section(
658 heading = "If nothing appears",
659 body = "Most commonly this means your server isn't broadcasting UPnP, or it's on a " +
660 "different network segment than the TV. Make sure the server advertises as a " +
661 "DLNA/UPnP media server (not just a music-only protocol like Subsonic), and " +
662 "that both devices are on the same subnet. Guest Wi-Fi networks often isolate " +
663 "clients from each other — that's usually the culprit.",
664 )
665 )
666 add(GuideBlock.BackToContents)
667 add(GuideBlock.Divider)
668
669 add(GuideBlock.Act("II", "Browsing your library"))
670 add(
671 GuideBlock.Section(
672 heading = "Folders, albums, tracks",
673 body = "The top level of a server is usually organised by category — Artists, " +
674 "Albums, Genres, Composers, etc. Click into a category to drill in. Albums " +
675 "open into a dedicated album view with artwork; generic folders open into a " +
676 "flat list. There's no distinction between 'library' and 'playlists' — " +
677 "whatever structure your server provides is what you navigate through.",
678 )
679 )
680 add(
681 GuideBlock.Section(
682 heading = "Getting around",
683 body = "D-pad up and down move through lists. Left and right cross between panels — " +
684 "for instance, inside an album view, Right steps into the track list and Left " +
685 "walks back out through the album's action buttons. Pressing Left on the " +
686 "leftmost control of any screen jumps straight to the Now Playing bar when " +
687 "music is playing, so you never need to scroll down through a long list to " +
688 "reach transport controls.",
689 )
690 )
691 add(
692 GuideBlock.Section(
693 heading = "The Back button",
694 body = "Back always steps up one level in the hierarchy, exactly as you'd expect. " +
695 "Holding it doesn't do anything special — Earhorn doesn't have modal screens " +
696 "that trap you, so a single press is always enough to retreat.",
697 )
698 )
699 add(GuideBlock.BackToContents)
700 add(GuideBlock.Divider)
701
702 add(GuideBlock.Act("III", "Playback and the queue"))
703 add(
704 GuideBlock.Section(
705 heading = "The rule of one press",
706 body = "A single click on any song queues it. A single click on an album's title or " +
707 "cover opens the album (not playback). A long-press — holding select for " +
708 "about half a second — opens the full action menu for that item. Earhorn never " +
709 "asks you a question on a single click; clicking always acts.",
710 )
711 )
712 add(
713 GuideBlock.Section(
714 heading = "Queue versus play",
715 body = "When the queue is empty and you click a song, playback begins immediately. " +
716 "When the queue has tracks in it already, clicking a song appends that track " +
717 "to the end of the queue without disturbing what's playing. This is the safe " +
718 "default: one click will never wipe out a carefully-built queue.",
719 )
720 )
721 add(
722 GuideBlock.Section(
723 heading = "Replacing the queue",
724 body = "To replace the whole queue with an album, use the large play button on the " +
725 "album's page. If the queue isn't empty, a dialog will appear asking whether " +
726 "you really mean to clear it. Replacing is always deliberate; there's no way " +
727 "to clobber the queue by accident.",
728 )
729 )
730 add(
731 GuideBlock.Section(
732 heading = "Long-press on a song",
733 body = "Hold select on any song to open its action menu. From there you can queue " +
734 "the track, insert it next (play immediately after whatever's playing), or — " +
735 "in the context of an album — queue every track from this one through the end. " +
736 "None of these options replace the queue.",
737 )
738 )
739 add(
740 GuideBlock.Section(
741 heading = "Stop versus pause",
742 body = "Pause holds your place; press play again and the track resumes where you " +
743 "left it. Stop ends playback and hides the Now Playing bar, but it keeps the " +
744 "queue intact — press play once the bar reappears and the track restarts from " +
745 "the beginning, rather than resuming mid-song.",
746 )
747 )
748 add(GuideBlock.BackToContents)
749 add(GuideBlock.Divider)
750
751 add(GuideBlock.Act("IV", "The Now Playing screen"))
752 add(
753 GuideBlock.Section(
754 heading = "Opening the full view",
755 body = "The Now Playing bar docked at the bottom of every screen is a compact " +
756 "transport. Click its artwork or title to open the full Now Playing view — " +
757 "larger artwork, full metadata, a progress bar you can scrub, and the More " +
758 "actions row.",
759 )
760 )
761 add(
762 GuideBlock.Section(
763 heading = "Scrubbing the timeline",
764 body = "On the Now Playing view, press D-pad Up from the transport row to focus " +
765 "the progress bar. You'll see it thicken, an amber outline appear, and a " +
766 "small amber knob mark the playhead. From there, Left and Right seek by ten " +
767 "seconds each press. Hold the key and auto-repeat gives you smooth " +
768 "continuous scrubbing. Press Down to return to the transport.",
769 )
770 )
771 add(
772 GuideBlock.Section(
773 heading = "The transport row",
774 body = "Previous, Stop, Play/Pause, Next. Ordering matches the buttons on every " +
775 "classical hi-fi remote: skip back on the far left, play/pause central. " +
776 "Focus lands on Play/Pause when the screen first opens.",
777 )
778 )
779 add(
780 GuideBlock.Section(
781 heading = "The more-actions row",
782 body = "Below the transport: Queue (opens the queue list), More by Artist (every " +
783 "other album by the current track's artist), and More in Genre (every album " +
784 "tagged with this track's genre). If the current track has multiple genres, " +
785 "the genre action opens a picker so you can choose which one to browse.",
786 )
787 )
788 add(GuideBlock.BackToContents)
789 add(GuideBlock.Divider)
790
791 add(GuideBlock.Act("V", "The Now Playing bar"))
792 add(
793 GuideBlock.Section(
794 heading = "What it shows",
795 body = "When anything is queued and not stopped, a thin strip pins itself to the " +
796 "bottom of the screen with artwork, track title, elapsed and total time, " +
797 "and an inline mini-transport: Previous, Stop, Play/Pause, Next, Queue, " +
798 "Expand.",
799 )
800 )
801 add(
802 GuideBlock.Section(
803 heading = "Reaching it in one press",
804 body = "From any list screen, press Left past the leftmost focusable item to jump " +
805 "straight to the bar's play/pause button. You never need to scroll down " +
806 "through a list to hit pause — one press, from anywhere. Once focus is on " +
807 "the bar, Left and Right move between bar controls normally.",
808 )
809 )
810 add(
811 GuideBlock.Section(
812 heading = "Leaving the bar",
813 body = "Either Up or Down from any bar control returns focus to the content " +
814 "above. Down wraps around to the top of the page — useful because the " +
815 "bar is already at the screen's bottom and Down would otherwise do " +
816 "nothing. Up and Down both land on whichever focusable sits nearest " +
817 "above the bar, so behavior stays predictable across screens.",
818 )
819 )
820 add(GuideBlock.BackToContents)
821 add(GuideBlock.Divider)
822
823 add(GuideBlock.Act("VI", "Searching your library"))
824 add(
825 GuideBlock.Section(
826 heading = "Opening search",
827 body = "Two ways in, both from the Browse screen. Press D-pad Left on the Earhorn " +
828 "button in the header — the keyboard slides in on the left half of the " +
829 "screen. Or navigate to the search pill (the text field between the " +
830 "\"Browse\" title and the Earhorn button) and press Enter. Either way the " +
831 "keyboard belongs to Earhorn. No system IME. No Google TV search overlay. " +
832 "No external app takes over.",
833 )
834 )
835 add(
836 GuideBlock.Section(
837 heading = "The 4×12 grid",
838 body = "Four rows of twelve keys each, uniform. Top row: QWERTY letters plus " +
839 "apostrophe and quote. Second row: more QWERTY plus hyphen, underscore, " +
840 "period. Third row: the last letter row plus question mark, exclamation, " +
841 "comma, slash, colon. Bottom row: digits zero through nine plus " +
842 "ampersand and at-sign. Every character a search query needs is one " +
843 "press from a letter. Search is case-insensitive, so there is no Shift " +
844 "and no Caps — saves two modes and two function-row slots.",
845 )
846 )
847 add(
848 GuideBlock.Section(
849 heading = "The function row",
850 body = "Space, Backspace, Clear, Mic (if the device has a speech recognizer), " +
851 "Done. Clear wipes the entire query in one press — useful when you want " +
852 "to change search terms without backspacing through the old one. " +
853 "Done saves your query to recent searches and closes the keyboard, leaving " +
854 "the bucket summary visible.",
855 )
856 )
857 add(
858 GuideBlock.Section(
859 heading = "D-pad flow on the keyboard",
860 body = "Left and Right move within a row; Left on the first key wraps to the " +
861 "last, Right on the last key wraps to the first. Up and Down move " +
862 "between rows, column-aligned: Up from \"f\" lands on \"r\", Up from " +
863 "\"r\" lands on the \"4\" above it, and so on. Up from the top row " +
864 "closes the keyboard. Down from the bottom letter row lands on the " +
865 "function key directly below — Space if you're on the left side, " +
866 "Done if you're on the right.",
867 )
868 )
869 add(
870 GuideBlock.Section(
871 heading = "Recent searches",
872 body = "When the keyboard opens with an empty query, the right pane shows your " +
873 "ten most recent searches with the newest on top. Initial focus lands on " +
874 "the top entry, so picking up where you left off is one Enter press. " +
875 "D-pad Left from any recent jumps focus back to the keyboard's Q key.",
876 )
877 )
878 add(
879 GuideBlock.Section(
880 heading = "The five buckets",
881 body = "Once you start typing, the server returns five category counts: " +
882 "Albums whose title matches, Albums whose artist matches, Artists whose " +
883 "name matches, Tracks whose title matches, and Tracks whose artist " +
884 "matches. Empty buckets hide themselves. Click any bucket row to expand " +
885 "into the full matching list. This mirrors how BubbleUPnP organizes " +
886 "results — it scales: a search for a common word returns hundreds of " +
887 "tracks but still shows at a glance that there are only two albums.",
888 )
889 )
890 add(
891 GuideBlock.Section(
892 heading = "Relevance ranking inside a bucket",
893 body = "Inside a title-matched bucket, matches are sorted by relevance: items " +
894 "whose title starts with your query first (articles like \"The\" and \"A\" " +
895 "ignored for the prefix check), then items where the query appears at a " +
896 "word boundary, then substring matches, then everything else. " +
897 "Alphabetical within each rank. Inside an artist-matched bucket, items " +
898 "are grouped by artist — one artist's albums or tracks sit together.",
899 )
900 )
901 add(
902 GuideBlock.Section(
903 heading = "Back cycles focus",
904 body = "With the keyboard open and focus on a key, Back moves focus into the " +
905 "bucket summary (keyboard stays visible). Back from inside the pane " +
906 "returns focus to the Q key. Back with empty query and no recents " +
907 "closes the keyboard entirely. If you've drilled into a bucket and come " +
908 "back to Browse, Back reopens the keyboard with your previous query " +
909 "preserved, ready to refine.",
910 )
911 )
912 add(
913 GuideBlock.Section(
914 heading = "Remote shortcuts",
915 body = "On a remote with media keys: Play/Pause inserts a space, and Rewind " +
916 "acts as Backspace. Saves the trip to the function row for the two " +
917 "most common edits.",
918 )
919 )
920 add(
921 GuideBlock.Section(
922 heading = "Voice search",
923 body = "The mic key opens a modal voice prompt. Earhorn uses the " +
924 "system speech recognizer with the \"prefer offline\" hint, " +
925 "so when an on-device model is installed (common on modern " +
926 "Android TVs) your speech stays on the device; otherwise it " +
927 "falls back to the cloud path. An amber halo pulses while " +
928 "it listens; text appears as you speak. Press Stop to " +
929 "commit what was heard, Cancel to bail. First use requests " +
930 "microphone permission; Earhorn never uses the mic otherwise.",
931 )
932 )
933 add(GuideBlock.BackToContents)
934 add(GuideBlock.Divider)
935
936 add(GuideBlock.Act("VII", "Random & discovery"))
937 add(
938 GuideBlock.Section(
939 heading = "More by Artist, More in Genre",
940 body = "On the Now Playing screen, the library icon jumps to every other album " +
941 "by the current track's artist. The tag icon jumps to every album in the " +
942 "current track's genre. On an album view, the action row has the same " +
943 "shortcuts — labelled with the artist and genre name when known.",
944 )
945 )
946 add(
947 GuideBlock.Section(
948 heading = "Random",
949 body = "Click Random in the Browse header and pick a count and a type. Random " +
950 "Albums builds a shelf of scattered albums from across the whole library " +
951 "for you to browse. Random Tracks queues them up and starts playing " +
952 "immediately — good for an unfamiliar shuffle session. Each pick " +
953 "produces genuinely scattered results, not a contiguous slice.",
954 )
955 )
956 add(
957 GuideBlock.Section(
958 heading = "The random list persists",
959 body = "Once you've generated a random list, stepping into an album and back " +
960 "out keeps the same list so you can peruse it properly. You'll only get " +
961 "a fresh random set when you explicitly re-enter the Random flow from " +
962 "the header.",
963 )
964 )
965 add(GuideBlock.BackToContents)
966 add(GuideBlock.Divider)
967
968 add(GuideBlock.Act("VIII", "The queue in depth"))
969 add(
970 GuideBlock.Section(
971 heading = "Opening the queue",
972 body = "Every screen's header has a Queue button, and so does the Now Playing view. " +
973 "The list shows what's coming, with the current track highlighted in amber " +
974 "and marked with a triangle.",
975 )
976 )
977 add(
978 GuideBlock.Section(
979 heading = "Acting on queue items",
980 body = "A single click on any queued track jumps playback to that track. Long-press " +
981 "for a menu: Play from here (jump), Remove from queue, Move up, Move down. " +
982 "The header has a Clear button that empties the whole queue and stops " +
983 "playback — useful when you want to start fresh.",
984 )
985 )
986 add(GuideBlock.BackToContents)
987 add(GuideBlock.Divider)
988
989 add(GuideBlock.Act("IX", "Making it your own"))
990 add(
991 GuideBlock.Section(
992 heading = "Marquee long titles",
993 body = "Long classical titles — \"Symphony No. 9 in D minor, Op. 125 'Choral': " +
994 "IV. Presto\" — get truncated with an ellipsis by default. Turn on Marquee " +
995 "in Settings and text that would otherwise be cut off instead scrolls " +
996 "horizontally on a single line. Affects the Now Playing screen, the Now " +
997 "Playing bar, and queue rows.",
998 )
999 )
1000 add(
1001 GuideBlock.Section(
1002 heading = "Metadata fields",
1003 body = "The album view shows a block of metadata — artist, genre, year, audio " +
1004 "format, sample rate, bit depth, and so on — pulled from the tags on your " +
1005 "files. Scroll down in Settings and you'll find a toggle for each. Turn off " +
1006 "anything you don't want to see.",
1007 )
1008 )
1009 add(
1010 GuideBlock.Section(
1011 heading = "Changing servers",
1012 body = "Earhorn remembers your chosen server and jumps directly to it on launch. To " +
1013 "pick a different one, press Back to the servers list, or Rescan if you want " +
1014 "to refresh discovery. Pressing Rescan also forgets your preference so the " +
1015 "next server you click becomes the new default.",
1016 )
1017 )
1018 add(GuideBlock.BackToContents)
1019
1020 add(GuideBlock.Ornament)
1021}
1022
1023
1024// ---------------------------------------------------------------------------
1025// Rendering
1026// ---------------------------------------------------------------------------
1027
1028@Composable
1029private fun SectionBlock(heading: String, body: String) {
1030 if (isCompactWidth()) {
1031 // Compact phone: left-aligned, full-width body. The TV layout
1032 // centers the heading and caps body width to 70% — elegant on a
1033 // wide TV viewport but on a 411dp portrait phone that's ~250dp of
1034 // usable text column, which forces 3-word-per-line wraps that
1035 // make the guide unreadable. Compact uses the screen width with
1036 // left-alignment for normal reading flow.
1037 Column(
1038 Modifier
1039 .fillMaxWidth()
1040 .padding(vertical = 4.dp),
1041 ) {
1042 Text(
1043 heading,
1044 style = MaterialTheme.typography.titleMedium,
1045 color = OnPrimary,
1046 )
1047 Spacer(Modifier.height(4.dp))
1048 Box(
1049 Modifier
1050 .width(40.dp)
1051 .height(1.dp)
1052 .background(SurfaceDivider),
1053 )
1054 Spacer(Modifier.height(8.dp))
1055 Text(
1056 withInterDigits(body),
1057 style = MaterialTheme.typography.bodyMedium,
1058 color = OnMuted,
1059 )
1060 }
1061 return
1062 }
1063 Column(
1064 Modifier
1065 .fillMaxWidth()
1066 .padding(vertical = 4.dp),
1067 horizontalAlignment = Alignment.CenterHorizontally,
1068 ) {
1069 Text(
1070 heading,
1071 style = MaterialTheme.typography.headlineSmall,
1072 color = OnPrimary,
1073 textAlign = TextAlign.Center,
1074 )
1075 Spacer(Modifier.height(6.dp))
1076 Box(
1077 Modifier
1078 .width(40.dp)
1079 .height(1.dp)
1080 .background(SurfaceDivider),
1081 )
1082 Spacer(Modifier.height(12.dp))
1083 Text(
1084 withInterDigits(body),
1085 style = MaterialTheme.typography.bodyLarge,
1086 color = OnMuted,
1087 textAlign = TextAlign.Center,
1088 modifier = Modifier.fillMaxWidth(0.70f),
1089 )
1090 }
1091}
1092