home: show album more screens as lists with a section-relevant detail column

This commit is contained in:
austinried
2026-10-04 00:30:06 +00:00
parent 53aa0f71b3
commit b0a31d0291
3 changed files with 278 additions and 4 deletions
@@ -2,11 +2,19 @@ package com.subtracks.ui.home
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -14,13 +22,18 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.LoadState import androidx.paging.LoadState
@@ -35,10 +48,10 @@ import com.subtracks.data.model.Song
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.library.AlbumsContent
import com.subtracks.ui.library.ArtistsContent import com.subtracks.ui.library.ArtistsContent
import com.subtracks.ui.library.SongRow import com.subtracks.ui.library.SongRow
import org.koin.compose.koinInject import org.koin.compose.koinInject
@@ -112,6 +125,7 @@ fun HomeListScreen(
onDecadeClick: (Long) -> Unit = {}, onDecadeClick: (Long) -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val now = remember { System.currentTimeMillis() }
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
@@ -182,10 +196,10 @@ fun HomeListScreen(
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
) )
} else { } else {
AlbumsContent( AlbumsList(
items = albums, albums = albums,
coverArt = coverArt, coverArt = coverArt,
bottomInset = 0.dp, info = { albumListInfo(request, it, now) },
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
onLongClick = onLongClick, onLongClick = onLongClick,
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
@@ -250,3 +264,140 @@ private fun SongsList(
} }
} }
} }
private val ALBUM_ROW_COVER = 48.dp
@Composable
private fun AlbumsList(
albums: LazyPagingItems<Album>,
coverArt: (String?, Boolean) -> CoverArtRef?,
info: (Album) -> String?,
onAlbumClick: (Album) -> Unit,
onLongClick: (MenuTarget) -> Unit,
modifier: Modifier = Modifier,
) {
if (albums.itemCount == 0 && albums.loadState.refresh is LoadState.Loading) {
LoadingState(modifier)
return
}
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 24.dp),
) {
items(count = albums.itemCount, key = albums.itemKey { it.id }) { index ->
val album = albums[index]
if (album != null) {
AlbumListRow(
album = album,
coverArt = coverArt,
info = info(album),
onClick = { onAlbumClick(album) },
onLongClick = { onLongClick(MenuTarget.Album(album, coverArt(album.coverArt, true))) },
)
}
}
}
}
@Composable
private fun AlbumListRow(
album: Album,
coverArt: (String?, Boolean) -> CoverArtRef?,
info: String?,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CoverArt(
ref = coverArt(album.coverArt, true),
name = album.name,
modifier = Modifier.size(ALBUM_ROW_COVER).clip(RoundedCornerShape(2.dp)),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = album.name,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = album.albumArtist.orEmpty(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (info != null) {
Text(
text = info,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
modifier = Modifier.padding(start = 8.dp),
)
}
}
}
}
}
internal fun albumListInfo(
request: HomeListRequest,
album: Album,
now: Long,
): String? =
when {
request.section == HomeSection.RecentlyAddedAlbums -> {
relativeTimeLabel(album.created, now)
}
request.section == HomeSection.RecentlyPlayedAlbums || request.section == HomeSection.Rediscover -> {
album.played?.let { relativeTimeLabel(it, now) }
}
request.section == HomeSection.MostPlayedAlbums -> {
playCountLabel(album.playCount)
}
request.decade != null -> {
album.year?.takeIf { it > 0 }?.toString()
}
else -> {
null
}
}
private fun playCountLabel(count: Long): String? = if (count <= 0L) null else "$count ${if (count == 1L) "play" else "plays"}"
internal fun relativeTimeLabel(
epochSeconds: Long,
nowMillis: Long,
): String? {
if (epochSeconds <= 0L) return null
val elapsedMillis = nowMillis - epochSeconds * 1000L
if (elapsedMillis < 0L) return "Just now"
val days = elapsedMillis / 86_400_000L
return when {
elapsedMillis < 60_000L -> "Just now"
days == 0L -> "Today"
days == 1L -> "Yesterday"
days < 7L -> "$days days ago"
days < 14L -> "Last week"
days < 30L -> "${days / 7L} weeks ago"
days < 60L -> "Last month"
days < 365L -> "${days / 30L} months ago"
days < 730L -> "Last year"
else -> "${days / 365L} years ago"
}
}
@@ -0,0 +1,76 @@
package com.subtracks.ui.home
import com.subtracks.data.model.Album
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class HomeListInfoTest {
private val now = 1_700_000_000_000L
@Test
fun relativeTimeUsesFriendlyBuckets() {
assertNull(relativeTimeLabel(0, now))
assertEquals("Just now", relativeTimeLabel(now / 1000 - 10, now))
assertEquals("Today", relativeTimeLabel((now - 3 * 3_600_000L) / 1000L, now))
assertEquals("Yesterday", relativeTimeLabel(secondsAgo(1), now))
assertEquals("3 days ago", relativeTimeLabel(secondsAgo(3), now))
assertEquals("Last week", relativeTimeLabel(secondsAgo(10), now))
assertEquals("3 weeks ago", relativeTimeLabel(secondsAgo(21), now))
assertEquals("Last month", relativeTimeLabel(secondsAgo(45), now))
assertEquals("3 months ago", relativeTimeLabel(secondsAgo(100), now))
assertEquals("Last year", relativeTimeLabel(secondsAgo(400), now))
assertEquals("2 years ago", relativeTimeLabel(secondsAgo(800), now))
}
@Test
fun albumInfoMatchesTheSection() {
val album = album(created = secondsAgo(1), played = secondsAgo(3), playCount = 5, year = 1999)
assertEquals("Yesterday", albumListInfo(request(HomeSection.RecentlyAddedAlbums), album, now))
assertEquals("3 days ago", albumListInfo(request(HomeSection.RecentlyPlayedAlbums), album, now))
assertEquals("5 plays", albumListInfo(request(HomeSection.MostPlayedAlbums), album, now))
assertEquals("3 days ago", albumListInfo(request(HomeSection.Rediscover), album, now))
}
@Test
fun albumInfoIsNullWhenTheSectionHasNothingToShow() {
val album = album(created = 0, played = null, playCount = 0, year = 0)
assertNull(albumListInfo(request(HomeSection.RecentlyAddedAlbums), album, now))
assertNull(albumListInfo(request(HomeSection.RecentlyPlayedAlbums), album, now))
assertNull(albumListInfo(request(HomeSection.MostPlayedAlbums), album, now))
}
@Test
fun aDecadeListShowsTheYear() {
val album = album(created = 0, played = null, playCount = 0, year = 1999)
assertEquals("1999", albumListInfo(HomeListRequest(title = "1990s", decade = 1990), album, now))
}
private fun secondsAgo(days: Long): Long = (now - days * 86_400_000L) / 1000L
private fun request(section: HomeSection) = HomeListRequest(title = section.title, section = section)
private fun album(
created: Long,
played: Long?,
playCount: Long,
year: Long,
) = Album(
sourceId = 1,
id = "a",
artistId = "ar",
name = "Album",
albumArtist = "Artist",
created = created,
coverArt = null,
genre = null,
year = year,
starred = null,
songCount = 1,
playCount = playCount,
played = played,
)
}
@@ -3,12 +3,15 @@ package com.subtracks.ui.home
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeUp import androidx.compose.ui.test.swipeUp
import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.github.takahirom.roborazzi.captureRoboImage import com.github.takahirom.roborazzi.captureRoboImage
@@ -17,6 +20,7 @@ import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.ui.theme.SubtracksTheme import com.subtracks.ui.theme.SubtracksTheme
import kotlinx.coroutines.flow.flowOf
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@@ -75,6 +79,49 @@ class HomeScreenshotTest {
composeRule.onRoot().captureRoboImage("src/test/screenshots/home_genres.png") composeRule.onRoot().captureRoboImage("src/test/screenshots/home_genres.png")
} }
@Test
fun homeRecentlyAddedList() {
val now = System.currentTimeMillis()
val albums =
listOf(
listAlbum("Added Yesterday", (now - 86_400_000L) / 1000L),
listAlbum("Added This Week", (now - 3 * 86_400_000L) / 1000L),
listAlbum("Added Weeks Ago", (now - 21 * 86_400_000L) / 1000L),
)
composeRule.setContent {
SubtracksTheme {
HomeListScreen(
request = HomeListRequest(title = "Recently added", section = HomeSection.RecentlyAddedAlbums),
albums = remember { flowOf(PagingData.from(albums)) }.collectAsLazyPagingItems(),
artists = remember { flowOf(PagingData.empty<Artist>()) }.collectAsLazyPagingItems(),
songs = remember { flowOf(PagingData.empty<AlbumSongItem>()) }.collectAsLazyPagingItems(),
coverArt = { _, _ -> null },
)
}
}
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithText("Added Yesterday").fetchSemanticsNodes().isNotEmpty()
}
composeRule.onRoot().captureRoboImage("src/test/screenshots/home_album_list.png")
}
private fun listAlbum(
name: String,
created: Long,
) = Album(
sourceId = 1,
id = name,
artistId = "ar",
name = name,
albumArtist = "Radiohead",
created = created,
coverArt = null,
genre = null,
year = 2000,
starred = null,
songCount = 10,
)
private fun feed() = private fun feed() =
HomeFeed( HomeFeed(
recentlyPlayedAlbums = albums("Recent"), recentlyPlayedAlbums = albums("Recent"),