tint the library header with the now playing surface colour

This commit is contained in:
austinried
2026-10-04 00:29:54 +00:00
parent 702bbe133a
commit da7af592d6
4 changed files with 182 additions and 164 deletions
+168 -161
View File
@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
@@ -55,6 +56,8 @@ import com.subtracks.ui.playback.NowPlayingRoute
import com.subtracks.ui.playback.QueueRoute import com.subtracks.ui.playback.QueueRoute
import com.subtracks.ui.settings.AddSourceRoute import com.subtracks.ui.settings.AddSourceRoute
import com.subtracks.ui.settings.SettingsRoute import com.subtracks.ui.settings.SettingsRoute
import com.subtracks.ui.theme.LocalPlayerSurfaceColor
import com.subtracks.ui.theme.playerSurfaceColor
import com.subtracks.ui.theme.rememberArtworkColors import com.subtracks.ui.theme.rememberArtworkColors
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -143,176 +146,180 @@ private fun MainNavigation() {
nowPlayingProgress = 0f nowPlayingProgress = 0f
} }
} }
val miniArt = playbackController.coverArt(playback.item, thumbnail = true)
val miniArtwork = rememberArtworkColors(miniArt)
val playerSurface = playerSurfaceColor(miniArtwork)
val density = LocalDensity.current val density = LocalDensity.current
val navBarInset = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() } val navBarInset = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
Box(Modifier.fillMaxSize()) { CompositionLocalProvider(LocalPlayerSurfaceColor provides playerSurface) {
Column(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
NavHost( Column(Modifier.fillMaxSize()) {
navController = navController, NavHost(
modifier = navController = navController,
Modifier modifier =
.weight(1f) Modifier
.then( .weight(1f)
if (playerVisible) { .then(
Modifier.consumeWindowInsets(WindowInsets.navigationBars) if (playerVisible) {
} else { Modifier.consumeWindowInsets(WindowInsets.navigationBars)
Modifier } else {
}, Modifier
),
startDestination = Routes.LIBRARY,
enterTransition = {
slideInHorizontally(initialOffsetX = { it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) +
fadeIn(tween(NAVIGATION_DURATION_MS))
},
exitTransition = {
slideOutHorizontally(targetOffsetX = { -it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) +
fadeOut(tween(NAVIGATION_DURATION_MS))
},
popEnterTransition = {
slideInHorizontally(initialOffsetX = { -it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) +
fadeIn(tween(NAVIGATION_DURATION_MS))
},
popExitTransition = {
slideOutHorizontally(targetOffsetX = { it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) +
fadeOut(tween(NAVIGATION_DURATION_MS))
},
) {
composable(Routes.LIBRARY) {
LibraryRoute(
onAlbumClick = { album -> navController.navigate(Routes.album(album.id, album.coverArt)) },
onArtistClick = { artist -> navController.navigate(Routes.artist(artist.id, artist.coverArt)) },
onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) },
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
bottomInset = if (playerVisible) 0.dp else navBarInset,
)
}
composable(Routes.SETTINGS) {
SettingsRoute(
onAddServer = { navController.navigate(Routes.ADD_SERVER) },
onBack = { navController.popBackStack() },
)
}
composable(
route = Routes.ALBUM_DETAIL,
arguments =
listOf(
navArgument("albumId") { type = NavType.StringType },
navArgument("coverArt") {
type = NavType.StringType
defaultValue = ""
},
),
) { entry ->
AlbumDetailRoute(
albumId = entry.arguments?.getString("albumId").orEmpty(),
coverArtId =
entry.arguments
?.getString("coverArt")
.orEmpty()
.ifEmpty { null },
onBack = { navController.popBackStack() },
)
}
composable(
route = Routes.ARTIST_DETAIL,
arguments =
listOf(
navArgument("artistId") { type = NavType.StringType },
navArgument("coverArt") {
type = NavType.StringType
defaultValue = ""
},
),
) { entry ->
ArtistDetailRoute(
artistId = entry.arguments?.getString("artistId").orEmpty(),
coverArtId =
entry.arguments
?.getString("coverArt")
.orEmpty()
.ifEmpty { null },
onBack = { navController.popBackStack() },
onAlbumClick = { album -> navController.navigate(Routes.album(album.id, album.coverArt)) },
)
}
composable(
route = Routes.PLAYLIST_DETAIL,
arguments = listOf(navArgument("playlistId") { type = NavType.StringType }),
) { entry ->
PlaylistDetailRoute(
playlistId = entry.arguments?.getString("playlistId").orEmpty(),
onBack = { navController.popBackStack() },
)
}
composable(Routes.ADD_SERVER) {
AddSourceRoute(
onSaved = { navController.popBackStack() },
onBack = { navController.popBackStack() },
)
}
}
if (playerVisible) {
val miniArt = playbackController.coverArt(playback.item, thumbnail = true)
MiniPlayer(
state = playback,
coverArt = miniArt,
artwork = rememberArtworkColors(miniArt),
onExpand = { settleNowPlaying(true) },
onPlayPause = playbackController::togglePlayPause,
onNext = playbackController::next,
onExpandDrag = { dragUpPx ->
nowPlayingOpen = true
settleJob?.cancel()
if (miniPlayerTopPx > 0f) {
nowPlayingProgress = (dragUpPx / miniPlayerTopPx).coerceIn(0f, 1f)
}
},
onExpandRelease = { velocity -> settleNowPlaying(nowPlayingProgress > 0.05f || velocity < -FLING_VELOCITY) },
modifier = Modifier.onGloballyPositioned { miniPlayerTopPx = it.positionInRoot().y },
)
}
}
if (nowPlayingOpen) {
Box(
modifier =
Modifier
.fillMaxSize()
.graphicsLayer {
translationY = (1f - nowPlayingProgress) * miniPlayerTopPx
alpha = (nowPlayingProgress / EXPAND_FADE).coerceAtMost(1f)
}.draggable(
orientation = Orientation.Vertical,
state =
rememberDraggableState { delta ->
settleJob?.cancel()
if (miniPlayerTopPx > 0f) {
nowPlayingProgress = (nowPlayingProgress - delta / miniPlayerTopPx).coerceIn(0f, 1f)
}
}, },
onDragStopped = { velocity -> settleNowPlaying(nowPlayingProgress > 0.9f && velocity < FLING_VELOCITY) }, ),
), startDestination = Routes.LIBRARY,
enterTransition = {
slideInHorizontally(initialOffsetX = { it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) +
fadeIn(tween(NAVIGATION_DURATION_MS))
},
exitTransition = {
slideOutHorizontally(targetOffsetX = { -it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) +
fadeOut(tween(NAVIGATION_DURATION_MS))
},
popEnterTransition = {
slideInHorizontally(initialOffsetX = { -it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) +
fadeIn(tween(NAVIGATION_DURATION_MS))
},
popExitTransition = {
slideOutHorizontally(targetOffsetX = { it / 4 }, animationSpec = tween(NAVIGATION_DURATION_MS)) +
fadeOut(tween(NAVIGATION_DURATION_MS))
},
) {
composable(Routes.LIBRARY) {
LibraryRoute(
onAlbumClick = { album -> navController.navigate(Routes.album(album.id, album.coverArt)) },
onArtistClick = { artist -> navController.navigate(Routes.artist(artist.id, artist.coverArt)) },
onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) },
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
bottomInset = if (playerVisible) 0.dp else navBarInset,
)
}
composable(Routes.SETTINGS) {
SettingsRoute(
onAddServer = { navController.navigate(Routes.ADD_SERVER) },
onBack = { navController.popBackStack() },
)
}
composable(
route = Routes.ALBUM_DETAIL,
arguments =
listOf(
navArgument("albumId") { type = NavType.StringType },
navArgument("coverArt") {
type = NavType.StringType
defaultValue = ""
},
),
) { entry ->
AlbumDetailRoute(
albumId = entry.arguments?.getString("albumId").orEmpty(),
coverArtId =
entry.arguments
?.getString("coverArt")
.orEmpty()
.ifEmpty { null },
onBack = { navController.popBackStack() },
)
}
composable(
route = Routes.ARTIST_DETAIL,
arguments =
listOf(
navArgument("artistId") { type = NavType.StringType },
navArgument("coverArt") {
type = NavType.StringType
defaultValue = ""
},
),
) { entry ->
ArtistDetailRoute(
artistId = entry.arguments?.getString("artistId").orEmpty(),
coverArtId =
entry.arguments
?.getString("coverArt")
.orEmpty()
.ifEmpty { null },
onBack = { navController.popBackStack() },
onAlbumClick = { album -> navController.navigate(Routes.album(album.id, album.coverArt)) },
)
}
composable(
route = Routes.PLAYLIST_DETAIL,
arguments = listOf(navArgument("playlistId") { type = NavType.StringType }),
) { entry ->
PlaylistDetailRoute(
playlistId = entry.arguments?.getString("playlistId").orEmpty(),
onBack = { navController.popBackStack() },
)
}
composable(Routes.ADD_SERVER) {
AddSourceRoute(
onSaved = { navController.popBackStack() },
onBack = { navController.popBackStack() },
)
}
}
if (playerVisible) {
MiniPlayer(
state = playback,
coverArt = miniArt,
artwork = miniArtwork,
onExpand = { settleNowPlaying(true) },
onPlayPause = playbackController::togglePlayPause,
onNext = playbackController::next,
onExpandDrag = { dragUpPx ->
nowPlayingOpen = true
settleJob?.cancel()
if (miniPlayerTopPx > 0f) {
nowPlayingProgress = (dragUpPx / miniPlayerTopPx).coerceIn(0f, 1f)
}
},
onExpandRelease = { velocity -> settleNowPlaying(nowPlayingProgress > 0.05f || velocity < -FLING_VELOCITY) },
modifier = Modifier.onGloballyPositioned { miniPlayerTopPx = it.positionInRoot().y },
)
}
}
if (nowPlayingOpen) {
Box(
modifier =
Modifier
.fillMaxSize()
.graphicsLayer {
translationY = (1f - nowPlayingProgress) * miniPlayerTopPx
alpha = (nowPlayingProgress / EXPAND_FADE).coerceAtMost(1f)
}.draggable(
orientation = Orientation.Vertical,
state =
rememberDraggableState { delta ->
settleJob?.cancel()
if (miniPlayerTopPx > 0f) {
nowPlayingProgress = (nowPlayingProgress - delta / miniPlayerTopPx).coerceIn(0f, 1f)
}
},
onDragStopped = { velocity -> settleNowPlaying(nowPlayingProgress > 0.9f && velocity < FLING_VELOCITY) },
),
) {
NowPlayingRoute(
onBack = { settleNowPlaying(false) },
onQueue = { showingQueue = true },
modifier = Modifier.fillMaxSize(),
)
}
}
AnimatedVisibility(
visible = showingQueue,
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(OVERLAY_DURATION_MS)),
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(OVERLAY_DURATION_MS)),
) { ) {
NowPlayingRoute( QueueRoute(
onBack = { settleNowPlaying(false) }, onBack = { showingQueue = false },
onQueue = { showingQueue = true },
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
} }
AnimatedVisibility(
visible = showingQueue,
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(OVERLAY_DURATION_MS)),
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(OVERLAY_DURATION_MS)),
) {
QueueRoute(
onBack = { showingQueue = false },
modifier = Modifier.fillMaxSize(),
)
}
} }
BackHandler(enabled = showingQueue) { showingQueue = false } BackHandler(enabled = showingQueue) { showingQueue = false }
@@ -51,6 +51,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
@@ -68,6 +69,7 @@ import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.data.model.SongListItem import com.subtracks.data.model.SongListItem
import com.subtracks.ui.theme.LocalPlayerSurfaceColor
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -167,6 +169,10 @@ fun LibraryScreen(
val titleFraction = scrollBehavior.state.collapsedFraction val titleFraction = scrollBehavior.state.collapsedFraction
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() } val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
val headerColor =
LocalPlayerSurfaceColor.current.let {
if (it.isSpecified) it else MaterialTheme.colorScheme.surfaceContainerHigh
}
Column( Column(
modifier = modifier =
@@ -179,7 +185,7 @@ fun LibraryScreen(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerHigh) .background(headerColor)
.padding(top = statusBarTop), .padding(top = statusBarTop),
) { ) {
Box( Box(
@@ -36,13 +36,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.playback.PlaybackState import com.subtracks.playback.PlaybackState
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.ArtworkTheme import com.subtracks.ui.theme.ArtworkTheme
import com.subtracks.ui.theme.playerSurfaceColor
@Composable @Composable
fun MiniPlayer( fun MiniPlayer(
@@ -63,7 +63,7 @@ fun MiniPlayer(
ArtworkTheme(artwork) { ArtworkTheme(artwork) {
Surface( Surface(
color = color =
artwork?.let { lerp(it.scheme.surfaceContainerHigh, it.gradientHigh, 0.5f) } artwork?.let(::playerSurfaceColor)
?: MaterialTheme.colorScheme.surfaceContainerHigh, ?: MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurface, contentColor = MaterialTheme.colorScheme.onSurface,
modifier = modifier =
@@ -7,6 +7,7 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -205,6 +206,10 @@ fun ArtworkTheme(
} }
} }
val LocalPlayerSurfaceColor = compositionLocalOf { Color.Unspecified }
fun playerSurfaceColor(artwork: ArtworkColors): Color = lerp(artwork.scheme.surfaceContainerHigh, artwork.gradientHigh, 0.5f)
@Composable @Composable
fun HeroGradient( fun HeroGradient(
colors: ArtworkColors?, colors: ArtworkColors?,