mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
theme album screens from their cover art
This commit is contained in:
@@ -116,6 +116,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.room3.sqlite.wrapper)
|
implementation(libs.androidx.room3.sqlite.wrapper)
|
||||||
implementation(libs.androidx.room3.paging)
|
implementation(libs.androidx.room3.paging)
|
||||||
implementation(libs.androidx.paging.compose)
|
implementation(libs.androidx.paging.compose)
|
||||||
|
implementation(libs.androidx.palette)
|
||||||
implementation(libs.reorderable)
|
implementation(libs.reorderable)
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.androidx.media3.exoplayer)
|
implementation(libs.androidx.media3.exoplayer)
|
||||||
|
|||||||
@@ -2,29 +2,44 @@ package com.subtracks.ui.library
|
|||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
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.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
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.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.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.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
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.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.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.subtracks.data.model.Album
|
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.data.model.Song
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import com.subtracks.ui.components.CoverArt
|
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.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
|
|
||||||
|
private val ART_SIZE = 240.dp
|
||||||
|
private const val DOT = "\u00B7"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AlbumDetailRoute(
|
fun AlbumDetailRoute(
|
||||||
albumId: String,
|
albumId: String,
|
||||||
@@ -52,6 +74,7 @@ fun AlbumDetailRoute(
|
|||||||
album = album,
|
album = album,
|
||||||
songs = songs,
|
songs = songs,
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
|
artwork = rememberArtworkColors(viewModel.coverArt(album?.coverArt, false)),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onSongClick = viewModel::play,
|
onSongClick = viewModel::play,
|
||||||
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
|
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
|
||||||
@@ -64,50 +87,44 @@ fun AlbumDetailScreen(
|
|||||||
album: Album?,
|
album: Album?,
|
||||||
songs: List<Song>,
|
songs: List<Song>,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
|
artwork: ArtworkColors?,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onSongClick: (Int) -> Unit,
|
onSongClick: (Int) -> Unit,
|
||||||
|
onDownload: () -> Unit = {},
|
||||||
|
onShuffle: () -> Unit = {},
|
||||||
|
onMore: () -> Unit = {},
|
||||||
playingSongId: String? = null,
|
playingSongId: String? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
ArtworkTheme(artwork) {
|
||||||
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||||
|
Box(modifier.fillMaxSize()) {
|
||||||
|
HeroGradient(artwork, Modifier.fillMaxSize())
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
containerColor = Color.Transparent,
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(album?.name.orEmpty(), maxLines = 1) },
|
title = {},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
LazyColumn(Modifier.padding(padding).fillMaxSize()) {
|
LazyColumn(Modifier.padding(padding).fillMaxSize()) {
|
||||||
item {
|
item {
|
||||||
Row(
|
AlbumHeader(
|
||||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
album = album,
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
hasSongs = songs.isNotEmpty(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
coverArt = coverArt,
|
||||||
) {
|
onPlay = { onSongClick(0) },
|
||||||
CoverArt(
|
onShuffle = onShuffle,
|
||||||
ref = coverArt(album?.coverArt, false),
|
onDownload = onDownload,
|
||||||
name = album?.name.orEmpty(),
|
onMore = onMore,
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
itemsIndexed(songs, key = { _, song -> song.id }) { index, song ->
|
itemsIndexed(songs, key = { _, song -> song.id }) { index, song ->
|
||||||
SongRow(
|
SongRow(
|
||||||
@@ -119,3 +136,72 @@ fun AlbumDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ import com.subtracks.ui.settings.AddSourceScreen
|
|||||||
import com.subtracks.ui.settings.AddSourceState
|
import com.subtracks.ui.settings.AddSourceState
|
||||||
import com.subtracks.ui.settings.SettingsScreen
|
import com.subtracks.ui.settings.SettingsScreen
|
||||||
import com.subtracks.ui.theme.SubtracksTheme
|
import com.subtracks.ui.theme.SubtracksTheme
|
||||||
|
import com.subtracks.ui.theme.artworkColorsFromSeed
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
@@ -120,6 +121,7 @@ class AppScreenshotTest {
|
|||||||
album = Fixtures.albums.first(),
|
album = Fixtures.albums.first(),
|
||||||
songs = Fixtures.songs,
|
songs = Fixtures.songs,
|
||||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
||||||
|
artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)),
|
||||||
onBack = {},
|
onBack = {},
|
||||||
onSongClick = {},
|
onSongClick = {},
|
||||||
playingSongId = Fixtures.songs.first().id,
|
playingSongId = Fixtures.songs.first().id,
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.subtracks.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ArtworkColorsTest {
|
||||||
|
private val seeds =
|
||||||
|
listOf(
|
||||||
|
0xFF3A7BD5.toInt(),
|
||||||
|
0xFFD53A3A.toInt(),
|
||||||
|
0xFF3AD55F.toInt(),
|
||||||
|
0xFFB0B0B0.toInt(),
|
||||||
|
0x00000000,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun darkBackgroundWithReadableText() {
|
||||||
|
for (seed in seeds) {
|
||||||
|
val scheme = artworkColorsFromSeed(seed).scheme
|
||||||
|
assertTrue("background for $seed", scheme.background.luminance() < 0.1f)
|
||||||
|
assertTrue("onBackground for $seed", contrast(scheme.background, scheme.onBackground) >= 4.5f)
|
||||||
|
assertTrue("primary text for $seed", contrast(scheme.primary, scheme.onPrimary) >= 3f)
|
||||||
|
assertTrue("onSurface for $seed", contrast(scheme.surface, scheme.onSurface) >= 4.5f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun independentOfAlphaBits() {
|
||||||
|
assertEquals(
|
||||||
|
artworkColorsFromSeed(0xFF3A7BD5.toInt()).scheme.primary,
|
||||||
|
artworkColorsFromSeed(0x3A7BD5).scheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun contrast(
|
||||||
|
a: Color,
|
||||||
|
b: Color,
|
||||||
|
): Float {
|
||||||
|
val hi = maxOf(a.luminance(), b.luminance())
|
||||||
|
val lo = minOf(a.luminance(), b.luminance())
|
||||||
|
return (hi + 0.05f) / (lo + 0.05f)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ coroutines = "1.11.0"
|
|||||||
room3 = "3.0.3"
|
room3 = "3.0.3"
|
||||||
sqliteBundled = "2.7.1"
|
sqliteBundled = "2.7.1"
|
||||||
paging = "3.5.1"
|
paging = "3.5.1"
|
||||||
|
palette = "1.0.0"
|
||||||
navigationCompose = "2.10.1"
|
navigationCompose = "2.10.1"
|
||||||
reorderable = "3.1.0"
|
reorderable = "3.1.0"
|
||||||
datastore = "1.2.1"
|
datastore = "1.2.1"
|
||||||
@@ -49,6 +50,7 @@ androidx-room3-compiler = { group = "androidx.room3", name = "room3-compiler", v
|
|||||||
androidx-room3-sqlite-wrapper = { group = "androidx.room3", name = "room3-sqlite-wrapper", version.ref = "room3" }
|
androidx-room3-sqlite-wrapper = { group = "androidx.room3", name = "room3-sqlite-wrapper", version.ref = "room3" }
|
||||||
androidx-room3-paging = { group = "androidx.room3", name = "room3-paging", version.ref = "room3" }
|
androidx-room3-paging = { group = "androidx.room3", name = "room3-paging", version.ref = "room3" }
|
||||||
androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" }
|
androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" }
|
||||||
|
androidx-palette = { group = "androidx.palette", name = "palette-ktx", version.ref = "palette" }
|
||||||
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }
|
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }
|
||||||
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
|
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
|
||||||
androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" }
|
androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" }
|
||||||
|
|||||||
Reference in New Issue
Block a user