mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
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:
@@ -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 = {},
|
||||
|
||||
Reference in New Issue
Block a user