diff --git a/AGENTS.md b/AGENTS.md
index a933726..91f6e85 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -538,8 +538,8 @@ Roborazzi writes and compares the files. No emulator, no device, no box.
Three levels, and the distinction matters:
-- **`app/`** — the whole `CoilApp` scaffold with a screen inside it: top bar with the box
- indicator, bottom navigation, mini player, offline banner. This is what the app looks like.
+- **`app/`** — the whole `CoilApp` scaffold with a screen inside it: the header row with
+ the box indicator, bottom navigation, mini player, offline banner. This is what the app looks like.
Captured on **four device profiles** (`_phone`, `_small`, `_landscape`, `_tablet`), so the goldens say
something about layout and not only about colour.
- **`player/`, `library/`** — one screen on its own, for states that would be tedious to reach
@@ -638,10 +638,10 @@ dark), the library screen
The favourites goldens carry **two entries with a `coverFile` and one without**, because both halves
matter: a cover from the box has to render, and a favourite it has no artwork for has to get the
-stand-in the app picks from its own set. `favourites_compact_*` reaches the list layout by *clicking* the top bar
+stand-in the app picks from its own set. `favourites_compact_*` reaches the list layout by *clicking* the header
action rather than presetting the stored preference, so a toggle that stopped switching fails the
test instead of quietly capturing the same picture twice. `favourites_sorted_*` reaches A–Z the
-same way, through the top bar menu — and `Fixtures.favorites` is deliberately saved in an order
+same way, through the header's menu — and `Fixtures.favorites` is deliberately saved in an order
that is *not* alphabetical, so a sort that stopped sorting cannot keep that picture either.
`Fixtures.albums` does the same for the album grid, four of six with artwork.
@@ -931,12 +931,29 @@ still needs doing, in rough order of importance:
**A short window is handled separately, by the scaffold.** Below 480 dp of height (Material's
compact height class — a phone on its side, a split-screen half; `isCompactHeight()` in
- `CoilApp.kt`) the bottom navigation bar becomes a `NavigationRail` at the side, the top bar
- drops to 48 dp, and the mini player sits under the screen rather than under the rail.
+ `CoilApp.kt`) the bottom navigation bar becomes a `NavigationRail` at the side, the header
+ row drops to 48 dp, and the mini player sits under the screen rather than under the rail.
Stacked, the three bars took two thirds of a landscape phone and the library showed no rows
at all. Two screens adapt on their own through the same check: the library puts its search
field and Folders/Albums tabs on one line, and the favourites grid uses smaller tiles so a
whole row fits. See `app/*_landscape.png`.
+
+ **There is no app bar.** The box pill, a pushed screen's back arrow and the favourites'
+ actions are one `AppHeader` row at the top of the content column — beside the rail, not above
+ it — because a full-width band for a pill that is switched almost never was the biggest
+ single cost on a landscape phone. On a tab it collapses with the content through
+ `CollapsingHeaderState`, a `NestedScrollConnection` on the shell's content column: it goes
+ before the content on the way up and comes back only once the content is at its top, i.e.
+ exactly like a first list item. It is done in the shell on purpose — the library alone has
+ four scrolling containers, and a header item copied into each would drift. On a pushed screen
+ it stays put, so the back arrow cannot scroll out of reach.
+
+ **The scaffold's insets include the display cutout.** `ScaffoldDefaults.contentWindowInsets`
+ is the system bars only, and on a phone on its side the front camera is at one *end* of the
+ window, where no system bar is — so the rail's labels and the grid's edge ran under it. The
+ scaffold now takes `systemBars.union(displayCutout)`; deliberately not `safeDrawing`, which
+ would also pull in the IME and lift the mini player over the keyboard. The goldens cannot see
+ any of this: Robolectric simulates a window with no cutout.
6. **Android 16's local network restriction will eventually break Coil outright.** Access to
local-network addresses — which is every socket this app opens, plus `NsdManager` discovery and
the HTTP cover fetches — will require the `NEARBY_WIFI_DEVICES` permission, granted by the user
@@ -968,13 +985,13 @@ still needs doing, in rough order of importance:
- Both screens address a box **by id** (`BoxesViewModel`), not through "the active box". Renaming
or re-addressing a non-active box no longer requires switching to it — on the settings screen
that was impossible, since every field there described whichever box was active.
- - Switching boxes stays in the top bar only, and a box row *opens* the box rather than selecting
+ - Switching boxes stays in the header's box pill only, and a box row *opens* the box rather than selecting
it. A box page for the non-active box offers "Switch to this box"; the active one just says so.
- The library actions (rescan, the search crawl) stay in settings, because they run against the
active box — on a box page, three of four pages could not offer them honestly.
- The rows both screens are built from live in `ui/components/SettingsRows.kt`, so box management
looks like the screen it was reached from.
- - Sub-screens of a tab (box management, a box, add-box) get a back arrow in the top bar and keep
+ - Sub-screens of a tab (box management, a box, add-box) get a back arrow in the header and keep
the settings tab lit — see `owningDestination` in `CoilApp`.
- Adding a box deliberately does **not** make it active: nothing should tear down a live connection
the user did not ask to change.
@@ -986,18 +1003,18 @@ still needs doing, in rough order of importance:
`core-data/.../db/Migrations.kt` — no destructive fallback, favourites are the one thing here that
cannot be rebuilt from the box), a `track` variant of the `coil://play` deep link, and settings
backup **format version 2**.
-- **The favourites tab has two layouts, and the switch is in the top bar.** `FavoritesLayout` in
+- **The favourites tab has two layouts, and the switch is in the header.** `FavoritesLayout` in
`AppSettings` chooses between the cover grid (the default — §7.2's point is a target a child can
aim at without reading) and a compact row list for a collection that has outgrown a screenful of
tiles. Consequences worth keeping: the preference is owned by `AppViewModel` and passed *into*
- `FavoritesScreen`, because the control lives in the shell's top bar and one preference should have
- one owner; the top bar's `actions` slot is per destination, so anything added there must stay
+ `FavoritesScreen`, because the control lives in the shell's header row and one preference should have
+ one owner; the header's `actions` slot is per destination, so anything added there must stay
conditional on the route the way this is; and both layouts share one `LazyVerticalGrid` —
`GridCells.Fixed(1)` is the list — plus one `FavoriteEntry`, so neither shape can quietly lose an
action the other has. It also rides in the settings backup, which did **not** bump
`FORMAT_VERSION`: an older build that drops the field loses a layout preference, not the ability
to play anything. Same for the `coverFile` now exported per favourite.
-- **The favourites tab also has two orders, and that switch is in the top bar as well.**
+- **The favourites tab also has two orders, and that switch is in the header as well.**
`FavoritesSort` in `AppSettings` is either `MANUAL` — the arrangement made with move up and move
down, which before any move is the order things were saved in — or `NAME`. `MANUAL` stays the
default: a wall of covers is something a parent arranges, and an update that reshuffled it would
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ab31c65..b8ba44b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,20 @@ automatically from the `## [x.y.z]` heading matching `versionName` in `app/build
## [Unreleased]
+## [1.2.6] - 2026-09-25
+
+### Changed
+- **No more bar across the top.** The box name and the favourites' sort and layout buttons are
+ now the first row of each screen, and scroll out of the way with the list, so the content gets
+ that space back. In landscape the row sits beside the navigation rail rather than above it.
+- **Long names in lists wrap instead of being cut off.** Folders, tracks and albums in the
+ library, and favourites in the list layout, now use a second line before shortening a name,
+ so the part that tells "Folge 12" from "Folge 13" stays visible.
+
+### Fixed
+- **Landscape keeps clear of the camera.** With the phone on its side, the navigation rail and
+ the edge of the screen's content no longer run under the front camera cutout.
+
## [1.2.5] - 2026-09-25
### Added
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index a2de654..da3b57f 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -20,8 +20,8 @@ android {
applicationId = "app.coilforphoniebox"
minSdk = 26
targetSdk = 36
- versionCode = 13
- versionName = "1.2.5"
+ versionCode = 14
+ versionName = "1.2.6"
}
androidResources {
diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/AppViewModel.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/AppViewModel.kt
index 48f5bbb..950c1fa 100644
--- a/app/src/main/kotlin/app/coilforphoniebox/ui/AppViewModel.kt
+++ b/app/src/main/kotlin/app/coilforphoniebox/ui/AppViewModel.kt
@@ -144,7 +144,7 @@ class AppViewModel @Inject constructor(
/**
* Switches the favourites tab between covers and rows.
*
- * Lives here rather than in `FavoritesViewModel` because the control is in the top bar,
+ * Lives here rather than in `FavoritesViewModel` because the control is in the shell's header,
* which the shell owns — and it is a stored preference, so the tab comes back the way it
* was left.
*/
@@ -159,7 +159,7 @@ class AppViewModel @Inject constructor(
/**
* Chooses what order the favourites tab shows.
*
- * Here for the same reason as [toggleFavoritesLayout]: the control is in the top bar and
+ * Here for the same reason as [toggleFavoritesLayout]: the control is in the header and
* the choice is stored. Unlike the layout it takes the order it should switch to rather
* than cycling one itself — the three orders are not a ring, and which one follows the
* current one is a question about the menu's rows, so it is answered there.
diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/CoilApp.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/CoilApp.kt
index 94b7020..2a4a2fe 100644
--- a/app/src/main/kotlin/app/coilforphoniebox/ui/CoilApp.kt
+++ b/app/src/main/kotlin/app/coilforphoniebox/ui/CoilApp.kt
@@ -4,12 +4,17 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxHeight
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.systemBars
+import androidx.compose.foundation.layout.union
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.Sort
@@ -22,10 +27,8 @@ import androidx.compose.material.icons.rounded.LibraryMusic
import androidx.compose.material.icons.rounded.PlayCircle
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Star
-import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
-import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.NavigationBar
@@ -36,15 +39,23 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@@ -81,6 +92,7 @@ import app.coilforphoniebox.ui.settings.SettingsScreen
import app.coilforphoniebox.ui.settings.SettingsViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlin.math.roundToInt
private enum class Destination(
val route: String,
@@ -111,7 +123,6 @@ private fun owningDestination(route: String?): Destination? = when (route) {
else -> Destination.entries.firstOrNull { it.route == route }
}
-@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CoilApp(appViewModel: AppViewModel) {
val state by appViewModel.state.collectAsStateWithLifecycle()
@@ -160,68 +171,20 @@ fun CoilApp(appViewModel: AppViewModel) {
}
}
+ // Only the screens pushed on top of a tab get a back arrow. The tabs themselves are not a
+ // stack, so a back arrow there would be a lie.
+ val onTab = Destination.entries.any { it.route == currentRoute }
+ val header = remember { CollapsingHeaderState() }
+ header.enabled = onTab
+ // A screen starts with its header showing, including the one navigated back to.
+ LaunchedEffect(currentRoute) { header.expand() }
+
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
- topBar = {
- CenterAlignedTopAppBar(
- title = {
- BoxIndicator(
- activeBox = state.activeBox,
- connection = state.connection,
- switchable = state.boxes.size > 1,
- onClick = {
- switcherOpen = true
- appViewModel.probeBoxes()
- },
- )
- },
- // Only for the screens that are pushed on top of a tab. The tabs themselves
- // are not a stack, so a back arrow there would be a lie.
- navigationIcon = {
- if (currentRoute != null && Destination.entries.none { it.route == currentRoute }) {
- IconButton(onClick = { navController.popBackStack() }) {
- Icon(
- imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
- contentDescription = stringResource(R.string.action_back),
- )
- }
- }
- },
- // Only the favourites tab has anything to put here: how its entries are
- // ordered, and what shape they take. The layout action shows what a tap would
- // switch *to*, so the icon and its label describe the same thing.
- actions = {
- if (currentRoute == Destination.FAVOURITES.route) {
- FavoritesSortAction(
- current = state.settings.favoritesSort,
- onSelect = appViewModel::setFavoritesSort,
- )
-
- val list = state.settings.favoritesLayout == FavoritesLayout.LIST
- IconButton(onClick = appViewModel::toggleFavoritesLayout) {
- Icon(
- imageVector = if (list) {
- Icons.Rounded.GridView
- } else {
- Icons.AutoMirrored.Rounded.ViewList
- },
- contentDescription = stringResource(
- if (list) R.string.action_favourites_show_grid
- else R.string.action_favourites_show_list,
- ),
- )
- }
- }
- },
- // A phone on its side has height to spare nowhere, and the bar is mostly empty
- // space around one pill — so it gives back a quarter of itself there.
- expandedHeight = if (compactHeight) {
- COMPACT_TOP_BAR_HEIGHT
- } else {
- TopAppBarDefaults.TopAppBarExpandedHeight
- },
- )
- },
+ // The display cutout as well as the system bars. The default is the system bars only,
+ // which on a phone on its side put the rail's labels and the edge of the grid under
+ // the front camera — the cutout is at one end of a landscape window, not at the top.
+ contentWindowInsets = WindowInsets.systemBars.union(WindowInsets.displayCutout),
bottomBar = {
if (!compactHeight) {
Column {
@@ -245,11 +208,61 @@ fun CoilApp(appViewModel: AppViewModel) {
SideRail(selected = selectedDestination, onNavigate = onNavigate)
}
+ // No app bar above all this: the box pill is something a user reads now and then and
+ // switches almost never, which did not earn a full-width band across the top of
+ // every screen. It is the first row of the content instead — beside the rail rather
+ // than above it — and on a tab it scrolls away with the content, like a list's first
+ // item would.
Column(
Modifier
.weight(1f)
- .fillMaxHeight(),
+ .fillMaxHeight()
+ .nestedScroll(header.connection),
) {
+ CollapsingHeader(header) {
+ AppHeader(
+ compact = compactHeight,
+ onBack = if (onTab) null else ({ navController.popBackStack() }),
+ indicator = {
+ BoxIndicator(
+ activeBox = state.activeBox,
+ connection = state.connection,
+ switchable = state.boxes.size > 1,
+ onClick = {
+ switcherOpen = true
+ appViewModel.probeBoxes()
+ },
+ )
+ },
+ // Only the favourites tab has anything to put here: how its entries are
+ // ordered, and what shape they take. The layout action shows what a tap
+ // would switch *to*, so the icon and its label describe the same thing.
+ actions = {
+ if (currentRoute == Destination.FAVOURITES.route) {
+ FavoritesSortAction(
+ current = state.settings.favoritesSort,
+ onSelect = appViewModel::setFavoritesSort,
+ )
+
+ val list = state.settings.favoritesLayout == FavoritesLayout.LIST
+ IconButton(onClick = appViewModel::toggleFavoritesLayout) {
+ Icon(
+ imageVector = if (list) {
+ Icons.Rounded.GridView
+ } else {
+ Icons.AutoMirrored.Rounded.ViewList
+ },
+ contentDescription = stringResource(
+ if (list) R.string.action_favourites_show_grid
+ else R.string.action_favourites_show_list,
+ ),
+ )
+ }
+ }
+ },
+ )
+ }
+
if (state.isOffline) {
OfflineBanner(
text = stringResource(R.string.offline_banner),
@@ -480,6 +493,98 @@ private fun SideRail(selected: Destination?, onNavigate: (Destination) -> Unit)
}
}
+/**
+ * The row that replaced the app bar: back arrow (on a pushed screen), box pill, and the current
+ * screen's actions at the far end. Left-aligned rather than centred, because it now sits at the
+ * top of the content and lines up with what is under it.
+ */
+@Composable
+private fun AppHeader(
+ compact: Boolean,
+ onBack: (() -> Unit)?,
+ indicator: @Composable () -> Unit,
+ actions: @Composable RowScope.() -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(if (compact) COMPACT_HEADER_HEIGHT else HEADER_HEIGHT)
+ .padding(start = if (onBack == null) 12.dp else 4.dp, end = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (onBack != null) {
+ IconButton(onClick = onBack) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
+ contentDescription = stringResource(R.string.action_back),
+ )
+ }
+ }
+
+ // The slot takes all the width the actions leave, so a long box name truncates inside
+ // the pill instead of pushing them off the edge; the pill itself keeps its own size.
+ Box(Modifier.weight(1f)) { indicator() }
+ actions()
+ }
+}
+
+/**
+ * How far [CollapsingHeader] has scrolled out of view, driven by the content's own scrolling.
+ *
+ * It collapses before the content scrolls and only comes back once the content is at its top
+ * again — so it moves exactly as the first item of the list under it would, whichever lazy
+ * list, grid or scrolling column a screen happens to be built from. That is why this lives in
+ * the shell rather than as a first item in each screen: the library alone has four scrolling
+ * containers, and a header copied into each would be four headers to keep alike.
+ */
+@Stable
+private class CollapsingHeaderState {
+ /** From 0 (fully shown) down to minus the header's height (gone). */
+ var offset by mutableFloatStateOf(0f)
+ private set
+
+ /** Measured, not state: nothing is drawn from it, and writing state in layout would loop. */
+ var height = 0f
+
+ /** Off on pushed screens, whose back arrow should not scroll out of reach. */
+ var enabled = true
+
+ fun expand() {
+ offset = 0f
+ }
+
+ val connection = object : NestedScrollConnection {
+ // Upward: the header goes first, then the content.
+ override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset =
+ if (available.y < 0f) consume(available.y) else Offset.Zero
+
+ // Downward: only what the content had no use for, i.e. once it is back at its top.
+ override fun onPostScroll(
+ consumed: Offset,
+ available: Offset,
+ source: NestedScrollSource,
+ ): Offset = if (available.y > 0f) consume(available.y) else Offset.Zero
+ }
+
+ private fun consume(delta: Float): Offset {
+ if (!enabled) return Offset.Zero
+ val before = offset
+ offset = (offset + delta).coerceIn(-height, 0f)
+ return Offset(0f, offset - before)
+ }
+}
+
+/** Lays [content] out at its full height and shows as much of it as [state] allows. */
+@Composable
+private fun CollapsingHeader(state: CollapsingHeaderState, content: @Composable () -> Unit) {
+ Layout(content = content, modifier = Modifier.clipToBounds()) { measurables, constraints ->
+ val placeable = measurables.single().measure(constraints.copy(minHeight = 0))
+ state.height = placeable.height.toFloat()
+ val offset = state.offset.roundToInt().coerceAtLeast(-placeable.height)
+ layout(placeable.width, placeable.height + offset) { placeable.place(0, offset) }
+ }
+}
+
/**
* Whether the window is short enough that the navigation belongs at the side: a phone in
* landscape, or a split-screen half. The window, not the device — `screenHeightDp` follows
@@ -490,7 +595,10 @@ internal fun isCompactHeight(): Boolean =
LocalConfiguration.current.screenHeightDp < COMPACT_HEIGHT_DP
private const val COMPACT_HEIGHT_DP = 480
-private val COMPACT_TOP_BAR_HEIGHT = 48.dp
+private val HEADER_HEIGHT = 56.dp
+
+// A phone on its side has height to spare nowhere, so the header gives some back there.
+private val COMPACT_HEADER_HEIGHT = 48.dp
/** Shows one-off view model messages, which arrive as string resources rather than text. */
@Composable
diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/boxes/BoxesScreen.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/boxes/BoxesScreen.kt
index 15dcf2c..88de942 100644
--- a/app/src/main/kotlin/app/coilforphoniebox/ui/boxes/BoxesScreen.kt
+++ b/app/src/main/kotlin/app/coilforphoniebox/ui/boxes/BoxesScreen.kt
@@ -39,7 +39,8 @@ import app.coilforphoniebox.ui.components.SectionDivider
* Its own screen rather than a stretch of the settings list: the box rows used to sit between
* global settings and the active box's fields, which read as one long list where picking a box,
* adding a box and editing a box were three neighbouring things that happened to look alike.
- * Tapping a row here opens that box; switching boxes stays where it always was, in the top bar.
+ * Tapping a row here opens that box; switching boxes stays where it always was, in the
+ * header's box pill.
*/
@Composable
fun BoxesScreen(
diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/components/BoxSwitcher.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/components/BoxSwitcher.kt
index 0f497f0..683f19b 100644
--- a/app/src/main/kotlin/app/coilforphoniebox/ui/components/BoxSwitcher.kt
+++ b/app/src/main/kotlin/app/coilforphoniebox/ui/components/BoxSwitcher.kt
@@ -36,7 +36,7 @@ import app.coilforphoniebox.domain.model.Box
import app.coilforphoniebox.domain.model.ConnectionState
/**
- * Top bar element for the active box.
+ * Header element for the active box.
*
* With exactly one box configured this collapses to a plain connection indicator: no
* one should pay UI complexity for a feature they do not use (§7.5). German and Dutch
diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/favorites/FavoritesScreen.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/favorites/FavoritesScreen.kt
index 8e832f0..db37e1f 100644
--- a/app/src/main/kotlin/app/coilforphoniebox/ui/favorites/FavoritesScreen.kt
+++ b/app/src/main/kotlin/app/coilforphoniebox/ui/favorites/FavoritesScreen.kt
@@ -59,7 +59,7 @@ import app.coilforphoniebox.ui.isCompactHeight
* The favourites tab, in one of two layouts (§7.2) and one of three orders.
*
* [layout] and [sort] come from settings via the shell rather than from this screen's own
- * view model, because the controls that change them live in the top bar — one preference,
+ * view model, because the controls that change them live in the shell's header — one preference,
* one owner.
*/
@Composable
@@ -227,7 +227,9 @@ private fun FavoriteCell(
/**
* The compact row, for a collection that has outgrown a screenful of tiles: the same cover
- * at thumbnail size, and the label on one line so four times as many fit.
+ * at thumbnail size, and the label beside it so four times as many fit. The label wraps once
+ * rather than cutting off at one line, since a long name usually differs from its neighbours
+ * at the end; a two-line label still fits inside the thumbnail's height.
*/
@Composable
private fun FavoriteRow(
@@ -258,7 +260,7 @@ private fun FavoriteRow(
Text(
text = favorite.label,
style = MaterialTheme.typography.bodyLarge,
- maxLines = 1,
+ maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
diff --git a/app/src/main/kotlin/app/coilforphoniebox/ui/library/LibraryScreen.kt b/app/src/main/kotlin/app/coilforphoniebox/ui/library/LibraryScreen.kt
index 7732fef..1a78524 100644
--- a/app/src/main/kotlin/app/coilforphoniebox/ui/library/LibraryScreen.kt
+++ b/app/src/main/kotlin/app/coilforphoniebox/ui/library/LibraryScreen.kt
@@ -96,6 +96,13 @@ import kotlinx.coroutines.flow.emptyFlow
private const val TAB_FOLDERS = 0
private const val TAB_ALBUMS = 1
+/**
+ * A row's title wraps once before it ellipsises. Library names are long and differ at the end
+ * — `Folge 12` from `Folge 13`, part one from part two — and one line cut exactly that off.
+ * Two lines, not a marquee as in the player: a screenful of rows scrolling at once is noise.
+ */
+private const val ROW_TITLE_LINES = 2
+
/** Room for both tab labels in the longest launch locale, beside the search field. */
private val COMPACT_TABS_WIDTH = 280.dp
@@ -518,7 +525,7 @@ private fun FolderRow(
Text(
text = folder.displayName,
style = MaterialTheme.typography.bodyLarge,
- maxLines = 1,
+ maxLines = ROW_TITLE_LINES,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
@@ -585,7 +592,7 @@ private fun TrackRow(
Text(
text = track.displayTitle,
style = MaterialTheme.typography.bodyLarge,
- maxLines = 1,
+ maxLines = ROW_TITLE_LINES,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
@@ -666,7 +673,7 @@ private fun AlbumRow(
Text(
text = album.album.ifBlank { stringResource(R.string.library_unknown_album) },
style = MaterialTheme.typography.bodyLarge,
- maxLines = 1,
+ maxLines = ROW_TITLE_LINES,
overflow = TextOverflow.Ellipsis,
)
Row(verticalAlignment = Alignment.CenterVertically) {
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7fb892c..a2b3e2b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -17,7 +17,7 @@
Library
Favourites
Settings
-
+
Back
@@ -171,11 +171,11 @@
Move up
Move down
This launcher does not support home screen shortcuts.
-
Show as list
Show as covers
-