Reorganize UI packages
This commit is contained in:
@@ -9,17 +9,11 @@ import it.vfsfitvnm.route.Route1
|
||||
import it.vfsfitvnm.route.RouteHandlerScope
|
||||
import it.vfsfitvnm.vimusic.enums.BuiltInPlaylist
|
||||
|
||||
val aboutRoute = Route0("aboutRoute")
|
||||
val albumRoute = Route1<String?>("albumRoute")
|
||||
val appearanceSettingsRoute = Route0("appearanceSettingsRoute")
|
||||
val artistRoute = Route1<String?>("artistRoute")
|
||||
val backupAndRestoreRoute = Route0("backupAndRestoreRoute")
|
||||
val builtInPlaylistRoute = Route1<BuiltInPlaylist>("builtInPlaylistRoute")
|
||||
val cacheSettingsRoute = Route0("cacheSettingsRoute")
|
||||
val intentUriRoute = Route1<Uri?>("intentUriRoute")
|
||||
val localPlaylistRoute = Route1<Long?>("localPlaylistRoute")
|
||||
val otherSettingsRoute = Route0("otherSettingsRoute")
|
||||
val playerSettingsRoute = Route0("playerSettingsRoute")
|
||||
val playlistRoute = Route1<String?>("playlistRoute")
|
||||
val searchResultRoute = Route1<String>("searchResultRoute")
|
||||
val searchRoute = Route1<String>("searchRoute")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens
|
||||
package it.vfsfitvnm.vimusic.ui.screens.home
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
@@ -11,10 +11,19 @@ import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.models.SearchQuery
|
||||
import it.vfsfitvnm.vimusic.query
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold
|
||||
import it.vfsfitvnm.vimusic.ui.screens.BuiltInPlaylistScreen
|
||||
import it.vfsfitvnm.vimusic.ui.screens.IntentUriScreen
|
||||
import it.vfsfitvnm.vimusic.ui.screens.LocalPlaylistScreen
|
||||
import it.vfsfitvnm.vimusic.ui.screens.SearchResultScreen
|
||||
import it.vfsfitvnm.vimusic.ui.screens.builtInPlaylistRoute
|
||||
import it.vfsfitvnm.vimusic.ui.screens.globalRoutes
|
||||
import it.vfsfitvnm.vimusic.ui.screens.intentUriRoute
|
||||
import it.vfsfitvnm.vimusic.ui.screens.localPlaylistRoute
|
||||
import it.vfsfitvnm.vimusic.ui.screens.search.SearchScreen
|
||||
import it.vfsfitvnm.vimusic.ui.screens.searchResultRoute
|
||||
import it.vfsfitvnm.vimusic.ui.screens.searchRoute
|
||||
import it.vfsfitvnm.vimusic.ui.screens.settings.SettingsScreen
|
||||
import it.vfsfitvnm.vimusic.ui.views.PlaylistsTab
|
||||
import it.vfsfitvnm.vimusic.ui.views.SongsTab
|
||||
import it.vfsfitvnm.vimusic.ui.screens.settingsRoute
|
||||
import it.vfsfitvnm.vimusic.utils.homeScreenTabIndexKey
|
||||
import it.vfsfitvnm.vimusic.utils.rememberPreference
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens.home
|
||||
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.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.query
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.Header
|
||||
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.views.BuiltInPlaylistItem
|
||||
import it.vfsfitvnm.vimusic.ui.views.PlaylistPreviewItem
|
||||
import it.vfsfitvnm.vimusic.utils.medium
|
||||
|
||||
@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) }
|
||||
) {
|
||||
Header(title = "Playlists") {
|
||||
@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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens.home
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.content.edit
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.enums.PlaylistSortBy
|
||||
import it.vfsfitvnm.vimusic.enums.SortOrder
|
||||
import it.vfsfitvnm.vimusic.models.PlaylistPreview
|
||||
import it.vfsfitvnm.vimusic.utils.getEnum
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens.home
|
||||
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
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.graphics.graphicsLayer
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
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.Header
|
||||
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.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.semiBold
|
||||
|
||||
@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
|
||||
) {
|
||||
Header(title = "Songs") {
|
||||
@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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens.home
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.content.edit
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.enums.SongSortBy
|
||||
import it.vfsfitvnm.vimusic.enums.SortOrder
|
||||
import it.vfsfitvnm.vimusic.models.DetailedSong
|
||||
import it.vfsfitvnm.vimusic.utils.getEnum
|
||||
import it.vfsfitvnm.vimusic.utils.mutableStatePreferenceOf
|
||||
import it.vfsfitvnm.vimusic.utils.preferences
|
||||
import it.vfsfitvnm.vimusic.utils.putEnum
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens.player
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateDp
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
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.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
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.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.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.models.Song
|
||||
import it.vfsfitvnm.vimusic.query
|
||||
import it.vfsfitvnm.vimusic.ui.components.SeekBar
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
||||
import it.vfsfitvnm.vimusic.ui.styling.favoritesIcon
|
||||
import it.vfsfitvnm.vimusic.utils.bold
|
||||
import it.vfsfitvnm.vimusic.utils.forceSeekToNext
|
||||
import it.vfsfitvnm.vimusic.utils.forceSeekToPrevious
|
||||
import it.vfsfitvnm.vimusic.utils.rememberRepeatMode
|
||||
import it.vfsfitvnm.vimusic.utils.secondary
|
||||
import it.vfsfitvnm.vimusic.utils.semiBold
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
@Composable
|
||||
fun Controls(
|
||||
mediaId: String,
|
||||
title: String?,
|
||||
artist: String?,
|
||||
shouldBePlaying: Boolean,
|
||||
position: Long,
|
||||
duration: Long,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val (colorPalette, typography) = LocalAppearance.current
|
||||
|
||||
val binder = LocalPlayerServiceBinder.current
|
||||
binder?.player ?: return
|
||||
|
||||
val repeatMode by rememberRepeatMode(binder.player)
|
||||
|
||||
var scrubbingPosition by remember(mediaId) {
|
||||
mutableStateOf<Long?>(null)
|
||||
}
|
||||
|
||||
val likedAt by remember(mediaId) {
|
||||
Database.likedAt(mediaId).distinctUntilChanged()
|
||||
}.collectAsState(initial = null, context = Dispatchers.IO)
|
||||
|
||||
val shouldBePlayingTransition = updateTransition(shouldBePlaying, label = "shouldBePlaying")
|
||||
|
||||
val playPauseRoundness by shouldBePlayingTransition.animateDp(
|
||||
transitionSpec = { tween(durationMillis = 100, easing = LinearEasing) },
|
||||
label = "playPauseRoundness",
|
||||
targetValueByState = { if (it) 32.dp else 16.dp }
|
||||
)
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp)
|
||||
) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
)
|
||||
|
||||
BasicText(
|
||||
text = title ?: "",
|
||||
style = typography.l.bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
BasicText(
|
||||
text = artist ?: "",
|
||||
style = typography.s.semiBold.secondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.weight(0.5f)
|
||||
)
|
||||
|
||||
SeekBar(
|
||||
value = scrubbingPosition ?: position,
|
||||
minimumValue = 0,
|
||||
maximumValue = duration,
|
||||
onDragStart = {
|
||||
scrubbingPosition = it
|
||||
},
|
||||
onDrag = { delta ->
|
||||
scrubbingPosition = if (duration != C.TIME_UNSET) {
|
||||
scrubbingPosition?.plus(delta)?.coerceIn(0, duration)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
onDragEnd = {
|
||||
scrubbingPosition?.let(binder.player::seekTo)
|
||||
scrubbingPosition = null
|
||||
},
|
||||
color = colorPalette.text,
|
||||
backgroundColor = colorPalette.background2,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.height(8.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
BasicText(
|
||||
text = DateUtils.formatElapsedTime((scrubbingPosition ?: position) / 1000),
|
||||
style = typography.xxs.semiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
if (duration != C.TIME_UNSET) {
|
||||
BasicText(
|
||||
text = DateUtils.formatElapsedTime(duration / 1000),
|
||||
style = typography.xxs.semiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(if (likedAt == null) R.drawable.heart_outline else R.drawable.heart),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.favoritesIcon),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
val currentMediaItem = binder.player.currentMediaItem
|
||||
query {
|
||||
if (Database.like(mediaId, if (likedAt == null) System.currentTimeMillis() else null) == 0) {
|
||||
currentMediaItem?.takeIf { it.mediaId == mediaId }?.let {
|
||||
Database.insert(currentMediaItem, Song::toggleLike)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.weight(1f)
|
||||
.size(24.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.play_skip_back),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable(onClick = binder.player::forceSeekToPrevious)
|
||||
.weight(1f)
|
||||
.size(24.dp)
|
||||
)
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.width(8.dp)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(playPauseRoundness))
|
||||
.clickable {
|
||||
if (shouldBePlaying) {
|
||||
binder.player.pause()
|
||||
} else {
|
||||
if (binder.player.playbackState == Player.STATE_IDLE) {
|
||||
binder.player.prepare()
|
||||
}
|
||||
binder.player.play()
|
||||
}
|
||||
}
|
||||
.background(colorPalette.background2)
|
||||
.size(64.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(if (shouldBePlaying) R.drawable.pause else R.drawable.play),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(28.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.width(8.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.play_skip_forward),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable(onClick = binder.player::forceSeekToNext)
|
||||
.weight(1f)
|
||||
.size(24.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.infinite),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(
|
||||
if (repeatMode == Player.REPEAT_MODE_ONE) {
|
||||
colorPalette.text
|
||||
} else {
|
||||
colorPalette.textDisabled
|
||||
}
|
||||
),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
binder.player.repeatMode = when (binder.player.repeatMode) {
|
||||
Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_ALL
|
||||
else -> Player.REPEAT_MODE_ONE
|
||||
}
|
||||
}
|
||||
.weight(1f)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens.player
|
||||
|
||||
import android.app.SearchManager
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
import it.vfsfitvnm.kugou.KuGou
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.query
|
||||
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.Menu
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.MenuEntry
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
|
||||
import it.vfsfitvnm.vimusic.ui.styling.DefaultDarkColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
||||
import it.vfsfitvnm.vimusic.ui.styling.PureBlackColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.onOverlayShimmer
|
||||
import it.vfsfitvnm.vimusic.utils.SynchronizedLyrics
|
||||
import it.vfsfitvnm.vimusic.utils.center
|
||||
import it.vfsfitvnm.vimusic.utils.color
|
||||
import it.vfsfitvnm.vimusic.utils.isShowingSynchronizedLyricsKey
|
||||
import it.vfsfitvnm.vimusic.utils.medium
|
||||
import it.vfsfitvnm.vimusic.utils.relaunchableEffect
|
||||
import it.vfsfitvnm.vimusic.utils.rememberPreference
|
||||
import it.vfsfitvnm.vimusic.utils.verticalFadingEdge
|
||||
import it.vfsfitvnm.youtubemusic.YouTube
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun Lyrics(
|
||||
mediaId: String,
|
||||
isDisplayed: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
size: Dp,
|
||||
mediaMetadataProvider: () -> MediaMetadata,
|
||||
durationProvider: () -> Long,
|
||||
onLyricsUpdate: (Boolean, String, String) -> Unit,
|
||||
nestedScrollConnectionProvider: () -> NestedScrollConnection,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = isDisplayed,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
val (colorPalette, typography) = LocalAppearance.current
|
||||
val context = LocalContext.current
|
||||
val menuState = LocalMenuState.current
|
||||
|
||||
var isShowingSynchronizedLyrics by rememberPreference(isShowingSynchronizedLyricsKey, false)
|
||||
|
||||
var state by remember(mediaId, isShowingSynchronizedLyrics) {
|
||||
mutableStateOf(LyricsState())
|
||||
}
|
||||
|
||||
val fetchLyrics = relaunchableEffect(mediaId, isShowingSynchronizedLyrics) {
|
||||
if (isShowingSynchronizedLyrics) {
|
||||
Database.synchronizedLyrics(mediaId)
|
||||
} else {
|
||||
Database.lyrics(mediaId)
|
||||
}.distinctUntilChanged().map flowMap@{ lyrics ->
|
||||
if (lyrics != null) return@flowMap lyrics
|
||||
|
||||
state = state.copy(isLoading = true)
|
||||
|
||||
if (isShowingSynchronizedLyrics) {
|
||||
val mediaMetadata = mediaMetadataProvider()
|
||||
var duration = withContext(Dispatchers.Main) {
|
||||
durationProvider()
|
||||
}
|
||||
|
||||
while (duration == C.TIME_UNSET) {
|
||||
delay(100)
|
||||
duration = withContext(Dispatchers.Main) {
|
||||
durationProvider()
|
||||
}
|
||||
}
|
||||
|
||||
KuGou.lyrics(
|
||||
artist = mediaMetadata.artist?.toString() ?: "",
|
||||
title = mediaMetadata.title?.toString() ?: "",
|
||||
duration = duration / 1000
|
||||
)?.map { it?.value }
|
||||
} else {
|
||||
YouTube.next(mediaId, null)
|
||||
?.map { nextResult -> nextResult.lyrics?.text()?.getOrNull() }
|
||||
}?.map { newLyrics ->
|
||||
onLyricsUpdate(isShowingSynchronizedLyrics, mediaId, newLyrics ?: "")
|
||||
state = state.copy(isLoading = false)
|
||||
return@flowMap newLyrics ?: ""
|
||||
}
|
||||
|
||||
state = state.copy(isLoading = false)
|
||||
null
|
||||
}.flowOn(Dispatchers.IO).collect { state = state.copy(lyrics = it) }
|
||||
}
|
||||
|
||||
if (state.isEditing) {
|
||||
TextFieldDialog(
|
||||
hintText = "Enter the lyrics",
|
||||
initialTextInput = state.lyrics ?: "",
|
||||
singleLine = false,
|
||||
maxLines = 10,
|
||||
isTextInputValid = { true },
|
||||
onDismiss = { state = state.copy(isEditing = false) },
|
||||
onDone = {
|
||||
query {
|
||||
if (isShowingSynchronizedLyrics) {
|
||||
Database.updateSynchronizedLyrics(mediaId, it)
|
||||
} else {
|
||||
Database.updateLyrics(mediaId, it)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onTap = { onDismiss() }
|
||||
)
|
||||
}
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(0.8f))
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = !state.isLoading && state.lyrics == null,
|
||||
enter = slideInVertically { -it },
|
||||
exit = slideOutVertically { -it },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
) {
|
||||
BasicText(
|
||||
text = "An error has occurred while fetching the ${if (isShowingSynchronizedLyrics) "synchronized " else ""}lyrics",
|
||||
style = typography.xs.center.medium.color(PureBlackColorPalette.text),
|
||||
modifier = Modifier
|
||||
.background(Color.Black.copy(0.4f))
|
||||
.padding(all = 8.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = state.lyrics?.let(String::isEmpty) ?: false,
|
||||
enter = slideInVertically { -it },
|
||||
exit = slideOutVertically { -it },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
) {
|
||||
BasicText(
|
||||
text = "${if (isShowingSynchronizedLyrics) "Synchronized l" else "L"}yrics are not available for this song",
|
||||
style = typography.xs.center.medium.color(PureBlackColorPalette.text),
|
||||
modifier = Modifier
|
||||
.background(Color.Black.copy(0.4f))
|
||||
.padding(all = 8.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.shimmer()
|
||||
) {
|
||||
repeat(4) { index ->
|
||||
TextPlaceholder(
|
||||
color = colorPalette.onOverlayShimmer,
|
||||
modifier = Modifier
|
||||
.alpha(1f - index * 0.05f)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state.lyrics?.let { lyrics ->
|
||||
if (lyrics.isNotEmpty() && lyrics != ".") {
|
||||
if (isShowingSynchronizedLyrics) {
|
||||
val density = LocalDensity.current
|
||||
val player = LocalPlayerServiceBinder.current?.player
|
||||
?: return@AnimatedVisibility
|
||||
|
||||
val synchronizedLyrics = remember(lyrics) {
|
||||
SynchronizedLyrics(KuGou.Lyrics(lyrics).sentences) {
|
||||
player.currentPosition + 50
|
||||
}
|
||||
}
|
||||
|
||||
val lazyListState = rememberLazyListState(
|
||||
synchronizedLyrics.index,
|
||||
with(density) { size.roundToPx() } / 6)
|
||||
|
||||
LaunchedEffect(synchronizedLyrics) {
|
||||
val center = with(density) { size.roundToPx() } / 6
|
||||
|
||||
while (isActive) {
|
||||
delay(50)
|
||||
if (synchronizedLyrics.update()) {
|
||||
lazyListState.animateScrollToItem(
|
||||
synchronizedLyrics.index,
|
||||
center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
userScrollEnabled = false,
|
||||
contentPadding = PaddingValues(vertical = size / 2),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.verticalFadingEdge()
|
||||
) {
|
||||
itemsIndexed(items = synchronizedLyrics.sentences) { index, sentence ->
|
||||
BasicText(
|
||||
text = sentence.second,
|
||||
style = typography.xs.center.medium.color(if (index == synchronizedLyrics.index) PureBlackColorPalette.text else PureBlackColorPalette.textDisabled),
|
||||
modifier = Modifier
|
||||
.padding(vertical = 4.dp, horizontal = 32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
BasicText(
|
||||
text = lyrics,
|
||||
style = typography.xs.center.medium.color(PureBlackColorPalette.text),
|
||||
modifier = Modifier
|
||||
.nestedScroll(remember { nestedScrollConnectionProvider() })
|
||||
.verticalFadingEdge()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = size / 4, horizontal = 32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ellipsis_horizontal),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(DefaultDarkColorPalette.text),
|
||||
modifier = Modifier
|
||||
.padding(all = 4.dp)
|
||||
.clickable {
|
||||
menuState.display {
|
||||
Menu {
|
||||
MenuEntry(
|
||||
icon = R.drawable.time,
|
||||
text = "Show ${if (isShowingSynchronizedLyrics) "un" else ""}synchronized lyrics",
|
||||
secondaryText = if (isShowingSynchronizedLyrics) null else "Provided by kugou.com",
|
||||
onClick = {
|
||||
menuState.hide()
|
||||
isShowingSynchronizedLyrics =
|
||||
!isShowingSynchronizedLyrics
|
||||
}
|
||||
)
|
||||
|
||||
MenuEntry(
|
||||
icon = R.drawable.pencil,
|
||||
text = "Edit lyrics",
|
||||
onClick = {
|
||||
menuState.hide()
|
||||
state = state.copy(isEditing = true)
|
||||
}
|
||||
)
|
||||
|
||||
MenuEntry(
|
||||
icon = R.drawable.search,
|
||||
text = "Search lyrics online",
|
||||
onClick = {
|
||||
menuState.hide()
|
||||
val mediaMetadata = mediaMetadataProvider()
|
||||
|
||||
val intent =
|
||||
Intent(Intent.ACTION_WEB_SEARCH).apply {
|
||||
putExtra(
|
||||
SearchManager.QUERY,
|
||||
"${mediaMetadata.title} ${mediaMetadata.artist} lyrics"
|
||||
)
|
||||
}
|
||||
|
||||
if (intent.resolveActivity(context.packageManager) != null) {
|
||||
context.startActivity(intent)
|
||||
} else {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
"No browser app found!",
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
MenuEntry(
|
||||
icon = R.drawable.download,
|
||||
text = "Fetch lyrics again",
|
||||
onClick = {
|
||||
menuState.hide()
|
||||
if (state.lyrics == null) {
|
||||
fetchLyrics()
|
||||
} else {
|
||||
query {
|
||||
if (isShowingSynchronizedLyrics) {
|
||||
Database.updateSynchronizedLyrics(
|
||||
mediaId,
|
||||
null
|
||||
)
|
||||
} else {
|
||||
Database.updateLyrics(mediaId, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(all = 8.dp)
|
||||
.size(20.dp)
|
||||
.align(Alignment.BottomEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class LyricsState(
|
||||
val isLoading: Boolean = false,
|
||||
val isEditing: Boolean = false,
|
||||
val lyrics: String? = ".",
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens.player
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.dp
|
||||
import it.vfsfitvnm.vimusic.ui.styling.PureBlackColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
||||
import it.vfsfitvnm.vimusic.utils.center
|
||||
import it.vfsfitvnm.vimusic.utils.color
|
||||
import it.vfsfitvnm.vimusic.utils.medium
|
||||
|
||||
@Composable
|
||||
fun PlaybackError(
|
||||
isDisplayed: Boolean,
|
||||
messageProvider: () -> String,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val (_, typography) = LocalAppearance.current
|
||||
|
||||
Box {
|
||||
AnimatedVisibility(
|
||||
visible = isDisplayed,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Spacer(
|
||||
modifier = modifier
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onTap = {
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(0.8f))
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isDisplayed,
|
||||
enter = slideInVertically { -it },
|
||||
exit = slideOutVertically { -it },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
) {
|
||||
BasicText(
|
||||
text = remember { messageProvider() },
|
||||
style = typography.xs.center.medium.color(PureBlackColorPalette.text),
|
||||
modifier = Modifier
|
||||
.background(Color.Black.copy(0.4f))
|
||||
.padding(all = 8.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens.player
|
||||
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
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.*
|
||||
import androidx.compose.foundation.lazy.items
|
||||
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.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.alpha
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
import it.vfsfitvnm.reordering.ReorderingLazyColumn
|
||||
import it.vfsfitvnm.reordering.animateItemPlacement
|
||||
import it.vfsfitvnm.reordering.draggedItem
|
||||
import it.vfsfitvnm.reordering.rememberReorderingState
|
||||
import it.vfsfitvnm.reordering.reorder
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
|
||||
import it.vfsfitvnm.vimusic.ui.components.BottomSheet
|
||||
import it.vfsfitvnm.vimusic.ui.components.BottomSheetState
|
||||
import it.vfsfitvnm.vimusic.ui.components.MusicBars
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.QueuedMediaItemMenu
|
||||
import it.vfsfitvnm.vimusic.ui.screens.SmallSongItemShimmer
|
||||
import it.vfsfitvnm.vimusic.ui.styling.Dimensions
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
||||
import it.vfsfitvnm.vimusic.ui.styling.onOverlay
|
||||
import it.vfsfitvnm.vimusic.ui.styling.px
|
||||
import it.vfsfitvnm.vimusic.ui.views.SongItem
|
||||
import it.vfsfitvnm.vimusic.utils.medium
|
||||
import it.vfsfitvnm.vimusic.utils.rememberMediaItemIndex
|
||||
import it.vfsfitvnm.vimusic.utils.rememberShouldBePlaying
|
||||
import it.vfsfitvnm.vimusic.utils.rememberWindows
|
||||
import it.vfsfitvnm.vimusic.utils.shuffleQueue
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun PlayerBottomSheet(
|
||||
backgroundColorProvider: () -> Color,
|
||||
layoutState: BottomSheetState,
|
||||
onGlobalRouteEmitted: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
val (colorPalette, typography) = LocalAppearance.current
|
||||
|
||||
BottomSheet(
|
||||
state = layoutState,
|
||||
modifier = modifier,
|
||||
collapsedContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.drawBehind { drawRect(backgroundColorProvider()) }
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.playlist),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(18.dp)
|
||||
)
|
||||
|
||||
content()
|
||||
}
|
||||
}
|
||||
) {
|
||||
val binder = LocalPlayerServiceBinder.current
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
|
||||
binder?.player ?: return@BottomSheet
|
||||
|
||||
val thumbnailSize = Dimensions.thumbnails.song.px
|
||||
|
||||
val mediaItemIndex by rememberMediaItemIndex(binder.player)
|
||||
val windows by rememberWindows(binder.player)
|
||||
val shouldBePlaying by rememberShouldBePlaying(binder.player)
|
||||
|
||||
val reorderingState = rememberReorderingState(
|
||||
lazyListState = rememberLazyListState(initialFirstVisibleItemIndex = mediaItemIndex),
|
||||
key = windows,
|
||||
onDragEnd = { fromIndex, toIndex ->
|
||||
binder.player.moveMediaItem(fromIndex, toIndex)
|
||||
},
|
||||
extraItemCount = 0
|
||||
)
|
||||
|
||||
val paddingValues = WindowInsets.systemBars.asPaddingValues()
|
||||
val bottomPadding = paddingValues.calculateBottomPadding()
|
||||
|
||||
Column {
|
||||
ReorderingLazyColumn(
|
||||
reorderingState = reorderingState,
|
||||
contentPadding = PaddingValues(
|
||||
top = paddingValues.calculateTopPadding(),
|
||||
start = paddingValues.calculateStartPadding(layoutDirection),
|
||||
end = paddingValues.calculateEndPadding(layoutDirection),
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.background(colorPalette.background0)
|
||||
.fillMaxSize()
|
||||
.nestedScroll(remember {
|
||||
layoutState.nestedScrollConnection(reorderingState.lazyListState.firstVisibleItemIndex == 0 && reorderingState.lazyListState.firstVisibleItemScrollOffset == 0)
|
||||
})
|
||||
.background(colorPalette.background1)
|
||||
.weight(1f)
|
||||
) {
|
||||
items(
|
||||
items = windows,
|
||||
key = { it.uid.hashCode() }
|
||||
) { window ->
|
||||
val isPlayingThisMediaItem = mediaItemIndex == window.firstPeriodIndex
|
||||
|
||||
SongItem(
|
||||
mediaItem = window.mediaItem,
|
||||
thumbnailSize = thumbnailSize,
|
||||
onClick = {
|
||||
if (isPlayingThisMediaItem) {
|
||||
if (shouldBePlaying) {
|
||||
binder.player.pause()
|
||||
} else {
|
||||
binder.player.play()
|
||||
}
|
||||
} else {
|
||||
binder.player.playWhenReady = true
|
||||
binder.player.seekToDefaultPosition(window.firstPeriodIndex)
|
||||
}
|
||||
},
|
||||
menuContent = {
|
||||
QueuedMediaItemMenu(
|
||||
mediaItem = window.mediaItem,
|
||||
indexInQueue = if (isPlayingThisMediaItem) null else window.firstPeriodIndex,
|
||||
onGlobalRouteEmitted = onGlobalRouteEmitted
|
||||
)
|
||||
},
|
||||
onThumbnailContent = {
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = isPlayingThisMediaItem,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = Color.Black.copy(alpha = 0.25f),
|
||||
shape = ThumbnailRoundness.shape
|
||||
)
|
||||
.size(Dimensions.thumbnails.song)
|
||||
) {
|
||||
if (shouldBePlaying) {
|
||||
MusicBars(
|
||||
color = colorPalette.onOverlay,
|
||||
modifier = Modifier
|
||||
.height(24.dp)
|
||||
)
|
||||
} else {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.play),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.onOverlay),
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.reorder),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.textSecondary),
|
||||
modifier = Modifier
|
||||
.clickable { }
|
||||
.reorder(
|
||||
reorderingState = reorderingState,
|
||||
index = window.firstPeriodIndex
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.animateItemPlacement(reorderingState)
|
||||
.draggedItem(
|
||||
reorderingState = reorderingState,
|
||||
index = window.firstPeriodIndex
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
if (binder.isLoadingRadio) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.shimmer()
|
||||
) {
|
||||
repeat(3) { index ->
|
||||
SmallSongItemShimmer(
|
||||
thumbnailSizeDp = Dimensions.thumbnails.song,
|
||||
modifier = Modifier
|
||||
.alpha(1f - index * 0.125f)
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
indication = rememberRipple(bounded = true),
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = layoutState::collapseSoft
|
||||
)
|
||||
.height(64.dp + bottomPadding)
|
||||
.background(colorPalette.background2)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
.padding(bottom = bottomPadding)
|
||||
) {
|
||||
BasicText(
|
||||
text = "${windows.size} songs",
|
||||
style = typography.xxs.medium,
|
||||
modifier = Modifier
|
||||
.padding(start = 4.dp)
|
||||
.background(color = colorPalette.background1, shape = RoundedCornerShape(16.dp))
|
||||
.align(Alignment.CenterStart)
|
||||
.padding(all = 8.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.chevron_down),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(18.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.shuffle),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.padding(end = 2.dp)
|
||||
.clickable {
|
||||
reorderingState.coroutineScope.launch {
|
||||
reorderingState.lazyListState.animateScrollToItem(0)
|
||||
}.invokeOnCompletion {
|
||||
binder.player.shuffleQueue()
|
||||
}
|
||||
}
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(all = 8.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens.player
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.media.audiofx.AudioEffect
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.LocalActivityResultRegistryOwner
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
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.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.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.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.Player
|
||||
import coil.compose.AsyncImage
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.ui.components.BottomSheet
|
||||
import it.vfsfitvnm.vimusic.ui.components.BottomSheetState
|
||||
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
|
||||
import it.vfsfitvnm.vimusic.ui.components.rememberBottomSheetState
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.BaseMediaItemMenu
|
||||
import it.vfsfitvnm.vimusic.ui.styling.Dimensions
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
||||
import it.vfsfitvnm.vimusic.ui.styling.collapsedPlayerProgressBar
|
||||
import it.vfsfitvnm.vimusic.ui.styling.px
|
||||
import it.vfsfitvnm.vimusic.utils.rememberMediaItem
|
||||
import it.vfsfitvnm.vimusic.utils.rememberPositionAndDuration
|
||||
import it.vfsfitvnm.vimusic.utils.rememberShouldBePlaying
|
||||
import it.vfsfitvnm.vimusic.utils.seamlessPlay
|
||||
import it.vfsfitvnm.vimusic.utils.secondary
|
||||
import it.vfsfitvnm.vimusic.utils.semiBold
|
||||
import it.vfsfitvnm.vimusic.utils.thumbnail
|
||||
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun PlayerView(
|
||||
layoutState: BottomSheetState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val menuState = LocalMenuState.current
|
||||
|
||||
val (colorPalette, typography, thumbnailShape) = LocalAppearance.current
|
||||
val binder = LocalPlayerServiceBinder.current
|
||||
val context = LocalContext.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
|
||||
binder?.player ?: return
|
||||
|
||||
val nullableMediaItem by rememberMediaItem(binder.player)
|
||||
|
||||
val mediaItem = nullableMediaItem ?: return
|
||||
|
||||
val shouldBePlaying by rememberShouldBePlaying(binder.player)
|
||||
val positionAndDuration by rememberPositionAndDuration(binder.player)
|
||||
|
||||
BottomSheet(
|
||||
state = layoutState,
|
||||
modifier = modifier,
|
||||
onDismiss = {
|
||||
binder.stopRadio()
|
||||
binder.player.clearMediaItems()
|
||||
},
|
||||
collapsedContent = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier
|
||||
.background(colorPalette.background1)
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
.drawBehind {
|
||||
val progress =
|
||||
positionAndDuration.first.toFloat() / positionAndDuration.second.absoluteValue
|
||||
|
||||
drawLine(
|
||||
color = colorPalette.collapsedPlayerProgressBar,
|
||||
start = Offset(x = 0f, y = 1.dp.toPx()),
|
||||
end = Offset(x = size.width * progress, y = 1.dp.toPx()),
|
||||
strokeWidth = 2.dp.toPx()
|
||||
)
|
||||
}
|
||||
) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.width(2.dp)
|
||||
)
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.height(Dimensions.collapsedPlayer)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = mediaItem.mediaMetadata.artworkUri.thumbnail(Dimensions.thumbnails.player.songPreview.px),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.clip(thumbnailShape)
|
||||
.size(48.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.height(Dimensions.collapsedPlayer)
|
||||
.weight(1f)
|
||||
) {
|
||||
BasicText(
|
||||
text = mediaItem.mediaMetadata.title?.toString() ?: "",
|
||||
style = typography.xs.semiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
BasicText(
|
||||
text = mediaItem.mediaMetadata.artist?.toString() ?: "",
|
||||
style = typography.xs.semiBold.secondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.width(2.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.height(Dimensions.collapsedPlayer)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
if (shouldBePlaying) {
|
||||
binder.player.pause()
|
||||
} else {
|
||||
if (binder.player.playbackState == Player.STATE_IDLE) {
|
||||
binder.player.prepare()
|
||||
}
|
||||
binder.player.play()
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 4.dp, vertical = 8.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(if (shouldBePlaying) R.drawable.pause else R.drawable.play),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable(onClick = binder.player::seekToNext)
|
||||
.padding(horizontal = 4.dp, vertical = 8.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.play_skip_forward),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.width(2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
var isShowingLyrics by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
var isShowingStatsForNerds by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val paddingValues = WindowInsets.navigationBars.asPaddingValues()
|
||||
val playerBottomSheetState = rememberBottomSheetState(64.dp + paddingValues.calculateBottomPadding(), layoutState.expandedBound)
|
||||
|
||||
when (configuration.orientation) {
|
||||
Configuration.ORIENTATION_LANDSCAPE -> {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.background(colorPalette.background1)
|
||||
.padding(
|
||||
top = 32.dp + paddingValues.calculateTopPadding(),
|
||||
start = paddingValues.calculateStartPadding(layoutDirection),
|
||||
end = paddingValues.calculateEndPadding(layoutDirection),
|
||||
bottom = playerBottomSheetState.collapsedBound
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.weight(0.66f)
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
Thumbnail(
|
||||
isShowingLyrics = isShowingLyrics,
|
||||
onShowLyrics = { isShowingLyrics = it },
|
||||
isShowingStatsForNerds = isShowingStatsForNerds,
|
||||
onShowStatsForNerds = { isShowingStatsForNerds = it },
|
||||
nestedScrollConnectionProvider = layoutState::nestedScrollConnection,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Controls(
|
||||
mediaId = mediaItem.mediaId,
|
||||
title = mediaItem.mediaMetadata.title?.toString(),
|
||||
artist = mediaItem.mediaMetadata.artist?.toString(),
|
||||
shouldBePlaying = shouldBePlaying,
|
||||
position = positionAndDuration.first,
|
||||
duration = positionAndDuration.second,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp)
|
||||
.fillMaxHeight()
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.background(colorPalette.background1)
|
||||
.padding(
|
||||
top = 54.dp + paddingValues.calculateTopPadding(),
|
||||
start = paddingValues.calculateStartPadding(layoutDirection),
|
||||
end = paddingValues.calculateEndPadding(layoutDirection),
|
||||
bottom = playerBottomSheetState.collapsedBound
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.weight(1.25f)
|
||||
) {
|
||||
Thumbnail(
|
||||
isShowingLyrics = isShowingLyrics,
|
||||
onShowLyrics = { isShowingLyrics = it },
|
||||
isShowingStatsForNerds = isShowingStatsForNerds,
|
||||
onShowStatsForNerds = { isShowingStatsForNerds = it },
|
||||
nestedScrollConnectionProvider = layoutState::nestedScrollConnection,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 32.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Controls(
|
||||
mediaId = mediaItem.mediaId,
|
||||
title = mediaItem.mediaMetadata.title?.toString(),
|
||||
artist = mediaItem.mediaMetadata.artist?.toString(),
|
||||
shouldBePlaying = shouldBePlaying,
|
||||
position = positionAndDuration.first,
|
||||
duration = positionAndDuration.second,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp)
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PlayerBottomSheet(
|
||||
layoutState = playerBottomSheetState,
|
||||
onGlobalRouteEmitted = layoutState::collapseSoft,
|
||||
content = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(horizontal = 8.dp)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ellipsis_horizontal),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
menuState.display {
|
||||
val resultRegistryOwner =
|
||||
LocalActivityResultRegistryOwner.current
|
||||
|
||||
BaseMediaItemMenu(
|
||||
mediaItem = mediaItem,
|
||||
onStartRadio = {
|
||||
binder.stopRadio()
|
||||
binder.player.seamlessPlay(mediaItem)
|
||||
binder.setupRadio(
|
||||
NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId)
|
||||
)
|
||||
},
|
||||
onGoToEqualizer = {
|
||||
val intent =
|
||||
Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply {
|
||||
putExtra(
|
||||
AudioEffect.EXTRA_AUDIO_SESSION,
|
||||
binder.player.audioSessionId
|
||||
)
|
||||
putExtra(
|
||||
AudioEffect.EXTRA_PACKAGE_NAME,
|
||||
context.packageName
|
||||
)
|
||||
putExtra(
|
||||
AudioEffect.EXTRA_CONTENT_TYPE,
|
||||
AudioEffect.CONTENT_TYPE_MUSIC
|
||||
)
|
||||
}
|
||||
|
||||
if (intent.resolveActivity(context.packageManager) != null) {
|
||||
val contract =
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
|
||||
resultRegistryOwner?.activityResultRegistry
|
||||
?.register("", contract) {}
|
||||
?.launch(intent)
|
||||
} else {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
"No equalizer app found!",
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
},
|
||||
onSetSleepTimer = {},
|
||||
onDismiss = menuState::hide,
|
||||
onGlobalRouteEmitted = layoutState::collapseSoft,
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(all = 8.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.width(4.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
backgroundColorProvider = { colorPalette.background2 },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens.player
|
||||
|
||||
import android.text.format.Formatter
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.datasource.cache.Cache
|
||||
import androidx.media3.datasource.cache.CacheSpan
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.models.Format
|
||||
import it.vfsfitvnm.vimusic.query
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
|
||||
import it.vfsfitvnm.vimusic.ui.styling.onOverlay
|
||||
import it.vfsfitvnm.vimusic.ui.styling.overlay
|
||||
import it.vfsfitvnm.vimusic.utils.color
|
||||
import it.vfsfitvnm.vimusic.utils.medium
|
||||
import it.vfsfitvnm.vimusic.utils.rememberVolume
|
||||
import it.vfsfitvnm.youtubemusic.YouTube
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
@Composable
|
||||
fun StatsForNerds(
|
||||
mediaId: String,
|
||||
isDisplayed: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val (colorPalette, typography) = LocalAppearance.current
|
||||
val context = LocalContext.current
|
||||
val binder = LocalPlayerServiceBinder.current ?: return
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isDisplayed,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
var cachedBytes by remember(mediaId) {
|
||||
mutableStateOf(binder.cache.getCachedBytes(mediaId, 0, -1))
|
||||
}
|
||||
|
||||
val format by remember(mediaId) {
|
||||
Database.format(mediaId).distinctUntilChanged()
|
||||
}.collectAsState(initial = null, context = Dispatchers.IO)
|
||||
|
||||
val volume by rememberVolume(binder.player)
|
||||
|
||||
DisposableEffect(mediaId) {
|
||||
val listener = object : Cache.Listener {
|
||||
override fun onSpanAdded(cache: Cache, span: CacheSpan) {
|
||||
cachedBytes += span.length
|
||||
}
|
||||
|
||||
override fun onSpanRemoved(cache: Cache, span: CacheSpan) {
|
||||
cachedBytes -= span.length
|
||||
}
|
||||
|
||||
override fun onSpanTouched(
|
||||
cache: Cache,
|
||||
oldSpan: CacheSpan,
|
||||
newSpan: CacheSpan
|
||||
) = Unit
|
||||
}
|
||||
|
||||
binder.cache.addListener(mediaId, listener)
|
||||
|
||||
onDispose {
|
||||
binder.cache.removeListener(mediaId, listener)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onTap = {
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
.background(colorPalette.overlay)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(all = 16.dp)
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
BasicText(
|
||||
text = "Id",
|
||||
style = typography.xs.medium.color(colorPalette.onOverlay)
|
||||
)
|
||||
BasicText(
|
||||
text = "Volume",
|
||||
style = typography.xs.medium.color(colorPalette.onOverlay)
|
||||
)
|
||||
BasicText(
|
||||
text = "Loudness",
|
||||
style = typography.xs.medium.color(colorPalette.onOverlay)
|
||||
)
|
||||
BasicText(
|
||||
text = "Bitrate",
|
||||
style = typography.xs.medium.color(colorPalette.onOverlay)
|
||||
)
|
||||
BasicText(
|
||||
text = "Size",
|
||||
style = typography.xs.medium.color(colorPalette.onOverlay)
|
||||
)
|
||||
BasicText(
|
||||
text = "Cached",
|
||||
style = typography.xs.medium.color(colorPalette.onOverlay)
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
BasicText(
|
||||
text = mediaId,
|
||||
style = typography.xs.medium.color(colorPalette.onOverlay)
|
||||
)
|
||||
BasicText(
|
||||
text = "${volume.times(100).roundToInt()}%",
|
||||
style = typography.xs.medium.color(colorPalette.onOverlay)
|
||||
)
|
||||
BasicText(
|
||||
text = format?.loudnessDb?.let { loudnessDb ->
|
||||
"%.2f dB".format(loudnessDb)
|
||||
} ?: "Unknown",
|
||||
style = typography.xs.medium.color(colorPalette.onOverlay)
|
||||
)
|
||||
BasicText(
|
||||
text = format?.bitrate?.let { bitrate ->
|
||||
"${bitrate / 1000} kbps"
|
||||
} ?: "Unknown",
|
||||
style = typography.xs.medium.color(colorPalette.onOverlay)
|
||||
)
|
||||
BasicText(
|
||||
text = format?.contentLength?.let { contentLength ->
|
||||
Formatter.formatShortFileSize(
|
||||
context,
|
||||
contentLength
|
||||
)
|
||||
} ?: "Unknown",
|
||||
style = typography.xs.medium.color(colorPalette.onOverlay)
|
||||
)
|
||||
BasicText(
|
||||
text = buildString {
|
||||
append(Formatter.formatShortFileSize(context, cachedBytes))
|
||||
|
||||
format?.contentLength?.let { contentLength ->
|
||||
append(" (${(cachedBytes.toFloat() / contentLength * 100).roundToInt()}%)")
|
||||
}
|
||||
},
|
||||
style = typography.xs.medium.color(colorPalette.onOverlay)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (format != null && format?.itag == null) {
|
||||
BasicText(
|
||||
text = "FETCH MISSING DATA",
|
||||
style = typography.xxs.medium.color(colorPalette.onOverlay),
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
indication = rememberRipple(bounded = true),
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = {
|
||||
query {
|
||||
runBlocking(Dispatchers.IO) {
|
||||
YouTube.player(mediaId)
|
||||
?.map { response ->
|
||||
response.streamingData?.adaptiveFormats
|
||||
?.findLast { format ->
|
||||
format.itag == 251 || format.itag == 140
|
||||
}
|
||||
?.let { format ->
|
||||
Format(
|
||||
songId = mediaId,
|
||||
itag = format.itag,
|
||||
mimeType = format.mimeType,
|
||||
bitrate = format.bitrate,
|
||||
loudnessDb = response.playerConfig?.audioConfig?.loudnessDb?.toFloat(),
|
||||
contentLength = format.contentLength,
|
||||
lastModified = format.lastModified
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
?.getOrNull()
|
||||
?.let(Database::insert)
|
||||
}
|
||||
}
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.align(Alignment.BottomEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens.player
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||
import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
|
||||
import it.vfsfitvnm.vimusic.service.LoginRequiredException
|
||||
import it.vfsfitvnm.vimusic.service.PlayableFormatNotFoundException
|
||||
import it.vfsfitvnm.vimusic.service.UnplayableException
|
||||
import it.vfsfitvnm.vimusic.ui.styling.Dimensions
|
||||
import it.vfsfitvnm.vimusic.ui.styling.px
|
||||
import it.vfsfitvnm.vimusic.utils.rememberError
|
||||
import it.vfsfitvnm.vimusic.utils.rememberMediaItemIndex
|
||||
import it.vfsfitvnm.vimusic.utils.thumbnail
|
||||
import java.net.UnknownHostException
|
||||
import java.nio.channels.UnresolvedAddressException
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun Thumbnail(
|
||||
isShowingLyrics: Boolean,
|
||||
onShowLyrics: (Boolean) -> Unit,
|
||||
isShowingStatsForNerds: Boolean,
|
||||
onShowStatsForNerds: (Boolean) -> Unit,
|
||||
nestedScrollConnectionProvider: () -> NestedScrollConnection,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val binder = LocalPlayerServiceBinder.current
|
||||
val player = binder?.player ?: return
|
||||
|
||||
val (thumbnailSizeDp, thumbnailSizePx) = Dimensions.thumbnails.player.song.let {
|
||||
it to (it - 64.dp).px
|
||||
}
|
||||
|
||||
val mediaItemIndex by rememberMediaItemIndex(player)
|
||||
|
||||
val error by rememberError(player)
|
||||
|
||||
AnimatedContent(
|
||||
targetState = mediaItemIndex,
|
||||
transitionSpec = {
|
||||
val duration = 500
|
||||
val slideDirection =
|
||||
if (targetState > initialState) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right
|
||||
|
||||
ContentTransform(
|
||||
targetContentEnter = slideIntoContainer(
|
||||
towards = slideDirection,
|
||||
animationSpec = tween(duration)
|
||||
) + fadeIn(
|
||||
animationSpec = tween(duration)
|
||||
) + scaleIn(
|
||||
initialScale = 0.85f,
|
||||
animationSpec = tween(duration)
|
||||
),
|
||||
initialContentExit = slideOutOfContainer(
|
||||
towards = slideDirection,
|
||||
animationSpec = tween(duration)
|
||||
) + fadeOut(
|
||||
animationSpec = tween(duration)
|
||||
) + scaleOut(
|
||||
targetScale = 0.85f,
|
||||
animationSpec = tween(duration)
|
||||
),
|
||||
sizeTransform = SizeTransform(clip = false)
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) { currentMediaItemIndex ->
|
||||
val mediaItem = remember(currentMediaItemIndex) {
|
||||
player.getMediaItemAt(currentMediaItemIndex)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.aspectRatio(1f)
|
||||
.clip(ThumbnailRoundness.shape)
|
||||
.size(thumbnailSizeDp)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = mediaItem.mediaMetadata.artworkUri.thumbnail(thumbnailSizePx),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onTap = { onShowLyrics(true) },
|
||||
onLongPress = { onShowStatsForNerds(true) }
|
||||
)
|
||||
}
|
||||
.fillMaxSize()
|
||||
)
|
||||
|
||||
Lyrics(
|
||||
mediaId = mediaItem.mediaId,
|
||||
isDisplayed = isShowingLyrics && error == null,
|
||||
onDismiss = { onShowLyrics(false) },
|
||||
onLyricsUpdate = { areSynchronized, mediaId, lyrics ->
|
||||
if (areSynchronized) {
|
||||
if (Database.updateSynchronizedLyrics(mediaId, lyrics) == 0) {
|
||||
if (mediaId == mediaItem.mediaId) {
|
||||
Database.insert(mediaItem) { song ->
|
||||
song.copy(synchronizedLyrics = lyrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (Database.updateLyrics(mediaId, lyrics) == 0) {
|
||||
if (mediaId == mediaItem.mediaId) {
|
||||
Database.insert(mediaItem) { song ->
|
||||
song.copy(lyrics = lyrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
size = thumbnailSizeDp,
|
||||
mediaMetadataProvider = mediaItem::mediaMetadata,
|
||||
durationProvider = player::getDuration,
|
||||
nestedScrollConnectionProvider = nestedScrollConnectionProvider,
|
||||
)
|
||||
|
||||
StatsForNerds(
|
||||
mediaId = mediaItem.mediaId,
|
||||
isDisplayed = isShowingStatsForNerds && error == null,
|
||||
onDismiss = { onShowStatsForNerds(false) }
|
||||
)
|
||||
|
||||
PlaybackError(
|
||||
isDisplayed = error != null,
|
||||
messageProvider = {
|
||||
when (error?.cause?.cause) {
|
||||
is UnresolvedAddressException, is UnknownHostException -> "A network error has occurred"
|
||||
is PlayableFormatNotFoundException -> "Couldn't find a playable audio format"
|
||||
is UnplayableException -> "The original video source of this song has been deleted"
|
||||
is LoginRequiredException -> "This song cannot be played due to server restrictions"
|
||||
else -> "An unknown playback error has occurred"
|
||||
}
|
||||
},
|
||||
onDismiss = player::prepare
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user