From 563c6175f70565c8b33253889ce33d9019825776 Mon Sep 17 00:00:00 2001 From: vfsfitvnm Date: Thu, 22 Sep 2022 19:08:01 +0200 Subject: [PATCH] Start UI redesign (#172) --- app/build.gradle.kts | 2 + .../kotlin/it/vfsfitvnm/vimusic/Database.kt | 2 +- .../vimusic/ui/components/TabColumn.kt | 105 ++++ .../vimusic/ui/components/themed/Scaffold.kt | 148 +++++ .../ui/components/themed/VerticalBar.kt | 186 +++++++ .../vimusic/ui/screens/HomeScreen.kt | 525 ++---------------- .../vimusic/ui/styling/Typography.kt | 2 + .../vimusic/ui/views/PlaylistPreviewItem.kt | 48 +- .../vimusic/ui/views/PlaylistsTab.kt | 297 ++++++++++ .../it/vfsfitvnm/vimusic/ui/views/SongsTab.kt | 261 +++++++++ .../it/vfsfitvnm/vimusic/utils/Preferences.kt | 12 +- .../vimusic/utils/RememberLazyListStates.kt | 61 ++ app/src/main/res/drawable/arrow_down.xml | 20 + app/src/main/res/drawable/arrow_up.xml | 20 + app/src/main/res/drawable/calendar.xml | 12 + app/src/main/res/drawable/chevron_down.xml | 4 +- app/src/main/res/drawable/medical.xml | 9 + app/src/main/res/drawable/musical_notes.xml | 9 + app/src/main/res/drawable/trending.xml | 20 + settings.gradle.kts | 1 + 20 files changed, 1219 insertions(+), 525 deletions(-) create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/TabColumn.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Scaffold.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/VerticalBar.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlaylistsTab.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongsTab.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RememberLazyListStates.kt create mode 100644 app/src/main/res/drawable/arrow_down.xml create mode 100644 app/src/main/res/drawable/arrow_up.xml create mode 100644 app/src/main/res/drawable/calendar.xml create mode 100644 app/src/main/res/drawable/medical.xml create mode 100644 app/src/main/res/drawable/musical_notes.xml create mode 100644 app/src/main/res/drawable/trending.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5687011..58bf36d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -97,4 +97,6 @@ dependencies { implementation(projects.kugou) coreLibraryDesugaring(libs.desugaring) + + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1") } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt index 6d12504..2f07d66 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt @@ -386,7 +386,7 @@ abstract class DatabaseInitializer protected constructor() : RoomDatabase() { lateinit var Instance: DatabaseInitializer context(Context) - operator fun invoke() { + operator fun invoke() { if (!::Instance.isInitialized) { Instance = Room .databaseBuilder(this@Context, DatabaseInitializer::class.java, "data.db") diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/TabColumn.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/TabColumn.kt new file mode 100644 index 0000000..60170e8 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/TabColumn.kt @@ -0,0 +1,105 @@ +package it.vfsfitvnm.vimusic.ui.components + +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp + +@Composable +fun TabColumn( + tabIndex: Int, + onTabIndexChanged: (Int) -> Unit, + selectedTextColor: Color, + disabledTextColor: Color, + textStyle: TextStyle, + modifier: Modifier = Modifier, + content: @Composable (@Composable (Int, String, Int) -> Unit) -> Unit +) { + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + ) { + val transition = updateTransition(targetState = tabIndex, label = null) + + content { index, text, icon -> + val dothAlpha by transition.animateFloat(label = "") { + if (it == index) 1f else 0f + } + + val textColor by transition.animateColor(label = "") { + if (it == index) selectedTextColor else disabledTextColor + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .clickable( + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() }, + onClick = { onTabIndexChanged(index) } + ) + .padding(horizontal = 8.dp) + ) { + Image( + painter = painterResource(icon), + contentDescription = null, + colorFilter = ColorFilter.tint(selectedTextColor), + modifier = Modifier + .vertical() + .graphicsLayer { + alpha = dothAlpha + translationX = (1f - dothAlpha) * -48.dp.toPx() + rotationZ = -90f + } + .size(12.dp) + ) + + BasicText( + text = text, + style = textStyle.copy(color = textColor), + modifier = Modifier + .vertical() + .rotate(-90f) + .padding(horizontal = 16.dp) + ) + } + } + } +} + +fun Modifier.vertical() = + layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.height, placeable.width) { + placeable.place( + x = -(placeable.width / 2 - placeable.height / 2), + y = -(placeable.height / 2 - placeable.width / 2) + ) + } + } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Scaffold.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Scaffold.kt new file mode 100644 index 0000000..ed4a95a --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Scaffold.kt @@ -0,0 +1,148 @@ +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 +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.animation.with +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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +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.IntOffset +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance + +@SuppressLint("ModifierParameter") +@ExperimentalAnimationApi +@Composable +fun Scaffold( + topIconButtonId: Int, + onTopIconButtonClick: () -> Unit, + tabIndex: Int, + onTabChanged: (Int) -> Unit, + tabColumnContent: @Composable (@Composable (Int, String, Int) -> Unit) -> Unit, + primaryIconButtonId: Int? = null, + onPrimaryIconButtonClick: () -> Unit = {}, + modifier: Modifier = Modifier, + content: @Composable AnimatedVisibilityScope.(Int) -> Unit +) { + val (colorPalette) = LocalAppearance.current + + Box( + modifier = modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + Row( + modifier = modifier + .fillMaxSize() + ) { + VerticalBar( + topIconButtonId = topIconButtonId, + onTopIconButtonClick = onTopIconButtonClick, + tabIndex = tabIndex, + onTabChanged = onTabChanged, + tabColumnContent = tabColumnContent, +// primaryIconButtonId = primaryIconButtonId, +// onPrimaryIconButtonClick = onPrimaryIconButtonClick, + modifier = Modifier + .padding(LocalPlayerAwarePaddingValues.current) + ) + + AnimatedContent( + targetState = tabIndex, + transitionSpec = { + val slideDirection = when (targetState > initialState) { + true -> AnimatedContentScope.SlideDirection.Up + false -> AnimatedContentScope.SlideDirection.Down + } + + val animationSpec = spring( + dampingRatio = 0.9f, + stiffness = Spring.StiffnessLow, + visibilityThreshold = IntOffset.VisibilityThreshold + ) + + slideIntoContainer(slideDirection, animationSpec) with + slideOutOfContainer(slideDirection, animationSpec) + }, + content = content, + ) + } + + primaryIconButtonId?.let { + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(all = 16.dp) + .padding(LocalPlayerAwarePaddingValues.current) + .clip(RoundedCornerShape(16.dp)) + .clickable(onClick = onPrimaryIconButtonClick) + .background(colorPalette.background2) + .size(62.dp) + ) { + Image( + painter = painterResource(primaryIconButtonId), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .align(Alignment.Center) + .size(20.dp) + ) + } + } + } +} + +@SuppressLint("ModifierParameter") +@ExperimentalAnimationApi +@Composable +fun SimpleScaffold( + topIconButtonId: Int, + onTopIconButtonClick: () -> Unit, + title: String = "", + primaryIconButtonId: Int? = null, + primaryIconButtonEnabled: Boolean = true, + onPrimaryIconButtonClick: () -> Unit = {}, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + val (colorPalette) = LocalAppearance.current + + Row( + modifier = modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + VerticalTitleBar( + topIconButtonId = topIconButtonId, + onTopIconButtonClick = onTopIconButtonClick, + title = title, + primaryIconButtonId = primaryIconButtonId, + primaryIconButtonEnabled = primaryIconButtonEnabled, + onPrimaryIconButtonClick = onPrimaryIconButtonClick, + modifier = Modifier + .padding(LocalPlayerAwarePaddingValues.current) + ) + + content() + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/VerticalBar.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/VerticalBar.kt new file mode 100644 index 0000000..d37a256 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/VerticalBar.kt @@ -0,0 +1,186 @@ +package it.vfsfitvnm.vimusic.ui.components.themed + +import android.annotation.SuppressLint +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.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +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.draw.rotate +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.vimusic.ui.components.TabColumn +import it.vfsfitvnm.vimusic.ui.components.vertical +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.utils.semiBold + +@SuppressLint("ModifierParameter") +@Composable +fun VerticalBar( + topIconButtonId: Int, + onTopIconButtonClick: () -> Unit, + tabIndex: Int, + onTabChanged: (Int) -> Unit, + tabColumnContent: @Composable (@Composable (Int, String, Int) -> Unit) -> Unit, +// primaryIconButtonId: Int? = null, +// onPrimaryIconButtonClick: () -> Unit = {}, + modifier: Modifier = Modifier +) { + val (colorPalette, typography) = LocalAppearance.current + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .padding(vertical = 16.dp) + ) { +// Box( +// modifier = Modifier +// .clip(RoundedCornerShape(16.dp)) +// .clickable(onClick = onTopIconButtonClick) +// .background(color = colorPalette.background1) +// .size(48.dp) +// ) { + Image( + painter = painterResource(topIconButtonId), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.textSecondary), + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .clickable(onClick = onTopIconButtonClick) + .padding(all = 12.dp) +// .align(Alignment.Center) + .size(22.dp) + ) +// } + + Spacer( + modifier = Modifier + .width(64.dp) + .height(32.dp) + ) + + TabColumn( + tabIndex = tabIndex, + onTabIndexChanged = onTabChanged, + selectedTextColor = colorPalette.text, + disabledTextColor = colorPalette.textDisabled, + textStyle = typography.xs.semiBold, + content = tabColumnContent, + ) + +// Spacer( +// modifier = Modifier +// .weight(1f) +// ) + +// primaryIconButtonId?.let { +// Box( +// modifier = Modifier +// .offset(x = 8.dp) +// .clip(RoundedCornerShape(16.dp)) +// .clickable(onClick = onPrimaryIconButtonClick) +// .background(colorPalette.background1) +// .size(62.dp) +// ) { +// Image( +// painter = painterResource(primaryIconButtonId), +// contentDescription = null, +// colorFilter = ColorFilter.tint(colorPalette.text), +// modifier = Modifier +// .align(Alignment.Center) +// .size(20.dp) +// ) +// } +// } + } +} + +@SuppressLint("ModifierParameter") +@Composable +fun VerticalTitleBar( + topIconButtonId: Int, + onTopIconButtonClick: () -> Unit, + title: String, + primaryIconButtonId: Int? = null, + primaryIconButtonEnabled: Boolean = true, + onPrimaryIconButtonClick: () -> Unit = {}, + modifier: Modifier = Modifier +) { + val (colorPalette, typography) = LocalAppearance.current + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .padding(vertical = 16.dp) + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .clickable(onClick = onTopIconButtonClick) + .background(color = colorPalette.background1) + .size(48.dp) + ) { + Image( + painter = painterResource(topIconButtonId), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.textSecondary), + modifier = Modifier + .align(Alignment.Center) + .size(22.dp) + ) + } + + Spacer( + modifier = Modifier + .width(78.dp) + .height(32.dp) + ) + + BasicText( + text = title, + style = typography.m.semiBold, + modifier = Modifier + .vertical() + .rotate(-90f) + .padding(horizontal = 16.dp) + ) + + Spacer( + modifier = Modifier + .weight(1f) + ) + + primaryIconButtonId?.let { + Box( + modifier = Modifier + .offset(x = 8.dp) + .clip(RoundedCornerShape(16.dp)) + .clickable(enabled = primaryIconButtonEnabled, onClick = onPrimaryIconButtonClick) + .background(colorPalette.background1) + .size(62.dp) + ) { + Image( + painter = painterResource(primaryIconButtonId), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .align(Alignment.Center) + .size(20.dp) + ) + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/HomeScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/HomeScreen.kt index 43c9bff..89a3a6e 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/HomeScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/HomeScreen.kt @@ -1,115 +1,26 @@ package it.vfsfitvnm.vimusic.ui.screens import android.net.Uri -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.BasicText -import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex +import androidx.compose.runtime.saveable.rememberSaveableStateHolder import it.vfsfitvnm.route.RouteHandler import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues -import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.enums.BuiltInPlaylist -import it.vfsfitvnm.vimusic.enums.PlaylistSortBy -import it.vfsfitvnm.vimusic.enums.SongSortBy -import it.vfsfitvnm.vimusic.enums.SortOrder -import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness -import it.vfsfitvnm.vimusic.models.DetailedSong -import it.vfsfitvnm.vimusic.models.Playlist import it.vfsfitvnm.vimusic.models.SearchQuery import it.vfsfitvnm.vimusic.query -import it.vfsfitvnm.vimusic.ui.components.TopAppBar -import it.vfsfitvnm.vimusic.ui.components.badge -import it.vfsfitvnm.vimusic.ui.components.themed.DropDownSection -import it.vfsfitvnm.vimusic.ui.components.themed.DropDownSectionSpacer -import it.vfsfitvnm.vimusic.ui.components.themed.DropDownTextItem -import it.vfsfitvnm.vimusic.ui.components.themed.DropdownMenu -import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu -import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.ui.styling.px -import it.vfsfitvnm.vimusic.ui.views.BuiltInPlaylistItem -import it.vfsfitvnm.vimusic.ui.views.PlaylistPreviewItem -import it.vfsfitvnm.vimusic.ui.views.SongItem -import it.vfsfitvnm.vimusic.utils.asMediaItem -import it.vfsfitvnm.vimusic.utils.center -import it.vfsfitvnm.vimusic.utils.color -import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex -import it.vfsfitvnm.vimusic.utils.forcePlayFromBeginning -import it.vfsfitvnm.vimusic.utils.isFirstLaunchKey -import it.vfsfitvnm.vimusic.utils.playlistGridExpandedKey -import it.vfsfitvnm.vimusic.utils.playlistSortByKey -import it.vfsfitvnm.vimusic.utils.playlistSortOrderKey +import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold +import it.vfsfitvnm.vimusic.ui.views.PlaylistsTab +import it.vfsfitvnm.vimusic.ui.views.SongsTab +import it.vfsfitvnm.vimusic.utils.homeScreenTabIndexKey import it.vfsfitvnm.vimusic.utils.rememberPreference -import it.vfsfitvnm.vimusic.utils.semiBold -import it.vfsfitvnm.vimusic.utils.songSortByKey -import it.vfsfitvnm.vimusic.utils.songSortOrderKey -import kotlinx.coroutines.Dispatchers @ExperimentalFoundationApi @ExperimentalAnimationApi @Composable fun HomeScreen() { - val (colorPalette, typography) = LocalAppearance.current - - val lazyListState = rememberLazyListState() - val lazyHorizontalGridState = rememberLazyGridState() - - var playlistSortBy by rememberPreference(playlistSortByKey, PlaylistSortBy.DateAdded) - var playlistSortOrder by rememberPreference(playlistSortOrderKey, SortOrder.Descending) - var playlistGridExpanded by rememberPreference(playlistGridExpandedKey, false) - - val playlistPreviews by remember(playlistSortBy, playlistSortOrder) { - Database.playlistPreviews(playlistSortBy, playlistSortOrder) - }.collectAsState(initial = emptyList(), context = Dispatchers.IO) - - var songSortBy by rememberPreference(songSortByKey, SongSortBy.DateAdded) - var songSortOrder by rememberPreference(songSortOrderKey, SortOrder.Descending) - - val songCollection by remember(songSortBy, songSortOrder) { - Database.songs(songSortBy, songSortOrder) - }.collectAsState(initial = emptyList(), context = Dispatchers.IO) + val saveableStateHolder = rememberSaveableStateHolder() RouteHandler(listenToGlobalEmitter = true) { settingsRoute { @@ -153,15 +64,7 @@ fun HomeScreen() { ) } - albumRoute { browseId -> - AlbumScreen(browseId = browseId ?: error("browseId cannot be null")) - } - - artistRoute { browseId -> - ArtistScreen( - browseId = browseId ?: error("browseId cannot be null") - ) - } + globalRoutes() intentUriRoute { uri -> IntentUriScreen( @@ -170,392 +73,38 @@ fun HomeScreen() { } host { - // This somehow prevents items to not be displayed sometimes... - @Suppress("UNUSED_EXPRESSION") playlistPreviews - @Suppress("UNUSED_EXPRESSION") songCollection + val (tabIndex, onTabChanged) = rememberPreference(homeScreenTabIndexKey, defaultValue = 0) - val binder = LocalPlayerServiceBinder.current - - val isFirstLaunch by rememberPreference(isFirstLaunchKey, true) - - val thumbnailSize = Dimensions.thumbnails.song.px - - var isCreatingANewPlaylist by rememberSaveable { - mutableStateOf(false) - } - - if (isCreatingANewPlaylist) { - TextFieldDialog( - hintText = "Enter the playlist name", - onDismiss = { - isCreatingANewPlaylist = false - }, - onDone = { text -> - query { - Database.insert(Playlist(name = text)) - } + Scaffold( + topIconButtonId = R.drawable.equalizer, + onTopIconButtonClick = { settingsRoute() }, + tabIndex = tabIndex, + onTabChanged = onTabChanged, + tabColumnContent = { Item -> + Item(0, "Songs", R.drawable.musical_notes) + Item(1, "Playlists", R.drawable.playlist) + Item(2, "Artists", R.drawable.person) + Item(3, "Albums", R.drawable.disc) + }, + primaryIconButtonId = R.drawable.search, + onPrimaryIconButtonClick = { searchRoute("") } + ) { currentTabIndex -> + saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { + when (currentTabIndex) { + 0 -> SongsTab() + 1 -> PlaylistsTab( + onBuiltInPlaylistClicked = { builtInPlaylistRoute(it) }, + onPlaylistClicked = { localPlaylistRoute(it.id) } + ) +// 2 -> ArtistsTab( +// lazyListState = lazyListStates[currentTabIndex], +// onArtistClicked = { artistRoute(it.id) } +// ) +// 3 -> AlbumsTab( +// lazyListState = lazyListStates[currentTabIndex], +// onAlbumClicked = { albumRoute(it.id) } +// ) } - ) - } - - LazyColumn( - state = lazyListState, - contentPadding = LocalPlayerAwarePaddingValues.current, - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - ) { - item("topAppBar") { - TopAppBar( - modifier = Modifier - .height(52.dp) - ) { - Image( - painter = painterResource(R.drawable.equalizer), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { settingsRoute() } - .padding(horizontal = 16.dp, vertical = 8.dp) - .badge(color = colorPalette.red, isDisplayed = isFirstLaunch) - .size(24.dp) - ) - - Image( - painter = painterResource(R.drawable.search), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { searchRoute("") } - .padding(horizontal = 16.dp, vertical = 8.dp) - .size(24.dp) - ) - } - } - - item("playlistsHeader") { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .zIndex(1f) - .padding(horizontal = 8.dp) - .padding(top = 16.dp) - ) { - BasicText( - text = "Your playlists", - style = typography.m.semiBold, - modifier = Modifier - .weight(1f) - .padding(horizontal = 8.dp) - ) - - Image( - painter = painterResource(R.drawable.add), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { isCreatingANewPlaylist = true } - .padding(all = 8.dp) - .size(20.dp) - ) - - Box { - var isSortMenuDisplayed by remember { - mutableStateOf(false) - } - - Image( - painter = painterResource(R.drawable.sort), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { isSortMenuDisplayed = true } - .padding(horizontal = 8.dp, vertical = 8.dp) - .size(20.dp) - ) - - DropdownMenu( - isDisplayed = isSortMenuDisplayed, - onDismissRequest = { isSortMenuDisplayed = false } - ) { - DropDownSection { - DropDownTextItem( - text = "NAME", - isSelected = playlistSortBy == PlaylistSortBy.Name, - onClick = { - isSortMenuDisplayed = false - playlistSortBy = PlaylistSortBy.Name - } - ) - - DropDownTextItem( - text = "DATE ADDED", - isSelected = playlistSortBy == PlaylistSortBy.DateAdded, - onClick = { - isSortMenuDisplayed = false - playlistSortBy = PlaylistSortBy.DateAdded - } - ) - - DropDownTextItem( - text = "SONG COUNT", - isSelected = playlistSortBy == PlaylistSortBy.SongCount, - onClick = { - isSortMenuDisplayed = false - playlistSortBy = PlaylistSortBy.SongCount - } - ) - } - - DropDownSectionSpacer() - - DropDownSection { - DropDownTextItem( - text = when (playlistSortOrder) { - SortOrder.Ascending -> "ASCENDING" - SortOrder.Descending -> "DESCENDING" - }, - onClick = { - isSortMenuDisplayed = false - playlistSortOrder = !playlistSortOrder - } - ) - } - DropDownSectionSpacer() - - DropDownSection { - DropDownTextItem( - text = when (playlistGridExpanded) { - true -> "COLLAPSE" - false -> "EXPAND" - }, - onClick = { - isSortMenuDisplayed = false - playlistGridExpanded = !playlistGridExpanded - } - ) - } - } - } - } - } - - item("playlists") { - LazyHorizontalGrid( - state = lazyHorizontalGridState, - rows = GridCells.Fixed(if (playlistGridExpanded) 3 else 1), - contentPadding = PaddingValues(horizontal = 16.dp), - modifier = Modifier - .animateContentSize() - .fillMaxWidth() - .height(124.dp * (if (playlistGridExpanded) 3 else 1)) - ) { - item(key = "favorites") { - BuiltInPlaylistItem( - icon = R.drawable.heart, - colorTint = colorPalette.red, - name = "Favorites", - modifier = Modifier - .animateItemPlacement() - .padding(all = 8.dp) - .clickable( - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() }, - onClick = { builtInPlaylistRoute(BuiltInPlaylist.Favorites) } - ) - ) - } - - item(key = "offline") { - BuiltInPlaylistItem( - icon = R.drawable.airplane, - colorTint = colorPalette.blue, - name = "Offline", - modifier = Modifier - .animateItemPlacement() - .padding(all = 8.dp) - .clickable( - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() }, - onClick = { builtInPlaylistRoute(BuiltInPlaylist.Offline) } - ) - ) - } - - items( - items = playlistPreviews, - key = { it.playlist.id }, - contentType = { it } - ) { playlistPreview -> - PlaylistPreviewItem( - playlistPreview = playlistPreview, - modifier = Modifier - .animateItemPlacement() - .padding(all = 8.dp) - .clickable( - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() }, - onClick = { localPlaylistRoute(playlistPreview.playlist.id) } - ) - ) - } - } - } - - item("songs") { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .background(colorPalette.background0) - .zIndex(1f) - .padding(horizontal = 8.dp) - .padding(top = 32.dp) - ) { - BasicText( - text = "Songs", - style = typography.m.semiBold, - modifier = Modifier - .weight(1f) - .padding(horizontal = 8.dp) - ) - - Image( - painter = painterResource(R.drawable.shuffle), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable(enabled = songCollection.isNotEmpty()) { - binder?.stopRadio() - binder?.player?.forcePlayFromBeginning( - songCollection - .shuffled() - .map(DetailedSong::asMediaItem) - ) - } - .padding(horizontal = 8.dp, vertical = 8.dp) - .size(20.dp) - ) - - Box { - var isSortMenuDisplayed by remember { - mutableStateOf(false) - } - - Image( - painter = painterResource(R.drawable.sort), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { - isSortMenuDisplayed = true - } - .padding(horizontal = 8.dp, vertical = 8.dp) - .size(20.dp) - ) - - DropdownMenu( - isDisplayed = isSortMenuDisplayed, - onDismissRequest = { - isSortMenuDisplayed = false - } - ) { - DropDownSection { - DropDownTextItem( - text = "PLAY TIME", - isSelected = songSortBy == SongSortBy.PlayTime, - onClick = { - isSortMenuDisplayed = false - songSortBy = SongSortBy.PlayTime - } - ) - - DropDownTextItem( - text = "TITLE", - isSelected = songSortBy == SongSortBy.Title, - onClick = { - isSortMenuDisplayed = false - songSortBy = SongSortBy.Title - } - ) - - DropDownTextItem( - text = "DATE ADDED", - isSelected = songSortBy == SongSortBy.DateAdded, - onClick = { - isSortMenuDisplayed = false - songSortBy = SongSortBy.DateAdded - } - ) - } - - DropDownSectionSpacer() - - DropDownSection { - DropDownTextItem( - text = when (songSortOrder) { - SortOrder.Ascending -> "ASCENDING" - SortOrder.Descending -> "DESCENDING" - }, - onClick = { - isSortMenuDisplayed = false - songSortOrder = !songSortOrder - } - ) - } - } - } - } - } - - itemsIndexed( - items = songCollection, - key = { _, song -> song.id }, - contentType = { _, song -> song } - ) { index, song -> - SongItem( - song = song, - thumbnailSize = thumbnailSize, - onClick = { - binder?.stopRadio() - binder?.player?.forcePlayAtIndex( - songCollection.map(DetailedSong::asMediaItem), - index - ) - }, - menuContent = { - InHistoryMediaItemMenu(song = song) - }, - onThumbnailContent = { - AnimatedVisibility( - visible = songSortBy == SongSortBy.PlayTime, - enter = fadeIn(), - exit = fadeOut(), - modifier = Modifier - .align(Alignment.BottomCenter) - ) { - BasicText( - text = song.formattedTotalPlayTime, - style = typography.xxs.semiBold.center.color(Color.White), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .fillMaxWidth() - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color.Transparent, - Color.Black.copy(alpha = 0.75f) - ) - ), - shape = ThumbnailRoundness.shape - ) - .padding(horizontal = 8.dp, vertical = 4.dp) - ) - } - }, - modifier = Modifier - .animateItemPlacement() - ) } } } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Typography.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Typography.kt index 6b57ecb..d17b065 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Typography.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Typography.kt @@ -17,6 +17,7 @@ data class Typography( val s: TextStyle, val m: TextStyle, val l: TextStyle, + val xxl: TextStyle, ) fun typographyOf(color: Color): Typography { @@ -54,5 +55,6 @@ fun typographyOf(color: Color): Typography { s = textStyle.copy(fontSize = 16.sp), m = textStyle.copy(fontSize = 18.sp), l = textStyle.copy(fontSize = 20.sp), + xxl = textStyle.copy(fontSize = 32.sp), ) } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlaylistPreviewItem.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlaylistPreviewItem.kt index 13785d5..ddc99c9 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlaylistPreviewItem.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlaylistPreviewItem.kt @@ -3,11 +3,13 @@ package it.vfsfitvnm.vimusic.ui.views import androidx.annotation.DrawableRes import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable @@ -17,7 +19,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale @@ -31,7 +32,7 @@ import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.models.PlaylistPreview import it.vfsfitvnm.vimusic.ui.styling.Dimensions import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.color +import it.vfsfitvnm.vimusic.utils.center import it.vfsfitvnm.vimusic.utils.semiBold import it.vfsfitvnm.vimusic.utils.thumbnail import kotlinx.coroutines.Dispatchers @@ -61,7 +62,6 @@ fun PlaylistPreviewItem( PlaylistItem( name = playlistPreview.playlist.name, - textColor = Color.White, thumbnailSize = thumbnailSize, imageContent = { if (thumbnails.toSet().size == 1) { @@ -112,7 +112,6 @@ fun BuiltInPlaylistItem( PlaylistItem( name = name, thumbnailSize = thumbnailSize, - withGradient = false, imageContent = { Image( painter = painterResource(icon), @@ -131,48 +130,31 @@ fun BuiltInPlaylistItem( fun PlaylistItem( name: String, modifier: Modifier = Modifier, - textColor: Color? = null, thumbnailSize: Dp = Dimensions.thumbnails.song, - withGradient: Boolean = true, imageContent: @Composable BoxScope.() -> Unit ) { val (colorPalette, typography, thumbnailShape) = LocalAppearance.current - Box( + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier - .clip(thumbnailShape) - .background(colorPalette.background1) - .size(thumbnailSize * 2) + .requiredWidth(thumbnailSize * 2) ) { Box( modifier = Modifier - .size(thumbnailSize * 2), + .clip(thumbnailShape) + .background(colorPalette.background1) + .align(Alignment.CenterHorizontally) + .requiredSize(thumbnailSize * 2), content = imageContent ) BasicText( text = name, - style = typography.xxs.semiBold.color(textColor ?: colorPalette.text), + style = typography.xxs.semiBold.center, maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomStart) - .run { - if (withGradient) { - background( - Brush.verticalGradient( - colors = listOf( - Color.Transparent, - Color.Black.copy(alpha = 0.75f) - ) - ) - ) - } else { - this - } - } - .padding(horizontal = 8.dp, vertical = 4.dp) + overflow = TextOverflow.Ellipsis ) } } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlaylistsTab.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlaylistsTab.kt new file mode 100644 index 0000000..095c7d1 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlaylistsTab.kt @@ -0,0 +1,297 @@ +package it.vfsfitvnm.vimusic.ui.views + +import android.app.Application +import android.content.SharedPreferences +import androidx.annotation.DrawableRes +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +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.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +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.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.core.content.edit +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.enums.BuiltInPlaylist +import it.vfsfitvnm.vimusic.enums.PlaylistSortBy +import it.vfsfitvnm.vimusic.enums.SortOrder +import it.vfsfitvnm.vimusic.models.Playlist +import it.vfsfitvnm.vimusic.models.PlaylistPreview +import it.vfsfitvnm.vimusic.query +import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog +import it.vfsfitvnm.vimusic.ui.styling.Dimensions +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.utils.getEnum +import it.vfsfitvnm.vimusic.utils.medium +import it.vfsfitvnm.vimusic.utils.mutableStatePreferenceOf +import it.vfsfitvnm.vimusic.utils.playlistSortByKey +import it.vfsfitvnm.vimusic.utils.playlistSortOrderKey +import it.vfsfitvnm.vimusic.utils.preferences +import it.vfsfitvnm.vimusic.utils.putEnum +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.launch + +class PlaylistsTabViewModel(application: Application) : AndroidViewModel(application) { + var items by mutableStateOf(emptyList()) + private set + + var sortBy by mutableStatePreferenceOf( + preferences.getEnum( + playlistSortByKey, + PlaylistSortBy.DateAdded + ) + ) { + preferences.edit { putEnum(playlistSortByKey, it) } + collectItems(sortBy = it) + } + + var sortOrder by mutableStatePreferenceOf( + preferences.getEnum( + playlistSortOrderKey, + SortOrder.Ascending + ) + ) { + preferences.edit { putEnum(playlistSortOrderKey, it) } + collectItems(sortOrder = it) + } + + private var job: Job? = null + + private val preferences: SharedPreferences + get() = getApplication().preferences + + init { + collectItems() + } + + private fun collectItems( + sortBy: PlaylistSortBy = this.sortBy, + sortOrder: SortOrder = this.sortOrder + ) { + job?.cancel() + job = viewModelScope.launch { + Database.playlistPreviews(sortBy, sortOrder).flowOn(Dispatchers.IO).collect { + items = it + } + } + } +} + +@ExperimentalFoundationApi +@Composable +fun PlaylistsTab( + viewModel: PlaylistsTabViewModel = viewModel(), + onBuiltInPlaylistClicked: (BuiltInPlaylist) -> Unit, + onPlaylistClicked: (Playlist) -> Unit, +) { + val (colorPalette, typography) = LocalAppearance.current + + var isCreatingANewPlaylist by rememberSaveable { + mutableStateOf(false) + } + + if (isCreatingANewPlaylist) { + TextFieldDialog( + hintText = "Enter the playlist name", + onDismiss = { + isCreatingANewPlaylist = false + }, + onDone = { text -> + query { + Database.insert(Playlist(name = text)) + } + } + ) + } + + val sortOrderIconRotation by animateFloatAsState( + targetValue = if (viewModel.sortOrder == SortOrder.Ascending) 0f else 180f, + 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 + .fillMaxSize() + .background(colorPalette.background0) + ) { + item( + key = "header", + contentType = 0, + span = { GridItemSpan(maxLineSpan) } + ) { + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.Bottom, + modifier = Modifier + .padding(horizontal = 16.dp) + .height(128.dp) + .fillMaxWidth() + ) { + BasicText( + text = "Playlists", + style = typography.xxl.medium + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .padding(vertical = 8.dp) + ) { + @Composable + fun Item( + @DrawableRes iconId: Int, + sortBy: PlaylistSortBy + ) { + Image( + painter = painterResource(iconId), + contentDescription = null, + colorFilter = ColorFilter.tint(if (viewModel.sortBy == sortBy) colorPalette.text else colorPalette.textDisabled), + modifier = Modifier + .clickable { viewModel.sortBy = sortBy } + .padding(all = 4.dp) + .size(18.dp) + ) + } + + BasicText( + text = "New playlist", + style = typography.xxs.medium, + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .clickable { isCreatingANewPlaylist = true } + .background(colorPalette.background2) + .padding(all = 8.dp) + .padding(horizontal = 8.dp) + ) + + Spacer( + modifier = Modifier + .weight(1f) + ) + + Item( + iconId = R.drawable.medical, + sortBy = PlaylistSortBy.SongCount + ) + + Item( + iconId = R.drawable.text, + sortBy = PlaylistSortBy.Name + ) + + Item( + iconId = R.drawable.calendar, + sortBy = PlaylistSortBy.DateAdded + ) + + Spacer( + modifier = Modifier + .width(2.dp) + ) + + Image( + painter = painterResource(R.drawable.arrow_up), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { viewModel.sortOrder = !viewModel.sortOrder } + .padding(all = 4.dp) + .size(18.dp) + .graphicsLayer { rotationZ = sortOrderIconRotation } + ) + } + } + } + + item(key = "favorites") { + BuiltInPlaylistItem( + icon = R.drawable.heart, + colorTint = colorPalette.red, + name = "Favorites", + modifier = Modifier + .clickable( + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() }, + onClick = { onBuiltInPlaylistClicked(BuiltInPlaylist.Favorites) } + ) + ) + } + + item(key = "offline") { + BuiltInPlaylistItem( + icon = R.drawable.airplane, + colorTint = colorPalette.blue, + name = "Offline", + modifier = Modifier + .clickable( + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() }, + onClick = { onBuiltInPlaylistClicked(BuiltInPlaylist.Offline) } + ) + .animateItemPlacement() + ) + } + + items( + items = viewModel.items, + key = { it.playlist.id } + ) { playlistPreview -> + PlaylistPreviewItem( + playlistPreview = playlistPreview, + modifier = Modifier + .clickable( + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() }, + onClick = { onPlaylistClicked(playlistPreview.playlist) } + ) + .animateItemPlacement() + ) + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongsTab.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongsTab.kt new file mode 100644 index 0000000..ccfe8c1 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongsTab.kt @@ -0,0 +1,261 @@ +package it.vfsfitvnm.vimusic.ui.views + +import android.app.Application +import android.content.SharedPreferences +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.content.edit +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues +import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.enums.SongSortBy +import it.vfsfitvnm.vimusic.enums.SortOrder +import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness +import it.vfsfitvnm.vimusic.models.DetailedSong +import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu +import it.vfsfitvnm.vimusic.ui.styling.Dimensions +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.px +import it.vfsfitvnm.vimusic.utils.asMediaItem +import it.vfsfitvnm.vimusic.utils.center +import it.vfsfitvnm.vimusic.utils.color +import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex +import it.vfsfitvnm.vimusic.utils.getEnum +import it.vfsfitvnm.vimusic.utils.medium +import it.vfsfitvnm.vimusic.utils.mutableStatePreferenceOf +import it.vfsfitvnm.vimusic.utils.preferences +import it.vfsfitvnm.vimusic.utils.putEnum +import it.vfsfitvnm.vimusic.utils.semiBold +import it.vfsfitvnm.vimusic.utils.songSortByKey +import it.vfsfitvnm.vimusic.utils.songSortOrderKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.launch + +class SongsTabViewModel(application: Application) : AndroidViewModel(application) { + var items by mutableStateOf(emptyList()) + private set + + var sortBy by mutableStatePreferenceOf(preferences.getEnum(songSortByKey, SongSortBy.DateAdded)) { + preferences.edit { putEnum(songSortByKey, it) } + collectItems(sortBy = it) + } + + var sortOrder by mutableStatePreferenceOf(preferences.getEnum(songSortOrderKey, SortOrder.Ascending)) { + preferences.edit { putEnum(songSortOrderKey, it) } + collectItems(sortOrder = it) + } + + private var job: Job? = null + + private val preferences: SharedPreferences + get() = getApplication().preferences + + init { + collectItems() + } + + private fun collectItems(sortBy: SongSortBy = this.sortBy, sortOrder: SortOrder = this.sortOrder) { + job?.cancel() + job = viewModelScope.launch { + Database.songs(sortBy, sortOrder).flowOn(Dispatchers.IO).collect { + items = it + } + } + } +} + +@ExperimentalFoundationApi +@ExperimentalAnimationApi +@Composable +fun SongsTab( + viewModel: SongsTabViewModel = viewModel() +) { + val (colorPalette, typography) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + + val thumbnailSize = Dimensions.thumbnails.song.px + + val sortOrderIconRotation by animateFloatAsState( + targetValue = if (viewModel.sortOrder == SortOrder.Ascending) 0f else 180f, + animationSpec = tween(durationMillis = 400, easing = LinearEasing) + ) + + LazyColumn( + contentPadding = LocalPlayerAwarePaddingValues.current, + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0 + ) { + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.Bottom, + modifier = Modifier + .padding(horizontal = 16.dp) + .height(128.dp) + .fillMaxWidth() + ) { + BasicText( + text = "Songs", + style = typography.xxl.medium + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .padding(vertical = 8.dp) + ) { + @Composable + fun Item( + @DrawableRes iconId: Int, + sortBy: SongSortBy + ) { + Image( + painter = painterResource(iconId), + contentDescription = null, + colorFilter = ColorFilter.tint(if (viewModel.sortBy == sortBy) colorPalette.text else colorPalette.textDisabled), + modifier = Modifier + .clickable { viewModel.sortBy = sortBy } + .padding(all = 4.dp) + .size(18.dp) + ) + } + + Item( + iconId = R.drawable.trending, + sortBy = SongSortBy.PlayTime + ) + + Item( + iconId = R.drawable.text, + sortBy = SongSortBy.Title + ) + + Item( + iconId = R.drawable.calendar, + sortBy = SongSortBy.DateAdded + ) + + Spacer( + modifier = Modifier + .width(2.dp) + ) + + Image( + painter = painterResource(R.drawable.arrow_up), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { viewModel.sortOrder = !viewModel.sortOrder } + .padding(all = 4.dp) + .size(18.dp) + .graphicsLayer { rotationZ = sortOrderIconRotation } + ) + } + } + } + + itemsIndexed( + items = viewModel.items, + key = { _, song -> song.id } + ) { index, song -> + SongItem( + song = song, + thumbnailSize = thumbnailSize, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlayAtIndex( + viewModel.items.map(DetailedSong::asMediaItem), + index + ) + }, + menuContent = { + InHistoryMediaItemMenu(song = song) + }, + onThumbnailContent = { + AnimatedVisibility( + visible = viewModel.sortBy == SongSortBy.PlayTime, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .align(Alignment.BottomCenter) + ) { + BasicText( + text = song.formattedTotalPlayTime, + style = typography.xxs.semiBold.center.color( + Color.White + ), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.75f) + ) + ), + shape = ThumbnailRoundness.shape + ) + .padding( + horizontal = 8.dp, + vertical = 4.dp + ) + ) + } + }, + modifier = Modifier + .animateItemPlacement() + ) + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt index de2c103..945bc3f 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt @@ -21,7 +21,6 @@ const val songSortOrderKey = "songSortOrder" const val songSortByKey = "songSortBy" const val playlistSortOrderKey = "playlistSortOrder" const val playlistSortByKey = "playlistSortBy" -const val playlistGridExpandedKey = "playlistGridExpanded" const val searchFilterKey = "searchFilter" const val repeatModeKey = "repeatMode" const val skipSilenceKey = "skipSilence" @@ -29,6 +28,7 @@ const val volumeNormalizationKey = "volumeNormalization" const val persistentQueueKey = "persistentQueue" const val isShowingSynchronizedLyricsKey = "isShowingSynchronizedLyrics" const val isShowingThumbnailInLockscreenKey = "isShowingThumbnailInLockscreen" +const val homeScreenTabIndexKey = "homeScreenTabIndex" inline fun > SharedPreferences.getEnum( key: String, @@ -61,6 +61,16 @@ fun rememberPreference(key: String, defaultValue: Boolean): MutableState { + val context = LocalContext.current + return remember { + mutableStatePreferenceOf(context.preferences.getInt(key, defaultValue)) { + context.preferences.edit { putInt(key, it) } + } + } +} + @Composable fun rememberPreference(key: String, defaultValue: String): MutableState { val context = LocalContext.current diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RememberLazyListStates.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RememberLazyListStates.kt new file mode 100644 index 0000000..c914a7f --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RememberLazyListStates.kt @@ -0,0 +1,61 @@ +package it.vfsfitvnm.vimusic.utils + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable + +@Composable +fun rememberLazyListStates(count: Int): List { + return rememberSaveable( + saver = listSaver( + save = { states: List -> + List(states.size * 2) { + when (it % 2) { + 0 -> states[it / 2].firstVisibleItemIndex + 1 -> states[it / 2].firstVisibleItemScrollOffset + else -> error("unreachable") + } + } + }, + restore = { states -> + List(states.size / 2) { + LazyListState( + firstVisibleItemIndex = states[it * 2], + firstVisibleItemScrollOffset = states[it * 2 + 1] + ) + } + } + ) + ) { + List(count) { LazyListState(0, 0) } + } +} + +@Composable +fun rememberLazyGridStates(count: Int): List { + return rememberSaveable( + saver = listSaver( + save = { states: List -> + List(states.size * 2) { + when (it % 2) { + 0 -> states[it / 2].firstVisibleItemIndex + 1 -> states[it / 2].firstVisibleItemScrollOffset + else -> error("unreachable") + } + } + }, + restore = { states -> + List(states.size / 2) { + LazyGridState( + firstVisibleItemIndex = states[it * 2], + firstVisibleItemScrollOffset = states[it * 2 + 1] + ) + } + } + ) + ) { + List(count) { LazyGridState(0, 0) } + } +} diff --git a/app/src/main/res/drawable/arrow_down.xml b/app/src/main/res/drawable/arrow_down.xml new file mode 100644 index 0000000..9d45330 --- /dev/null +++ b/app/src/main/res/drawable/arrow_down.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/arrow_up.xml b/app/src/main/res/drawable/arrow_up.xml new file mode 100644 index 0000000..9de10de --- /dev/null +++ b/app/src/main/res/drawable/arrow_up.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/calendar.xml b/app/src/main/res/drawable/calendar.xml new file mode 100644 index 0000000..3eb788b --- /dev/null +++ b/app/src/main/res/drawable/calendar.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/chevron_down.xml b/app/src/main/res/drawable/chevron_down.xml index 4504c11..41cdd90 100644 --- a/app/src/main/res/drawable/chevron_down.xml +++ b/app/src/main/res/drawable/chevron_down.xml @@ -1,6 +1,6 @@ + + diff --git a/app/src/main/res/drawable/musical_notes.xml b/app/src/main/res/drawable/musical_notes.xml new file mode 100644 index 0000000..ac341fb --- /dev/null +++ b/app/src/main/res/drawable/musical_notes.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/trending.xml b/app/src/main/res/drawable/trending.xml new file mode 100644 index 0000000..ed34e01 --- /dev/null +++ b/app/src/main/res/drawable/trending.xml @@ -0,0 +1,20 @@ + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 6c9737d..176589f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,6 +6,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { setUrl("https://jitpack.io") } } versionCatalogs {