Rework player view UI (#105)

This commit is contained in:
vfsfitvnm
2022-07-21 20:41:19 +02:00
parent de46f90793
commit 707fee1b29
10 changed files with 604 additions and 475 deletions

View File

@@ -81,6 +81,18 @@ interface Database {
@Query("SELECT * FROM Song WHERE id = :id")
fun song(id: String): Flow<Song?>
@Query("SELECT likedAt FROM Song WHERE id = :songId")
fun likedAt(songId: String): Flow<Long?>
@Query("UPDATE Song SET likedAt = :likedAt WHERE id = :songId")
fun like(songId: String, likedAt: Long?): Int
@Query("SELECT lyrics FROM Song WHERE id = :songId")
fun lyrics(songId: String): Flow<String?>
@Query("UPDATE Song SET lyrics = :lyrics WHERE id = :songId")
fun updateLyrics(songId: String, lyrics: String): Int
@Query("SELECT * FROM Artist WHERE id = :id")
fun artist(id: String): Flow<Artist?>

View File

@@ -90,7 +90,6 @@ fun BottomSheet(
}
}
@Stable
class BottomSheetState(
draggableState: DraggableState,

View File

@@ -7,10 +7,13 @@ import androidx.compose.animation.fadeOut
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.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.BasicText
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -18,6 +21,8 @@ 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.rotate
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@@ -36,8 +41,14 @@ 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.*
import it.vfsfitvnm.vimusic.ui.styling.Dimensions
import it.vfsfitvnm.vimusic.ui.styling.LightColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.ui.styling.px
import it.vfsfitvnm.vimusic.utils.PlayerState
import it.vfsfitvnm.vimusic.utils.medium
import it.vfsfitvnm.vimusic.utils.secondary
import it.vfsfitvnm.vimusic.utils.semiBold
@ExperimentalAnimationApi
@@ -50,7 +61,7 @@ fun CurrentPlaylistView(
) {
val binder = LocalPlayerServiceBinder.current
val hapticFeedback = LocalHapticFeedback.current
val (colorPalette) = LocalAppearance.current
val (colorPalette, typography) = LocalAppearance.current
val thumbnailSize = Dimensions.thumbnails.song.px
@@ -63,120 +74,169 @@ fun CurrentPlaylistView(
val reorderingState = rememberReorderingState(playerState?.mediaItems ?: emptyList())
LazyColumn(
state = lazyListState,
modifier = modifier
.nestedScroll(remember {
layoutState.nestedScrollConnection(lazyListState.firstVisibleItemIndex == 0 && lazyListState.firstVisibleItemScrollOffset == 0)
})
) {
itemsIndexed(
items = playerState?.mediaItems ?: emptyList()
) { index, mediaItem ->
val isPlayingThisMediaItem by derivedStateOf {
playerState?.mediaItemIndex == index
}
Box {
LazyColumn(
state = lazyListState,
contentPadding = PaddingValues(top = 16.dp, bottom = 64.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.nestedScroll(remember {
layoutState.nestedScrollConnection(lazyListState.firstVisibleItemIndex == 0 && lazyListState.firstVisibleItemScrollOffset == 0)
})
) {
itemsIndexed(
items = playerState?.mediaItems ?: emptyList()
) { index, mediaItem ->
val isPlayingThisMediaItem by derivedStateOf {
playerState?.mediaItemIndex == index
}
SongItem(
mediaItem = mediaItem,
thumbnailSize = thumbnailSize,
onClick = {
if (isPlayingThisMediaItem) {
if (isPaused) {
binder?.player?.play()
} else {
binder?.player?.pause()
}
} else {
binder?.player?.playWhenReady = true
binder?.player?.seekToDefaultPosition(index)
}
},
menuContent = {
QueuedMediaItemMenu(
mediaItem = mediaItem,
indexInQueue = if (isPlayingThisMediaItem) null else index,
onGlobalRouteEmitted = onGlobalRouteEmitted
)
},
onThumbnailContent = {
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)
) {
SongItem(
mediaItem = mediaItem,
thumbnailSize = thumbnailSize,
onClick = {
if (isPlayingThisMediaItem) {
if (isPaused) {
Image(
painter = painterResource(R.drawable.play),
contentDescription = null,
colorFilter = ColorFilter.tint(LightColorPalette.background),
modifier = Modifier
.size(24.dp)
)
binder?.player?.play()
} else {
MusicBars(
color = LightColorPalette.background,
modifier = Modifier
.height(24.dp)
)
binder?.player?.pause()
}
} else {
binder?.player?.playWhenReady = true
binder?.player?.seekToDefaultPosition(index)
}
},
menuContent = {
QueuedMediaItemMenu(
mediaItem = mediaItem,
indexInQueue = if (isPlayingThisMediaItem) null else index,
onGlobalRouteEmitted = onGlobalRouteEmitted
)
},
onThumbnailContent = {
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 (isPaused) {
Image(
painter = painterResource(R.drawable.play),
contentDescription = null,
colorFilter = ColorFilter.tint(LightColorPalette.background),
modifier = Modifier
.size(24.dp)
)
} else {
MusicBars(
color = LightColorPalette.background,
modifier = Modifier
.height(24.dp)
)
}
}
}
}
},
trailingContent = {
Image(
painter = painterResource(R.drawable.reorder),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.textSecondary),
modifier = Modifier
.clickable {}
.padding(horizontal = 8.dp, vertical = 4.dp)
.size(20.dp)
)
},
backgroundColor = colorPalette.elevatedBackground,
modifier = Modifier
.verticalDragAfterLongPressToReorder(
reorderingState = reorderingState,
index = index,
onDragStart = {
hapticFeedback.performHapticFeedback(
HapticFeedbackType.LongPress
)
},
onDragEnd = { reachedIndex ->
binder?.player?.moveMediaItem(index, reachedIndex)
}
)
)
}
item {
if (binder?.isLoadingRadio == true) {
Column(
modifier = Modifier
.shimmer()
) {
repeat(3) { index ->
SmallSongItemShimmer(
thumbnailSizeDp = Dimensions.thumbnails.song,
},
trailingContent = {
Image(
painter = painterResource(R.drawable.reorder),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.textSecondary),
modifier = Modifier
.alpha(1f - index * 0.125f)
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 16.dp)
.clickable {}
.padding(horizontal = 8.dp, vertical = 4.dp)
.size(20.dp)
)
},
backgroundColor = colorPalette.background,
modifier = Modifier
.verticalDragAfterLongPressToReorder(
reorderingState = reorderingState,
index = index,
onDragStart = {
hapticFeedback.performHapticFeedback(
HapticFeedbackType.LongPress
)
},
onDragEnd = { reachedIndex ->
binder?.player?.moveMediaItem(index, reachedIndex)
}
)
)
}
item {
if (binder?.isLoadingRadio == true) {
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)
)
}
}
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
modifier = Modifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = layoutState.collapse
)
.shadow(elevation = 8.dp)
.height(64.dp)
.background(colorPalette.elevatedBackground)
.fillMaxWidth()
.align(Alignment.BottomCenter)
) {
Image(
painter = painterResource(R.drawable.chevron_up),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.rotate(180f)
.padding(all = 16.dp)
.size(18.dp)
)
Column {
BasicText(
text = "Queue",
style = typography.s.medium,
modifier = Modifier
)
BasicText(
text = "${playerState?.mediaItems?.size ?: 0} songs",
style = typography.xxs.semiBold.secondary,
modifier = Modifier
)
}
Spacer(
modifier = Modifier
.padding(all = 16.dp)
.size(18.dp)
)
}
}
}

View File

@@ -1,126 +0,0 @@
package it.vfsfitvnm.vimusic.ui.views
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.valentinilk.shimmer.shimmer
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.ui.components.themed.TextCard
import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.utils.center
import it.vfsfitvnm.vimusic.utils.secondary
@Composable
fun LyricsView(
lyrics: String?,
onInitialize: () -> Unit,
onSearchOnline: () -> Unit,
onLyricsUpdate: (String) -> Unit,
nestedScrollConnectionProvider: () -> NestedScrollConnection,
) {
val (_, typography) = LocalAppearance.current
var isEditingLyrics by remember {
mutableStateOf(false)
}
if (isEditingLyrics) {
TextFieldDialog(
hintText = "Enter the lyrics",
initialTextInput = lyrics ?: "",
singleLine = false,
maxLines = 10,
isTextInputValid = { true },
onDismiss = {
isEditingLyrics = false
},
onDone = onLyricsUpdate
)
}
if (lyrics != null ) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(top = 64.dp)
.nestedScroll(remember { nestedScrollConnectionProvider() })
.verticalScroll(rememberScrollState())
.fillMaxWidth()
.padding(vertical = 16.dp)
.padding(horizontal = 48.dp)
) {
if (lyrics.isEmpty()) {
TextCard(icon = R.drawable.sad) {
Title(text = "Lyrics not available")
Text(text = "...but you can always search them online and add them here!")
}
} else {
BasicText(
text = lyrics,
style = typography.xs.center,
)
}
Row(
modifier = Modifier
.padding(top = 32.dp)
) {
BasicText(
text = "Search online",
style = typography.xs.secondary.copy(textDecoration = TextDecoration.Underline),
modifier = Modifier
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = onSearchOnline
)
.padding(horizontal = 8.dp)
)
BasicText(
text = "Edit",
style = typography.xs.secondary.copy(textDecoration = TextDecoration.Underline),
modifier = Modifier
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
isEditingLyrics = true
}
.padding(horizontal = 8.dp)
)
}
}
} else {
SideEffect(onInitialize)
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.shimmer()
) {
repeat(16) { index ->
TextPlaceholder(
modifier = Modifier
.alpha(1f - index * 0.05f)
)
}
}
}
}

View File

@@ -1,42 +1,22 @@
package it.vfsfitvnm.vimusic.ui.views
import android.app.SearchManager
import android.content.Intent
import android.widget.Toast
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
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.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
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 it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.models.Song
import it.vfsfitvnm.vimusic.query
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.HorizontalTabPager
import it.vfsfitvnm.vimusic.ui.components.rememberTabPagerState
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.utils.PlayerState
import it.vfsfitvnm.vimusic.utils.center
import it.vfsfitvnm.vimusic.utils.color
import it.vfsfitvnm.vimusic.utils.medium
import it.vfsfitvnm.youtubemusic.YouTube
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ExperimentalAnimationApi
@@ -44,197 +24,89 @@ import kotlinx.coroutines.withContext
fun PlayerBottomSheet(
playerState: PlayerState?,
layoutState: BottomSheetState,
padding: Dp,
song: Song?,
onShowLyrics: () -> Unit,
onShowStatsForNerds: () -> Unit,
onGlobalRouteEmitted: () -> Unit,
modifier: Modifier = Modifier,
) {
val (colorPalette, typography) = LocalAppearance.current
val coroutineScope = rememberCoroutineScope()
val tabPagerState = rememberTabPagerState(initialPageIndex = 0, pageCount = 2)
var nextResult by remember(playerState?.mediaItem?.mediaId) {
mutableStateOf<Result<YouTube.NextResult>?>(null)
}
val (colorPalette) = LocalAppearance.current
BottomSheet(
state = layoutState,
peekHeight = padding,
elevation = 16.dp,
shape = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp),
handleOutsideInteractionsWhenExpanded = true,
modifier = modifier
.padding(bottom = padding),
modifier = modifier,
collapsedContent = {
Column(
verticalArrangement = Arrangement.SpaceEvenly,
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.graphicsLayer {
alpha = 1f - (layoutState.progress * 16).coerceAtMost(1f)
}
.fillMaxWidth()
.height(layoutState.lowerBound)
.background(colorPalette.elevatedBackground)
.background(colorPalette.background)
) {
Spacer(
Row(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.background(
color = colorPalette.textDisabled,
shape = RoundedCornerShape(16.dp)
)
.width(36.dp)
.height(4.dp)
.padding(top = 8.dp)
.padding(horizontal = 8.dp)
) {
Spacer(
modifier = Modifier
.padding(all = 8.dp)
.size(20.dp)
)
Spacer(
modifier = Modifier
.padding(all = 8.dp)
.size(20.dp)
)
}
Image(
painter = painterResource(R.drawable.chevron_up),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.padding(all = 8.dp)
.size(18.dp)
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(horizontal = 8.dp)
) {
@Composable
fun Element(
text: String,
pageIndex: Int
) {
val color by animateColorAsState(
if (tabPagerState.pageIndex == pageIndex) {
colorPalette.text
} else {
colorPalette.textDisabled
}
)
val scale by animateFloatAsState(
if (pageIndex == pageIndex) {
1f
} else {
0.9f
}
)
BasicText(
text = text,
style = typography.xs.medium.color(color).center,
modifier = Modifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = {
coroutineScope.launch(Dispatchers.Main) {
layoutState.expand()
if (layoutState.isCollapsed) {
tabPagerState.pageIndex = pageIndex
} else {
tabPagerState.animateScrollTo(pageIndex)
}
}
}
)
.padding(vertical = 8.dp)
.scale(scale)
.weight(1f)
)
}
Element(
text = "UP NEXT",
pageIndex = 0
Image(
painter = painterResource(R.drawable.text),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable(onClick = onShowLyrics)
.padding(all = 8.dp)
.size(20.dp)
)
Element(
text = "LYRICS",
pageIndex = 1
Image(
painter = painterResource(R.drawable.information),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable(onClick = onShowStatsForNerds)
.padding(all = 8.dp)
.size(20.dp)
)
}
}
}
) {
HorizontalTabPager(
state = tabPagerState,
CurrentPlaylistView(
playerState = playerState,
layoutState = layoutState,
onGlobalRouteEmitted = onGlobalRouteEmitted,
modifier = Modifier
.background(colorPalette.elevatedBackground)
.background(colorPalette.background)
.fillMaxSize()
) { index ->
when (index) {
0 -> {
CurrentPlaylistView(
playerState = playerState,
layoutState = layoutState,
onGlobalRouteEmitted = onGlobalRouteEmitted,
modifier = Modifier
.padding(top = 64.dp)
)
}
1 -> {
val player = LocalPlayerServiceBinder.current?.player
val context = LocalContext.current
var lyricsResult by remember(song) {
mutableStateOf(song?.lyrics?.let { Result.success(it) })
}
LyricsView(
lyrics = lyricsResult?.getOrNull(),
nestedScrollConnectionProvider = layoutState::nestedScrollConnection,
onInitialize = {
coroutineScope.launch(Dispatchers.Main) {
val mediaItem = player?.currentMediaItem!!
if (nextResult == null) {
val mediaItemIndex = player.currentMediaItemIndex
nextResult = withContext(Dispatchers.IO) {
YouTube.next(
mediaItem.mediaId,
mediaItem.mediaMetadata.extras?.getString("playlistId"),
mediaItemIndex
)
}
}
lyricsResult = nextResult?.map { nextResult ->
nextResult.lyrics?.text()?.getOrNull() ?: ""
}?.map { lyrics ->
query {
song?.let {
Database.update(song.copy(lyrics = lyrics))
} ?: Database.insert(mediaItem) { song ->
song.copy(lyrics = lyrics)
}
}
lyrics
}
}
},
onSearchOnline = {
val mediaMetadata = player?.mediaMetadata ?: return@LyricsView
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()
}
},
onLyricsUpdate = { lyrics ->
val mediaItem = player?.currentMediaItem
query {
song?.let {
Database.update(song.copy(lyrics = lyrics))
} ?: mediaItem?.let {
Database.insert(mediaItem) { song ->
song.copy(lyrics = lyrics)
}
}
}
}
)
}
else -> {}
}
}
)
}
}

View File

@@ -1,5 +1,6 @@
package it.vfsfitvnm.vimusic.ui.views
import android.app.SearchManager
import android.content.Intent
import android.content.res.Configuration
import android.media.audiofx.AudioEffect
@@ -9,9 +10,7 @@ import android.widget.Toast
import androidx.activity.compose.LocalActivityResultRegistryOwner
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.*
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
@@ -23,23 +22,28 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.*
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.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.media3.common.*
import androidx.media3.common.C
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.datasource.cache.Cache
import androidx.media3.datasource.cache.CacheSpan
import coil.compose.AsyncImage
import com.valentinilk.shimmer.shimmer
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R
@@ -47,16 +51,13 @@ import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
import it.vfsfitvnm.vimusic.models.Song
import it.vfsfitvnm.vimusic.query
import it.vfsfitvnm.vimusic.ui.components.*
import it.vfsfitvnm.vimusic.ui.components.themed.BaseMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.LoadingOrError
import it.vfsfitvnm.vimusic.ui.styling.BlackColorPalette
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.components.themed.*
import it.vfsfitvnm.vimusic.ui.styling.*
import it.vfsfitvnm.vimusic.utils.*
import it.vfsfitvnm.youtubemusic.YouTube
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import kotlin.math.roundToInt
@@ -170,9 +171,13 @@ fun PlayerView(
}
}
) {
val song by remember(playerState.mediaItem.mediaId) {
playerState.mediaItem.mediaId.let(Database::song).distinctUntilChanged()
}.collectAsState(initial = null, context = Dispatchers.IO)
var isShowingLyrics by rememberSaveable {
mutableStateOf(false)
}
var isShowingStatsForNerds by rememberSaveable {
mutableStateOf(false)
}
when (configuration.orientation) {
Configuration.ORIENTATION_LANDSCAPE -> {
@@ -190,12 +195,17 @@ fun PlayerView(
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp)
) {
Thumbnail()
Thumbnail(
isShowingLyrics = isShowingLyrics,
onShowLyrics = { isShowingLyrics = it },
isShowingStatsForNerds = isShowingStatsForNerds,
onShowStatsForNerds = { isShowingStatsForNerds = it },
nestedScrollConnectionProvider = layoutState::nestedScrollConnection,
)
}
Controls(
playerState = playerState,
song = song,
modifier = Modifier
.padding(vertical = 8.dp)
.fillMaxHeight()
@@ -217,12 +227,17 @@ fun PlayerView(
.weight(1.25f)
.padding(horizontal = 32.dp, vertical = 8.dp)
) {
Thumbnail()
Thumbnail(
isShowingLyrics = isShowingLyrics,
onShowLyrics = { isShowingLyrics = it },
isShowingStatsForNerds = isShowingStatsForNerds,
onShowStatsForNerds = { isShowingStatsForNerds = it },
nestedScrollConnectionProvider = layoutState::nestedScrollConnection,
)
}
Controls(
playerState = playerState,
song = song,
modifier = Modifier
.padding(vertical = 8.dp)
.fillMaxWidth()
@@ -297,10 +312,16 @@ fun PlayerView(
PlayerBottomSheet(
playerState = playerState,
layoutState = rememberBottomSheetState(64.dp, layoutState.upperBound * 0.9f),
layoutState = rememberBottomSheetState(64.dp, layoutState.upperBound),
onShowLyrics = {
isShowingStatsForNerds = false
isShowingLyrics = !isShowingLyrics
},
onShowStatsForNerds = {
isShowingLyrics = false
isShowingStatsForNerds = !isShowingStatsForNerds
},
onGlobalRouteEmitted = layoutState.collapse,
padding = layoutState.upperBound * 0.1f,
song = song,
modifier = Modifier
.align(Alignment.BottomCenter)
)
@@ -310,6 +331,11 @@ fun PlayerView(
@ExperimentalAnimationApi
@Composable
private fun Thumbnail(
isShowingLyrics: Boolean,
onShowLyrics: (Boolean) -> Unit,
isShowingStatsForNerds: Boolean,
onShowStatsForNerds: (Boolean) -> Unit,
nestedScrollConnectionProvider: () -> NestedScrollConnection,
modifier: Modifier = Modifier
) {
val binder = LocalPlayerServiceBinder.current
@@ -323,10 +349,6 @@ private fun Thumbnail(
val error by rememberError(player)
var isShowingStatsForNerds by rememberSaveable {
mutableStateOf(false)
}
if (error == null) {
AnimatedContent(
targetState = mediaItemIndex,
@@ -339,6 +361,7 @@ private fun Thumbnail(
SizeTransform(clip = false)
)
},
contentAlignment = Alignment.Center,
modifier = modifier
.aspectRatio(1f)
) { currentMediaItemIndex ->
@@ -358,20 +381,44 @@ private fun Thumbnail(
modifier = Modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = {
onShowLyrics(true)
},
onLongPress = {
isShowingStatsForNerds = true
onShowStatsForNerds(true)
}
)
}
.fillMaxSize()
)
Lyrics(
mediaId = mediaItem.mediaId,
isDisplayed = isShowingLyrics,
onDismiss = {
onShowLyrics(false)
},
onLyricsUpdate = { mediaId, lyrics ->
if (Database.updateLyrics(mediaId, lyrics) == 0) {
if (mediaId == mediaItem.mediaId) {
Database.insert(mediaItem) { song ->
song.copy(lyrics = lyrics)
}
}
}
},
size = thumbnailSizeDp,
mediaMetadataProvider = mediaItem::mediaMetadata,
nestedScrollConnectionProvider = nestedScrollConnectionProvider,
)
StatsForNerds(
mediaId = mediaItem.mediaId,
isDisplayed = isShowingStatsForNerds,
onDismiss = {
isShowingStatsForNerds = false
}
onShowStatsForNerds(false)
},
modifier = Modifier
)
}
}
@@ -394,6 +441,203 @@ private fun Thumbnail(
}
}
@Composable
private fun Lyrics(
mediaId: String,
isDisplayed: Boolean,
onDismiss: () -> Unit,
size: Dp,
mediaMetadataProvider: () -> MediaMetadata,
onLyricsUpdate: (String, String) -> Unit,
nestedScrollConnectionProvider: () -> NestedScrollConnection,
modifier: Modifier = Modifier
) {
val (_, typography) = LocalAppearance.current
val context = LocalContext.current
AnimatedVisibility(
visible = isDisplayed,
enter = fadeIn(),
exit = fadeOut(),
) {
var isLoading by remember(mediaId) {
mutableStateOf(false)
}
var isEditingLyrics by remember(mediaId) {
mutableStateOf(false)
}
val lyrics by remember(mediaId) {
Database.lyrics(mediaId).distinctUntilChanged().map flowMap@{ lyrics ->
if (lyrics != null) return@flowMap lyrics
isLoading = true
YouTube.next(mediaId, null)?.map { nextResult ->
nextResult.lyrics?.text()?.map { newLyrics ->
onLyricsUpdate(mediaId, newLyrics ?: "")
isLoading = false
return@flowMap newLyrics ?: ""
}
}
isLoading = false
null
}.distinctUntilChanged()
}.collectAsState(initial = ".", context = Dispatchers.IO)
if (isEditingLyrics) {
TextFieldDialog(
hintText = "Enter the lyrics",
initialTextInput = lyrics ?: "",
singleLine = false,
maxLines = 10,
isTextInputValid = { true },
onDismiss = {
isEditingLyrics = false
},
onDone = {
query {
Database.updateLyrics(mediaId, it)
}
}
)
}
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = {
onDismiss()
}
)
}
.fillMaxSize()
.background(Color.Black.copy(0.8f))
) {
AnimatedVisibility(
visible = !isLoading && lyrics == null,
enter = slideInVertically { -it },
exit = slideOutVertically { -it },
modifier = Modifier
.align(Alignment.TopCenter)
) {
BasicText(
text = "An error has occurred while fetching the lyrics",
style = typography.xs.center.medium.color(BlackColorPalette.text),
modifier = Modifier
.background(Color.Black.copy(0.4f))
.padding(all = 8.dp)
.fillMaxWidth()
)
}
AnimatedVisibility(
visible = lyrics?.let(String::isEmpty) ?: false,
enter = slideInVertically { -it },
exit = slideOutVertically { -it },
modifier = Modifier
.align(Alignment.TopCenter)
) {
BasicText(
text = "Lyrics are not available for this song",
style = typography.xs.center.medium.color(BlackColorPalette.text),
modifier = Modifier
.background(Color.Black.copy(0.4f))
.padding(all = 8.dp)
.fillMaxWidth()
)
}
if (isLoading) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.shimmer()
) {
repeat(4) { index ->
TextPlaceholder(
modifier = Modifier
.alpha(1f - index * 0.05f)
)
}
}
} else {
lyrics?.let { lyrics ->
if (lyrics.isNotEmpty()) {
BasicText(
text = lyrics,
style = typography.xs.center.medium.color(BlackColorPalette.text),
modifier = Modifier
.nestedScroll(remember { nestedScrollConnectionProvider() })
.verticalFadingEdge()
.verticalScroll(rememberScrollState())
.padding(vertical = size / 4, horizontal = 32.dp)
)
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom,
modifier = Modifier
.aspectRatio(1f)
.size(size)
) {
Image(
painter = painterResource(R.drawable.search),
contentDescription = null,
colorFilter = ColorFilter.tint(DarkColorPalette.text),
modifier = Modifier
.padding(all = 4.dp)
.clickable {
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()
}
}
.padding(all = 8.dp)
.size(20.dp)
)
Image(
painter = painterResource(R.drawable.pencil),
contentDescription = null,
colorFilter = ColorFilter.tint(DarkColorPalette.text),
modifier = Modifier
.padding(all = 4.dp)
.clickable {
isEditingLyrics = true
}
.padding(all = 8.dp)
.size(20.dp)
)
}
}
}
}
}
}
@Composable
private fun StatsForNerds(
mediaId: String,
@@ -465,50 +709,50 @@ private fun StatsForNerds(
Column {
BasicText(
text = "Id",
style = typography.xs.semiBold.color(BlackColorPalette.text)
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = "Volume",
style = typography.xs.semiBold.color(BlackColorPalette.text)
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = "Loudness",
style = typography.xs.semiBold.color(BlackColorPalette.text)
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = "Bitrate",
style = typography.xs.semiBold.color(BlackColorPalette.text)
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = "Size",
style = typography.xs.semiBold.color(BlackColorPalette.text)
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = "Cached",
style = typography.xs.semiBold.color(BlackColorPalette.text)
style = typography.xs.medium.color(BlackColorPalette.text)
)
}
Column {
BasicText(
text = mediaId,
style = typography.xs.semiBold.color(BlackColorPalette.text)
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = "${volume.times(100).roundToInt()}%",
style = typography.xs.semiBold.color(BlackColorPalette.text)
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = format?.loudnessDb?.let { loudnessDb ->
"%.2f dB".format(loudnessDb)
} ?: "Unknown",
style = typography.xs.semiBold.color(BlackColorPalette.text)
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = format?.bitrate?.let { bitrate ->
"${bitrate / 1000} kbps"
} ?: "Unknown",
style = typography.xs.semiBold.color(BlackColorPalette.text)
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = format?.contentLength?.let { contentLength ->
@@ -517,7 +761,7 @@ private fun StatsForNerds(
contentLength
)
} ?: "Unknown",
style = typography.xs.semiBold.color(BlackColorPalette.text)
style = typography.xs.medium.color(BlackColorPalette.text)
)
BasicText(
text = buildString {
@@ -527,7 +771,7 @@ private fun StatsForNerds(
append(" (${(cachedBytes.toFloat() / contentLength * 100).roundToInt()}%)")
}
},
style = typography.xs.semiBold.color(BlackColorPalette.text)
style = typography.xs.medium.color(BlackColorPalette.text)
)
}
}
@@ -535,7 +779,7 @@ private fun StatsForNerds(
if (format != null && format?.itag == null) {
BasicText(
text = "FETCH MISSING DATA",
style = typography.xxs.semiBold.color(BlackColorPalette.text),
style = typography.xxs.medium.color(BlackColorPalette.text),
modifier = Modifier
.clickable(
indication = rememberRipple(bounded = true),
@@ -579,18 +823,22 @@ private fun StatsForNerds(
@Composable
private fun Controls(
playerState: PlayerState,
song: Song?,
modifier: Modifier = Modifier
) {
val (colorPalette, typography) = LocalAppearance.current
val binder = LocalPlayerServiceBinder.current
val player = binder?.player ?: return
val mediaId = playerState.mediaItem?.mediaId ?: return
var scrubbingPosition by remember(playerState.mediaItemIndex) {
mutableStateOf<Long?>(null)
}
val likedAt by remember(mediaId) {
Database.likedAt(mediaId).distinctUntilChanged()
}.collectAsState(initial = null, context = Dispatchers.IO)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
@@ -688,15 +936,17 @@ private fun Controls(
Image(
painter = painterResource(R.drawable.heart),
contentDescription = null,
colorFilter = ColorFilter.tint(
song?.likedAt?.let { colorPalette.red } ?: colorPalette.textDisabled
),
colorFilter = ColorFilter.tint(if (likedAt != null) colorPalette.red else colorPalette.textDisabled),
modifier = Modifier
.clickable {
query {
song?.let { song ->
Database.update(song.toggleLike())
} ?: Database.insert(playerState.mediaItem!!, Song::toggleLike)
if (Database.like(
mediaId,
if (likedAt == null) System.currentTimeMillis() else null
) == 0
) {
Database.insert(playerState.mediaItem, Song::toggleLike)
}
}
}
.weight(1f)
@@ -797,4 +1047,4 @@ private fun Controls(
.weight(1f)
)
}
}
}

View File

@@ -0,0 +1,24 @@
package it.vfsfitvnm.vimusic.utils
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
fun Modifier.verticalFadingEdge() =
graphicsLayer(alpha = 0.99f)
.drawWithContent {
drawContent()
drawRect(
brush = Brush.verticalGradient(
listOf(
Color.Transparent,
Color.Black, Color.Black, Color.Black,
Color.Transparent
)
),
blendMode = BlendMode.DstIn
)
}

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M292.6,407.78l-120,-320a22,22 0,0 0,-41.2 0l-120,320a22,22 0,0 0,41.2 15.44L88.76,326.8a2,2 0,0 1,1.87 -1.3L213.37,325.5a2,2 0,0 1,1.87 1.3l36.16,96.42a22,22 0,0 0,41.2 -15.44ZM106.76,278.78 L150.13,163.13a2,2 0,0 1,3.74 0L197.24,278.8a2,2 0,0 1,-1.87 2.7L108.63,281.5A2,2 0,0 1,106.76 278.8Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M400.77,169.5c-41.72,-0.3 -79.08,23.87 -95,61.4a22,22 0,0 0,40.5 17.2c8.88,-20.89 29.77,-34.44 53.32,-34.6C431.91,213.28 458,240 458,272.35h0a1.5,1.5 0,0 1,-1.45 1.5c-21.92,0.61 -47.92,2.07 -71.12,4.8C330.68,285.09 298,314.94 298,358.5c0,23.19 8.76,44 24.67,58.68C337.6,430.93 358,438.5 380,438.5c31,0 57.69,-8 77.94,-23.22 0,0 0.06,0 0.06,0h0a22,22 0,1 0,44 0.19v-143C502,216.29 457,169.91 400.77,169.5ZM380,394.5c-17.53,0 -38,-9.43 -38,-36 0,-10.67 3.83,-18.14 12.43,-24.23 8.37,-5.93 21.2,-10.16 36.14,-11.92 21.12,-2.49 44.82,-3.86 65.14,-4.47a2,2 0,0 1,2 2.1C455,370.1 429.46,394.5 380,394.5Z"/>
</vector>