feat: new launcher icon + motion polish (adjacent-tab glide, animateItem rows, header crossfade, cheaper pressDip)
BIN
Satsamp Icon.png
Normal file
|
After Width: | Height: | Size: 960 KiB |
|
|
@ -1,5 +1,6 @@
|
||||||
package com.rada.ui.library
|
package com.rada.ui.library
|
||||||
|
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
|
@ -284,14 +285,18 @@ fun LibraryScreen(
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.padding(top = 4.dp, bottom = 2.dp),
|
.padding(top = 4.dp, bottom = 2.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
// Crossfade the header when the selected folder changes — a
|
||||||
text = headerTitle,
|
// hard text swap reads as a glitch when tabs are tapped.
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
Crossfade(targetState = headerTitle, label = "libraryHeader") { t ->
|
||||||
fontWeight = FontWeight.Bold,
|
Text(
|
||||||
color = LocalTokens.current.text,
|
text = t,
|
||||||
maxLines = 1,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
overflow = TextOverflow.Ellipsis,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
color = LocalTokens.current.text,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = "${uiState.songs.size} songs",
|
text = "${uiState.songs.size} songs",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
|
@ -477,8 +482,12 @@ fun LibraryScreen(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
items(uiState.songs, key = { it.id }) { song ->
|
items(uiState.songs, key = { it.id }) { song ->
|
||||||
|
// Keyed rows animate in/out when the folder tab or
|
||||||
|
// search filter changes instead of popping.
|
||||||
|
val rowMod = if (LocalReduceMotion.current) Modifier else Modifier.animateItem()
|
||||||
SongListItem(
|
SongListItem(
|
||||||
song = song,
|
song = song,
|
||||||
|
modifier = rowMod,
|
||||||
isPlaying = song.id == uiState.currentPlayingId,
|
isPlaying = song.id == uiState.currentPlayingId,
|
||||||
onClick = { viewModel.playSong(song) },
|
onClick = { viewModel.playSong(song) },
|
||||||
onToggleFavorite = { viewModel.toggleFavorite(song.id) },
|
onToggleFavorite = { viewModel.toggleFavorite(song.id) },
|
||||||
|
|
@ -768,10 +777,11 @@ private fun SongListItem(
|
||||||
onToggleFavorite: () -> Unit,
|
onToggleFavorite: () -> Unit,
|
||||||
onAddToPlaylist: () -> Unit,
|
onAddToPlaylist: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val interaction = remember { MutableInteractionSource() }
|
val interaction = remember { MutableInteractionSource() }
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.pressDip(interaction, LocalReduceMotion.current)
|
.pressDip(interaction, LocalReduceMotion.current)
|
||||||
.clickable(interactionSource = interaction, indication = androidx.compose.foundation.LocalIndication.current, onClick = onClick)
|
.clickable(interactionSource = interaction, indication = androidx.compose.foundation.LocalIndication.current, onClick = onClick)
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import com.rada.ui.player.NowPlayingBar
|
||||||
import com.rada.ui.playlists.PlaylistManagerScreen
|
import com.rada.ui.playlists.PlaylistManagerScreen
|
||||||
import com.rada.ui.stations.StationBrowserScreen
|
import com.rada.ui.stations.StationBrowserScreen
|
||||||
import com.rada.ui.streams.StreamsScreen
|
import com.rada.ui.streams.StreamsScreen
|
||||||
|
import com.rada.ui.theme.LocalReduceMotion
|
||||||
import com.rada.ui.theme.LocalTokens
|
import com.rada.ui.theme.LocalTokens
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
@ -49,14 +50,26 @@ fun TabsScreen(
|
||||||
) {
|
) {
|
||||||
val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, 4)) { 5 }
|
val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, 4)) { 5 }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val reduceMotion = LocalReduceMotion.current
|
||||||
// Deep-link target for Library (playlist picker → open that playlist).
|
// Deep-link target for Library (playlist picker → open that playlist).
|
||||||
var libraryPlaylistId by remember { mutableStateOf(initialPlaylistId) }
|
var libraryPlaylistId by remember { mutableStateOf(initialPlaylistId) }
|
||||||
// Tab taps land immediately. Swiping remains animated, but animating a
|
// Adjacent tabs glide (neighbors stay composed, so it's smooth); far
|
||||||
// tap composes and disposes heavy pages while the gesture is in flight.
|
// taps jump instantly — animating a multi-page hop composes heavy pages
|
||||||
|
// mid-flight and reintroduces the jank the jump was there to avoid.
|
||||||
fun goToPage(page: Int) {
|
fun goToPage(page: Int) {
|
||||||
if (pagerState.currentPage == page) return
|
if (pagerState.currentPage == page) return
|
||||||
scope.launch {
|
scope.launch {
|
||||||
pagerState.scrollToPage(page)
|
if (kotlin.math.abs(pagerState.currentPage - page) == 1 && !reduceMotion) {
|
||||||
|
pagerState.animateScrollToPage(
|
||||||
|
page,
|
||||||
|
animationSpec = androidx.compose.animation.core.tween(
|
||||||
|
durationMillis = 260,
|
||||||
|
easing = com.rada.ui.theme.MotionArrival,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
pagerState.scrollToPage(page)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.rada.ui.theme
|
package com.rada.ui.theme
|
||||||
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||||
|
|
@ -130,12 +131,15 @@ fun feedbackDuration(reduce: Boolean): Int = if (reduce) 0 else FeedbackMs
|
||||||
/**
|
/**
|
||||||
* Press-dip for list rows: scales to 0.97 while pressed. Use on rows that
|
* Press-dip for list rows: scales to 0.97 while pressed. Use on rows that
|
||||||
* already have a clickable/combinedClickable modifier earlier in the chain.
|
* already have a clickable/combinedClickable modifier earlier in the chain.
|
||||||
|
* (A plain @Composable extension — the old composed{} form spun up an extra
|
||||||
|
* subcomposition per row, wasted recomposition churn in long lists.)
|
||||||
*/
|
*/
|
||||||
|
@Composable
|
||||||
fun Modifier.pressDip(
|
fun Modifier.pressDip(
|
||||||
interactionSource: MutableInteractionSource,
|
interactionSource: MutableInteractionSource,
|
||||||
reduce: Boolean,
|
reduce: Boolean,
|
||||||
): Modifier = composed {
|
): Modifier {
|
||||||
if (reduce) return@composed this
|
if (reduce) return this
|
||||||
val pressed by interactionSource.collectIsPressedAsState()
|
val pressed by interactionSource.collectIsPressedAsState()
|
||||||
val scale by animateFloatAsState(
|
val scale by animateFloatAsState(
|
||||||
targetValue = if (pressed) 0.97f else 1f,
|
targetValue = if (pressed) 0.97f else 1f,
|
||||||
|
|
@ -145,7 +149,7 @@ fun Modifier.pressDip(
|
||||||
),
|
),
|
||||||
label = "pressDip",
|
label = "pressDip",
|
||||||
)
|
)
|
||||||
this.graphicsLayer { scaleX = scale; scaleY = scale }
|
return this.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dismiss-by-drag ───────────────────────────────────────────────────────
|
// ── Dismiss-by-drag ───────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 379 B After Width: | Height: | Size: 7 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 7 KiB |
|
Before Width: | Height: | Size: 264 B After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 485 B After Width: | Height: | Size: 11 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 707 B After Width: | Height: | Size: 22 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 975 B After Width: | Height: | Size: 37 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 37 KiB |