Files
subtracks/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt
T
austinried 7a0dd9aa5b show download status on the library tabs and offer it in their menus
Album tiles, artist rows and playlist rows now carry the badge (lower right on
tiles, trailing end on rows) and their long-press menus take the same bulk
Download / Cancel / Delete entries the hero uses, driven by the shared actions
rule so a partly downloaded item offers both rows and an in-flight one offers
Cancel. The artist page's album cells get the badge and album menu too.

The three list callbacks the artist menu used (and its artist-specific
view-model calls) collapse into one bulkDownload action over a MenuTarget, since
the same menu code now serves album, playlist and artist.
2026-10-04 00:30:03 +00:00

530 lines
24 KiB
Kotlin

package com.subtracks.ui.library
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.MoreHoriz
import androidx.compose.material.icons.rounded.Shuffle
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarBorder
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
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 com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.DeleteDownloadsDialog
import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.ListDownloadIndicator
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.PendingDownloadDelete
import com.subtracks.ui.components.bulkRef
import com.subtracks.ui.components.rememberViewportFill
import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.ArtworkTheme
import com.subtracks.ui.theme.HeroGradient
import com.subtracks.ui.theme.PrefetchArtworkSeeds
import com.subtracks.ui.theme.baseArtworkColors
import com.subtracks.ui.theme.heroBarColor
import com.subtracks.ui.theme.rememberArtworkColors
import com.subtracks.ui.theme.rememberOverlaidNameBusy
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
private val ART_HEIGHT = 420.dp
private val TITLE_INSET = 16.dp
private val ROW_GAP = 16.dp
private val SHUFFLE_WIDTH = 68.dp
private val SHUFFLE_HEIGHT = 48.dp
private val SHUFFLE_ICON = 30.dp
private val SHUFFLE_END = 22.dp
private val SHUFFLE_RESERVE = 86.dp
private val SHUFFLE_HANG = SHUFFLE_HEIGHT / 2
internal fun estimateScrollPx(
index: Int,
offset: Float,
imageHeightPx: Float,
albumHeightPx: Float,
albumRowGapPx: Float,
): Float =
if (index <= 0) {
offset
} else {
imageHeightPx + albumRowGapPx + (albumHeightPx + albumRowGapPx) * ((index - 1) / 2) + offset
}
private val FADE_LEAD = 24.dp
private const val THEME_TRANSITION_MS = 100
private const val SCRIM_FADE_MS = 180
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
@Composable
fun ArtistDetailRoute(
artistId: String,
coverArtId: String?,
onBack: () -> Unit,
onAlbumClick: (Album) -> Unit,
onViewAlbum: (String) -> Unit,
contextMenuHost: ContextMenuHost? = null,
setStar: (StarType, String, Boolean) -> Unit,
viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) },
playbackController: PlaybackController = koinInject(),
) {
val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null)
val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = emptyList())
val art by viewModel.art.collectAsStateWithLifecycle()
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
val albumDownloads by viewModel.albumDownloads.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
val actions =
ItemActions(
playAlbum = { viewModel.playAlbum(it.id) },
shuffleAlbum = { viewModel.shuffleAlbum(it.id) },
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
bulkDownload = { target, action ->
target.bulkRef()?.let { (list, refId) ->
if (action == BulkDownloadAction.Delete) {
scope.launch {
pendingDelete =
PendingDownloadDelete(target.title, list, refId, viewModel.downloadedBytes(list, refId))
}
} else {
viewModel.onDownloadAction(list, refId, action)
}
}
},
setStar = setStar,
viewAlbum = onViewAlbum,
)
ArtistDetailScreen(
artist = artist,
albums = albums,
art = art,
artThumbnail = artThumbnail,
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS),
coverArt = viewModel::coverArt,
onBack = onBack,
onAlbumClick = onAlbumClick,
albumDownloads = albumDownloads,
onAlbumLongClick = { contextMenuHost?.show(it, actions) },
onMore = { artist?.let { contextMenuHost?.show(MenuTarget.Artist(it, artThumbnail ?: art, downloadStatus), actions) } },
starred = artist?.starred != null,
onToggleStar = artist?.let { a -> { setStar(StarType.Artist, a.id, a.starred == null) } },
)
pendingDelete?.let { pending ->
DeleteDownloadsDialog(
name = pending.name,
bytes = pending.bytes,
onConfirm = { viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) },
onDismiss = { pendingDelete = null },
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ArtistDetailScreen(
artist: Artist?,
albums: List<Album>,
art: CoverArtRef?,
artThumbnail: CoverArtRef? = null,
artwork: ArtworkColors? = null,
coverArt: (String?, Boolean) -> CoverArtRef?,
onBack: () -> Unit,
onAlbumClick: (Album) -> Unit,
albumDownloads: Map<String, ListDownloadStatus> = emptyMap(),
onAlbumLongClick: (MenuTarget) -> Unit = {},
onMore: () -> Unit = {},
starred: Boolean = false,
onToggleStar: (() -> Unit)? = null,
onShuffle: () -> Unit = {},
modifier: Modifier = Modifier,
) {
val listState = rememberLazyGridState()
val fill = rememberViewportFill(listState)
val density = LocalDensity.current
val lineHeight =
with(density) {
MaterialTheme.typography.headlineLarge.lineHeight
.toDp()
}
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
val navBarBottom = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
val barHeight = statusBarTop + TopAppBarDefaults.TopAppBarExpandedHeight
val imageTitleTop = ART_HEIGHT - TITLE_INSET - lineHeight
val fadeStartPx = with(density) { (imageTitleTop - barHeight - FADE_LEAD).toPx() }
val fadeEndPx = with(density) { (imageTitleTop + lineHeight / 2 - barHeight).toPx() }
val barFraction by remember(fadeStartPx, fadeEndPx) {
derivedStateOf {
if (listState.firstVisibleItemIndex > 0) {
1f
} else {
((listState.firstVisibleItemScrollOffset - fadeStartPx) / (fadeEndPx - fadeStartPx)).coerceIn(0f, 1f)
}
}
}
var imageHeightPx by remember { mutableFloatStateOf(0f) }
var albumHeightPx by remember { mutableFloatStateOf(0f) }
val albumRowGapPx = with(density) { ROW_GAP.toPx() }
val scrollPx by remember {
derivedStateOf {
estimateScrollPx(
index = listState.firstVisibleItemIndex,
offset = listState.firstVisibleItemScrollOffset.toFloat(),
imageHeightPx = imageHeightPx,
albumHeightPx = albumHeightPx,
albumRowGapPx = albumRowGapPx,
)
}
}
val nameTextStyle = MaterialTheme.typography.headlineLarge
val imageNameStyle =
nameTextStyle.copy(
shadow =
Shadow(
color = Color.Black.copy(alpha = 0.8f),
offset = Offset(0f, 2f),
blurRadius = 10f,
),
)
val nameBusy = rememberOverlaidNameBusy(artThumbnail ?: art)
val nameScrimAlpha by animateFloatAsState(if (nameBusy) 1f else 0f, tween(SCRIM_FADE_MS), label = "artistNameScrim")
val effectiveArtwork = artwork ?: baseArtworkColors
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
ArtworkTheme(effectiveArtwork) {
BoxWithConstraints(modifier.fillMaxSize().background(Color.Black)) {
val screenHeightPx = with(density) { maxHeight.toPx() }
val barHeightPx = with(density) { barHeight.toPx() }
val gradientScrollPx = scrollPx - imageHeightPx
val barColor = heroBarColor(effectiveArtwork, gradientScrollPx, barHeightPx, screenHeightPx)
HeroGradient(
colors = effectiveArtwork,
scrollPx = { gradientScrollPx },
modifier = Modifier.fillMaxSize(),
)
LazyVerticalGrid(
state = listState,
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(bottom = 16.dp + navBarBottom),
horizontalArrangement = Arrangement.spacedBy(ROW_GAP),
verticalArrangement = Arrangement.spacedBy(ROW_GAP),
modifier = Modifier.fillMaxSize(),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
Column(
Modifier
.fillMaxWidth()
.onSizeChanged { imageHeightPx = it.height.toFloat() },
) {
Box(Modifier.fillMaxWidth()) {
CoverArt(
ref = art,
name = artist?.name.orEmpty(),
thumbnailRef = artThumbnail,
showPlaceholder = art == null,
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
)
if (nameScrimAlpha > 0f) {
Box(
modifier =
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.height(ART_HEIGHT * 0.4f)
.graphicsLayer { alpha = nameScrimAlpha }
.background(
Brush.verticalGradient(
listOf(Color.Transparent, Color.Black.copy(alpha = 0.6f)),
),
).testTag(ARTIST_NAME_SCRIM_TAG),
)
}
Box(Modifier.align(Alignment.BottomStart).fillMaxWidth()) {
ArtistTitle(
text = artist?.name.orEmpty(),
style = imageNameStyle,
color = Color.White,
endReserve = SHUFFLE_RESERVE,
modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(TITLE_INSET),
)
FilledIconButton(
onClick = onShuffle,
shape = RoundedCornerShape(24.dp),
modifier =
Modifier
.align(Alignment.BottomEnd)
.offset(y = SHUFFLE_HANG)
.padding(end = SHUFFLE_END)
.width(SHUFFLE_WIDTH)
.height(SHUFFLE_HEIGHT),
) {
Icon(
imageVector = Icons.Rounded.Shuffle,
contentDescription = "Shuffle artist",
modifier = Modifier.size(SHUFFLE_ICON),
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 34.dp, bottom = 0.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "${artist?.albumCount ?: 0} ${if (artist?.albumCount == 1L) "album" else "albums"}",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = { onToggleStar?.invoke() },
enabled = onToggleStar != null,
) {
Icon(
imageVector = if (starred) Icons.Rounded.Star else Icons.Rounded.StarBorder,
contentDescription = if (starred) "Unstar" else "Star",
tint =
if (starred) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
IconButton(onClick = onMore) {
Icon(Icons.Rounded.MoreHoriz, contentDescription = "More options")
}
}
}
}
itemsIndexed(albums, key = { _, album -> album.id }) { index, album ->
val art = coverArt(album.coverArt, false)
val thumbnail = coverArt(album.coverArt, true)
PrefetchArtworkSeeds(thumbnail)
Column(
modifier =
Modifier
.padding(
start = if (index % 2 == 0) 16.dp else 0.dp,
end = if (index % 2 == 1) 16.dp else 0.dp,
).combinedClickable(
onClick = { onAlbumClick(album) },
onLongClick = { onAlbumLongClick(MenuTarget.Album(album, thumbnail, albumDownloads[album.id])) },
).onSizeChanged { albumHeightPx = it.height.toFloat() },
) {
Box(
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
) {
CoverArt(
ref = art,
name = album.name,
thumbnailRef = thumbnail,
showPlaceholder = art == null,
modifier = Modifier.fillMaxSize(),
)
ListDownloadIndicator(
status = albumDownloads[album.id],
modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp),
)
}
Text(
text = album.name,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
Text(
text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.height(fill))
}
}
Box(
modifier =
Modifier
.align(Alignment.TopStart)
.fillMaxWidth()
.height(barHeight + 24.dp)
.alpha(1f - barFraction)
.background(
Brush.verticalGradient(listOf(Color.Black.copy(alpha = 0.8f), Color.Transparent)),
),
)
TopAppBar(
title = {
Text(
text = artist?.name.orEmpty(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.graphicsLayer { alpha = barFraction },
)
},
navigationIcon = {
IconButton(onClick = onBack, modifier = Modifier.graphicsLayer { alpha = barFraction }) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
modifier =
Modifier
.align(Alignment.TopStart)
.background(barColor.copy(alpha = barFraction))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {},
)
}
}
}
}
@Composable
private fun ArtistTitle(
text: String,
style: TextStyle,
color: Color,
endReserve: Dp,
modifier: Modifier = Modifier,
) {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
BoxWithConstraints(modifier) {
val fullWidth = with(density) { maxWidth.roundToPx() }
val reserve = with(density) { endReserve.roundToPx() }
val lines = remember(text, style, fullWidth, reserve) { splitTitle(measurer, text, style, fullWidth, reserve) }
Column {
lines.forEachIndexed { index, line ->
Text(
text = line,
style = style,
color = color,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.fillMaxWidth()
.then(if (index == lines.lastIndex) Modifier.padding(end = endReserve) else Modifier),
)
}
}
}
}
internal fun splitTitle(
measurer: TextMeasurer,
text: String,
style: TextStyle,
fullWidth: Int,
reserve: Int,
): List<String> {
if (text.isBlank()) return listOf(text)
if (measurer.measure(AnnotatedString(text), style, maxLines = 1).size.width <= fullWidth - reserve) {
return listOf(text)
}
val words = text.split(' ').filter { it.isNotEmpty() }
if (words.size <= 1) return listOf(text)
var count = 0
var acc = ""
for (i in words.indices) {
val candidate = if (acc.isEmpty()) words[i] else "$acc ${words[i]}"
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= fullWidth) {
acc = candidate
count = i + 1
} else {
break
}
}
if (count >= words.size) count = words.size - 1
if (count < 1) count = 1
return listOf(words.take(count).joinToString(" "), words.drop(count).joinToString(" "))
}