From 2e925f2f78bda1a76466fd6d1e0f0bb028548088 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:05:50 +0000 Subject: [PATCH] ui nits: cover placeholder ratio, now playing scrim/title, add-server keyboard, titlebars, search dismiss, fab icon - CoverArt: use the thumbnail's intrinsic ratio until the full-res painter loads, so non-square art no longer snaps - Now Playing: move the top scrim out of the sliding screen into MainNavigation, static under the status bar, fading in as the screen settles at the top - Now Playing: titlebar shows the playback source (album/playlist name, or Library) instead of "Now playing" - Add server: keep the test/save bar above the keyboard on first launch - Titlebars: use the theme default title style instead of headlineMedium - Library: clicking an item in a searched tab dismisses the search - Library FAB: use the AutoMirrored Sort icon at 28dp so the glyph reads a little thicker --- .../subtracks/data/repo/QueueRepository.kt | 14 ++++++++ .../subtracks/playback/PlaybackController.kt | 5 ++- .../main/kotlin/com/subtracks/ui/AppRoot.kt | 23 ++++++++++++ .../com/subtracks/ui/components/Components.kt | 12 +++++-- .../ui/components/HeroDetailScaffold.kt | 1 - .../com/subtracks/ui/library/LibraryScreen.kt | 35 +++++++++++++++---- .../subtracks/ui/playback/NowPlayingScreen.kt | 28 ++++++--------- .../com/subtracks/ui/playback/QueueScreen.kt | 2 +- .../subtracks/ui/settings/AddSourceScreen.kt | 13 +++++-- .../subtracks/ui/settings/SettingsScreen.kt | 2 +- .../playback/PlaybackControllerTest.kt | 4 +-- .../com/subtracks/ui/AppScreenshotTest.kt | 2 ++ 12 files changed, 106 insertions(+), 35 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt index bc911841..f8d42c2f 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt @@ -13,6 +13,7 @@ import com.subtracks.data.model.ShuffleOrder import com.subtracks.data.model.SongListItem import com.subtracks.data.prefs.SongSort import com.subtracks.data.prefs.StarredFilter +import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.util.concurrent.atomic.AtomicLong @@ -119,6 +120,19 @@ class QueueRepository( return if (index >= 0) index.toLong() else null } + suspend fun sourceName( + kind: QueueKind, + sourceId: Long, + refId: String, + ): String? { + val library = db.libraryDao() + return when (kind) { + QueueKind.Album -> library.album(sourceId, refId).first()?.name + QueueKind.Playlist -> library.playlist(sourceId, refId).first()?.name + QueueKind.Songs, QueueKind.Song -> null + } + } + private fun songsQuery(refId: String): QueueSongQuery? { val parts = refId.split("|") if (parts.size != 3) return null diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 39b8c441..f10e7592 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -34,6 +34,7 @@ data class QueueItem( data class QueueContext( val kind: QueueKind, + val sourceId: Long, val refId: String, ) @@ -889,9 +890,11 @@ class PlaybackController( private fun contextAt(position: Long?): QueueContext? { val entry = position?.let { snapshot?.locate(it)?.first } ?: return null - return QueueContext(entry.kind, entry.refId) + return QueueContext(entry.kind, entry.sourceId, entry.refId) } + suspend fun sourceTitle(context: QueueContext?): String? = context?.let { queueRepository.sourceName(it.kind, it.sourceId, it.refId) } + private fun updateBuffering() { if (player?.isBuffering != true) { bufferingJob?.cancel() diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index 7fdbfbc8..88e2bd7d 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -14,6 +14,7 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState @@ -23,9 +24,12 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imeAnimationTarget import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.statusBars import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -37,6 +41,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot @@ -88,6 +94,7 @@ private const val OVERLAY_DURATION_MS = 200 private const val EXPAND_FADE = 0.1f private const val FLING_VELOCITY = 1000f private const val MINI_PLAYER_ANIM_MS = 200 +private const val SCRIM_FADE_START = 0.6f private object Routes { const val LIBRARY = "library" @@ -163,6 +170,7 @@ private fun MainNavigation() { } val density = LocalDensity.current val navBarInset = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() } + val statusBarInset = with(density) { WindowInsets.statusBars.getTop(this).toDp() } val keyboardUp = WindowInsets.imeAnimationTarget.getBottom(density) > 0 val showMiniPlayer = playerVisible && !keyboardUp val bottomInset by animateDpAsState( @@ -333,6 +341,21 @@ private fun MainNavigation() { modifier = Modifier.fillMaxSize(), ) } + + Box( + modifier = + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(statusBarInset + 8.dp) + .graphicsLayer { + alpha = ((nowPlayingProgress - SCRIM_FADE_START) / (1f - SCRIM_FADE_START)).coerceIn(0f, 1f) + }.background( + Brush.verticalGradient( + listOf(Color.Black.copy(alpha = 0.55f), Color.Transparent), + ), + ), + ) } AnimatedVisibility( diff --git a/app/src/main/kotlin/com/subtracks/ui/components/Components.kt b/app/src/main/kotlin/com/subtracks/ui/components/Components.kt index 6b6ea5f9..3ecc08a8 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/Components.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/Components.kt @@ -85,8 +85,16 @@ fun CoverArt( onError = { failed = true }, contentScale = ContentScale.Fit, ) + val thumbnailPainter = + rememberAsyncImagePainter( + model = remember(thumbnailRef) { thumbnailRef?.let { imageRequest(context, it, crossfade = false) } }, + contentScale = ContentScale.Fit, + ) BoxWithConstraints(modifier, contentAlignment = Alignment.Center) { - val ratio = painter.intrinsicSize.ratioOrOne() + val ratio = + painter.intrinsicSize.ratioOrNull() + ?: thumbnailPainter.intrinsicSize.ratioOrNull() + ?: 1f val width = if (ratio > maxWidth.value / maxHeight.value) maxWidth else maxHeight * ratio Box(Modifier.size(width, width / ratio).then(frameModifier)) { CoverArtContent( @@ -146,7 +154,7 @@ private fun BoxScope.CoverArtContent( main() } -private fun Size.ratioOrOne(): Float = if (width > 0f && height > 0f && width.isFinite() && height.isFinite()) width / height else 1f +private fun Size.ratioOrNull(): Float? = if (width > 0f && height > 0f && width.isFinite() && height.isFinite()) width / height else null private fun imageRequest( context: Context, diff --git a/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt b/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt index bfae5b99..a4a6a280 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt @@ -166,7 +166,6 @@ fun HeroDetailScaffold( title = { Text( text = title, - style = MaterialTheme.typography.headlineMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.graphicsLayer { alpha = barFraction }, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index c11d189c..0a3ea0c8 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -25,8 +25,8 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Sort import androidx.compose.material.icons.automirrored.rounded.PlaylistPlay -import androidx.compose.material.icons.filled.Sort import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Album import androidx.compose.material.icons.rounded.ArrowDownward @@ -304,6 +304,13 @@ fun LibraryScreen( if (search.isNotEmpty()) searchActive = true } + fun dismissSearch() { + if (searchActive) { + searchActive = false + onSearchChange("") + } + } + val density = LocalDensity.current val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() } val filtersActive = listQuery.starred != StarredFilter.Any || search.isNotEmpty() @@ -347,7 +354,10 @@ fun LibraryScreen( albums, coverArt, listBottomInset, - onAlbumClick, + { + dismissSearch() + onAlbumClick(it) + }, filtered = filtersActive, onClearFilters = onClearFilters, resetKey = resetKey, @@ -361,7 +371,10 @@ fun LibraryScreen( artists, coverArt, listBottomInset, - onArtistClick, + { + dismissSearch() + onArtistClick(it) + }, filtered = filtersActive, onClearFilters = onClearFilters, resetKey = resetKey, @@ -375,7 +388,10 @@ fun LibraryScreen( songs, coverArt, listBottomInset, - onSongClick, + { + dismissSearch() + onSongClick(it) + }, playingSongId, filtered = filtersActive, onClearFilters = onClearFilters, @@ -390,7 +406,10 @@ fun LibraryScreen( playlists, coverArt, listBottomInset, - onPlaylistClick, + { + dismissSearch() + onPlaylistClick(it) + }, filtered = filtersActive, onClearFilters = onClearFilters, resetKey = resetKey, @@ -460,7 +479,11 @@ fun LibraryScreen( modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = tabBarHeight + 16.dp), ) { Box { - Icon(Icons.Filled.Sort, contentDescription = "List options") + Icon( + Icons.AutoMirrored.Filled.Sort, + contentDescription = "List options", + modifier = Modifier.size(28.dp), + ) if (filtersActive) { Box( modifier = diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt index 981538e3..1fd3fd9d 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt @@ -7,13 +7,11 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack @@ -45,11 +43,11 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.PlatformTextStyle @@ -61,6 +59,7 @@ import coil3.SingletonImageLoader import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.model.QueueKind import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackState import com.subtracks.playback.RepeatMode @@ -83,6 +82,11 @@ fun NowPlayingRoute( val context = LocalPlatformContext.current val art = controller.coverArt(state.item) val thumbnail = controller.coverArt(state.item, thumbnail = true) + val queueContext = state.context + val fallbackTitle = if (queueContext?.kind == QueueKind.Album) state.item?.album.orEmpty() else "Library" + val sourceTitle by produceState(initialValue = fallbackTitle, queueContext) { + value = controller.sourceTitle(queueContext) ?: fallbackTitle + } LaunchedEffect(state.item?.id, state.hasNext) { if (!state.hasNext) return@LaunchedEffect controller.upcomingCoverArt(thumbnail = true)?.let { ArtworkSeedCache.prefetch(context, it) } @@ -90,6 +94,7 @@ fun NowPlayingRoute( } NowPlayingScreen( state = state, + title = sourceTitle, coverArt = art, thumbnailRef = thumbnail, artwork = rememberArtworkColors(thumbnail ?: art), @@ -123,6 +128,7 @@ private fun prefetchImage( @Composable fun NowPlayingScreen( state: PlaybackState, + title: String, coverArt: CoverArtRef?, artwork: ArtworkColors?, onBack: () -> Unit, @@ -157,19 +163,6 @@ fun NowPlayingScreen( scrollPx = { 0f }, modifier = Modifier.fillMaxSize(), ) - val statusBarDp = with(LocalDensity.current) { WindowInsets.statusBars.getTop(this).toDp() } - Box( - modifier = - Modifier - .align(Alignment.TopStart) - .fillMaxWidth() - .height(statusBarDp + 8.dp) - .background( - Brush.verticalGradient( - listOf(Color.Black.copy(alpha = 0.55f), Color.Transparent), - ), - ), - ) Scaffold( modifier = Modifier.fillMaxSize(), containerColor = Color.Transparent, @@ -177,8 +170,7 @@ fun NowPlayingScreen( TopAppBar( title = { Text( - text = "Now playing", - style = MaterialTheme.typography.headlineMedium, + text = title, maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt index c83006c0..2b31efca 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt @@ -336,7 +336,7 @@ fun QueueScreen( modifier = modifier, topBar = { TopAppBar( - title = { Text("Queue", style = MaterialTheme.typography.headlineMedium) }, + title = { Text("Queue") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceScreen.kt index d3dd846e..77b38bcd 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceScreen.kt @@ -2,9 +2,11 @@ package com.subtracks.ui.settings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -56,7 +58,7 @@ fun AddSourceRoute( ) } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun AddSourceScreen( state: AddSourceState, @@ -74,7 +76,7 @@ fun AddSourceScreen( modifier = modifier, topBar = { TopAppBar( - title = { Text("Add server", style = MaterialTheme.typography.headlineMedium) }, + title = { Text("Add server") }, navigationIcon = { if (onBack != null) { IconButton(onClick = onBack) { @@ -86,7 +88,12 @@ fun AddSourceScreen( }, bottomBar = { Row( - modifier = Modifier.fillMaxWidth().navigationBarsPadding().padding(16.dp), + modifier = + Modifier + .fillMaxWidth() + .imePadding() + .navigationBarsPadding() + .padding(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt index 463fa243..bec5ca50 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt @@ -97,7 +97,7 @@ fun SettingsScreen( modifier = modifier, topBar = { TopAppBar( - title = { Text("Settings", style = MaterialTheme.typography.headlineMedium) }, + title = { Text("Settings") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index 9bb5e771..715ef49c 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -78,7 +78,7 @@ class PlaybackControllerTest { await { handle.operations.contains("play") } assertEquals(51, handle.itemCount) - assertEquals(QueueContext(QueueKind.Songs, ""), controller.state.value.context) + assertEquals(QueueContext(QueueKind.Songs, 1, ""), controller.state.value.context) assertTrue(controller.state.value.hasNext) assertTrue(controller.state.value.hasPrevious) } @@ -305,7 +305,7 @@ class PlaybackControllerTest { controller.playAlbum(1, "al1", 0) await { controller.state.value.item != null } - assertEquals(QueueContext(QueueKind.Album, "al1"), controller.state.value.context) + assertEquals(QueueContext(QueueKind.Album, 1, "al1"), controller.state.value.context) assertTrue(controller.state.value.isPlaying) assertTrue(controller.state.value.hasNext) assertTrue(controller.state.value.hasPrevious) diff --git a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt index d06d55bd..d7b89844 100644 --- a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt @@ -370,6 +370,7 @@ class AppScreenshotTest { SubtracksTheme { NowPlayingScreen( state = Fixtures.playbackState(), + title = "Kid A", coverArt = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"), artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)), onBack = {}, @@ -392,6 +393,7 @@ class AppScreenshotTest { SubtracksTheme { NowPlayingScreen( state = Fixtures.playbackState().copy(shuffle = true, repeat = RepeatMode.One), + title = "Kid A", coverArt = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"), artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)), onBack = {},