Start UI redesign (#172)

This commit is contained in:
vfsfitvnm 2022-09-22 19:08:01 +02:00
parent b0e5344560
commit 563c6175f7
20 changed files with 1219 additions and 525 deletions

View file

@ -97,4 +97,6 @@ dependencies {
implementation(projects.kugou)
coreLibraryDesugaring(libs.desugaring)
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1")
}

View file

@ -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")

View file

@ -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)
)
}
}

View file

@ -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()
}
}

View file

@ -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)
)
}
}
}
}

View file

@ -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()
)
}
}
}

View file

@ -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),
)
}

View file

@ -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
)
}
}

View file

@ -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<PlaylistPreview>())
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<Application>().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()
)
}
}
}

View file

@ -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<DetailedSong>())
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<Application>().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()
)
}
}
}

View file

@ -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 <reified T : Enum<T>> SharedPreferences.getEnum(
key: String,
@ -61,6 +61,16 @@ fun rememberPreference(key: String, defaultValue: Boolean): MutableState<Boolean
}
}
@Composable
fun rememberPreference(key: String, defaultValue: Int): MutableState<Int> {
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<String> {
val context = LocalContext.current

View file

@ -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<LazyListState> {
return rememberSaveable(
saver = listSaver(
save = { states: List<LazyListState> ->
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<LazyGridState> {
return rememberSaveable(
saver = listSaver(
save = { states: List<LazyGridState> ->
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) }
}
}

View file

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

View file

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

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M480,128a64,64 0,0 0,-64 -64H400V48.45c0,-8.61 -6.62,-16 -15.23,-16.43A16,16 0,0 0,368 48V64H144V48.45c0,-8.61 -6.62,-16 -15.23,-16.43A16,16 0,0 0,112 48V64H96a64,64 0,0 0,-64 64v12a4,4 0,0 0,4 4H476a4,4 0,0 0,4 -4Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M32,416a64,64 0,0 0,64 64L416,480a64,64 0,0 0,64 -64L480,179a3,3 0,0 0,-3 -3L35,176a3,3 0,0 0,-3 3ZM376,208a24,24 0,1 1,-24 24A24,24 0,0 1,376 208ZM376,288a24,24 0,1 1,-24 24A24,24 0,0 1,376 288ZM296,208a24,24 0,1 1,-24 24A24,24 0,0 1,296 208ZM296,288a24,24 0,1 1,-24 24A24,24 0,0 1,296 288ZM296,368a24,24 0,1 1,-24 24A24,24 0,0 1,296 368ZM216,288a24,24 0,1 1,-24 24A24,24 0,0 1,216 288ZM216,368a24,24 0,1 1,-24 24A24,24 0,0 1,216 368ZM136,288a24,24 0,1 1,-24 24A24,24 0,0 1,136 288ZM136,368a24,24 0,1 1,-24 24A24,24 0,0 1,136 368Z"/>
</vector>

View file

@ -1,6 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M272,464L240,464a32,32 0,0 1,-32 -32l0.05,-85.82a4,4 0,0 0,-6 -3.47l-74.34,43.06a31.48,31.48 0,0 1,-43 -11.52L68.21,345.61l-0.06,-0.1a31.65,31.65 0,0 1,11.56 -42.8l74.61,-43.25a4,4 0,0 0,0 -6.92L79.78,209.33a31.41,31.41 0,0 1,-11.55 -43l16.44,-28.55a31.48,31.48 0,0 1,19.27 -14.74,31.14 31.14,0 0,1 23.8,3.2l74.31,43a4,4 0,0 0,6 -3.47L208,80a32,32 0,0 1,32 -32h32a32,32 0,0 1,32 32L304,165.72a4,4 0,0 0,6 3.47l74.34,-43.06a31.51,31.51 0,0 1,43 11.52l16.49,28.64 0.06,0.09a31.52,31.52 0,0 1,-11.64 42.86l-74.53,43.2a4,4 0,0 0,0 6.92l74.53,43.2a31.42,31.42 0,0 1,11.56 43l-16.44,28.55a31.48,31.48 0,0 1,-19.27 14.74,31.14 31.14,0 0,1 -23.8,-3.2l-74.31,-43a4,4 0,0 0,-6 3.46L304,432A32,32 0,0 1,272 464ZM178.44,266.52h0ZM178.44,245.52h0ZM333.54,245.44ZM333.54,245.44h0Z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M421.84,37.37a25.86,25.86 0,0 0,-22.6 -4.46L199.92,86.49A32.3,32.3 0,0 0,176 118v226c0,6.74 -4.36,12.56 -11.11,14.83l-0.12,0.05 -52,18C92.88,383.53 80,402 80,423.91a55.54,55.54 0,0 0,23.23 45.63A54.78,54.78 0,0 0,135.34 480a55.82,55.82 0,0 0,17.75 -2.93l0.38,-0.13L175.31,469A47.84,47.84 0,0 0,208 423.91v-212c0,-7.29 4.77,-13.21 12.16,-15.07l0.21,-0.06L395,150.14a4,4 0,0 1,5 3.86V295.93c0,6.75 -4.25,12.38 -11.11,14.68l-0.25,0.09 -50.89,18.11A49.09,49.09 0,0 0,304 375.92a55.67,55.67 0,0 0,23.23 45.8,54.63 54.63,0 0,0 49.88,7.35l0.36,-0.12L399.31,421A47.83,47.83 0,0 0,432 375.92V58A25.74,25.74 0,0 0,421.84 37.37Z"/>
</vector>

View file

@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M352,144l112,0l0,112"
android:strokeLineJoin="round"
android:strokeWidth="32"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
<path
android:pathData="M48,368 L169.37,246.63a32,32 0,0 1,45.26 0l50.74,50.74a32,32 0,0 0,45.26 0L448,160"
android:strokeLineJoin="round"
android:strokeWidth="32"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
</vector>

View file

@ -6,6 +6,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { setUrl("https://jitpack.io") }
}
versionCatalogs {