Reorganize UI packages

This commit is contained in:
vfsfitvnm
2022-09-23 17:22:26 +02:00
parent 35e0070bda
commit ef0567650c
14 changed files with 164 additions and 135 deletions

View File

@@ -1,288 +0,0 @@
package it.vfsfitvnm.vimusic.ui.views
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.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)
)
}
}
}
}

View File

@@ -1,410 +0,0 @@
package it.vfsfitvnm.vimusic.ui.views
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.ui.views.player.Controls
import it.vfsfitvnm.vimusic.ui.views.player.Thumbnail
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)
)
}
}

View File

@@ -1,275 +0,0 @@
package it.vfsfitvnm.vimusic.ui.views
import android.app.Application
import android.content.SharedPreferences
import androidx.annotation.DrawableRes
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.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.core.content.edit
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.enums.BuiltInPlaylist
import it.vfsfitvnm.vimusic.enums.PlaylistSortBy
import it.vfsfitvnm.vimusic.enums.SortOrder
import it.vfsfitvnm.vimusic.models.Playlist
import it.vfsfitvnm.vimusic.models.PlaylistPreview
import it.vfsfitvnm.vimusic.query
import it.vfsfitvnm.vimusic.ui.components.themed.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.utils.getEnum
import it.vfsfitvnm.vimusic.utils.medium
import it.vfsfitvnm.vimusic.utils.mutableStatePreferenceOf
import it.vfsfitvnm.vimusic.utils.playlistSortByKey
import it.vfsfitvnm.vimusic.utils.playlistSortOrderKey
import it.vfsfitvnm.vimusic.utils.preferences
import it.vfsfitvnm.vimusic.utils.putEnum
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
class PlaylistsTabViewModel(application: Application) : AndroidViewModel(application) {
var items by mutableStateOf(emptyList<PlaylistPreview>())
private set
var sortBy by mutableStatePreferenceOf(
preferences.getEnum(
playlistSortByKey,
PlaylistSortBy.DateAdded
)
) {
preferences.edit { putEnum(playlistSortByKey, it) }
collectItems(sortBy = it)
}
var sortOrder by mutableStatePreferenceOf(
preferences.getEnum(
playlistSortOrderKey,
SortOrder.Ascending
)
) {
preferences.edit { putEnum(playlistSortOrderKey, it) }
collectItems(sortOrder = it)
}
private var job: Job? = null
private val preferences: SharedPreferences
get() = getApplication<Application>().preferences
init {
collectItems()
}
private fun collectItems(
sortBy: PlaylistSortBy = this.sortBy,
sortOrder: SortOrder = this.sortOrder
) {
job?.cancel()
job = viewModelScope.launch {
Database.playlistPreviews(sortBy, sortOrder).flowOn(Dispatchers.IO).collect {
items = it
}
}
}
}
@ExperimentalFoundationApi
@Composable
fun PlaylistsTab(
viewModel: PlaylistsTabViewModel = viewModel(),
onBuiltInPlaylistClicked: (BuiltInPlaylist) -> Unit,
onPlaylistClicked: (Playlist) -> Unit,
) {
val (colorPalette, typography) = LocalAppearance.current
var isCreatingANewPlaylist by rememberSaveable {
mutableStateOf(false)
}
if (isCreatingANewPlaylist) {
TextFieldDialog(
hintText = "Enter the playlist name",
onDismiss = {
isCreatingANewPlaylist = false
},
onDone = { text ->
query {
Database.insert(Playlist(name = text))
}
}
)
}
val sortOrderIconRotation by animateFloatAsState(
targetValue = if (viewModel.sortOrder == SortOrder.Ascending) 0f else 180f,
animationSpec = tween(durationMillis = 400, easing = LinearEasing)
)
LazyVerticalGrid(
columns = GridCells.Adaptive(Dimensions.thumbnails.song * 2 + Dimensions.itemsVerticalPadding * 2),
contentPadding = LocalPlayerAwarePaddingValues.current,
verticalArrangement = Arrangement.spacedBy(Dimensions.itemsVerticalPadding * 2),
horizontalArrangement = Arrangement.spacedBy(
space = Dimensions.itemsVerticalPadding * 2,
alignment = Alignment.CenterHorizontally
),
modifier = Modifier
.fillMaxSize()
.background(colorPalette.background0)
) {
item(
key = "header",
contentType = 0,
span = { GridItemSpan(maxLineSpan) }
) {
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()
)
}
}
}

View File

@@ -1,236 +0,0 @@
package it.vfsfitvnm.vimusic.ui.views
import android.app.Application
import android.content.SharedPreferences
import androidx.annotation.DrawableRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.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.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.enums.SongSortBy
import it.vfsfitvnm.vimusic.enums.SortOrder
import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
import it.vfsfitvnm.vimusic.models.DetailedSong
import it.vfsfitvnm.vimusic.ui.components.themed.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.utils.asMediaItem
import it.vfsfitvnm.vimusic.utils.center
import it.vfsfitvnm.vimusic.utils.color
import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex
import it.vfsfitvnm.vimusic.utils.getEnum
import it.vfsfitvnm.vimusic.utils.mutableStatePreferenceOf
import it.vfsfitvnm.vimusic.utils.preferences
import it.vfsfitvnm.vimusic.utils.putEnum
import it.vfsfitvnm.vimusic.utils.semiBold
import it.vfsfitvnm.vimusic.utils.songSortByKey
import it.vfsfitvnm.vimusic.utils.songSortOrderKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
class SongsTabViewModel(application: Application) : AndroidViewModel(application) {
var items by mutableStateOf(emptyList<DetailedSong>())
private set
var sortBy by mutableStatePreferenceOf(preferences.getEnum(songSortByKey, SongSortBy.DateAdded)) {
preferences.edit { putEnum(songSortByKey, it) }
collectItems(sortBy = it)
}
var sortOrder by mutableStatePreferenceOf(preferences.getEnum(songSortOrderKey, SortOrder.Ascending)) {
preferences.edit { putEnum(songSortOrderKey, it) }
collectItems(sortOrder = it)
}
private var job: Job? = null
private val preferences: SharedPreferences
get() = getApplication<Application>().preferences
init {
collectItems()
}
private fun collectItems(sortBy: SongSortBy = this.sortBy, sortOrder: SortOrder = this.sortOrder) {
job?.cancel()
job = viewModelScope.launch {
Database.songs(sortBy, sortOrder).flowOn(Dispatchers.IO).collect {
items = it
}
}
}
}
@ExperimentalFoundationApi
@ExperimentalAnimationApi
@Composable
fun SongsTab(
viewModel: SongsTabViewModel = viewModel()
) {
val (colorPalette, typography) = LocalAppearance.current
val binder = LocalPlayerServiceBinder.current
val thumbnailSize = Dimensions.thumbnails.song.px
val sortOrderIconRotation by animateFloatAsState(
targetValue = if (viewModel.sortOrder == SortOrder.Ascending) 0f else 180f,
animationSpec = tween(durationMillis = 400, easing = LinearEasing)
)
LazyColumn(
contentPadding = LocalPlayerAwarePaddingValues.current,
modifier = Modifier
.background(colorPalette.background0)
.fillMaxSize()
) {
item(
key = "header",
contentType = 0
) {
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()
)
}
}
}

View File

@@ -1,281 +0,0 @@
package it.vfsfitvnm.vimusic.ui.views.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)
)
}
}

View File

@@ -1,389 +0,0 @@
package it.vfsfitvnm.vimusic.ui.views.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? = ".",
)

View File

@@ -1,75 +0,0 @@
package it.vfsfitvnm.vimusic.ui.views.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()
)
}
}
}

View File

@@ -1,228 +0,0 @@
package it.vfsfitvnm.vimusic.ui.views.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)
)
}
}
}
}

View File

@@ -1,169 +0,0 @@
package it.vfsfitvnm.vimusic.ui.views.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
)
}
}
}