Drop home page song collections in favor of a sort feature (#11)

This commit is contained in:
vfsfitvnm 2022-07-06 19:30:40 +02:00
parent ae00f8ea3d
commit 59b6c61bb2
9 changed files with 418 additions and 74 deletions

View file

@ -8,9 +8,11 @@ import android.os.Parcel
import androidx.media3.common.MediaItem
import androidx.room.*
import androidx.room.migration.AutoMigrationSpec
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.room.migration.Migration
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteDatabase
import it.vfsfitvnm.vimusic.enums.SongSortBy
import it.vfsfitvnm.vimusic.enums.SortOrder
import it.vfsfitvnm.vimusic.models.*
import it.vfsfitvnm.vimusic.utils.getFloatOrNull
import it.vfsfitvnm.vimusic.utils.getLongOrNull
@ -21,6 +23,35 @@ import kotlinx.coroutines.flow.Flow
interface Database {
companion object : Database by DatabaseInitializer.Instance.database
@Transaction
@Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY ROWID ASC")
fun songsByRowIdAsc(): Flow<List<DetailedSong>>
@Transaction
@Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY ROWID DESC")
fun songsByRowIdDesc(): Flow<List<DetailedSong>>
@Transaction
@Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY totalPlayTimeMs ASC")
fun songsByPlayTimeAsc(): Flow<List<DetailedSong>>
@Transaction
@Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY totalPlayTimeMs DESC")
fun songsByPlayTimeDesc(): Flow<List<DetailedSong>>
fun songs(sortBy: SongSortBy, sortOrder: SortOrder): Flow<List<DetailedSong>> {
return when (sortBy) {
SongSortBy.PlayTime -> when (sortOrder) {
SortOrder.Ascending -> songsByPlayTimeAsc()
SortOrder.Descending -> songsByPlayTimeDesc()
}
SongSortBy.DateAdded -> when (sortOrder) {
SortOrder.Ascending -> songsByRowIdAsc()
SortOrder.Descending -> songsByRowIdDesc()
}
}
}
@Transaction
@Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY ROWID DESC")
fun history(): Flow<List<DetailedSong>>
@ -29,10 +60,6 @@ interface Database {
@Query("SELECT * FROM Song WHERE likedAt IS NOT NULL ORDER BY likedAt DESC")
fun favorites(): Flow<List<DetailedSong>>
@Transaction
@Query("SELECT * FROM Song WHERE totalPlayTimeMs >= 60000 ORDER BY totalPlayTimeMs DESC LIMIT 20")
fun mostPlayed(): Flow<List<DetailedSong>>
@Query("SELECT * FROM QueuedMediaItem")
fun queue(): List<QueuedMediaItem>

View file

@ -1,7 +0,0 @@
package it.vfsfitvnm.vimusic.enums
enum class SongCollection {
MostPlayed,
Favorites,
History
}

View file

@ -0,0 +1,6 @@
package it.vfsfitvnm.vimusic.enums
enum class SongSortBy {
PlayTime,
DateAdded
}

View file

@ -0,0 +1,11 @@
package it.vfsfitvnm.vimusic.enums
enum class SortOrder {
Ascending,
Descending;
operator fun not() = when (this) {
Ascending -> Descending
Descending -> Ascending
}
}

View file

@ -0,0 +1,191 @@
package it.vfsfitvnm.vimusic.ui.components.themed
import androidx.compose.animation.core.*
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.*
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import kotlin.math.max
import kotlin.math.min
@Composable
fun DropdownMenu(
isDisplayed: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
offset: DpOffset = DpOffset(0.dp, 0.dp),
properties: PopupProperties = PopupProperties(focusable = true),
content: @Composable ColumnScope.() -> Unit
) {
val expandedStates = remember {
MutableTransitionState(false)
}.apply { targetState = isDisplayed }
if (expandedStates.currentState || expandedStates.targetState) {
val density = LocalDensity.current
var transformOrigin by remember {
mutableStateOf(TransformOrigin.Center)
}
val popupPositionProvider =
DropdownMenuPositionProvider(offset, density) { parentBounds, menuBounds ->
transformOrigin = calculateTransformOrigin(parentBounds, menuBounds)
}
Popup(
onDismissRequest = onDismissRequest,
popupPositionProvider = popupPositionProvider,
properties = properties
) {
DropdownMenuContent(
expandedStates = expandedStates,
transformOrigin = transformOrigin,
modifier = modifier,
content = content
)
}
}
}
@Composable
internal fun DropdownMenuContent(
expandedStates: MutableTransitionState<Boolean>,
transformOrigin: TransformOrigin,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
val transition = updateTransition(expandedStates, "DropDownMenu")
val scale by transition.animateFloat(
transitionSpec = {
if (false isTransitioningTo true) {
// Dismissed to expanded
tween(
durationMillis = 128,
easing = LinearOutSlowInEasing
)
} else {
// Expanded to dismissed.
tween(
durationMillis = 64,
delayMillis = 64
)
}
}, label = ""
) { isDisplayed ->
if (isDisplayed) 1f else 0.9f
}
Column(
modifier = modifier
.graphicsLayer {
scaleX = scale
scaleY = scale
this.transformOrigin = transformOrigin
},
content = content,
)
}
@Immutable
private data class DropdownMenuPositionProvider(
val contentOffset: DpOffset,
val density: Density,
val onPositionCalculated: (IntRect, IntRect) -> Unit = { _, _ -> }
) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize
): IntOffset {
// The min margin above and below the menu, relative to the screen.
val verticalMargin = with(density) { 48.dp.roundToPx() }
// The content offset specified using the dropdown offset parameter.
val contentOffsetX = with(density) { contentOffset.x.roundToPx() }
val contentOffsetY = with(density) { contentOffset.y.roundToPx() }
// Compute horizontal position.
val toRight = anchorBounds.left + contentOffsetX
val toLeft = anchorBounds.right - contentOffsetX - popupContentSize.width
val toDisplayRight = windowSize.width - popupContentSize.width
val toDisplayLeft = 0
val x = if (layoutDirection == LayoutDirection.Ltr) {
sequenceOf(
toRight,
toLeft,
// If the anchor gets outside of the window on the left, we want to position
// toDisplayLeft for proximity to the anchor. Otherwise, toDisplayRight.
if (anchorBounds.left >= 0) toDisplayRight else toDisplayLeft
)
} else {
sequenceOf(
toLeft,
toRight,
// If the anchor gets outside of the window on the right, we want to position
// toDisplayRight for proximity to the anchor. Otherwise, toDisplayLeft.
if (anchorBounds.right <= windowSize.width) toDisplayLeft else toDisplayRight
)
}.firstOrNull {
it >= 0 && it + popupContentSize.width <= windowSize.width
} ?: toLeft
// Compute vertical position.
val toBottom = maxOf(anchorBounds.bottom + contentOffsetY, verticalMargin)
val toTop = anchorBounds.top - contentOffsetY - popupContentSize.height
val toCenter = anchorBounds.top - popupContentSize.height / 2
val toDisplayBottom = windowSize.height - popupContentSize.height - verticalMargin
val y = sequenceOf(toBottom, toTop, toCenter, toDisplayBottom).firstOrNull {
it >= verticalMargin &&
it + popupContentSize.height <= windowSize.height - verticalMargin
} ?: toTop
onPositionCalculated(
anchorBounds,
IntRect(x, y, x + popupContentSize.width, y + popupContentSize.height)
)
return IntOffset(x, y)
}
}
fun calculateTransformOrigin(
parentBounds: IntRect,
menuBounds: IntRect
): TransformOrigin {
val pivotX = when {
menuBounds.left >= parentBounds.right -> 0f
menuBounds.right <= parentBounds.left -> 1f
menuBounds.width == 0 -> 0f
else -> {
val intersectionCenter =
(
max(parentBounds.left, menuBounds.left) +
min(parentBounds.right, menuBounds.right)
) / 2
(intersectionCenter - menuBounds.left).toFloat() / menuBounds.width
}
}
val pivotY = when {
menuBounds.top >= parentBounds.bottom -> 0f
menuBounds.bottom <= parentBounds.top -> 1f
menuBounds.height == 0 -> 0f
else -> {
val intersectionCenter =
(
max(parentBounds.top, menuBounds.top) +
min(parentBounds.bottom, menuBounds.bottom)
) / 2
(intersectionCenter - menuBounds.top).toFloat() / menuBounds.height
}
}
return TransformOrigin(pivotX, pivotY)
}

View file

@ -14,12 +14,14 @@ import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
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
@ -27,20 +29,22 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.route.fastFade
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.enums.SongCollection
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.models.DetailedSong
import it.vfsfitvnm.vimusic.query
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
import it.vfsfitvnm.vimusic.ui.components.themed.InFavoritesMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.DropdownMenu
import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog
@ -75,12 +79,8 @@ fun HomeScreen() {
val preferences = LocalPreferences.current
val songCollection by remember(preferences.homePageSongCollection) {
when (preferences.homePageSongCollection) {
SongCollection.MostPlayed -> Database.mostPlayed()
SongCollection.Favorites -> Database.favorites()
SongCollection.History -> Database.history()
}
val songCollection by remember(preferences.songSortBy, preferences.songSortOrder) {
Database.songs(preferences.songSortBy, preferences.songSortOrder)
}.collectAsState(initial = emptyList(), context = Dispatchers.IO)
RouteHandler(
@ -313,48 +313,13 @@ fun HomeScreen() {
.padding(horizontal = 8.dp)
.padding(top = 32.dp)
) {
Row(
verticalAlignment = Alignment.Bottom,
BasicText(
text = "Songs",
style = typography.m.semiBold,
modifier = Modifier
.weight(1f)
.padding(horizontal = 8.dp)
) {
BasicText(
text = when (preferences.homePageSongCollection) {
SongCollection.MostPlayed -> "Most played"
SongCollection.Favorites -> "Favorites"
SongCollection.History -> "History"
},
style = typography.m.semiBold,
modifier = Modifier
.alignByBaseline()
.animateContentSize()
)
val songCollections = enumValues<SongCollection>()
val nextSongCollection =
songCollections[(preferences.homePageSongCollection.ordinal + 1) % songCollections.size]
BasicText(
text = when (nextSongCollection) {
SongCollection.MostPlayed -> "Most played"
SongCollection.Favorites -> "Favorites"
SongCollection.History -> "History"
},
style = typography.xxs.secondary.bold,
modifier = Modifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = {
preferences.homePageSongCollection = nextSongCollection
}
)
.alignByBaseline()
.padding(horizontal = 16.dp)
.animateContentSize()
)
}
)
Image(
painter = painterResource(R.drawable.shuffle),
@ -372,6 +337,126 @@ fun HomeScreen() {
.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
}
) {
@Composable
fun Item(
text: String,
textColor: Color,
backgroundColor: Color,
onClick: () -> Unit
) {
BasicText(
text = text,
style = typography.xxs.copy(color = textColor, letterSpacing = 1.sp),
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = {
isSortMenuDisplayed = false
onClick()
}
)
.background(backgroundColor)
.fillMaxWidth()
.widthIn(min = 124.dp, max = 248.dp)
.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
@Composable
fun Item(
text: String,
isSelected: Boolean,
onClick: () -> Unit
) {
Item(
text = text,
textColor = if (isSelected) {
colorPalette.onPrimaryContainer
} else {
colorPalette.textSecondary
},
backgroundColor = if (isSelected) {
colorPalette.primaryContainer
} else {
colorPalette.elevatedBackground
},
onClick = onClick
)
}
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(colorPalette.elevatedBackground)
.width(IntrinsicSize.Max),
) {
Item(
text = "PLAY TIME",
isSelected = preferences.songSortBy == SongSortBy.PlayTime,
onClick = {
preferences.songSortBy = SongSortBy.PlayTime
}
)
Item(
text = "DATE ADDED",
isSelected = preferences.songSortBy == SongSortBy.DateAdded,
onClick = {
preferences.songSortBy = SongSortBy.DateAdded
}
)
}
Spacer(
modifier = Modifier
.height(4.dp)
)
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(colorPalette.elevatedBackground)
.width(IntrinsicSize.Max),
) {
Item(
text = when (preferences.songSortOrder) {
SortOrder.Ascending -> "ASCENDING"
SortOrder.Descending -> "DESCENDING"
},
textColor = colorPalette.text,
backgroundColor = colorPalette.elevatedBackground,
onClick = {
preferences.songSortOrder = !preferences.songSortOrder
}
)
}
}
}
}
}
@ -393,15 +478,14 @@ fun HomeScreen() {
)
},
menuContent = {
when (preferences.homePageSongCollection) {
SongCollection.MostPlayed -> NonQueuedMediaItemMenu(mediaItem = song.asMediaItem)
SongCollection.Favorites -> InFavoritesMediaItemMenu(song = song)
SongCollection.History -> InHistoryMediaItemMenu(song = song)
when (preferences.songSortBy) {
SongSortBy.PlayTime -> NonQueuedMediaItemMenu(mediaItem = song.asMediaItem)
SongSortBy.DateAdded -> InHistoryMediaItemMenu(song = song)
}
},
onThumbnailContent = {
AnimatedVisibility(
visible = preferences.homePageSongCollection == SongCollection.MostPlayed,
visible = preferences.songSortBy == SongSortBy.PlayTime,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier

View file

@ -6,19 +6,18 @@ import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.edit
import androidx.media3.common.Player
import it.vfsfitvnm.vimusic.enums.ColorPaletteMode
import it.vfsfitvnm.vimusic.enums.SongCollection
import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
import it.vfsfitvnm.vimusic.enums.*
import it.vfsfitvnm.youtubemusic.YouTube
@Stable
class Preferences(
private val edit: (action: SharedPreferences.Editor.() -> Unit) -> Unit,
initialSongSortBy: SongSortBy,
initialSongSortOrder: SortOrder,
initialColorPaletteMode: ColorPaletteMode,
initialSearchFilter: String,
initialRepeatMode: Int,
initialHomePageSongCollection: SongCollection,
initialThumbnailRoundness: ThumbnailRoundness,
initialCoilDiskCacheMaxSizeBytes: Long,
initialExoPlayerDiskCacheMaxSizeBytes: Long,
@ -30,10 +29,11 @@ class Preferences(
edit = { action: SharedPreferences.Editor.() -> Unit ->
preferences.edit(action = action)
},
initialSongSortBy = preferences.getEnum(Keys.songSortBy, SongSortBy.DateAdded),
initialSongSortOrder = preferences.getEnum(Keys.songSortOrder, SortOrder.Descending),
initialColorPaletteMode = preferences.getEnum(Keys.colorPaletteMode, ColorPaletteMode.System),
initialSearchFilter = preferences.getString(Keys.searchFilter, YouTube.Item.Song.Filter.value)!!,
initialRepeatMode = preferences.getInt(Keys.repeatMode, Player.REPEAT_MODE_OFF),
initialHomePageSongCollection = preferences.getEnum(Keys.homePageSongCollection, SongCollection.History),
initialThumbnailRoundness = preferences.getEnum(Keys.thumbnailRoundness, ThumbnailRoundness.Light),
initialCoilDiskCacheMaxSizeBytes = preferences.getLong(Keys.coilDiskCacheMaxSizeBytes, 512L * 1024 * 1024),
initialExoPlayerDiskCacheMaxSizeBytes = preferences.getLong(Keys.exoPlayerDiskCacheMaxSizeBytes, 512L * 1024 * 1024),
@ -42,6 +42,12 @@ class Preferences(
initialPersistentQueue = preferences.getBoolean(Keys.persistentQueue, false)
)
var songSortBy = initialSongSortBy
set(value) = edit { putEnum(Keys.songSortBy, value) }
var songSortOrder = initialSongSortOrder
set(value) = edit { putEnum(Keys.songSortOrder, value) }
var colorPaletteMode = initialColorPaletteMode
set(value) = edit { putEnum(Keys.colorPaletteMode, value) }
@ -51,9 +57,6 @@ class Preferences(
var repeatMode = initialRepeatMode
set(value) = edit { putInt(Keys.repeatMode, value) }
var homePageSongCollection = initialHomePageSongCollection
set(value) = edit { putEnum(Keys.homePageSongCollection, value) }
var thumbnailRoundness = initialThumbnailRoundness
set(value) = edit { putEnum(Keys.thumbnailRoundness, value) }
@ -73,10 +76,11 @@ class Preferences(
set(value) = edit { putBoolean(Keys.persistentQueue, value) }
object Keys {
const val songSortOrder = "songSortOrder"
const val songSortBy = "songSortBy"
const val colorPaletteMode = "colorPaletteMode"
const val searchFilter = "searchFilter"
const val repeatMode = "repeatMode"
const val homePageSongCollection = "homePageSongCollection"
const val thumbnailRoundness = "thumbnailRoundness"
const val coilDiskCacheMaxSizeBytes = "coilDiskCacheMaxSizeBytes"
const val exoPlayerDiskCacheMaxSizeBytes = "exoPlayerDiskCacheMaxSizeBytes"

View file

@ -0,0 +1,13 @@
<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="M416,128l-224,256l-96,-96"
android:strokeLineJoin="round"
android:strokeWidth="32"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
</vector>

View file

@ -0,0 +1,15 @@
<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="M472,168H40a24,24 0,0 1,0 -48H472a24,24 0,0 1,0 48Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M312,280H40a24,24 0,0 1,0 -48h272a24,24 0,0 1,0 48z"/>
<path
android:fillColor="#FF000000"
android:pathData="M120,392H40a24,24 0,0 1,0 -48h80a24,24 0,0 1,0 48z"/>
</vector>