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 -