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
This commit is contained in:
austinried
2026-10-04 00:29:58 +00:00
parent 2552e1a10c
commit 2e925f2f78
12 changed files with 106 additions and 35 deletions
@@ -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
@@ -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()
@@ -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(
@@ -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,
@@ -166,7 +166,6 @@ fun HeroDetailScaffold(
title = {
Text(
text = title,
style = MaterialTheme.typography.headlineMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.graphicsLayer { alpha = barFraction },
@@ -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 =
@@ -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<String>(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,
)
@@ -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")
@@ -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,
) {
@@ -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")
@@ -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)
@@ -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 = {},