From 707fee1b29329468d96256d4f63599ea1ab16d86 Mon Sep 17 00:00:00 2001 From: vfsfitvnm Date: Thu, 21 Jul 2022 20:41:19 +0200 Subject: [PATCH] Rework player view UI (#105) --- .../kotlin/it/vfsfitvnm/vimusic/Database.kt | 12 + .../vimusic/ui/components/BottomSheet.kt | 1 - .../vimusic/ui/views/CurrentPlaylistView.kt | 272 ++++++++------ .../vfsfitvnm/vimusic/ui/views/LyricsView.kt | 126 ------- .../vimusic/ui/views/PlayerBottomSheet.kt | 254 ++++--------- .../vfsfitvnm/vimusic/ui/views/PlayerView.kt | 352 +++++++++++++++--- .../vimusic/utils/verticalFadingEdge.kt | 24 ++ app/src/main/res/drawable-up/chevron_up.xml | 13 + app/src/main/res/drawable/chevron_up.xml | 13 + app/src/main/res/drawable/text.xml | 12 + 10 files changed, 604 insertions(+), 475 deletions(-) delete mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/LyricsView.kt create mode 100644 app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/verticalFadingEdge.kt create mode 100644 app/src/main/res/drawable-up/chevron_up.xml create mode 100644 app/src/main/res/drawable/chevron_up.xml create mode 100644 app/src/main/res/drawable/text.xml diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt index ef75e08..58082ed 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt @@ -81,6 +81,18 @@ interface Database { @Query("SELECT * FROM Song WHERE id = :id") fun song(id: String): Flow + @Query("SELECT likedAt FROM Song WHERE id = :songId") + fun likedAt(songId: String): Flow + + @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 + + @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 diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/BottomSheet.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/BottomSheet.kt index e6e09e1..4a9ba52 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/BottomSheet.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/BottomSheet.kt @@ -90,7 +90,6 @@ fun BottomSheet( } } - @Stable class BottomSheetState( draggableState: DraggableState, diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/CurrentPlaylistView.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/CurrentPlaylistView.kt index b18bceb..d66ba80 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/CurrentPlaylistView.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/CurrentPlaylistView.kt @@ -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) + ) + } } } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/LyricsView.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/LyricsView.kt deleted file mode 100644 index 64070d3..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/LyricsView.kt +++ /dev/null @@ -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) - ) - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerBottomSheet.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerBottomSheet.kt index 55dc5e4..097d998 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerBottomSheet.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerBottomSheet.kt @@ -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?>(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 -> {} - } - } + ) } } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt index ac05f07..02cf64a 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt @@ -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(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) ) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/verticalFadingEdge.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/verticalFadingEdge.kt new file mode 100644 index 0000000..8d79e5d --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/verticalFadingEdge.kt @@ -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 + ) + } diff --git a/app/src/main/res/drawable-up/chevron_up.xml b/app/src/main/res/drawable-up/chevron_up.xml new file mode 100644 index 0000000..cae48cc --- /dev/null +++ b/app/src/main/res/drawable-up/chevron_up.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/chevron_up.xml b/app/src/main/res/drawable/chevron_up.xml new file mode 100644 index 0000000..cae48cc --- /dev/null +++ b/app/src/main/res/drawable/chevron_up.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/text.xml b/app/src/main/res/drawable/text.xml new file mode 100644 index 0000000..70c024a --- /dev/null +++ b/app/src/main/res/drawable/text.xml @@ -0,0 +1,12 @@ + + + +