Hide floating action button when scrolling down and add scroll to top button to each screen

This commit is contained in:
vfsfitvnm 2022-10-06 16:14:08 +02:00
parent 78c44988d7
commit b30b282628
25 changed files with 902 additions and 573 deletions

View file

@ -0,0 +1,149 @@
package it.vfsfitvnm.vimusic.ui.components.themed
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.utils.isScrollingDown
import it.vfsfitvnm.vimusic.utils.isScrollingDownToIsFar
import it.vfsfitvnm.vimusic.utils.smoothScrollToTop
import kotlinx.coroutines.launch
@ExperimentalAnimationApi
@Composable
fun BoxScope.FloatingActionsContainerWithScrollToTop(
lazyGridState: LazyGridState,
modifier: Modifier = Modifier,
iconId: Int? = null,
onClick: (() -> Unit)? = null,
) {
val transitionState = remember {
MutableTransitionState(false to false)
}.apply { targetState = lazyGridState.isScrollingDownToIsFar() }
FloatingActions(
transitionState = transitionState,
onScrollToTop = lazyGridState::smoothScrollToTop,
iconId = iconId,
onClick = onClick,
modifier = modifier
)
}
@ExperimentalAnimationApi
@Composable
fun BoxScope.FloatingActionsContainerWithScrollToTop(
lazyListState: LazyListState,
modifier: Modifier = Modifier,
iconId: Int? = null,
onClick: (() -> Unit)? = null,
) {
val transitionState = remember {
MutableTransitionState(false to false)
}.apply { targetState = lazyListState.isScrollingDownToIsFar() }
FloatingActions(
transitionState = transitionState,
onScrollToTop = lazyListState::smoothScrollToTop,
iconId = iconId,
onClick = onClick,
modifier = modifier
)
}
@ExperimentalAnimationApi
@Composable
fun BoxScope.FloatingActionsContainerWithScrollToTop(
scrollState: ScrollState,
modifier: Modifier = Modifier,
iconId: Int? = null,
onClick: (() -> Unit)? = null,
) {
val transitionState = remember {
MutableTransitionState(false to false)
}.apply { targetState = scrollState.isScrollingDown() to false }
FloatingActions(
transitionState = transitionState,
iconId = iconId,
onClick = onClick,
modifier = modifier
)
}
@ExperimentalAnimationApi
@Composable
fun BoxScope.FloatingActions(
transitionState: MutableTransitionState<Pair<Boolean, Boolean>>,
modifier: Modifier = Modifier,
onScrollToTop: (suspend () -> Unit)? = null,
iconId: Int? = null,
onClick: (() -> Unit)? = null,
) {
val transition = updateTransition(transitionState, "FloatingActionsContainer")
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.Bottom,
modifier = modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp)
.padding(LocalPlayerAwarePaddingValues.current)
) {
onScrollToTop?.let {
transition.AnimatedVisibility(
visible = { it.first && it.second },
enter = slideInVertically(tween(500, if (iconId == null) 0 else 100)) { it },
exit = slideOutVertically(tween(500, 0)) { it },
) {
val coroutineScope = rememberCoroutineScope()
SecondaryButton(
onClick = {
coroutineScope.launch {
onScrollToTop()
}
},
iconId = R.drawable.chevron_up,
modifier = Modifier
.padding(bottom = 16.dp)
)
}
}
iconId?.let {
onClick?.let {
transition.AnimatedVisibility(
visible = { it.first },
enter = slideInVertically(tween(500, 0)) { it },
exit = slideOutVertically(tween(500, 100)) { it },
) {
PrimaryButton(
iconId = iconId,
onClick = onClick,
modifier = Modifier
.padding(bottom = 16.dp)
)
}
}
}
}
}

View file

@ -35,11 +35,11 @@ import it.vfsfitvnm.vimusic.utils.isLandscape
import it.vfsfitvnm.vimusic.utils.semiBold
@Composable
fun NavigationRail(
inline fun NavigationRail(
topIconButtonId: Int,
onTopIconButtonClick: () -> Unit,
noinline onTopIconButtonClick: () -> Unit,
tabIndex: Int,
onTabIndexChanged: (Int) -> Unit,
crossinline onTabIndexChanged: (Int) -> Unit,
content: @Composable ColumnScope.(@Composable (Int, String, Int) -> Unit) -> Unit,
modifier: Modifier = Modifier
) {
@ -144,7 +144,7 @@ fun NavigationRail(
}
}
private fun Modifier.vertical(enabled: Boolean = true) =
fun Modifier.vertical(enabled: Boolean = true) =
if (enabled)
layout { measurable, constraints ->
val placeable = measurable.measure(constraints)

View file

@ -5,8 +5,6 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
@ -16,11 +14,10 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
@Composable
fun BoxScope.PrimaryButton(
fun PrimaryButton(
onClick: () -> Unit,
@DrawableRes iconId: Int,
modifier: Modifier = Modifier,
@ -30,9 +27,6 @@ fun BoxScope.PrimaryButton(
Box(
modifier = modifier
.align(Alignment.BottomEnd)
.padding(all = 16.dp)
.padding(LocalPlayerAwarePaddingValues.current)
.clip(RoundedCornerShape(16.dp))
.clickable(enabled = isEnabled, onClick = onClick)
.background(colorPalette.background2)

View file

@ -1,6 +1,5 @@
package it.vfsfitvnm.vimusic.ui.components.themed
import android.annotation.SuppressLint
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.AnimatedVisibilityScope
@ -19,7 +18,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
@SuppressLint("ModifierParameter")
@ExperimentalAnimationApi
@Composable
fun Scaffold(
@ -28,8 +26,6 @@ fun Scaffold(
tabIndex: Int,
onTabChanged: (Int) -> Unit,
tabColumnContent: @Composable ColumnScope.(@Composable (Int, String, Int) -> Unit) -> Unit,
primaryIconButtonId: Int? = null,
onPrimaryIconButtonClick: () -> Unit = {},
modifier: Modifier = Modifier,
content: @Composable AnimatedVisibilityScope.(Int) -> Unit
) {
@ -69,14 +65,7 @@ fun Scaffold(
slideIntoContainer(slideDirection, animationSpec) with
slideOutOfContainer(slideDirection, animationSpec)
},
content = content,
)
}
primaryIconButtonId?.let {
PrimaryButton(
iconId = primaryIconButtonId,
onClick = onPrimaryIconButtonClick
content = content
)
}
}

View file

@ -1,83 +0,0 @@
package it.vfsfitvnm.vimusic.ui.components.themed
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.utils.smoothScrollToTop
import kotlinx.coroutines.launch
@Composable
fun ScrollToTop(
lazyListState: LazyListState,
modifier: Modifier = Modifier,
) {
val showScrollTopButton by remember {
derivedStateOf {
lazyListState.firstVisibleItemIndex > lazyListState.layoutInfo.visibleItemsInfo.size
}
}
ScrollToTop(
isVisible = showScrollTopButton,
onClick = lazyListState::smoothScrollToTop,
modifier = modifier
)
}
@Composable
private fun ScrollToTop(
isVisible: Boolean,
onClick: suspend () -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically { it },
exit = slideOutVertically { it },
modifier = modifier
) {
val coroutineScope = rememberCoroutineScope()
Box(
modifier = Modifier
.padding(all = 16.dp)
.padding(LocalPlayerAwarePaddingValues.current)
.clickable {
coroutineScope.launch {
onClick()
}
}
.size(32.dp)
) {
Image(
painter = painterResource(R.drawable.chevron_down),
contentDescription = null,
colorFilter = ColorFilter.tint(LocalAppearance.current.colorPalette.text),
modifier = Modifier
.align(Alignment.Center)
.rotate(180f)
.size(20.dp)
)
}
}
}

View file

@ -0,0 +1,44 @@
package it.vfsfitvnm.vimusic.ui.components.themed
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
@Composable
fun SecondaryButton(
onClick: () -> Unit,
@DrawableRes iconId: Int,
modifier: Modifier = Modifier,
isEnabled: Boolean = true,
) {
val (colorPalette) = LocalAppearance.current
Box(
modifier = modifier
.clip(CircleShape)
.clickable(enabled = isEnabled, onClick = onClick)
.background(colorPalette.background2)
.size(48.dp)
) {
Image(
painter = painterResource(iconId),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.align(Alignment.Center)
.size(18.dp)
)
}
}

View file

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -24,9 +25,9 @@ import it.vfsfitvnm.vimusic.models.DetailedSong
import it.vfsfitvnm.vimusic.savers.DetailedSongListSaver
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.ShimmerHost
import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop
import it.vfsfitvnm.vimusic.ui.components.themed.LayoutWithAdaptiveThumbnail
import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.PrimaryButton
import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton
import it.vfsfitvnm.vimusic.ui.items.SongItem
import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder
@ -68,9 +69,12 @@ fun AlbumSongs(
val thumbnailSizeDp = Dimensions.thumbnails.song
val lazyListState = rememberLazyListState()
LayoutWithAdaptiveThumbnail(thumbnailContent = thumbnailContent) {
Box {
LazyColumn(
state = lazyListState,
contentPadding = LocalPlayerAwarePaddingValues.current,
modifier = Modifier
.background(colorPalette.background0)
@ -152,14 +156,16 @@ fun AlbumSongs(
}
}
PrimaryButton(
FloatingActionsContainerWithScrollToTop(
lazyListState = lazyListState,
iconId = R.drawable.shuffle,
isEnabled = songs.isNotEmpty(),
onClick = {
binder?.stopRadio()
binder?.player?.forcePlayFromBeginning(
songs.shuffled().map(DetailedSong::asMediaItem)
)
if (songs.isNotEmpty()) {
binder?.stopRadio()
binder?.player?.forcePlayFromBeginning(
songs.shuffled().map(DetailedSong::asMediaItem)
)
}
}
)
}

View file

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
@ -21,9 +22,9 @@ import it.vfsfitvnm.vimusic.savers.DetailedSongListSaver
import it.vfsfitvnm.vimusic.savers.nullableSaver
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.ShimmerHost
import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop
import it.vfsfitvnm.vimusic.ui.components.themed.LayoutWithAdaptiveThumbnail
import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.PrimaryButton
import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton
import it.vfsfitvnm.vimusic.ui.items.SongItem
import it.vfsfitvnm.vimusic.ui.items.SongItemPlaceholder
@ -63,9 +64,12 @@ fun ArtistLocalSongs(
val songThumbnailSizeDp = Dimensions.thumbnails.song
val songThumbnailSizePx = songThumbnailSizeDp.px
val lazyListState = rememberLazyListState()
LayoutWithAdaptiveThumbnail(thumbnailContent = thumbnailContent) {
Box {
LazyColumn(
state = lazyListState,
contentPadding = LocalPlayerAwarePaddingValues.current,
modifier = Modifier
.background(colorPalette.background0)
@ -128,14 +132,18 @@ fun ArtistLocalSongs(
}
}
PrimaryButton(
FloatingActionsContainerWithScrollToTop(
lazyListState = lazyListState,
iconId = R.drawable.shuffle,
isEnabled = !songs.isNullOrEmpty(),
onClick = {
binder?.stopRadio()
binder?.player?.forcePlayFromBeginning(
songs!!.shuffled().map(DetailedSong::asMediaItem)
)
songs?.let { songs ->
if (songs.isNotEmpty()) {
binder?.stopRadio()
binder?.player?.forcePlayFromBeginning(
songs.shuffled().map(DetailedSong::asMediaItem)
)
}
}
}
)
}

View file

@ -26,9 +26,9 @@ import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.ShimmerHost
import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop
import it.vfsfitvnm.vimusic.ui.components.themed.LayoutWithAdaptiveThumbnail
import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.PrimaryButton
import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
import it.vfsfitvnm.vimusic.ui.items.AlbumItem
@ -70,6 +70,8 @@ fun ArtistOverview(
.padding(horizontal = 16.dp)
.padding(top = 24.dp, bottom = 8.dp)
val scrollState = rememberScrollState()
LayoutWithAdaptiveThumbnail(thumbnailContent = thumbnailContent) {
Box {
Column(
@ -77,7 +79,7 @@ fun ArtistOverview(
modifier = Modifier
.background(colorPalette.background0)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.verticalScroll(scrollState)
.padding(LocalPlayerAwarePaddingValues.current)
) {
headerContent {
@ -258,7 +260,8 @@ fun ArtistOverview(
}
youtubeArtistPage?.shuffleEndpoint?.let { shuffleEndpoint ->
PrimaryButton(
FloatingActionsContainerWithScrollToTop(
scrollState = scrollState,
iconId = R.drawable.shuffle,
onClick = {
binder?.stopRadio()

View file

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
@ -20,6 +21,7 @@ import it.vfsfitvnm.vimusic.enums.BuiltInPlaylist
import it.vfsfitvnm.vimusic.models.DetailedSong
import it.vfsfitvnm.vimusic.savers.DetailedSongListSaver
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop
import it.vfsfitvnm.vimusic.ui.components.themed.Header
import it.vfsfitvnm.vimusic.ui.components.themed.InFavoritesMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu
@ -70,8 +72,11 @@ fun BuiltInPlaylistSongs(builtInPlaylist: BuiltInPlaylist) {
val thumbnailSizeDp = Dimensions.thumbnails.song
val thumbnailSize = thumbnailSizeDp.px
val lazyListState = rememberLazyListState()
Box {
LazyColumn(
state = lazyListState,
contentPadding = LocalPlayerAwarePaddingValues.current,
modifier = Modifier
.background(colorPalette.background0)
@ -120,6 +125,7 @@ fun BuiltInPlaylistSongs(builtInPlaylist: BuiltInPlaylist) {
song = song,
onDismiss = menuState::hide
)
BuiltInPlaylist.Offline -> InHistoryMediaItemMenu(
song = song,
onDismiss = menuState::hide
@ -129,7 +135,10 @@ fun BuiltInPlaylistSongs(builtInPlaylist: BuiltInPlaylist) {
},
onClick = {
binder?.stopRadio()
binder?.player?.forcePlayAtIndex(songs.map(DetailedSong::asMediaItem), index)
binder?.player?.forcePlayAtIndex(
songs.map(DetailedSong::asMediaItem),
index
)
}
)
.animateItemPlacement()
@ -137,14 +146,16 @@ fun BuiltInPlaylistSongs(builtInPlaylist: BuiltInPlaylist) {
}
}
PrimaryButton(
FloatingActionsContainerWithScrollToTop(
lazyListState = lazyListState,
iconId = R.drawable.shuffle,
isEnabled = songs.isNotEmpty(),
onClick = {
binder?.stopRadio()
binder?.player?.forcePlayFromBeginning(
songs.shuffled().map(DetailedSong::asMediaItem)
)
if (songs.isNotEmpty()) {
binder?.stopRadio()
binder?.player?.forcePlayFromBeginning(
songs.shuffled().map(DetailedSong::asMediaItem)
)
}
}
)
}

View file

@ -7,11 +7,13 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
@ -25,6 +27,7 @@ import it.vfsfitvnm.vimusic.enums.AlbumSortBy
import it.vfsfitvnm.vimusic.enums.SortOrder
import it.vfsfitvnm.vimusic.models.Album
import it.vfsfitvnm.vimusic.savers.AlbumListSaver
import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop
import it.vfsfitvnm.vimusic.ui.components.themed.Header
import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton
import it.vfsfitvnm.vimusic.ui.items.AlbumItem
@ -42,7 +45,8 @@ import kotlinx.coroutines.flow.flowOn
@ExperimentalAnimationApi
@Composable
fun HomeAlbums(
onAlbumClick: (Album) -> Unit
onAlbumClick: (Album) -> Unit,
onSearchClick: () -> Unit,
) {
val (colorPalette) = LocalAppearance.current
@ -68,62 +72,73 @@ fun HomeAlbums(
animationSpec = tween(durationMillis = 400, easing = LinearEasing)
)
LazyColumn(
contentPadding = LocalPlayerAwarePaddingValues.current,
modifier = Modifier
.background(colorPalette.background0)
.fillMaxSize()
) {
item(
key = "header",
contentType = 0
val lazyListState = rememberLazyListState()
Box {
LazyColumn(
state = lazyListState,
contentPadding = LocalPlayerAwarePaddingValues.current,
modifier = Modifier
.background(colorPalette.background0)
.fillMaxSize()
) {
Header(title = "Albums") {
HeaderIconButton(
icon = R.drawable.calendar,
color = if (sortBy == AlbumSortBy.Year) colorPalette.text else colorPalette.textDisabled,
onClick = { sortBy = AlbumSortBy.Year }
)
item(
key = "header",
contentType = 0
) {
Header(title = "Albums") {
HeaderIconButton(
icon = R.drawable.calendar,
color = if (sortBy == AlbumSortBy.Year) colorPalette.text else colorPalette.textDisabled,
onClick = { sortBy = AlbumSortBy.Year }
)
HeaderIconButton(
icon = R.drawable.text,
color = if (sortBy == AlbumSortBy.Title) colorPalette.text else colorPalette.textDisabled,
onClick = { sortBy = AlbumSortBy.Title }
)
HeaderIconButton(
icon = R.drawable.text,
color = if (sortBy == AlbumSortBy.Title) colorPalette.text else colorPalette.textDisabled,
onClick = { sortBy = AlbumSortBy.Title }
)
HeaderIconButton(
icon = R.drawable.time,
color = if (sortBy == AlbumSortBy.DateAdded) colorPalette.text else colorPalette.textDisabled,
onClick = { sortBy = AlbumSortBy.DateAdded }
)
HeaderIconButton(
icon = R.drawable.time,
color = if (sortBy == AlbumSortBy.DateAdded) colorPalette.text else colorPalette.textDisabled,
onClick = { sortBy = AlbumSortBy.DateAdded }
)
Spacer(
Spacer(
modifier = Modifier
.width(2.dp)
)
HeaderIconButton(
icon = R.drawable.arrow_up,
color = colorPalette.text,
onClick = { sortOrder = !sortOrder },
modifier = Modifier
.graphicsLayer { rotationZ = sortOrderIconRotation }
)
}
}
items(
items = items,
key = Album::id
) { album ->
AlbumItem(
album = album,
thumbnailSizePx = thumbnailSizePx,
thumbnailSizeDp = thumbnailSizeDp,
modifier = Modifier
.width(2.dp)
)
HeaderIconButton(
icon = R.drawable.arrow_up,
color = colorPalette.text,
onClick = { sortOrder = !sortOrder },
modifier = Modifier
.graphicsLayer { rotationZ = sortOrderIconRotation }
.clickable(onClick = { onAlbumClick(album) })
.animateItemPlacement()
)
}
}
items(
items = items,
key = Album::id
) { album ->
AlbumItem(
album = album,
thumbnailSizePx = thumbnailSizePx,
thumbnailSizeDp = thumbnailSizeDp,
modifier = Modifier
.clickable(onClick = { onAlbumClick(album) })
.animateItemPlacement()
)
}
FloatingActionsContainerWithScrollToTop(
lazyListState = lazyListState,
iconId = R.drawable.search,
onClick = onSearchClick
)
}
}

View file

@ -8,6 +8,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
@ -15,6 +16,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
@ -29,6 +31,7 @@ import it.vfsfitvnm.vimusic.enums.ArtistSortBy
import it.vfsfitvnm.vimusic.enums.SortOrder
import it.vfsfitvnm.vimusic.models.Artist
import it.vfsfitvnm.vimusic.savers.ArtistListSaver
import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop
import it.vfsfitvnm.vimusic.ui.components.themed.Header
import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton
import it.vfsfitvnm.vimusic.ui.items.ArtistItem
@ -46,7 +49,8 @@ import kotlinx.coroutines.flow.flowOn
@ExperimentalAnimationApi
@Composable
fun HomeArtistList(
onArtistClick: (Artist) -> Unit
onArtistClick: (Artist) -> Unit,
onSearchClick: () -> Unit,
) {
val (colorPalette) = LocalAppearance.current
@ -72,61 +76,72 @@ fun HomeArtistList(
animationSpec = tween(durationMillis = 400, easing = LinearEasing)
)
LazyVerticalGrid(
columns = GridCells.Adaptive(Dimensions.thumbnails.song * 2 + Dimensions.itemsVerticalPadding * 2),
contentPadding = LocalPlayerAwarePaddingValues.current,
verticalArrangement = Arrangement.spacedBy(Dimensions.itemsVerticalPadding * 2),
horizontalArrangement = Arrangement.spacedBy(
space = Dimensions.itemsVerticalPadding * 2,
alignment = Alignment.CenterHorizontally
),
modifier = Modifier
.background(colorPalette.background0)
.fillMaxSize()
) {
item(
key = "header",
contentType = 0,
span = { GridItemSpan(maxLineSpan) }
val lazyGridState = rememberLazyGridState()
Box {
LazyVerticalGrid(
state = lazyGridState,
columns = GridCells.Adaptive(Dimensions.thumbnails.song * 2 + Dimensions.itemsVerticalPadding * 2),
contentPadding = LocalPlayerAwarePaddingValues.current,
verticalArrangement = Arrangement.spacedBy(Dimensions.itemsVerticalPadding * 2),
horizontalArrangement = Arrangement.spacedBy(
space = Dimensions.itemsVerticalPadding * 2,
alignment = Alignment.CenterHorizontally
),
modifier = Modifier
.background(colorPalette.background0)
.fillMaxSize()
) {
Header(title = "Artists") {
HeaderIconButton(
icon = R.drawable.text,
color = if (sortBy == ArtistSortBy.Name) colorPalette.text else colorPalette.textDisabled,
onClick = { sortBy = ArtistSortBy.Name }
)
item(
key = "header",
contentType = 0,
span = { GridItemSpan(maxLineSpan) }
) {
Header(title = "Artists") {
HeaderIconButton(
icon = R.drawable.text,
color = if (sortBy == ArtistSortBy.Name) colorPalette.text else colorPalette.textDisabled,
onClick = { sortBy = ArtistSortBy.Name }
)
HeaderIconButton(
icon = R.drawable.time,
color = if (sortBy == ArtistSortBy.DateAdded) colorPalette.text else colorPalette.textDisabled,
onClick = { sortBy = ArtistSortBy.DateAdded }
)
HeaderIconButton(
icon = R.drawable.time,
color = if (sortBy == ArtistSortBy.DateAdded) colorPalette.text else colorPalette.textDisabled,
onClick = { sortBy = ArtistSortBy.DateAdded }
)
Spacer(
Spacer(
modifier = Modifier
.width(2.dp)
)
HeaderIconButton(
icon = R.drawable.arrow_up,
color = colorPalette.text,
onClick = { sortOrder = !sortOrder },
modifier = Modifier
.graphicsLayer { rotationZ = sortOrderIconRotation }
)
}
}
items(items = items, key = Artist::id) { artist ->
ArtistItem(
artist = artist,
thumbnailSizePx = thumbnailSizePx,
thumbnailSizeDp = thumbnailSizeDp,
alternative = true,
modifier = Modifier
.width(2.dp)
)
HeaderIconButton(
icon = R.drawable.arrow_up,
color = colorPalette.text,
onClick = { sortOrder = !sortOrder },
modifier = Modifier
.graphicsLayer { rotationZ = sortOrderIconRotation }
.clickable(onClick = { onArtistClick(artist) })
.animateItemPlacement()
)
}
}
items(items = items, key = Artist::id) { artist ->
ArtistItem(
artist = artist,
thumbnailSizePx = thumbnailSizePx,
thumbnailSizeDp = thumbnailSizeDp,
alternative = true,
modifier = Modifier
.clickable(onClick = { onArtistClick(artist) })
.animateItemPlacement()
)
}
FloatingActionsContainerWithScrollToTop(
lazyGridState = lazyGridState,
iconId = R.drawable.search,
onClick = onSearchClick
)
}
}

View file

@ -1,5 +1,6 @@
package it.vfsfitvnm.vimusic.ui.screens.home
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
@ -7,6 +8,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
@ -14,6 +16,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -32,6 +35,7 @@ import it.vfsfitvnm.vimusic.enums.SortOrder
import it.vfsfitvnm.vimusic.models.Playlist
import it.vfsfitvnm.vimusic.query
import it.vfsfitvnm.vimusic.savers.PlaylistPreviewListSaver
import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop
import it.vfsfitvnm.vimusic.ui.components.themed.Header
import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton
import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton
@ -47,11 +51,13 @@ import it.vfsfitvnm.vimusic.utils.rememberPreference
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
@ExperimentalAnimationApi
@ExperimentalFoundationApi
@Composable
fun HomePlaylists(
onBuiltInPlaylist: (BuiltInPlaylist) -> Unit,
onPlaylistClick: (Playlist) -> Unit,
onSearchClick: () -> Unit,
) {
val (colorPalette) = LocalAppearance.current
@ -95,101 +101,112 @@ fun HomePlaylists(
val thumbnailSizeDp = 108.dp
val thumbnailSizePx = thumbnailSizeDp.px
LazyVerticalGrid(
columns = GridCells.Adaptive(Dimensions.thumbnails.song * 2 + Dimensions.itemsVerticalPadding * 2),
contentPadding = LocalPlayerAwarePaddingValues.current,
verticalArrangement = Arrangement.spacedBy(Dimensions.itemsVerticalPadding * 2),
horizontalArrangement = Arrangement.spacedBy(
space = Dimensions.itemsVerticalPadding * 2,
alignment = Alignment.CenterHorizontally
),
modifier = Modifier
.fillMaxSize()
.background(colorPalette.background0)
) {
item(key = "header", contentType = 0, span = { GridItemSpan(maxLineSpan) }) {
Header(title = "Playlists") {
SecondaryTextButton(
text = "New playlist",
onClick = { isCreatingANewPlaylist = true }
)
val lazyGridState = rememberLazyGridState()
Spacer(
Box {
LazyVerticalGrid(
state = lazyGridState,
columns = GridCells.Adaptive(Dimensions.thumbnails.song * 2 + Dimensions.itemsVerticalPadding * 2),
contentPadding = LocalPlayerAwarePaddingValues.current,
verticalArrangement = Arrangement.spacedBy(Dimensions.itemsVerticalPadding * 2),
horizontalArrangement = Arrangement.spacedBy(
space = Dimensions.itemsVerticalPadding * 2,
alignment = Alignment.CenterHorizontally
),
modifier = Modifier
.fillMaxSize()
.background(colorPalette.background0)
) {
item(key = "header", contentType = 0, span = { GridItemSpan(maxLineSpan) }) {
Header(title = "Playlists") {
SecondaryTextButton(
text = "New playlist",
onClick = { isCreatingANewPlaylist = true }
)
Spacer(
modifier = Modifier
.weight(1f)
)
HeaderIconButton(
icon = R.drawable.medical,
color = if (sortBy == PlaylistSortBy.SongCount) colorPalette.text else colorPalette.textDisabled,
onClick = { sortBy = PlaylistSortBy.SongCount }
)
HeaderIconButton(
icon = R.drawable.text,
color = if (sortBy == PlaylistSortBy.Name) colorPalette.text else colorPalette.textDisabled,
onClick = { sortBy = PlaylistSortBy.Name }
)
HeaderIconButton(
icon = R.drawable.time,
color = if (sortBy == PlaylistSortBy.DateAdded) colorPalette.text else colorPalette.textDisabled,
onClick = { sortBy = PlaylistSortBy.DateAdded }
)
Spacer(
modifier = Modifier
.width(2.dp)
)
HeaderIconButton(
icon = R.drawable.arrow_up,
color = colorPalette.text,
onClick = { sortOrder = !sortOrder },
modifier = Modifier
.graphicsLayer { rotationZ = sortOrderIconRotation }
)
}
}
item(key = "favorites") {
PlaylistItem(
icon = R.drawable.heart,
colorTint = colorPalette.red,
name = "Favorites",
songCount = null,
thumbnailSizeDp = thumbnailSizeDp,
alternative = true,
modifier = Modifier
.weight(1f)
.clickable(onClick = { onBuiltInPlaylist(BuiltInPlaylist.Favorites) })
.animateItemPlacement()
)
}
HeaderIconButton(
icon = R.drawable.medical,
color = if (sortBy == PlaylistSortBy.SongCount) colorPalette.text else colorPalette.textDisabled,
onClick = { sortBy = PlaylistSortBy.SongCount }
)
HeaderIconButton(
icon = R.drawable.text,
color = if (sortBy == PlaylistSortBy.Name) colorPalette.text else colorPalette.textDisabled,
onClick = { sortBy = PlaylistSortBy.Name }
)
HeaderIconButton(
icon = R.drawable.time,
color = if (sortBy == PlaylistSortBy.DateAdded) colorPalette.text else colorPalette.textDisabled,
onClick = { sortBy = PlaylistSortBy.DateAdded }
)
Spacer(
item(key = "offline") {
PlaylistItem(
icon = R.drawable.airplane,
colorTint = colorPalette.blue,
name = "Offline",
songCount = null,
thumbnailSizeDp = thumbnailSizeDp,
alternative = true,
modifier = Modifier
.width(2.dp)
.clickable(onClick = { onBuiltInPlaylist(BuiltInPlaylist.Offline) })
.animateItemPlacement()
)
}
HeaderIconButton(
icon = R.drawable.arrow_up,
color = colorPalette.text,
onClick = { sortOrder = !sortOrder },
items(items = items, key = { it.playlist.id }) { playlistPreview ->
PlaylistItem(
playlist = playlistPreview,
thumbnailSizeDp = thumbnailSizeDp,
thumbnailSizePx = thumbnailSizePx,
alternative = true,
modifier = Modifier
.graphicsLayer { rotationZ = sortOrderIconRotation }
.clickable(onClick = { onPlaylistClick(playlistPreview.playlist) })
.animateItemPlacement()
)
}
}
item(key = "favorites") {
PlaylistItem(
icon = R.drawable.heart,
colorTint = colorPalette.red,
name = "Favorites",
songCount = null,
thumbnailSizeDp = thumbnailSizeDp,
alternative = true,
modifier = Modifier
.clickable(onClick = { onBuiltInPlaylist(BuiltInPlaylist.Favorites) })
.animateItemPlacement()
)
}
item(key = "offline") {
PlaylistItem(
icon = R.drawable.airplane,
colorTint = colorPalette.blue,
name = "Offline",
songCount = null,
thumbnailSizeDp = thumbnailSizeDp,
alternative = true,
modifier = Modifier
.clickable(onClick = { onBuiltInPlaylist(BuiltInPlaylist.Offline) })
.animateItemPlacement()
)
}
items(items = items, key = { it.playlist.id }) { playlistPreview ->
PlaylistItem(
playlist = playlistPreview,
thumbnailSizeDp = thumbnailSizeDp,
thumbnailSizePx = thumbnailSizePx,
alternative = true,
modifier = Modifier
.clickable(onClick = { onPlaylistClick(playlistPreview.playlist) })
.animateItemPlacement()
)
}
FloatingActionsContainerWithScrollToTop(
lazyGridState = lazyGridState,
iconId = R.drawable.search,
onClick = onSearchClick
)
}
}

View file

@ -114,8 +114,6 @@ fun HomeScreen(onPlaylistUrl: (String) -> Unit) {
Item(3, "Artists", R.drawable.person)
Item(4, "Albums", R.drawable.disc)
},
primaryIconButtonId = R.drawable.search,
onPrimaryIconButtonClick = { searchRoute("") }
) { currentTabIndex ->
saveableStateHolder.SaveableStateProvider(key = currentTabIndex) {
when (currentTabIndex) {
@ -123,14 +121,24 @@ fun HomeScreen(onPlaylistUrl: (String) -> Unit) {
onAlbumClick = { albumRoute(it) },
onArtistClick = { artistRoute(it) },
onPlaylistClick = { playlistRoute(it) },
onSearchClick = { searchRoute("") }
)
1 -> HomeSongs(
onSearchClick = { searchRoute("") }
)
1 -> HomeSongs()
2 -> HomePlaylists(
onBuiltInPlaylist = { builtInPlaylistRoute(it) },
onPlaylistClick = { localPlaylistRoute(it.id) }
onPlaylistClick = { localPlaylistRoute(it.id) },
onSearchClick = { searchRoute("") }
)
3 -> HomeArtistList(
onArtistClick = { artistRoute(it.id) },
onSearchClick = { searchRoute("") }
)
4 -> HomeAlbums(
onAlbumClick = { albumRoute(it.id) },
onSearchClick = { searchRoute("") }
)
3 -> HomeArtistList(onArtistClick = { artistRoute(it.id) })
4 -> HomeAlbums(onAlbumClick = { albumRoute(it.id) })
}
}
}

View file

@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
@ -37,10 +36,10 @@ import it.vfsfitvnm.vimusic.enums.SortOrder
import it.vfsfitvnm.vimusic.models.DetailedSong
import it.vfsfitvnm.vimusic.savers.DetailedSongListSaver
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop
import it.vfsfitvnm.vimusic.ui.components.themed.Header
import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton
import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.ScrollToTop
import it.vfsfitvnm.vimusic.ui.items.SongItem
import it.vfsfitvnm.vimusic.ui.styling.Dimensions
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
@ -62,7 +61,9 @@ import kotlinx.coroutines.flow.flowOn
@ExperimentalFoundationApi
@ExperimentalAnimationApi
@Composable
fun HomeSongs() {
fun HomeSongs(
onSearchClick: () -> Unit
) {
val (colorPalette, typography, thumbnailShape) = LocalAppearance.current
val binder = LocalPlayerServiceBinder.current
val menuState = LocalMenuState.current
@ -187,11 +188,10 @@ fun HomeSongs() {
}
}
ScrollToTop(
FloatingActionsContainerWithScrollToTop(
lazyListState = lazyListState,
modifier = Modifier
.offset(x = Dimensions.navigationRailIconOffset - Dimensions.navigationRailWidth)
.align(Alignment.BottomStart)
iconId = R.drawable.search,
onClick = onSearchClick
)
}
}

View file

@ -43,6 +43,7 @@ import it.vfsfitvnm.vimusic.savers.nullableSaver
import it.vfsfitvnm.vimusic.savers.resultSaver
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.ShimmerHost
import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop
import it.vfsfitvnm.vimusic.ui.components.themed.Header
import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
@ -81,6 +82,7 @@ fun QuickPicks(
onAlbumClick: (String) -> Unit,
onArtistClick: (String) -> Unit,
onPlaylistClick: (String) -> Unit,
onSearchClick: () -> Unit,
) {
val (colorPalette, typography) = LocalAppearance.current
val binder = LocalPlayerServiceBinder.current
@ -129,6 +131,8 @@ fun QuickPicks(
)
}
val scrollState = rememberScrollState()
BoxWithConstraints {
val itemInHorizontalGridWidth = maxWidth * quickPicksLazyGridItemWidthFactor
@ -136,7 +140,7 @@ fun QuickPicks(
modifier = Modifier
.background(colorPalette.background0)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.verticalScroll(scrollState)
.padding(LocalPlayerAwarePaddingValues.current)
) {
Header(title = "Quick picks")
@ -345,5 +349,11 @@ fun QuickPicks(
}
}
}
FloatingActionsContainerWithScrollToTop(
scrollState = scrollState,
iconId = R.drawable.search,
onClick = onSearchClick
)
}
}

View file

@ -35,13 +35,13 @@ import it.vfsfitvnm.vimusic.savers.nullableSaver
import it.vfsfitvnm.vimusic.transaction
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.themed.ConfirmationDialog
import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop
import it.vfsfitvnm.vimusic.ui.components.themed.Header
import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton
import it.vfsfitvnm.vimusic.ui.components.themed.IconButton
import it.vfsfitvnm.vimusic.ui.components.themed.InPlaylistMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.Menu
import it.vfsfitvnm.vimusic.ui.components.themed.MenuEntry
import it.vfsfitvnm.vimusic.ui.components.themed.PrimaryButton
import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton
import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog
import it.vfsfitvnm.vimusic.ui.items.SongItem
@ -274,17 +274,18 @@ fun LocalPlaylistSongs(
}
}
PrimaryButton(
FloatingActionsContainerWithScrollToTop(
lazyListState = lazyListState,
iconId = R.drawable.shuffle,
isEnabled = playlistWithSongs?.songs?.isNotEmpty() == true,
onClick = {
playlistWithSongs?.songs
?.shuffled()
?.map(DetailedSong::asMediaItem)
?.let { mediaItems ->
playlistWithSongs?.songs?.let { songs ->
if (songs.isNotEmpty()) {
binder?.stopRadio()
binder?.player?.forcePlayFromBeginning(mediaItems)
binder?.player?.forcePlayFromBeginning(
songs.shuffled().map(DetailedSong::asMediaItem)
)
}
}
}
)
}

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.saveable.autoSaver
@ -29,12 +30,12 @@ import it.vfsfitvnm.vimusic.savers.nullableSaver
import it.vfsfitvnm.vimusic.transaction
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.ShimmerHost
import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop
import it.vfsfitvnm.vimusic.ui.components.themed.Header
import it.vfsfitvnm.vimusic.ui.components.themed.HeaderIconButton
import it.vfsfitvnm.vimusic.ui.components.themed.HeaderPlaceholder
import it.vfsfitvnm.vimusic.ui.components.themed.LayoutWithAdaptiveThumbnail
import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.PrimaryButton
import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton
import it.vfsfitvnm.vimusic.ui.components.themed.adaptiveThumbnailContent
import it.vfsfitvnm.vimusic.ui.items.SongItem
@ -62,7 +63,7 @@ import kotlinx.coroutines.withContext
fun PlaylistSongList(
browseId: String,
) {
val (colorPalette, typography, thumbnailShape) = LocalAppearance.current
val (colorPalette) = LocalAppearance.current
val binder = LocalPlayerServiceBinder.current
val context = LocalContext.current
val menuState = LocalMenuState.current
@ -162,9 +163,12 @@ fun PlaylistSongList(
val thumbnailContent = adaptiveThumbnailContent(playlistPage == null, playlistPage?.thumbnail?.url)
val lazyListState = rememberLazyListState()
LayoutWithAdaptiveThumbnail(thumbnailContent = thumbnailContent) {
Box {
LazyColumn(
state = lazyListState,
contentPadding = LocalPlayerAwarePaddingValues.current,
modifier = Modifier
.background(colorPalette.background0)
@ -219,13 +223,17 @@ fun PlaylistSongList(
}
}
PrimaryButton(
FloatingActionsContainerWithScrollToTop(
lazyListState = lazyListState,
iconId = R.drawable.shuffle,
isEnabled = playlistPage?.songsPage?.items?.isNotEmpty() == true,
onClick = {
playlistPage?.songsPage?.items?.map(Innertube.SongItem::asMediaItem)?.let { mediaItems ->
binder?.stopRadio()
binder?.player?.forcePlayFromBeginning(mediaItems.shuffled())
playlistPage?.songsPage?.items?.let { songs ->
if (songs.isNotEmpty()) {
binder?.stopRadio()
binder?.player?.forcePlayFromBeginning(
songs.shuffled().map(Innertube.SongItem::asMediaItem)
)
}
}
}
)

View file

@ -3,9 +3,11 @@ package it.vfsfitvnm.vimusic.ui.screens.search
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
@ -21,6 +23,7 @@ import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.models.DetailedSong
import it.vfsfitvnm.vimusic.savers.DetailedSongListSaver
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop
import it.vfsfitvnm.vimusic.ui.components.themed.Header
import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton
@ -65,68 +68,75 @@ fun LocalSongSearch(
val thumbnailSizeDp = Dimensions.thumbnails.song
val thumbnailSizePx = thumbnailSizeDp.px
LazyColumn(
contentPadding = LocalPlayerAwarePaddingValues.current,
modifier = Modifier
.fillMaxSize()
) {
item(
key = "header",
contentType = 0
) {
Header(
titleContent = {
BasicTextField(
value = textFieldValue,
onValueChange = onTextFieldValueChanged,
textStyle = typography.xxl.medium.align(TextAlign.End),
singleLine = true,
maxLines = 1,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
cursorBrush = SolidColor(colorPalette.text),
decorationBox = decorationBox
)
},
actionsContent = {
if (textFieldValue.text.isNotEmpty()) {
SecondaryTextButton(
text = "Clear",
onClick = { onTextFieldValueChanged(TextFieldValue()) }
)
}
}
)
}
val lazyListState = rememberLazyListState()
items(
items = items,
key = DetailedSong::id,
) { song ->
SongItem(
song = song,
thumbnailSizePx = thumbnailSizePx,
thumbnailSizeDp = thumbnailSizeDp,
modifier = Modifier
.combinedClickable(
onLongClick = {
menuState.display {
InHistoryMediaItemMenu(
song = song,
onDismiss = menuState::hide
)
}
},
onClick = {
val mediaItem = song.asMediaItem
binder?.stopRadio()
binder?.player?.forcePlay(mediaItem)
binder?.setupRadio(
NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId)
Box {
LazyColumn(
state = lazyListState,
contentPadding = LocalPlayerAwarePaddingValues.current,
modifier = Modifier
.fillMaxSize()
) {
item(
key = "header",
contentType = 0
) {
Header(
titleContent = {
BasicTextField(
value = textFieldValue,
onValueChange = onTextFieldValueChanged,
textStyle = typography.xxl.medium.align(TextAlign.End),
singleLine = true,
maxLines = 1,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
cursorBrush = SolidColor(colorPalette.text),
decorationBox = decorationBox
)
},
actionsContent = {
if (textFieldValue.text.isNotEmpty()) {
SecondaryTextButton(
text = "Clear",
onClick = { onTextFieldValueChanged(TextFieldValue()) }
)
}
)
.animateItemPlacement()
)
}
)
}
items(
items = items,
key = DetailedSong::id,
) { song ->
SongItem(
song = song,
thumbnailSizePx = thumbnailSizePx,
thumbnailSizeDp = thumbnailSizeDp,
modifier = Modifier
.combinedClickable(
onLongClick = {
menuState.display {
InHistoryMediaItemMenu(
song = song,
onDismiss = menuState::hide
)
}
},
onClick = {
val mediaItem = song.asMediaItem
binder?.stopRadio()
binder?.player?.forcePlay(mediaItem)
binder?.setupRadio(
NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId)
)
}
)
.animateItemPlacement()
)
}
}
FloatingActionsContainerWithScrollToTop(lazyListState = lazyListState)
}
}

View file

@ -1,5 +1,6 @@
package it.vfsfitvnm.vimusic.ui.screens.search
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
@ -45,6 +47,7 @@ import it.vfsfitvnm.vimusic.query
import it.vfsfitvnm.vimusic.savers.SearchQuerySaver
import it.vfsfitvnm.vimusic.savers.listSaver
import it.vfsfitvnm.vimusic.savers.resultSaver
import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop
import it.vfsfitvnm.vimusic.ui.components.themed.Header
import it.vfsfitvnm.vimusic.ui.components.themed.SecondaryTextButton
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
@ -62,6 +65,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
@ExperimentalAnimationApi
@Composable
fun OnlineSearch(
textFieldValue: TextFieldValue,
@ -112,139 +116,74 @@ fun OnlineSearch(
FocusRequester()
}
LazyColumn(
contentPadding = LocalPlayerAwarePaddingValues.current,
modifier = Modifier
.fillMaxSize()
) {
item(
key = "header",
contentType = 0
val lazyListState = rememberLazyListState()
Box {
LazyColumn(
state = lazyListState,
contentPadding = LocalPlayerAwarePaddingValues.current,
modifier = Modifier
.fillMaxSize()
) {
Header(
titleContent = {
BasicTextField(
value = textFieldValue,
onValueChange = onTextFieldValueChanged,
textStyle = typography.xxl.medium.align(TextAlign.End),
singleLine = true,
maxLines = 1,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(
onSearch = {
if (textFieldValue.text.isNotEmpty()) {
onSearch(textFieldValue.text)
}
}
),
cursorBrush = SolidColor(colorPalette.text),
decorationBox = decorationBox,
modifier = Modifier
.focusRequester(focusRequester)
)
},
actionsContent = {
if (playlistId != null) {
val isAlbum = playlistId.startsWith("OLAK5uy_")
SecondaryTextButton(
text = "View ${if (isAlbum) "album" else "playlist"}",
onClick = { onViewPlaylist(textFieldValue.text) }
)
}
Spacer(
modifier = Modifier
.weight(1f)
)
if (textFieldValue.text.isNotEmpty()) {
SecondaryTextButton(
text = "Clear",
onClick = { onTextFieldValueChanged(TextFieldValue()) }
)
}
}
)
}
items(
items = history,
key = SearchQuery::id
) { searchQuery ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(onClick = { onSearch(searchQuery.query) })
.fillMaxWidth()
.padding(all = 16.dp)
item(
key = "header",
contentType = 0
) {
Spacer(
modifier = Modifier
.padding(horizontal = 8.dp)
.size(20.dp)
.paint(
painter = timeIconPainter,
colorFilter = ColorFilter.tint(colorPalette.textDisabled)
)
)
BasicText(
text = searchQuery.query,
style = typography.s.secondary,
modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1f)
)
Image(
painter = closeIconPainter,
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.textDisabled),
modifier = Modifier
.clickable(
indication = rippleIndication,
interactionSource = remember { MutableInteractionSource() },
onClick = {
query {
Database.delete(searchQuery)
Header(
titleContent = {
BasicTextField(
value = textFieldValue,
onValueChange = onTextFieldValueChanged,
textStyle = typography.xxl.medium.align(TextAlign.End),
singleLine = true,
maxLines = 1,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(
onSearch = {
if (textFieldValue.text.isNotEmpty()) {
onSearch(textFieldValue.text)
}
}
}
),
cursorBrush = SolidColor(colorPalette.text),
decorationBox = decorationBox,
modifier = Modifier
.focusRequester(focusRequester)
)
.padding(horizontal = 8.dp)
.size(20.dp)
)
},
actionsContent = {
if (playlistId != null) {
val isAlbum = playlistId.startsWith("OLAK5uy_")
Image(
painter = arrowForwardIconPainter,
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.textDisabled),
modifier = Modifier
.clickable(
indication = rippleIndication,
interactionSource = remember { MutableInteractionSource() },
onClick = {
onTextFieldValueChanged(
TextFieldValue(
text = searchQuery.query,
selection = TextRange(searchQuery.query.length)
)
)
}
SecondaryTextButton(
text = "View ${if (isAlbum) "album" else "playlist"}",
onClick = { onViewPlaylist(textFieldValue.text) }
)
}
Spacer(
modifier = Modifier
.weight(1f)
)
.rotate(225f)
.padding(horizontal = 8.dp)
.size(22.dp)
if (textFieldValue.text.isNotEmpty()) {
SecondaryTextButton(
text = "Clear",
onClick = { onTextFieldValueChanged(TextFieldValue()) }
)
}
}
)
}
}
suggestionsResult?.getOrNull()?.let { suggestions ->
items(items = suggestions) { suggestion ->
items(
items = history,
key = SearchQuery::id
) { searchQuery ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(onClick = { onSearch(suggestion) })
.clickable(onClick = { onSearch(searchQuery.query) })
.fillMaxWidth()
.padding(all = 16.dp)
) {
@ -252,16 +191,38 @@ fun OnlineSearch(
modifier = Modifier
.padding(horizontal = 8.dp)
.size(20.dp)
.paint(
painter = timeIconPainter,
colorFilter = ColorFilter.tint(colorPalette.textDisabled)
)
)
BasicText(
text = suggestion,
text = searchQuery.query,
style = typography.s.secondary,
modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1f)
)
Image(
painter = closeIconPainter,
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.textDisabled),
modifier = Modifier
.clickable(
indication = rippleIndication,
interactionSource = remember { MutableInteractionSource() },
onClick = {
query {
Database.delete(searchQuery)
}
}
)
.padding(horizontal = 8.dp)
.size(20.dp)
)
Image(
painter = arrowForwardIconPainter,
contentDescription = null,
@ -273,8 +234,8 @@ fun OnlineSearch(
onClick = {
onTextFieldValueChanged(
TextFieldValue(
text = suggestion,
selection = TextRange(suggestion.length)
text = searchQuery.query,
selection = TextRange(searchQuery.query.length)
)
)
}
@ -285,21 +246,71 @@ fun OnlineSearch(
)
}
}
} ?: suggestionsResult?.exceptionOrNull()?.let {
item {
Box(
modifier = Modifier
.fillMaxSize()
) {
BasicText(
text = "An error has occurred.",
style = typography.s.secondary.center,
suggestionsResult?.getOrNull()?.let { suggestions ->
items(items = suggestions) { suggestion ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.align(Alignment.Center)
)
.clickable(onClick = { onSearch(suggestion) })
.fillMaxWidth()
.padding(all = 16.dp)
) {
Spacer(
modifier = Modifier
.padding(horizontal = 8.dp)
.size(20.dp)
)
BasicText(
text = suggestion,
style = typography.s.secondary,
modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1f)
)
Image(
painter = arrowForwardIconPainter,
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.textDisabled),
modifier = Modifier
.clickable(
indication = rippleIndication,
interactionSource = remember { MutableInteractionSource() },
onClick = {
onTextFieldValueChanged(
TextFieldValue(
text = suggestion,
selection = TextRange(suggestion.length)
)
)
}
)
.rotate(225f)
.padding(horizontal = 8.dp)
.size(22.dp)
)
}
}
} ?: suggestionsResult?.exceptionOrNull()?.let {
item {
Box(
modifier = Modifier
.fillMaxSize()
) {
BasicText(
text = "An error has occurred.",
style = typography.s.secondary.center,
modifier = Modifier
.align(Alignment.Center)
)
}
}
}
}
FloatingActionsContainerWithScrollToTop(lazyListState = lazyListState)
}
LaunchedEffect(Unit) {

View file

@ -1,6 +1,7 @@
package it.vfsfitvnm.vimusic.ui.screens.searchresult
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -19,6 +20,7 @@ import androidx.compose.ui.unit.dp
import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues
import it.vfsfitvnm.vimusic.savers.nullableSaver
import it.vfsfitvnm.vimusic.ui.components.ShimmerHost
import it.vfsfitvnm.vimusic.ui.components.themed.FloatingActionsContainerWithScrollToTop
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.utils.center
import it.vfsfitvnm.vimusic.utils.produceSaveableState
@ -70,51 +72,55 @@ inline fun <T : Innertube.Item> ItemsPage(
}
}
LazyColumn(
state = lazyListState,
contentPadding = LocalPlayerAwarePaddingValues.current,
modifier = modifier
.fillMaxSize()
) {
item(
key = "header",
contentType = "header",
Box {
LazyColumn(
state = lazyListState,
contentPadding = LocalPlayerAwarePaddingValues.current,
modifier = modifier
.fillMaxSize()
) {
headerContent(null)
}
items(
items = itemsPage?.items ?: emptyList(),
key = Innertube.Item::key,
itemContent = itemContent
)
if (itemsPage != null && itemsPage?.items.isNullOrEmpty()) {
item(key = "empty") {
BasicText(
text = emptyItemsText,
style = typography.xs.secondary.center,
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 32.dp)
.fillMaxWidth()
)
item(
key = "header",
contentType = "header",
) {
headerContent(null)
}
}
if (!(itemsPage != null && itemsPage?.continuation == null)) {
item(key = "loading") {
val isFirstLoad = itemsPage?.items.isNullOrEmpty()
ShimmerHost(
modifier = Modifier
.run {
if (isFirstLoad) fillParentMaxSize() else this
items(
items = itemsPage?.items ?: emptyList(),
key = Innertube.Item::key,
itemContent = itemContent
)
if (itemsPage != null && itemsPage?.items.isNullOrEmpty()) {
item(key = "empty") {
BasicText(
text = emptyItemsText,
style = typography.xs.secondary.center,
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 32.dp)
.fillMaxWidth()
)
}
}
if (!(itemsPage != null && itemsPage?.continuation == null)) {
item(key = "loading") {
val isFirstLoad = itemsPage?.items.isNullOrEmpty()
ShimmerHost(
modifier = Modifier
.run {
if (isFirstLoad) fillParentMaxSize() else this
}
) {
repeat(if (isFirstLoad) initialPlaceholderCount else continuationPlaceholderCount) {
itemPlaceholderContent()
}
) {
repeat(if (isFirstLoad) initialPlaceholderCount else continuationPlaceholderCount) {
itemPlaceholderContent()
}
}
}
}
FloatingActionsContainerWithScrollToTop(lazyListState = lazyListState)
}
}

View file

@ -0,0 +1,40 @@
package it.vfsfitvnm.vimusic.utils
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
suspend fun LazyGridState.smoothScrollToTop() {
if (firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size) {
scrollToItem(layoutInfo.visibleItemsInfo.size)
}
animateScrollToItem(0)
}
@Composable
fun LazyGridState.isScrollingDownToIsFar(): Pair<Boolean, Boolean> {
var previousIndex by remember(this) {
mutableStateOf(firstVisibleItemIndex)
}
var previousScrollOffset by remember(this) {
mutableStateOf(firstVisibleItemScrollOffset)
}
return remember(this) {
derivedStateOf {
if (previousIndex != firstVisibleItemIndex) {
previousIndex > firstVisibleItemIndex
} else {
previousScrollOffset >= firstVisibleItemScrollOffset
}.also {
previousIndex = firstVisibleItemIndex
previousScrollOffset = firstVisibleItemScrollOffset
} to (firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size)
}
}.value
}

View file

@ -1,6 +1,12 @@
package it.vfsfitvnm.vimusic.utils
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
suspend fun LazyListState.smoothScrollToTop() {
if (firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size) {
@ -8,3 +14,27 @@ suspend fun LazyListState.smoothScrollToTop() {
}
animateScrollToItem(0)
}
@Composable
fun LazyListState.isScrollingDownToIsFar(): Pair<Boolean, Boolean> {
var previousIndex by remember(this) {
mutableStateOf(firstVisibleItemIndex)
}
var previousScrollOffset by remember(this) {
mutableStateOf(firstVisibleItemScrollOffset)
}
return remember(this) {
derivedStateOf {
if (previousIndex != firstVisibleItemIndex) {
previousIndex > firstVisibleItemIndex
} else {
previousScrollOffset >= firstVisibleItemScrollOffset
}.also {
previousIndex = firstVisibleItemIndex
previousScrollOffset = firstVisibleItemScrollOffset
} to (firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size)
}
}.value
}

View file

@ -0,0 +1,24 @@
package it.vfsfitvnm.vimusic.utils
import androidx.compose.foundation.ScrollState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@Composable
fun ScrollState.isScrollingDown(): Boolean {
var previousValue by remember(this) {
mutableStateOf(value)
}
return remember(this) {
derivedStateOf {
(previousValue >= value).also {
previousValue = value
}
}
}.value
}

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M112,328l144,-144l144,144"
android:strokeLineJoin="round"
android:strokeWidth="48"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
</vector>