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") @Query("SELECT * FROM Song WHERE id = :id")
fun song(id: String): Flow<Song?> 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") @Query("SELECT * FROM Artist WHERE id = :id")
fun artist(id: String): Flow<Artist?> fun artist(id: String): Flow<Artist?>

View File

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

View File

@@ -7,10 +7,13 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState 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.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -18,6 +21,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha 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.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.hapticfeedback.HapticFeedbackType 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.MusicBars
import it.vfsfitvnm.vimusic.ui.components.themed.QueuedMediaItemMenu import it.vfsfitvnm.vimusic.ui.components.themed.QueuedMediaItemMenu
import it.vfsfitvnm.vimusic.ui.screens.SmallSongItemShimmer 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.PlayerState
import it.vfsfitvnm.vimusic.utils.medium
import it.vfsfitvnm.vimusic.utils.secondary
import it.vfsfitvnm.vimusic.utils.semiBold
@ExperimentalAnimationApi @ExperimentalAnimationApi
@@ -50,7 +61,7 @@ fun CurrentPlaylistView(
) { ) {
val binder = LocalPlayerServiceBinder.current val binder = LocalPlayerServiceBinder.current
val hapticFeedback = LocalHapticFeedback.current val hapticFeedback = LocalHapticFeedback.current
val (colorPalette) = LocalAppearance.current val (colorPalette, typography) = LocalAppearance.current
val thumbnailSize = Dimensions.thumbnails.song.px val thumbnailSize = Dimensions.thumbnails.song.px
@@ -63,120 +74,169 @@ fun CurrentPlaylistView(
val reorderingState = rememberReorderingState(playerState?.mediaItems ?: emptyList()) val reorderingState = rememberReorderingState(playerState?.mediaItems ?: emptyList())
LazyColumn( Box {
state = lazyListState, LazyColumn(
modifier = modifier state = lazyListState,
.nestedScroll(remember { contentPadding = PaddingValues(top = 16.dp, bottom = 64.dp),
layoutState.nestedScrollConnection(lazyListState.firstVisibleItemIndex == 0 && lazyListState.firstVisibleItemScrollOffset == 0) horizontalAlignment = Alignment.CenterHorizontally,
}) modifier = modifier
) { .nestedScroll(remember {
itemsIndexed( layoutState.nestedScrollConnection(lazyListState.firstVisibleItemIndex == 0 && lazyListState.firstVisibleItemScrollOffset == 0)
items = playerState?.mediaItems ?: emptyList() })
) { index, mediaItem -> ) {
val isPlayingThisMediaItem by derivedStateOf { itemsIndexed(
playerState?.mediaItemIndex == index items = playerState?.mediaItems ?: emptyList()
} ) { index, mediaItem ->
val isPlayingThisMediaItem by derivedStateOf {
playerState?.mediaItemIndex == index
}
SongItem( SongItem(
mediaItem = mediaItem, mediaItem = mediaItem,
thumbnailSize = thumbnailSize, thumbnailSize = thumbnailSize,
onClick = { onClick = {
if (isPlayingThisMediaItem) { 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)
) {
if (isPaused) { if (isPaused) {
Image( binder?.player?.play()
painter = painterResource(R.drawable.play),
contentDescription = null,
colorFilter = ColorFilter.tint(LightColorPalette.background),
modifier = Modifier
.size(24.dp)
)
} else { } else {
MusicBars( binder?.player?.pause()
color = LightColorPalette.background, }
modifier = Modifier } else {
.height(24.dp) 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 = {
trailingContent = { Image(
Image( painter = painterResource(R.drawable.reorder),
painter = painterResource(R.drawable.reorder), contentDescription = null,
contentDescription = null, colorFilter = ColorFilter.tint(colorPalette.textSecondary),
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,
modifier = Modifier modifier = Modifier
.alpha(1f - index * 0.125f) .clickable {}
.fillMaxWidth() .padding(horizontal = 8.dp, vertical = 4.dp)
.padding(vertical = 4.dp, horizontal = 16.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 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.ExperimentalAnimationApi
import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.Image
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable
import androidx.compose.foundation.text.BasicText
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.Dp import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.models.Song
import it.vfsfitvnm.vimusic.query
import it.vfsfitvnm.vimusic.ui.components.BottomSheet import it.vfsfitvnm.vimusic.ui.components.BottomSheet
import it.vfsfitvnm.vimusic.ui.components.BottomSheetState 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.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.utils.PlayerState 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 @ExperimentalAnimationApi
@@ -44,197 +24,89 @@ import kotlinx.coroutines.withContext
fun PlayerBottomSheet( fun PlayerBottomSheet(
playerState: PlayerState?, playerState: PlayerState?,
layoutState: BottomSheetState, layoutState: BottomSheetState,
padding: Dp, onShowLyrics: () -> Unit,
song: Song?, onShowStatsForNerds: () -> Unit,
onGlobalRouteEmitted: () -> Unit, onGlobalRouteEmitted: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val (colorPalette, typography) = LocalAppearance.current val (colorPalette) = LocalAppearance.current
val coroutineScope = rememberCoroutineScope()
val tabPagerState = rememberTabPagerState(initialPageIndex = 0, pageCount = 2)
var nextResult by remember(playerState?.mediaItem?.mediaId) {
mutableStateOf<Result<YouTube.NextResult>?>(null)
}
BottomSheet( BottomSheet(
state = layoutState, state = layoutState,
peekHeight = padding,
elevation = 16.dp, elevation = 16.dp,
shape = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp), modifier = modifier,
handleOutsideInteractionsWhenExpanded = true,
modifier = modifier
.padding(bottom = padding),
collapsedContent = { collapsedContent = {
Column( Row(
verticalArrangement = Arrangement.SpaceEvenly, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.graphicsLayer {
alpha = 1f - (layoutState.progress * 16).coerceAtMost(1f)
}
.fillMaxWidth() .fillMaxWidth()
.height(layoutState.lowerBound) .height(layoutState.lowerBound)
.background(colorPalette.elevatedBackground) .background(colorPalette.background)
) { ) {
Spacer( Row(
modifier = Modifier modifier = Modifier
.align(Alignment.CenterHorizontally) .padding(horizontal = 8.dp)
.background( ) {
color = colorPalette.textDisabled, Spacer(
shape = RoundedCornerShape(16.dp) modifier = Modifier
) .padding(all = 8.dp)
.width(36.dp) .size(20.dp)
.height(4.dp) )
.padding(top = 8.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( Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.padding(horizontal = 8.dp)
) { ) {
@Composable Image(
fun Element( painter = painterResource(R.drawable.text),
text: String, contentDescription = null,
pageIndex: Int colorFilter = ColorFilter.tint(colorPalette.text),
) { modifier = Modifier
val color by animateColorAsState( .clickable(onClick = onShowLyrics)
if (tabPagerState.pageIndex == pageIndex) { .padding(all = 8.dp)
colorPalette.text .size(20.dp)
} 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
) )
Element( Image(
text = "LYRICS", painter = painterResource(R.drawable.information),
pageIndex = 1 contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable(onClick = onShowStatsForNerds)
.padding(all = 8.dp)
.size(20.dp)
) )
} }
} }
} }
) { ) {
HorizontalTabPager( CurrentPlaylistView(
state = tabPagerState, playerState = playerState,
layoutState = layoutState,
onGlobalRouteEmitted = onGlobalRouteEmitted,
modifier = Modifier modifier = Modifier
.background(colorPalette.elevatedBackground) .background(colorPalette.background)
.fillMaxSize() .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 package it.vfsfitvnm.vimusic.ui.views
import android.app.SearchManager
import android.content.Intent import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.media.audiofx.AudioEffect import android.media.audiofx.AudioEffect
@@ -9,9 +10,7 @@ import android.widget.Toast
import androidx.activity.compose.LocalActivityResultRegistryOwner import androidx.activity.compose.LocalActivityResultRegistryOwner
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.foundation.Image import androidx.compose.foundation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -23,23 +22,28 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
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.Cache
import androidx.media3.datasource.cache.CacheSpan import androidx.media3.datasource.cache.CacheSpan
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.valentinilk.shimmer.shimmer
import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R 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.models.Song
import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.query
import it.vfsfitvnm.vimusic.ui.components.* import it.vfsfitvnm.vimusic.ui.components.*
import it.vfsfitvnm.vimusic.ui.components.themed.BaseMediaItemMenu import it.vfsfitvnm.vimusic.ui.components.themed.*
import it.vfsfitvnm.vimusic.ui.components.themed.LoadingOrError import it.vfsfitvnm.vimusic.ui.styling.*
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.utils.* import it.vfsfitvnm.vimusic.utils.*
import it.vfsfitvnm.youtubemusic.YouTube import it.vfsfitvnm.youtubemusic.YouTube
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -170,9 +171,13 @@ fun PlayerView(
} }
} }
) { ) {
val song by remember(playerState.mediaItem.mediaId) { var isShowingLyrics by rememberSaveable {
playerState.mediaItem.mediaId.let(Database::song).distinctUntilChanged() mutableStateOf(false)
}.collectAsState(initial = null, context = Dispatchers.IO) }
var isShowingStatsForNerds by rememberSaveable {
mutableStateOf(false)
}
when (configuration.orientation) { when (configuration.orientation) {
Configuration.ORIENTATION_LANDSCAPE -> { Configuration.ORIENTATION_LANDSCAPE -> {
@@ -190,12 +195,17 @@ fun PlayerView(
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.padding(bottom = 16.dp) .padding(bottom = 16.dp)
) { ) {
Thumbnail() Thumbnail(
isShowingLyrics = isShowingLyrics,
onShowLyrics = { isShowingLyrics = it },
isShowingStatsForNerds = isShowingStatsForNerds,
onShowStatsForNerds = { isShowingStatsForNerds = it },
nestedScrollConnectionProvider = layoutState::nestedScrollConnection,
)
} }
Controls( Controls(
playerState = playerState, playerState = playerState,
song = song,
modifier = Modifier modifier = Modifier
.padding(vertical = 8.dp) .padding(vertical = 8.dp)
.fillMaxHeight() .fillMaxHeight()
@@ -217,12 +227,17 @@ fun PlayerView(
.weight(1.25f) .weight(1.25f)
.padding(horizontal = 32.dp, vertical = 8.dp) .padding(horizontal = 32.dp, vertical = 8.dp)
) { ) {
Thumbnail() Thumbnail(
isShowingLyrics = isShowingLyrics,
onShowLyrics = { isShowingLyrics = it },
isShowingStatsForNerds = isShowingStatsForNerds,
onShowStatsForNerds = { isShowingStatsForNerds = it },
nestedScrollConnectionProvider = layoutState::nestedScrollConnection,
)
} }
Controls( Controls(
playerState = playerState, playerState = playerState,
song = song,
modifier = Modifier modifier = Modifier
.padding(vertical = 8.dp) .padding(vertical = 8.dp)
.fillMaxWidth() .fillMaxWidth()
@@ -297,10 +312,16 @@ fun PlayerView(
PlayerBottomSheet( PlayerBottomSheet(
playerState = playerState, 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, onGlobalRouteEmitted = layoutState.collapse,
padding = layoutState.upperBound * 0.1f,
song = song,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
) )
@@ -310,6 +331,11 @@ fun PlayerView(
@ExperimentalAnimationApi @ExperimentalAnimationApi
@Composable @Composable
private fun Thumbnail( private fun Thumbnail(
isShowingLyrics: Boolean,
onShowLyrics: (Boolean) -> Unit,
isShowingStatsForNerds: Boolean,
onShowStatsForNerds: (Boolean) -> Unit,
nestedScrollConnectionProvider: () -> NestedScrollConnection,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val binder = LocalPlayerServiceBinder.current val binder = LocalPlayerServiceBinder.current
@@ -323,10 +349,6 @@ private fun Thumbnail(
val error by rememberError(player) val error by rememberError(player)
var isShowingStatsForNerds by rememberSaveable {
mutableStateOf(false)
}
if (error == null) { if (error == null) {
AnimatedContent( AnimatedContent(
targetState = mediaItemIndex, targetState = mediaItemIndex,
@@ -339,6 +361,7 @@ private fun Thumbnail(
SizeTransform(clip = false) SizeTransform(clip = false)
) )
}, },
contentAlignment = Alignment.Center,
modifier = modifier modifier = modifier
.aspectRatio(1f) .aspectRatio(1f)
) { currentMediaItemIndex -> ) { currentMediaItemIndex ->
@@ -358,20 +381,44 @@ private fun Thumbnail(
modifier = Modifier modifier = Modifier
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onTap = {
onShowLyrics(true)
},
onLongPress = { onLongPress = {
isShowingStatsForNerds = true onShowStatsForNerds(true)
} }
) )
} }
.fillMaxSize() .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( StatsForNerds(
mediaId = mediaItem.mediaId, mediaId = mediaItem.mediaId,
isDisplayed = isShowingStatsForNerds, isDisplayed = isShowingStatsForNerds,
onDismiss = { 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 @Composable
private fun StatsForNerds( private fun StatsForNerds(
mediaId: String, mediaId: String,
@@ -465,50 +709,50 @@ private fun StatsForNerds(
Column { Column {
BasicText( BasicText(
text = "Id", text = "Id",
style = typography.xs.semiBold.color(BlackColorPalette.text) style = typography.xs.medium.color(BlackColorPalette.text)
) )
BasicText( BasicText(
text = "Volume", text = "Volume",
style = typography.xs.semiBold.color(BlackColorPalette.text) style = typography.xs.medium.color(BlackColorPalette.text)
) )
BasicText( BasicText(
text = "Loudness", text = "Loudness",
style = typography.xs.semiBold.color(BlackColorPalette.text) style = typography.xs.medium.color(BlackColorPalette.text)
) )
BasicText( BasicText(
text = "Bitrate", text = "Bitrate",
style = typography.xs.semiBold.color(BlackColorPalette.text) style = typography.xs.medium.color(BlackColorPalette.text)
) )
BasicText( BasicText(
text = "Size", text = "Size",
style = typography.xs.semiBold.color(BlackColorPalette.text) style = typography.xs.medium.color(BlackColorPalette.text)
) )
BasicText( BasicText(
text = "Cached", text = "Cached",
style = typography.xs.semiBold.color(BlackColorPalette.text) style = typography.xs.medium.color(BlackColorPalette.text)
) )
} }
Column { Column {
BasicText( BasicText(
text = mediaId, text = mediaId,
style = typography.xs.semiBold.color(BlackColorPalette.text) style = typography.xs.medium.color(BlackColorPalette.text)
) )
BasicText( BasicText(
text = "${volume.times(100).roundToInt()}%", text = "${volume.times(100).roundToInt()}%",
style = typography.xs.semiBold.color(BlackColorPalette.text) style = typography.xs.medium.color(BlackColorPalette.text)
) )
BasicText( BasicText(
text = format?.loudnessDb?.let { loudnessDb -> text = format?.loudnessDb?.let { loudnessDb ->
"%.2f dB".format(loudnessDb) "%.2f dB".format(loudnessDb)
} ?: "Unknown", } ?: "Unknown",
style = typography.xs.semiBold.color(BlackColorPalette.text) style = typography.xs.medium.color(BlackColorPalette.text)
) )
BasicText( BasicText(
text = format?.bitrate?.let { bitrate -> text = format?.bitrate?.let { bitrate ->
"${bitrate / 1000} kbps" "${bitrate / 1000} kbps"
} ?: "Unknown", } ?: "Unknown",
style = typography.xs.semiBold.color(BlackColorPalette.text) style = typography.xs.medium.color(BlackColorPalette.text)
) )
BasicText( BasicText(
text = format?.contentLength?.let { contentLength -> text = format?.contentLength?.let { contentLength ->
@@ -517,7 +761,7 @@ private fun StatsForNerds(
contentLength contentLength
) )
} ?: "Unknown", } ?: "Unknown",
style = typography.xs.semiBold.color(BlackColorPalette.text) style = typography.xs.medium.color(BlackColorPalette.text)
) )
BasicText( BasicText(
text = buildString { text = buildString {
@@ -527,7 +771,7 @@ private fun StatsForNerds(
append(" (${(cachedBytes.toFloat() / contentLength * 100).roundToInt()}%)") 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) { if (format != null && format?.itag == null) {
BasicText( BasicText(
text = "FETCH MISSING DATA", text = "FETCH MISSING DATA",
style = typography.xxs.semiBold.color(BlackColorPalette.text), style = typography.xxs.medium.color(BlackColorPalette.text),
modifier = Modifier modifier = Modifier
.clickable( .clickable(
indication = rememberRipple(bounded = true), indication = rememberRipple(bounded = true),
@@ -579,18 +823,22 @@ private fun StatsForNerds(
@Composable @Composable
private fun Controls( private fun Controls(
playerState: PlayerState, playerState: PlayerState,
song: Song?,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val (colorPalette, typography) = LocalAppearance.current val (colorPalette, typography) = LocalAppearance.current
val binder = LocalPlayerServiceBinder.current val binder = LocalPlayerServiceBinder.current
val player = binder?.player ?: return val player = binder?.player ?: return
val mediaId = playerState.mediaItem?.mediaId ?: return
var scrubbingPosition by remember(playerState.mediaItemIndex) { var scrubbingPosition by remember(playerState.mediaItemIndex) {
mutableStateOf<Long?>(null) mutableStateOf<Long?>(null)
} }
val likedAt by remember(mediaId) {
Database.likedAt(mediaId).distinctUntilChanged()
}.collectAsState(initial = null, context = Dispatchers.IO)
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier modifier = modifier
@@ -688,15 +936,17 @@ private fun Controls(
Image( Image(
painter = painterResource(R.drawable.heart), painter = painterResource(R.drawable.heart),
contentDescription = null, contentDescription = null,
colorFilter = ColorFilter.tint( colorFilter = ColorFilter.tint(if (likedAt != null) colorPalette.red else colorPalette.textDisabled),
song?.likedAt?.let { colorPalette.red } ?: colorPalette.textDisabled
),
modifier = Modifier modifier = Modifier
.clickable { .clickable {
query { query {
song?.let { song -> if (Database.like(
Database.update(song.toggleLike()) mediaId,
} ?: Database.insert(playerState.mediaItem!!, Song::toggleLike) if (likedAt == null) System.currentTimeMillis() else null
) == 0
) {
Database.insert(playerState.mediaItem, Song::toggleLike)
}
} }
} }
.weight(1f) .weight(1f)
@@ -797,4 +1047,4 @@ private fun Controls(
.weight(1f) .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>