diff --git a/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt b/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt index 56c9a5db..b3df0486 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt @@ -95,7 +95,7 @@ fun HeroDetailScaffold( content: LazyListScope.(rowModifier: Modifier) -> Unit, modifier: Modifier = Modifier, ) { - val effectiveArtwork = artwork ?: baseArtworkColors + val effectiveArtwork = artwork ?: baseArtworkColors() CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) { ArtworkTheme(effectiveArtwork) { BoxWithConstraints(modifier.fillMaxSize().background(Color.Black)) { diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt index f0fec1cb..75eb4d7d 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt @@ -272,7 +272,7 @@ fun ArtistDetailScreen( ) val nameBusy = rememberOverlaidNameBusy(artThumbnail ?: art) val nameScrimAlpha by animateFloatAsState(if (nameBusy) 1f else 0f, tween(SCRIM_FADE_MS), label = "artistNameScrim") - val effectiveArtwork = artwork ?: baseArtworkColors + val effectiveArtwork = artwork ?: baseArtworkColors() CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) { ArtworkTheme(effectiveArtwork) { diff --git a/app/src/main/kotlin/com/subtracks/ui/theme/AnimatedArtworkColors.kt b/app/src/main/kotlin/com/subtracks/ui/theme/AnimatedArtworkColors.kt index e50a993b..7162b914 100644 --- a/app/src/main/kotlin/com/subtracks/ui/theme/AnimatedArtworkColors.kt +++ b/app/src/main/kotlin/com/subtracks/ui/theme/AnimatedArtworkColors.kt @@ -3,6 +3,7 @@ package com.subtracks.ui.theme import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -16,29 +17,35 @@ const val ARTWORK_THEME_TRANSITION_MS = 600 private const val TRANSITION_STEPS = 24 -internal val baseArtworkColors = - ArtworkColors( - scheme = SubtracksColorScheme, - gradientHigh = SubtracksColorScheme.surfaceContainerHigh, - gradientLow = SubtracksColorScheme.background, - accents = listOf(SubtracksColorScheme.surfaceContainerHighest, SubtracksColorScheme.surfaceContainerLow), - darkPrimary = SubtracksColorScheme.background, - blobSeed = 0, - ) +@Composable +internal fun baseArtworkColors(): ArtworkColors { + val scheme = MaterialTheme.colorScheme + return remember(scheme) { + ArtworkColors( + scheme = scheme, + gradientHigh = scheme.surfaceContainerHigh, + gradientLow = scheme.background, + accents = listOf(scheme.surfaceContainerHighest, scheme.surfaceContainerLow), + darkPrimary = scheme.background, + blobSeed = 0, + ) + } +} @Composable fun rememberAnimatedArtworkColors( target: ArtworkColors?, + base: ArtworkColors, durationMillis: Int = ARTWORK_THEME_TRANSITION_MS, ): ArtworkColors { - var from by remember { mutableStateOf(target ?: baseArtworkColors) } - var to by remember { mutableStateOf(target ?: baseArtworkColors) } + var from by remember { mutableStateOf(target ?: base) } + var to by remember { mutableStateOf(target ?: base) } val progress = remember { Animatable(1f) } - LaunchedEffect(target, durationMillis) { - val next = target ?: baseArtworkColors + LaunchedEffect(target, base, durationMillis) { + val next = target ?: base if (next !== to) { - if (to === baseArtworkColors) { + if (to === base) { from = next to = next } else { diff --git a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt index c85eb6d0..a1b1d90a 100644 --- a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt +++ b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt @@ -194,7 +194,8 @@ fun rememberArtworkColors( } } val target = remember(effective) { effective?.let { (primary, secondary) -> artworkColorsFromSeeds(primary, secondary) } } - val colors = rememberAnimatedArtworkColors(target, durationMillis) + val base = baseArtworkColors() + val colors = rememberAnimatedArtworkColors(target, base, durationMillis) return if (target == null) null else colors } diff --git a/app/src/main/kotlin/com/subtracks/ui/theme/Color.kt b/app/src/main/kotlin/com/subtracks/ui/theme/Color.kt deleted file mode 100644 index 91383b1f..00000000 --- a/app/src/main/kotlin/com/subtracks/ui/theme/Color.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.subtracks.ui.theme - -import androidx.compose.ui.graphics.Color - -val Black = Color(0xFF000000) -val NearBlack = Color(0xFF0A0A0A) -val DarkGrey = Color(0xFF1A1A1A) -val MidGrey = Color(0xFF2C2C2C) -val LightGrey = Color(0xFF9E9E9E) -val White = Color(0xFFFFFFFF) diff --git a/app/src/main/kotlin/com/subtracks/ui/theme/Theme.kt b/app/src/main/kotlin/com/subtracks/ui/theme/Theme.kt index c8e89bca..e7958ebd 100644 --- a/app/src/main/kotlin/com/subtracks/ui/theme/Theme.kt +++ b/app/src/main/kotlin/com/subtracks/ui/theme/Theme.kt @@ -1,40 +1,26 @@ package com.subtracks.ui.theme +import android.os.Build import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.runtime.Composable - -internal val SubtracksColorScheme = - darkColorScheme( - primary = White, - onPrimary = Black, - primaryContainer = White, - onPrimaryContainer = Black, - secondary = White, - onSecondary = Black, - secondaryContainer = White, - onSecondaryContainer = Black, - tertiary = White, - onTertiary = Black, - background = Black, - onBackground = White, - surface = Black, - onSurface = White, - surfaceVariant = DarkGrey, - onSurfaceVariant = LightGrey, - surfaceContainerLowest = Black, - surfaceContainerLow = NearBlack, - surfaceContainer = NearBlack, - surfaceContainerHigh = DarkGrey, - surfaceContainerHighest = MidGrey, - outline = MidGrey, - outlineVariant = DarkGrey, - ) +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext @Composable fun SubtracksTheme(content: @Composable () -> Unit) { + val context = LocalContext.current + val colorScheme = + remember(context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + dynamicDarkColorScheme(context) + } else { + darkColorScheme() + } + } MaterialTheme( - colorScheme = SubtracksColorScheme, + colorScheme = colorScheme, typography = SubtracksTypography, content = content, ) diff --git a/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkExtractionTest.kt b/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkExtractionTest.kt index cee4c823..5ff92070 100644 --- a/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkExtractionTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkExtractionTest.kt @@ -68,7 +68,9 @@ class ArtworkExtractionTest { val ref = CoverArtRef(url = "art", cacheKey = "art") var colors: ArtworkColors? = null composeRule.setContent { - colors = rememberArtworkColors(ref) + SubtracksTheme { + colors = rememberArtworkColors(ref) + } } composeRule.waitUntil(timeoutMillis = 5_000) { colors != null } assertNotNull(colors) @@ -99,15 +101,18 @@ class ArtworkExtractionTest { val artwork = mutableStateOf(null) val seen = mutableListOf() composeRule.setContent { - val colors = rememberAnimatedArtworkColors(artwork.value) - SideEffect { seen += colors.scheme.background } + SubtracksTheme { + val colors = rememberAnimatedArtworkColors(artwork.value, baseArtworkColors()) + SideEffect { seen += colors.scheme.background } + } } composeRule.waitForIdle() + val baseBackground = seen.first() composeRule.runOnIdle { artwork.value = target } composeRule.waitForIdle() - val firstPainting = seen.first { it != baseArtworkColors.scheme.background } + val firstPainting = seen.first { it != baseBackground } assertEquals(target.scheme.background, firstPainting) } diff --git a/app/src/test/kotlin/com/subtracks/ui/theme/ThemeTest.kt b/app/src/test/kotlin/com/subtracks/ui/theme/ThemeTest.kt new file mode 100644 index 00000000..fb13e629 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/theme/ThemeTest.kt @@ -0,0 +1,31 @@ +package com.subtracks.ui.theme + +import androidx.activity.ComponentActivity +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertNotEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ThemeTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun defaultSchemeIsNotMonochrome() { + var scheme: ColorScheme? = null + composeRule.setContent { + SubtracksTheme { + scheme = MaterialTheme.colorScheme + } + } + val resolved = requireNotNull(scheme) + assertNotEquals(Color.White, resolved.primary) + assertNotEquals(Color.Black, resolved.background) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index bc54c538..03952233 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -41,7 +41,7 @@ app/src/main/kotlin/com/subtracks playback/ Mini player and Now Playing screen settings/ Server list and add-server form components/ Cover art and empty/loading states - theme/ Material 3 theme (stark black/white) + theme/ Material 3 theme (Material You dynamic colour) playback/ PlaybackService.kt MediaSessionService hosting ExoPlayer PlaybackController.kt MediaController wrapper: state, queue, transport @@ -74,7 +74,7 @@ The library is one `LibraryScreen` with a pinned top bar: the current section as The Albums tab is a three-column grid of covers only — a later preference will toggle captions, and sorting will hang off a FAB, so Settings only manages servers and neither shows sort nor sync controls. -The theme is monochrome — black surface, white content and accent — with no colour extracted from cover art (that was a Flutter feature and is not ported yet). +The default theme is Material You: on Android 12+ the root scheme is the wallpaper's dynamic dark palette, falling back to the Material 3 baseline dark scheme elsewhere. Screens that show cover art layer an artwork-derived palette (`ArtworkTheme`) on top, and when no art is available they fall back to the same theme colours rather than a fixed monochrome scheme. ## Playback