mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
theme album screens from their cover art
This commit is contained in:
@@ -2,29 +2,44 @@ package com.subtracks.ui.library
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
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.Spacer
|
||||
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.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
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.Download
|
||||
import androidx.compose.material.icons.rounded.MoreHoriz
|
||||
import androidx.compose.material.icons.rounded.PlayArrow
|
||||
import androidx.compose.material.icons.rounded.Shuffle
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
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.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.subtracks.data.model.Album
|
||||
@@ -33,10 +48,17 @@ import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.theme.ArtworkColors
|
||||
import com.subtracks.ui.theme.ArtworkTheme
|
||||
import com.subtracks.ui.theme.HeroGradient
|
||||
import com.subtracks.ui.theme.rememberArtworkColors
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
private val ART_SIZE = 240.dp
|
||||
private const val DOT = "\u00B7"
|
||||
|
||||
@Composable
|
||||
fun AlbumDetailRoute(
|
||||
albumId: String,
|
||||
@@ -52,6 +74,7 @@ fun AlbumDetailRoute(
|
||||
album = album,
|
||||
songs = songs,
|
||||
coverArt = viewModel::coverArt,
|
||||
artwork = rememberArtworkColors(viewModel.coverArt(album?.coverArt, false)),
|
||||
onBack = onBack,
|
||||
onSongClick = viewModel::play,
|
||||
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
|
||||
@@ -64,57 +87,120 @@ fun AlbumDetailScreen(
|
||||
album: Album?,
|
||||
songs: List<Song>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artwork: ArtworkColors?,
|
||||
onBack: () -> Unit,
|
||||
onSongClick: (Int) -> Unit,
|
||||
onDownload: () -> Unit = {},
|
||||
onShuffle: () -> Unit = {},
|
||||
onMore: () -> Unit = {},
|
||||
playingSongId: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(album?.name.orEmpty(), maxLines = 1) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
LazyColumn(Modifier.padding(padding).fillMaxSize()) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CoverArt(
|
||||
ref = coverArt(album?.coverArt, false),
|
||||
name = album?.name.orEmpty(),
|
||||
modifier = Modifier.size(120.dp).clip(RoundedCornerShape(2.dp)),
|
||||
)
|
||||
Column {
|
||||
Text(album?.name.orEmpty(), style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
text = album?.albumArtist.orEmpty(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = listOfNotNull(album?.year?.takeIf { it > 0 }, "${songs.size} songs").joinToString(" • "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
ArtworkTheme(artwork) {
|
||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||
Box(modifier.fillMaxSize()) {
|
||||
HeroGradient(artwork, Modifier.fillMaxSize())
|
||||
Scaffold(
|
||||
containerColor = Color.Transparent,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
LazyColumn(Modifier.padding(padding).fillMaxSize()) {
|
||||
item {
|
||||
AlbumHeader(
|
||||
album = album,
|
||||
hasSongs = songs.isNotEmpty(),
|
||||
coverArt = coverArt,
|
||||
onPlay = { onSongClick(0) },
|
||||
onShuffle = onShuffle,
|
||||
onDownload = onDownload,
|
||||
onMore = onMore,
|
||||
)
|
||||
}
|
||||
itemsIndexed(songs, key = { _, song -> song.id }) { index, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
isPlaying = song.id == playingSongId,
|
||||
modifier = Modifier.clickable { onSongClick(index) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
itemsIndexed(songs, key = { _, song -> song.id }) { index, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
isPlaying = song.id == playingSongId,
|
||||
modifier = Modifier.clickable { onSongClick(index) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlbumHeader(
|
||||
album: Album?,
|
||||
hasSongs: Boolean,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
onPlay: () -> Unit,
|
||||
onShuffle: () -> Unit,
|
||||
onDownload: () -> Unit,
|
||||
onMore: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
CoverArt(
|
||||
ref = coverArt(album?.coverArt, false),
|
||||
name = album?.name.orEmpty(),
|
||||
modifier = Modifier.size(ART_SIZE).clip(RoundedCornerShape(8.dp)),
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
text = album?.name.orEmpty(),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val subtitle =
|
||||
listOfNotNull(
|
||||
album?.albumArtist?.takeIf { it.isNotBlank() },
|
||||
album?.year?.takeIf { it > 0 }?.toString(),
|
||||
).joinToString(" $DOT ")
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onDownload) {
|
||||
Icon(Icons.Rounded.Download, contentDescription = "Download")
|
||||
}
|
||||
Button(onClick = onPlay, enabled = hasSongs) {
|
||||
Icon(Icons.Rounded.PlayArrow, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Play")
|
||||
}
|
||||
IconButton(onClick = onShuffle, enabled = hasSongs) {
|
||||
Icon(Icons.Rounded.Shuffle, contentDescription = "Shuffle play")
|
||||
}
|
||||
IconButton(onClick = onMore) {
|
||||
Icon(Icons.Rounded.MoreHoriz, contentDescription = "More options")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package com.subtracks.ui.theme
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.palette.graphics.Palette
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.compose.LocalPlatformContext
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.SuccessResult
|
||||
import coil3.toBitmap
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class ArtworkColors(
|
||||
val scheme: ColorScheme,
|
||||
val gradientHigh: Color,
|
||||
val gradientLow: Color,
|
||||
val accents: List<Color>,
|
||||
)
|
||||
|
||||
fun artworkColorsFromSeed(seed: Int): ArtworkColors {
|
||||
val (hue, saturation, _) = Color(seed or 0xFF000000.toInt()).toHsl()
|
||||
val chroma = saturation.coerceIn(0.30f, 0.72f)
|
||||
val secondaryHue = (hue + 60f) % 360f
|
||||
|
||||
fun tone(
|
||||
h: Float,
|
||||
s: Float,
|
||||
l: Float,
|
||||
) = Color.hsl(h % 360f, s.coerceIn(0f, 1f), l.coerceIn(0f, 1f))
|
||||
|
||||
val primary = tone(hue, chroma, 0.80f)
|
||||
val background = tone(hue, (saturation * 0.7f).coerceIn(0.06f, 0.22f), 0.06f)
|
||||
val onBackground = tone(hue, 0.08f, 0.95f)
|
||||
|
||||
val scheme =
|
||||
darkColorScheme(
|
||||
primary = primary,
|
||||
onPrimary = tone(hue, chroma * 0.6f, 0.14f),
|
||||
primaryContainer = tone(hue, chroma, 0.32f),
|
||||
onPrimaryContainer = tone(hue, chroma * 0.5f, 0.92f),
|
||||
secondary = tone(hue, chroma * 0.5f, 0.78f),
|
||||
onSecondary = tone(hue, chroma * 0.4f, 0.14f),
|
||||
secondaryContainer = tone(hue, chroma * 0.45f, 0.30f),
|
||||
onSecondaryContainer = tone(hue, chroma * 0.4f, 0.90f),
|
||||
tertiary = tone(secondaryHue, chroma, 0.80f),
|
||||
onTertiary = tone(secondaryHue, chroma * 0.6f, 0.14f),
|
||||
tertiaryContainer = tone(secondaryHue, chroma, 0.30f),
|
||||
onTertiaryContainer = tone(secondaryHue, chroma * 0.5f, 0.90f),
|
||||
background = background,
|
||||
onBackground = onBackground,
|
||||
surface = background,
|
||||
onSurface = onBackground,
|
||||
surfaceTint = primary,
|
||||
surfaceVariant = tone(hue, 0.15f, 0.17f),
|
||||
onSurfaceVariant = tone(hue, 0.12f, 0.78f),
|
||||
surfaceContainerLowest = tone(hue, 0.16f, 0.04f),
|
||||
surfaceContainerLow = tone(hue, 0.16f, 0.08f),
|
||||
surfaceContainer = tone(hue, 0.16f, 0.10f),
|
||||
surfaceContainerHigh = tone(hue, 0.15f, 0.14f),
|
||||
surfaceContainerHighest = tone(hue, 0.14f, 0.18f),
|
||||
outline = tone(hue, 0.10f, 0.55f),
|
||||
outlineVariant = tone(hue, 0.12f, 0.28f),
|
||||
)
|
||||
|
||||
return ArtworkColors(
|
||||
scheme = scheme,
|
||||
gradientHigh = tone(hue, (saturation * 0.9f).coerceIn(0.30f, 0.60f), 0.24f),
|
||||
gradientLow = tone(hue, 0.12f, 0.04f),
|
||||
accents = listOf(tone(hue, chroma, 0.60f), tone(secondaryHue, chroma, 0.55f)),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberArtworkColors(ref: CoverArtRef?): ArtworkColors? {
|
||||
val seed by rememberArtworkSeed(ref)
|
||||
return remember(seed) { seed?.let(::artworkColorsFromSeed) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberArtworkSeed(ref: CoverArtRef?): State<Int?> {
|
||||
val context = LocalPlatformContext.current
|
||||
return produceState<Int?>(initialValue = null, ref?.cacheKey) {
|
||||
value = null
|
||||
val art = ref ?: return@produceState
|
||||
value =
|
||||
withContext(Dispatchers.Default) {
|
||||
val request =
|
||||
ImageRequest
|
||||
.Builder(context)
|
||||
.data(art.url)
|
||||
.memoryCacheKey(art.cacheKey)
|
||||
.diskCacheKey(art.cacheKey)
|
||||
.build()
|
||||
val image = (SingletonImageLoader.get(context).execute(request) as? SuccessResult)?.image
|
||||
image?.let { seedFrom(it.toBitmap()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun seedFrom(bitmap: Bitmap): Int? {
|
||||
val palette = Palette.from(bitmap).generate()
|
||||
return (
|
||||
palette.vibrantSwatch
|
||||
?: palette.lightVibrantSwatch
|
||||
?: palette.darkVibrantSwatch
|
||||
?: palette.mutedSwatch
|
||||
?: palette.dominantSwatch
|
||||
)?.rgb
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ArtworkTheme(
|
||||
colors: ArtworkColors?,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val scheme = colors?.scheme
|
||||
if (scheme == null) {
|
||||
content()
|
||||
} else {
|
||||
MaterialTheme(colorScheme = scheme, typography = SubtracksTypography, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HeroGradient(
|
||||
colors: ArtworkColors?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val high = colors?.gradientHigh ?: MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
val low = colors?.gradientLow ?: MaterialTheme.colorScheme.background
|
||||
val accents = colors?.accents.orEmpty()
|
||||
|
||||
Canvas(modifier) {
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.linearGradient(
|
||||
colors = listOf(high, low),
|
||||
start = Offset(size.width * 0.2f, 0f),
|
||||
end = Offset(size.width * 0.8f, size.height),
|
||||
),
|
||||
)
|
||||
accents.getOrNull(0)?.let { accent ->
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.radialGradient(
|
||||
colors = listOf(accent.copy(alpha = 0.35f), accent.copy(alpha = 0f)),
|
||||
center = Offset(size.width * 0.15f, size.height * 0.08f),
|
||||
radius = size.width * 0.95f,
|
||||
),
|
||||
)
|
||||
}
|
||||
accents.getOrNull(1)?.let { accent ->
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.radialGradient(
|
||||
colors = listOf(accent.copy(alpha = 0.30f), accent.copy(alpha = 0f)),
|
||||
center = Offset(size.width * 0.95f, size.height * 0.35f),
|
||||
radius = size.width * 0.9f,
|
||||
),
|
||||
)
|
||||
}
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.radialGradient(
|
||||
colors = listOf(high.copy(alpha = 0.45f), high.copy(alpha = 0f)),
|
||||
center = Offset(size.width * 0.55f, size.height * 0.85f),
|
||||
radius = size.width * 1.1f,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Color.toHsl(): Triple<Float, Float, Float> {
|
||||
val max = maxOf(red, green, blue)
|
||||
val min = minOf(red, green, blue)
|
||||
val lightness = (max + min) / 2f
|
||||
val delta = max - min
|
||||
if (delta == 0f) return Triple(0f, 0f, lightness)
|
||||
val saturation = if (lightness > 0.5f) delta / (2f - max - min) else delta / (max + min)
|
||||
val hue =
|
||||
when (max) {
|
||||
red -> 60f * (((green - blue) / delta) % 6f)
|
||||
green -> 60f * (((blue - red) / delta) + 2f)
|
||||
else -> 60f * (((red - green) / delta) + 4f)
|
||||
}
|
||||
return Triple((hue + 360f) % 360f, saturation, lightness)
|
||||
}
|
||||
Reference in New Issue
Block a user