Initial commit
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens
|
||||
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.Player
|
||||
import coil.compose.AsyncImage
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.internal
|
||||
import it.vfsfitvnm.vimusic.models.Playlist
|
||||
import it.vfsfitvnm.vimusic.models.SongInPlaylist
|
||||
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
|
||||
import it.vfsfitvnm.vimusic.ui.components.OutcomeItem
|
||||
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
|
||||
import it.vfsfitvnm.vimusic.ui.views.SongItem
|
||||
import it.vfsfitvnm.vimusic.utils.*
|
||||
import it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.*
|
||||
import it.vfsfitvnm.youtubemusic.Outcome
|
||||
import it.vfsfitvnm.youtubemusic.YouTube
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun AlbumScreen(
|
||||
browseId: String,
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
var album by remember {
|
||||
mutableStateOf<Outcome<YouTube.Album>>(Outcome.Loading)
|
||||
}
|
||||
|
||||
val onLoad = relaunchableEffect(Unit) {
|
||||
album = withContext(Dispatchers.IO) {
|
||||
YouTube.album(browseId)
|
||||
}
|
||||
}
|
||||
|
||||
val albumRoute = rememberAlbumRoute()
|
||||
val artistRoute = rememberArtistRoute()
|
||||
|
||||
RouteHandler(listenToGlobalEmitter = true) {
|
||||
albumRoute { browseId ->
|
||||
AlbumScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
artistRoute { browseId ->
|
||||
ArtistScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
host {
|
||||
val density = LocalDensity.current
|
||||
val player = LocalYoutubePlayer.current
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val typography = LocalTypography.current
|
||||
val menuState = LocalMenuState.current
|
||||
|
||||
val (thumbnailSizeDp, thumbnailSizePx) = remember {
|
||||
density.run {
|
||||
128.dp to 128.dp.roundToPx()
|
||||
}
|
||||
}
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(scrollState)
|
||||
.padding(bottom = 72.dp)
|
||||
.background(colorPalette.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
TopAppBar(
|
||||
modifier = Modifier
|
||||
.height(52.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.chevron_back),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable(onClick = pop)
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ellipsis_horizontal),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
menuState.display {
|
||||
Menu {
|
||||
MenuCloseButton(onClick = menuState::hide)
|
||||
|
||||
MenuEntry(
|
||||
icon = R.drawable.time,
|
||||
text = "Enqueue",
|
||||
enabled = player?.playbackState == Player.STATE_READY,
|
||||
onClick = {
|
||||
menuState.hide()
|
||||
album.valueOrNull?.let { album ->
|
||||
player?.mediaController?.enqueue(album.items.mapNotNull { song ->
|
||||
song.toMediaItem(browseId, album)
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
MenuEntry(
|
||||
icon = R.drawable.list,
|
||||
text = "Import as playlist",
|
||||
onClick = {
|
||||
menuState.hide()
|
||||
|
||||
album.valueOrNull?.let { album ->
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Database.internal.runInTransaction {
|
||||
val playlistId = Database.insert(Playlist(name = album.title))
|
||||
|
||||
album.items.forEachIndexed { index, song ->
|
||||
song.toMediaItem(browseId, album)?.let { mediaItem ->
|
||||
if (Database.song(mediaItem.mediaId) == null) {
|
||||
Database.insert(mediaItem)
|
||||
}
|
||||
|
||||
Database.insert(
|
||||
SongInPlaylist(
|
||||
songId = mediaItem.mediaId,
|
||||
playlistId = playlistId,
|
||||
position = index
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
OutcomeItem(
|
||||
outcome = album,
|
||||
onRetry = onLoad,
|
||||
onLoading = {
|
||||
Loading()
|
||||
}
|
||||
) { album ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Max)
|
||||
.padding(vertical = 8.dp, horizontal = 16.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = album.thumbnail.size(thumbnailSizePx),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(thumbnailSizeDp)
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceEvenly,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Column {
|
||||
BasicText(
|
||||
text = album.title,
|
||||
style = typography.m.semiBold
|
||||
)
|
||||
|
||||
BasicText(
|
||||
text = "${album.authors.joinToString("") { it.name }} • ${album.year}",
|
||||
style = typography.xs.secondary.semiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier
|
||||
.align(Alignment.End)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.shuffle),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
YoutubePlayer.Radio.reset()
|
||||
player?.mediaController?.forcePlayFromBeginning(
|
||||
album.items.shuffled().mapNotNull { song ->
|
||||
song.toMediaItem(browseId, album)
|
||||
})
|
||||
}
|
||||
.shadow(elevation = 2.dp, shape = CircleShape)
|
||||
.background(color = colorPalette.elevatedBackground, shape = CircleShape)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.play),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
YoutubePlayer.Radio.reset()
|
||||
player?.mediaController?.forcePlayFromBeginning(album.items.mapNotNull { song ->
|
||||
song.toMediaItem(browseId, album)
|
||||
})
|
||||
}
|
||||
.shadow(elevation = 2.dp, shape = CircleShape)
|
||||
.background(color = colorPalette.elevatedBackground, shape = CircleShape)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
album.items.forEachIndexed { index, song ->
|
||||
SongItem(
|
||||
title = song.info.name,
|
||||
authors = (song.authors ?: album.authors).joinToString("") { it.name },
|
||||
durationText = song.durationText,
|
||||
onClick = {
|
||||
YoutubePlayer.Radio.reset()
|
||||
player?.mediaController?.forcePlayAtIndex(album.items.mapNotNull { song ->
|
||||
song.toMediaItem(browseId, album)
|
||||
}, index)
|
||||
},
|
||||
startContent = {
|
||||
BasicText(
|
||||
text = "${index + 1}",
|
||||
style = typography.xs.secondary.bold.center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.width(36.dp)
|
||||
)
|
||||
},
|
||||
menuContent = {
|
||||
NonQueuedMediaItemMenu(
|
||||
mediaItem = song.toMediaItem(browseId, album) ?: return@SongItem,
|
||||
onDismiss = menuState::hide,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Loading() {
|
||||
val colorPalette = LocalColorPalette.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.shimmer()
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier
|
||||
.height(IntrinsicSize.Max)
|
||||
.padding(vertical = 8.dp, horizontal = 16.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.background(color = colorPalette.darkGray)
|
||||
.size(128.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceEvenly,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
Column {
|
||||
TextPlaceholder()
|
||||
|
||||
TextPlaceholder(
|
||||
modifier = Modifier
|
||||
.alpha(0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repeat(3) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.alpha(0.6f - it * 0.1f)
|
||||
.height(54.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.background(color = colorPalette.darkGray, shape = CircleShape)
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
TextPlaceholder()
|
||||
|
||||
TextPlaceholder(
|
||||
modifier = Modifier
|
||||
.alpha(0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens
|
||||
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.*
|
||||
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.shadow
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
import it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.ui.components.ExpandableText
|
||||
import it.vfsfitvnm.vimusic.ui.components.Message
|
||||
import it.vfsfitvnm.vimusic.ui.components.OutcomeItem
|
||||
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
|
||||
import it.vfsfitvnm.vimusic.utils.*
|
||||
import it.vfsfitvnm.youtubemusic.Outcome
|
||||
import it.vfsfitvnm.youtubemusic.YouTube
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun ArtistScreen(
|
||||
browseId: String,
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
var artist by remember {
|
||||
mutableStateOf<Outcome<YouTube.Artist>>(Outcome.Loading)
|
||||
}
|
||||
|
||||
val onLoad = relaunchableEffect(Unit) {
|
||||
artist = withContext(Dispatchers.IO) {
|
||||
YouTube.artist(browseId)
|
||||
}
|
||||
}
|
||||
|
||||
val albumRoute = rememberAlbumRoute()
|
||||
val artistRoute = rememberArtistRoute()
|
||||
|
||||
RouteHandler(listenToGlobalEmitter = true) {
|
||||
albumRoute { browseId ->
|
||||
AlbumScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
artistRoute { browseId ->
|
||||
ArtistScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
host {
|
||||
val density = LocalDensity.current
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val typography = LocalTypography.current
|
||||
|
||||
val (thumbnailSizeDp, thumbnailSizePx) = remember {
|
||||
density.run {
|
||||
192.dp to 192.dp.roundToPx()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.verticalScroll(scrollState)
|
||||
.padding(bottom = 72.dp)
|
||||
.background(colorPalette.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
TopAppBar(
|
||||
modifier = Modifier
|
||||
.height(52.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.chevron_back),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable(onClick = pop)
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
OutcomeItem(
|
||||
outcome = artist,
|
||||
onRetry = onLoad,
|
||||
onLoading = {
|
||||
Loading()
|
||||
}
|
||||
) { artist ->
|
||||
AsyncImage(
|
||||
model = artist.thumbnail?.size(thumbnailSizePx),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.size(thumbnailSizeDp)
|
||||
|
||||
)
|
||||
|
||||
BasicText(
|
||||
text = artist.name,
|
||||
style = typography.l.semiBold,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp, horizontal = 16.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.shuffle),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
YoutubePlayer.Radio.reset()
|
||||
artist.shuffleEndpoint?.let(YoutubePlayer.Radio::setup)
|
||||
}
|
||||
.shadow(elevation = 2.dp, shape = CircleShape)
|
||||
.background(color = colorPalette.elevatedBackground, shape = CircleShape)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.radio),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
YoutubePlayer.Radio.reset()
|
||||
artist.radioEndpoint?.let(YoutubePlayer.Radio::setup)
|
||||
}
|
||||
.shadow(elevation = 2.dp, shape = CircleShape)
|
||||
.background(color = colorPalette.elevatedBackground, shape = CircleShape)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
artist.description?.let { description ->
|
||||
ExpandableText(
|
||||
text = description,
|
||||
style = typography.xxs.secondary.align(TextAlign.Justify),
|
||||
minimizedMaxLines = 4,
|
||||
backgroundColor = colorPalette.background,
|
||||
showMoreTextStyle = typography.xxs.bold,
|
||||
modifier = Modifier
|
||||
.animateContentSize()
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Message(
|
||||
text = "Page under construction",
|
||||
icon = R.drawable.sad,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 64.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Loading() {
|
||||
val colorPalette = LocalColorPalette.current
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.shimmer()
|
||||
) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.background(color = colorPalette.darkGray, shape = CircleShape)
|
||||
.size(192.dp)
|
||||
)
|
||||
|
||||
TextPlaceholder(
|
||||
modifier = Modifier
|
||||
.alpha(0.9f)
|
||||
.padding(vertical = 8.dp, horizontal = 16.dp)
|
||||
)
|
||||
|
||||
repeat(3) {
|
||||
TextPlaceholder(
|
||||
modifier = Modifier
|
||||
.alpha(0.8f)
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens
|
||||
|
||||
import androidx.compose.animation.*
|
||||
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.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
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.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.media3.common.Player
|
||||
import it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.route.rememberRoute
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.enums.SongCollection
|
||||
import it.vfsfitvnm.vimusic.models.Playlist
|
||||
import it.vfsfitvnm.vimusic.models.SearchQuery
|
||||
import it.vfsfitvnm.vimusic.models.SongWithInfo
|
||||
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
|
||||
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
|
||||
import it.vfsfitvnm.vimusic.ui.components.rememberBottomSheetState
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.*
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
|
||||
import it.vfsfitvnm.vimusic.ui.views.PlayerView
|
||||
import it.vfsfitvnm.vimusic.ui.views.PlaylistPreviewItem
|
||||
import it.vfsfitvnm.vimusic.ui.views.SongItem
|
||||
import it.vfsfitvnm.vimusic.utils.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun HomeScreen(intentVideoId: String?) {
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val typography = LocalTypography.current
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
|
||||
val intentVideoRoute = rememberIntentVideoRoute(intentVideoId)
|
||||
val playlistRoute = rememberLocalPlaylistRoute()
|
||||
val searchRoute = rememberSearchRoute()
|
||||
val searchResultRoute = rememberSearchResultRoute()
|
||||
val albumRoute = rememberAlbumRoute()
|
||||
val artistRoute = rememberArtistRoute()
|
||||
|
||||
val (route, onRouteChanged) = rememberRoute(intentVideoId?.let { intentVideoRoute })
|
||||
|
||||
val playlistPreviews by remember {
|
||||
Database.playlistPreviews()
|
||||
}.collectAsState(initial = emptyList(), context = Dispatchers.IO)
|
||||
|
||||
val preferences = LocalPreferences.current
|
||||
|
||||
val songCollection by remember(preferences.homePageSongCollection) {
|
||||
when (preferences.homePageSongCollection) {
|
||||
SongCollection.MostPlayed -> Database.mostPlayed()
|
||||
SongCollection.Favorites -> Database.favorites()
|
||||
SongCollection.History -> Database.history()
|
||||
}
|
||||
}.collectAsState(initial = emptyList(), context = Dispatchers.IO)
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
RouteHandler(
|
||||
route = route,
|
||||
onRouteChanged = onRouteChanged,
|
||||
listenToGlobalEmitter = true
|
||||
) {
|
||||
intentVideoRoute { videoId ->
|
||||
IntentVideoScreen(
|
||||
videoId = videoId ?: error("videoId must be not null")
|
||||
)
|
||||
}
|
||||
|
||||
playlistRoute { playlistId ->
|
||||
LocalPlaylistScreen(
|
||||
playlistId = playlistId ?: error("playlistId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
searchResultRoute { query ->
|
||||
SearchResultScreen(
|
||||
query = query,
|
||||
onSearchAgain = {
|
||||
searchRoute(query)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
searchRoute { initialTextInput ->
|
||||
SearchScreen(
|
||||
initialTextInput = initialTextInput,
|
||||
onSearch = { query ->
|
||||
searchResultRoute(query)
|
||||
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Database.insert(SearchQuery(query = query))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
albumRoute { browseId ->
|
||||
AlbumScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
artistRoute { browseId ->
|
||||
ArtistScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
host {
|
||||
val player = LocalYoutubePlayer.current
|
||||
val menuState = LocalMenuState.current
|
||||
val density = LocalDensity.current
|
||||
|
||||
val thumbnailSize = remember {
|
||||
density.run {
|
||||
54.dp.roundToPx()
|
||||
}
|
||||
}
|
||||
|
||||
var isCreatingANewPlaylist by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (isCreatingANewPlaylist) {
|
||||
TextFieldDialog(
|
||||
hintText = "Enter the playlist name",
|
||||
onDismiss = {
|
||||
isCreatingANewPlaylist = false
|
||||
},
|
||||
onDone = { text ->
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Database.insert(Playlist(name = text))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
contentPadding = PaddingValues(bottom = 72.dp),
|
||||
modifier = Modifier
|
||||
.background(colorPalette.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
item {
|
||||
TopAppBar(
|
||||
modifier = Modifier
|
||||
.height(52.dp)
|
||||
) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.search),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
searchRoute("")
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
BasicText(
|
||||
text = "Your playlists",
|
||||
style = typography.m.semiBold,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
LazyHorizontalGrid(
|
||||
rows = GridCells.Fixed(2),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
modifier = Modifier
|
||||
.height(248.dp)
|
||||
) {
|
||||
item {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.padding(all = 8.dp)
|
||||
.width(108.dp)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
indication = rememberRipple(bounded = true),
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
isCreatingANewPlaylist = true
|
||||
}
|
||||
.background(colorPalette.lightBackground)
|
||||
.size(108.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.add),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(playlistPreviews) { playlistPreview ->
|
||||
PlaylistPreviewItem(
|
||||
playlistPreview = playlistPreview,
|
||||
modifier = Modifier
|
||||
.padding(all = 8.dp)
|
||||
.clickable(
|
||||
indication = rememberRipple(bounded = true),
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
playlistRoute(playlistPreview.playlist.id)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.zIndex(1f)
|
||||
.padding(horizontal = 8.dp)
|
||||
.padding(top = 32.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 8.dp)
|
||||
) {
|
||||
BasicText(
|
||||
text = when (preferences.homePageSongCollection) {
|
||||
SongCollection.MostPlayed -> "Most played"
|
||||
SongCollection.Favorites -> "Favorites"
|
||||
SongCollection.History -> "History"
|
||||
},
|
||||
style = typography.m.semiBold,
|
||||
modifier = Modifier
|
||||
.animateContentSize()
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.repeat),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.textSecondary),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
val values = SongCollection.values()
|
||||
|
||||
preferences.homePageSongCollection =
|
||||
values[(preferences.homePageSongCollection.ordinal + 1) % values.size]
|
||||
}
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
.size(16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ellipsis_horizontal),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
menuState.display {
|
||||
BasicMenu(onDismiss = menuState::hide) {
|
||||
MenuEntry(
|
||||
icon = R.drawable.play,
|
||||
text = "Play",
|
||||
enabled = songCollection.isNotEmpty(),
|
||||
onClick = {
|
||||
menuState.hide()
|
||||
YoutubePlayer.Radio.reset()
|
||||
player?.mediaController?.forcePlayFromBeginning(
|
||||
songCollection
|
||||
.map(SongWithInfo::asMediaItem)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
MenuEntry(
|
||||
icon = R.drawable.shuffle,
|
||||
text = "Shuffle",
|
||||
enabled = songCollection.isNotEmpty(),
|
||||
onClick = {
|
||||
menuState.hide()
|
||||
YoutubePlayer.Radio.reset()
|
||||
player?.mediaController?.forcePlayFromBeginning(
|
||||
songCollection
|
||||
.shuffled()
|
||||
.map(SongWithInfo::asMediaItem)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
MenuEntry(
|
||||
icon = R.drawable.time,
|
||||
text = "Enqueue",
|
||||
enabled = songCollection.isNotEmpty() && player?.playbackState == Player.STATE_READY,
|
||||
onClick = {
|
||||
menuState.hide()
|
||||
player?.mediaController?.enqueue(
|
||||
songCollection.map(SongWithInfo::asMediaItem)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
itemsIndexed(
|
||||
items = songCollection,
|
||||
key = { _, song ->
|
||||
song.song.id
|
||||
}
|
||||
) { index, song ->
|
||||
SongItem(
|
||||
song = song,
|
||||
thumbnailSize = thumbnailSize,
|
||||
onClick = {
|
||||
YoutubePlayer.Radio.reset()
|
||||
player?.mediaController?.forcePlayAtIndex(
|
||||
songCollection.map(SongWithInfo::asMediaItem),
|
||||
index
|
||||
)
|
||||
},
|
||||
menuContent = {
|
||||
when (preferences.homePageSongCollection) {
|
||||
SongCollection.MostPlayed -> NonQueuedMediaItemMenu(mediaItem = song.asMediaItem)
|
||||
SongCollection.Favorites -> InFavoritesMediaItemMenu(song = song)
|
||||
SongCollection.History -> InHistoryMediaItemMenu(song = song)
|
||||
}
|
||||
},
|
||||
onThumbnailContent = {
|
||||
AnimatedVisibility(
|
||||
visible = preferences.homePageSongCollection == SongCollection.MostPlayed,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
) {
|
||||
BasicText(
|
||||
text = song.song.formattedTotalPlayTime,
|
||||
style = typography.xxs.semiBold.center.color(Color.White),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.75f)
|
||||
)
|
||||
)
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PlayerView(
|
||||
layoutState = rememberBottomSheetState(lowerBound = 64.dp, upperBound = maxHeight),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens
|
||||
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.MediaItem
|
||||
import com.valentinilk.shimmer.ShimmerBounds
|
||||
import com.valentinilk.shimmer.rememberShimmer
|
||||
import it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.ui.components.OutcomeItem
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.views.SongItem
|
||||
import it.vfsfitvnm.vimusic.utils.LocalYoutubePlayer
|
||||
import it.vfsfitvnm.vimusic.utils.asMediaItem
|
||||
import it.vfsfitvnm.vimusic.utils.forcePlay
|
||||
import it.vfsfitvnm.youtubemusic.Outcome
|
||||
import it.vfsfitvnm.youtubemusic.YouTube
|
||||
import it.vfsfitvnm.youtubemusic.toNullable
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun IntentVideoScreen(videoId: String) {
|
||||
val albumRoute = rememberAlbumRoute()
|
||||
val artistRoute = rememberArtistRoute()
|
||||
|
||||
RouteHandler(listenToGlobalEmitter = true) {
|
||||
albumRoute { browseId ->
|
||||
AlbumScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
artistRoute { browseId ->
|
||||
ArtistScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
host {
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val density = LocalDensity.current
|
||||
val player = LocalYoutubePlayer.current
|
||||
|
||||
val mediaItem by produceState<Outcome<MediaItem>>(initialValue = Outcome.Loading) {
|
||||
value = withContext(Dispatchers.IO) {
|
||||
Database.songWithInfo(videoId)?.let { songWithInfo ->
|
||||
Outcome.Success(songWithInfo.asMediaItem)
|
||||
} ?: YouTube.getQueue(videoId).toNullable()
|
||||
?.map(YouTube.Item.Song::asMediaItem)
|
||||
?: Outcome.Error.Network
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(colorPalette.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
OutcomeItem(
|
||||
outcome = mediaItem,
|
||||
onLoading = {
|
||||
SmallSongItemShimmer(
|
||||
shimmer = rememberShimmer(shimmerBounds = ShimmerBounds.View),
|
||||
thumbnailSizeDp = 54.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
) { mediaItem ->
|
||||
SongItem(
|
||||
mediaItem = mediaItem,
|
||||
thumbnailSize = remember {
|
||||
density.run {
|
||||
54.dp.roundToPx()
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
player?.mediaController?.forcePlay(mediaItem)
|
||||
pop()
|
||||
},
|
||||
menuContent = {
|
||||
NonQueuedMediaItemMenu(mediaItem = mediaItem)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens
|
||||
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
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.shadow
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.Player
|
||||
import it.vfsfitvnm.reordering.rememberReorderingState
|
||||
import it.vfsfitvnm.reordering.verticalDragAfterLongPressToReorder
|
||||
import it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.models.PlaylistWithSongs
|
||||
import it.vfsfitvnm.vimusic.models.SongInPlaylist
|
||||
import it.vfsfitvnm.vimusic.models.SongWithInfo
|
||||
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
|
||||
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.*
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
|
||||
import it.vfsfitvnm.vimusic.ui.views.SongItem
|
||||
import it.vfsfitvnm.vimusic.utils.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun LocalPlaylistScreen(
|
||||
playlistId: Long,
|
||||
) {
|
||||
val playlistWithSongs by remember(playlistId) {
|
||||
Database.playlistWithSongs(playlistId).map { it ?: PlaylistWithSongs.NotFound }
|
||||
}.collectAsState(initial = PlaylistWithSongs.Empty, context = Dispatchers.IO)
|
||||
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
|
||||
val albumRoute = rememberAlbumRoute()
|
||||
val artistRoute = rememberArtistRoute()
|
||||
|
||||
RouteHandler(listenToGlobalEmitter = true) {
|
||||
albumRoute { browseId ->
|
||||
AlbumScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
artistRoute { browseId ->
|
||||
ArtistScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
host {
|
||||
val density = LocalDensity.current
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val menuState = LocalMenuState.current
|
||||
|
||||
val player = LocalYoutubePlayer.current
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val typography = LocalTypography.current
|
||||
|
||||
val thumbnailSize = remember {
|
||||
density.run {
|
||||
54.dp.roundToPx()
|
||||
}
|
||||
}
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val reorderingState = rememberReorderingState(playlistWithSongs.songs)
|
||||
|
||||
var isRenaming by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (isRenaming) {
|
||||
TextFieldDialog(
|
||||
hintText = "Enter the playlist name",
|
||||
initialTextInput = playlistWithSongs.playlist.name,
|
||||
onDismiss = {
|
||||
isRenaming = false
|
||||
},
|
||||
onDone = { text ->
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Database.update(playlistWithSongs.playlist.copy(name = text))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var isDeleting by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (isDeleting) {
|
||||
ConfirmationDialog(
|
||||
text = "Do you really want to delete this playlist?",
|
||||
onDismiss = {
|
||||
isDeleting = false
|
||||
},
|
||||
onConfirm = {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Database.delete(playlistWithSongs.playlist)
|
||||
}
|
||||
pop()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
contentPadding = PaddingValues(bottom = 64.dp),
|
||||
modifier = Modifier
|
||||
.background(colorPalette.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
item {
|
||||
TopAppBar(
|
||||
modifier = Modifier
|
||||
.height(52.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.chevron_back),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable(onClick = pop)
|
||||
.padding(vertical = 8.dp, horizontal = 16.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ellipsis_horizontal),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
menuState.display {
|
||||
Menu {
|
||||
MenuCloseButton(onClick = menuState::hide)
|
||||
|
||||
MenuEntry(
|
||||
icon = R.drawable.time,
|
||||
text = "Enqueue",
|
||||
enabled = playlistWithSongs.songs.isNotEmpty() && player?.playbackState == Player.STATE_READY,
|
||||
onClick = {
|
||||
menuState.hide()
|
||||
player?.mediaController?.enqueue(
|
||||
playlistWithSongs.songs.map(
|
||||
SongWithInfo::asMediaItem
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
MenuEntry(
|
||||
icon = R.drawable.pencil,
|
||||
text = "Rename",
|
||||
onClick = {
|
||||
menuState.hide()
|
||||
isRenaming = true
|
||||
}
|
||||
)
|
||||
|
||||
MenuEntry(
|
||||
icon = R.drawable.trash,
|
||||
text = "Delete",
|
||||
onClick = {
|
||||
menuState.hide()
|
||||
isDeleting = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.padding(top = 16.dp, bottom = 32.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
BasicText(
|
||||
text = playlistWithSongs.playlist.name,
|
||||
style = typography.m.semiBold
|
||||
)
|
||||
|
||||
BasicText(
|
||||
text = "${playlistWithSongs.songs.size} songs",
|
||||
style = typography.xxs.semiBold.secondary
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.shuffle),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
YoutubePlayer.Radio.reset()
|
||||
player?.mediaController?.forcePlayFromBeginning(
|
||||
playlistWithSongs.songs
|
||||
.map(SongWithInfo::asMediaItem)
|
||||
.shuffled()
|
||||
)
|
||||
}
|
||||
.shadow(elevation = 2.dp, shape = CircleShape)
|
||||
.background(
|
||||
color = colorPalette.elevatedBackground,
|
||||
shape = CircleShape
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.play),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
YoutubePlayer.Radio.reset()
|
||||
player?.mediaController?.forcePlayFromBeginning(
|
||||
playlistWithSongs.songs.map(
|
||||
SongWithInfo::asMediaItem
|
||||
)
|
||||
)
|
||||
}
|
||||
.shadow(elevation = 2.dp, shape = CircleShape)
|
||||
.background(
|
||||
color = colorPalette.elevatedBackground,
|
||||
shape = CircleShape
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
itemsIndexed(items = playlistWithSongs.songs, key = { _, song -> song.song.id }) { index, song ->
|
||||
SongItem(
|
||||
song = song,
|
||||
thumbnailSize = thumbnailSize,
|
||||
onClick = {
|
||||
YoutubePlayer.Radio.reset()
|
||||
player?.mediaController?.forcePlayAtIndex(
|
||||
playlistWithSongs.songs.map(
|
||||
SongWithInfo::asMediaItem
|
||||
), index
|
||||
)
|
||||
},
|
||||
menuContent = {
|
||||
InPlaylistMediaItemMenu(
|
||||
playlistId = playlistId,
|
||||
positionInPlaylist = index,
|
||||
song = song
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.verticalDragAfterLongPressToReorder(
|
||||
reorderingState = reorderingState,
|
||||
index = index,
|
||||
onDragStart = {
|
||||
hapticFeedback.performHapticFeedback(
|
||||
HapticFeedbackType.LongPress
|
||||
)
|
||||
},
|
||||
onDragEnd = { reachedIndex ->
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
if (index > reachedIndex) {
|
||||
Database.incrementSongPositions(
|
||||
playlistId = playlistWithSongs.playlist.id,
|
||||
fromPosition = reachedIndex,
|
||||
toPosition = index - 1
|
||||
)
|
||||
} else if (index < reachedIndex) {
|
||||
Database.decrementSongPositions(
|
||||
playlistId = playlistWithSongs.playlist.id,
|
||||
fromPosition = index + 1,
|
||||
toPosition = reachedIndex
|
||||
)
|
||||
}
|
||||
|
||||
Database.update(
|
||||
SongInPlaylist(
|
||||
songId = playlistWithSongs.songs[index].song.id,
|
||||
playlistId = playlistWithSongs.playlist.id,
|
||||
position = reachedIndex
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens
|
||||
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
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.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
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 coil.compose.AsyncImage
|
||||
import com.valentinilk.shimmer.Shimmer
|
||||
import com.valentinilk.shimmer.ShimmerBounds
|
||||
import com.valentinilk.shimmer.rememberShimmer
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
import it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.ui.components.*
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu
|
||||
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
|
||||
import it.vfsfitvnm.vimusic.ui.views.SongItem
|
||||
import it.vfsfitvnm.vimusic.utils.*
|
||||
import it.vfsfitvnm.youtubemusic.Outcome
|
||||
import it.vfsfitvnm.youtubemusic.YouTube
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun SearchResultScreen(
|
||||
query: String,
|
||||
onSearchAgain: () -> Unit,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val typography = LocalTypography.current
|
||||
val preferences = LocalPreferences.current
|
||||
val player = LocalYoutubePlayer.current
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
|
||||
var continuation by remember(preferences.searchFilter) {
|
||||
mutableStateOf<Outcome<String?>>(Outcome.Initial)
|
||||
}
|
||||
|
||||
val items = remember(preferences.searchFilter) {
|
||||
mutableStateListOf<YouTube.Item>()
|
||||
}
|
||||
|
||||
val onLoad = relaunchableEffect(preferences.searchFilter) {
|
||||
withContext(Dispatchers.Main) {
|
||||
val token = continuation.valueOrNull
|
||||
|
||||
continuation = Outcome.Loading
|
||||
|
||||
continuation = withContext(Dispatchers.IO) {
|
||||
YouTube.search(query, preferences.searchFilter, token)
|
||||
}.map { searchResult ->
|
||||
items.addAll(searchResult.items)
|
||||
searchResult.continuation
|
||||
}.recoverWith(token)
|
||||
}
|
||||
}
|
||||
|
||||
val thumbnailSizePx = remember {
|
||||
density.run {
|
||||
54.dp.roundToPx()
|
||||
}
|
||||
}
|
||||
|
||||
val albumRoute = rememberAlbumRoute()
|
||||
val artistRoute = rememberArtistRoute()
|
||||
|
||||
RouteHandler(
|
||||
listenToGlobalEmitter = true
|
||||
) {
|
||||
albumRoute { browseId ->
|
||||
AlbumScreen(
|
||||
browseId = browseId ?: "browseId cannot be null"
|
||||
)
|
||||
}
|
||||
|
||||
artistRoute { browseId ->
|
||||
ArtistScreen(
|
||||
browseId = browseId ?: "browseId cannot be null"
|
||||
)
|
||||
}
|
||||
|
||||
host {
|
||||
val shimmer = rememberShimmer(shimmerBounds = ShimmerBounds.Window)
|
||||
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
contentPadding = PaddingValues(bottom = 64.dp),
|
||||
modifier = Modifier
|
||||
.background(colorPalette.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
item {
|
||||
TopAppBar(
|
||||
modifier = Modifier
|
||||
.height(52.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.chevron_back),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable(onClick = pop)
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
|
||||
BasicText(
|
||||
text = query,
|
||||
style = typography.m.semiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onSearchAgain
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
ChipGroup(
|
||||
items = listOf(
|
||||
ChipItem(
|
||||
text = "Songs",
|
||||
value = YouTube.Item.Song.Filter.value
|
||||
),
|
||||
ChipItem(
|
||||
text = "Albums",
|
||||
value = YouTube.Item.Album.Filter.value
|
||||
),
|
||||
ChipItem(
|
||||
text = "Artists",
|
||||
value = YouTube.Item.Artist.Filter.value
|
||||
),
|
||||
ChipItem(
|
||||
text = "Videos",
|
||||
value = YouTube.Item.Video.Filter.value
|
||||
),
|
||||
),
|
||||
value = preferences.searchFilter,
|
||||
selectedBackgroundColor = colorPalette.primaryContainer,
|
||||
unselectedBackgroundColor = colorPalette.lightBackground,
|
||||
selectedTextStyle = typography.xs.medium.color(colorPalette.onPrimaryContainer),
|
||||
unselectedTextStyle = typography.xs.medium,
|
||||
shape = RoundedCornerShape(36.dp),
|
||||
onValueChanged = { filter ->
|
||||
preferences.searchFilter = filter
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
items(items) { item ->
|
||||
SmallItem(
|
||||
item = item,
|
||||
thumbnailSizeDp = 54.dp,
|
||||
thumbnailSizePx = thumbnailSizePx,
|
||||
onClick = {
|
||||
when (item) {
|
||||
is YouTube.Item.Album -> albumRoute(item.info.endpoint!!.browseId)
|
||||
is YouTube.Item.Artist -> artistRoute(item.info.endpoint!!.browseId)
|
||||
is YouTube.Item.Song -> {
|
||||
player?.mediaController?.forcePlay(item.asMediaItem)
|
||||
item.info.endpoint?.let(YoutubePlayer.Radio::setup)
|
||||
}
|
||||
is YouTube.Item.Video -> {
|
||||
player?.mediaController?.forcePlay(item.asMediaItem)
|
||||
item.info.endpoint?.let(YoutubePlayer.Radio::setup)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
when (val currentResult = continuation) {
|
||||
is Outcome.Error -> item {
|
||||
Error(
|
||||
error = currentResult,
|
||||
onRetry = onLoad,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
is Outcome.Recovered -> item {
|
||||
Error(
|
||||
error = currentResult.error,
|
||||
onRetry = onLoad,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
is Outcome.Success -> {
|
||||
if (items.isEmpty()) {
|
||||
item {
|
||||
Message(
|
||||
text = "No results found",
|
||||
modifier = Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentResult.value != null) {
|
||||
item {
|
||||
SideEffect(onLoad)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
if (continuation is Outcome.Loading || (continuation is Outcome.Success && continuation.valueOrNull != null)) {
|
||||
items(count = if (items.isEmpty()) 8 else 3, key = { it }) { index ->
|
||||
when (preferences.searchFilter) {
|
||||
YouTube.Item.Artist.Filter.value -> SmallArtistItemShimmer(
|
||||
shimmer = shimmer,
|
||||
thumbnailSizeDp = 54.dp,
|
||||
modifier = Modifier
|
||||
.alpha(1f - index * 0.125f)
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp)
|
||||
)
|
||||
else -> SmallSongItemShimmer(
|
||||
shimmer = shimmer,
|
||||
thumbnailSizeDp = 54.dp,
|
||||
modifier = Modifier
|
||||
.alpha(1f - index * 0.125f)
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SmallSongItemShimmer(
|
||||
shimmer: Shimmer,
|
||||
thumbnailSizeDp: Dp,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorPalette = LocalColorPalette.current
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier
|
||||
.shimmer(shimmer)
|
||||
) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.background(colorPalette.darkGray)
|
||||
.size(thumbnailSizeDp)
|
||||
)
|
||||
|
||||
Column {
|
||||
TextPlaceholder()
|
||||
TextPlaceholder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SmallArtistItemShimmer(
|
||||
shimmer: Shimmer,
|
||||
thumbnailSizeDp: Dp,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorPalette = LocalColorPalette.current
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier
|
||||
.shimmer(shimmer)
|
||||
) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.background(color = colorPalette.darkGray, shape = CircleShape)
|
||||
.size(thumbnailSizeDp)
|
||||
)
|
||||
|
||||
TextPlaceholder()
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun SmallItem(
|
||||
item: YouTube.Item,
|
||||
thumbnailSizeDp: Dp,
|
||||
thumbnailSizePx: Int,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (item) {
|
||||
is YouTube.Item.Artist -> SmallArtistItem(
|
||||
artist = item,
|
||||
thumbnailSizeDp = thumbnailSizeDp,
|
||||
thumbnailSizePx = thumbnailSizePx,
|
||||
modifier = modifier
|
||||
.clickable(
|
||||
indication = rememberRipple(bounded = true),
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = onClick
|
||||
)
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp)
|
||||
)
|
||||
is YouTube.Item.Song -> SmallSongItem(
|
||||
song = item,
|
||||
thumbnailSizePx = thumbnailSizePx,
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
)
|
||||
is YouTube.Item.Album -> SmallAlbumItem(
|
||||
album = item,
|
||||
thumbnailSizeDp = thumbnailSizeDp,
|
||||
thumbnailSizePx = thumbnailSizePx,
|
||||
modifier = modifier
|
||||
.clickable(
|
||||
indication = rememberRipple(bounded = true),
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = onClick
|
||||
)
|
||||
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp)
|
||||
)
|
||||
is YouTube.Item.Video -> SmallVideoItem(
|
||||
video = item,
|
||||
thumbnailSizePx = thumbnailSizePx,
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun SmallSongItem(
|
||||
song: YouTube.Item.Song,
|
||||
thumbnailSizePx: Int,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SongItem(
|
||||
thumbnailModel = song.thumbnail.size(thumbnailSizePx),
|
||||
title = song.info.name,
|
||||
authors = song.authors.joinToString("") { it.name },
|
||||
durationText = song.durationText,
|
||||
onClick = onClick,
|
||||
menuContent = {
|
||||
NonQueuedMediaItemMenu(mediaItem = song.asMediaItem)
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun SmallVideoItem(
|
||||
video: YouTube.Item.Video,
|
||||
thumbnailSizePx: Int,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SongItem(
|
||||
thumbnailModel = video.thumbnail.size(thumbnailSizePx),
|
||||
title = video.info.name,
|
||||
authors = video.views.joinToString("") { it.name },
|
||||
durationText = video.durationText,
|
||||
onClick = onClick,
|
||||
menuContent = {
|
||||
NonQueuedMediaItemMenu(mediaItem = video.asMediaItem)
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SmallAlbumItem(
|
||||
album: YouTube.Item.Album,
|
||||
thumbnailSizeDp: Dp,
|
||||
thumbnailSizePx: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val typography = LocalTypography.current
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier
|
||||
) {
|
||||
AsyncImage(
|
||||
model = album.thumbnail.size(thumbnailSizePx),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(thumbnailSizeDp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
) {
|
||||
BasicText(
|
||||
text = album.info.name,
|
||||
style = typography.xs.semiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
BasicText(
|
||||
text = "${album.authors.joinToString("") { it.name }} • ${album.year}",
|
||||
style = typography.xs,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SmallArtistItem(
|
||||
artist: YouTube.Item.Artist,
|
||||
thumbnailSizeDp: Dp,
|
||||
thumbnailSizePx: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val typography = LocalTypography.current
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier
|
||||
) {
|
||||
AsyncImage(
|
||||
model = artist.thumbnail.size(thumbnailSizePx),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.size(thumbnailSizeDp)
|
||||
)
|
||||
|
||||
BasicText(
|
||||
text = artist.info.name,
|
||||
style = typography.xs.semiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens
|
||||
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
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.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
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.rotate
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import it.vfsfitvnm.route.RouteHandler
|
||||
import it.vfsfitvnm.vimusic.Database
|
||||
import it.vfsfitvnm.vimusic.R
|
||||
import it.vfsfitvnm.vimusic.ui.components.OutcomeItem
|
||||
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
||||
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
|
||||
import it.vfsfitvnm.vimusic.utils.medium
|
||||
import it.vfsfitvnm.vimusic.utils.secondary
|
||||
import it.vfsfitvnm.youtubemusic.Outcome
|
||||
import it.vfsfitvnm.youtubemusic.YouTube
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun SearchScreen(
|
||||
initialTextInput: String,
|
||||
onSearch: (String) -> Unit
|
||||
) {
|
||||
var textFieldValue by rememberSaveable(initialTextInput, stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(
|
||||
TextFieldValue(
|
||||
text = initialTextInput,
|
||||
selection = TextRange(initialTextInput.length)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val focusRequester = remember {
|
||||
FocusRequester()
|
||||
}
|
||||
|
||||
val searchSuggestions by produceState<Outcome<List<String>?>>(
|
||||
initialValue = Outcome.Initial,
|
||||
key1 = textFieldValue
|
||||
) {
|
||||
value = if (textFieldValue.text.isNotEmpty()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
YouTube.getSearchSuggestions(textFieldValue.text)
|
||||
}
|
||||
} else {
|
||||
Outcome.Initial
|
||||
}
|
||||
}
|
||||
|
||||
val history by remember(textFieldValue.text) {
|
||||
Database.getRecentQueries("%${textFieldValue.text}%").distinctUntilChanged { old, new ->
|
||||
old.size == new.size
|
||||
}
|
||||
}.collectAsState(initial = null, context = Dispatchers.IO)
|
||||
|
||||
val albumRoute = rememberAlbumRoute()
|
||||
val artistRoute = rememberArtistRoute()
|
||||
|
||||
RouteHandler(listenToGlobalEmitter = true) {
|
||||
albumRoute { browseId ->
|
||||
AlbumScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
artistRoute { browseId ->
|
||||
ArtistScreen(
|
||||
browseId = browseId ?: error("browseId cannot be null")
|
||||
)
|
||||
}
|
||||
|
||||
host {
|
||||
val colorPalette = LocalColorPalette.current
|
||||
val typography = LocalTypography.current
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(300)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
TopAppBar(
|
||||
modifier = Modifier
|
||||
.height(52.dp)
|
||||
) {
|
||||
BasicTextField(
|
||||
value = textFieldValue,
|
||||
onValueChange = {
|
||||
textFieldValue = it
|
||||
},
|
||||
textStyle = typography.m.medium,
|
||||
singleLine = true,
|
||||
maxLines = 1,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(
|
||||
onSearch = {
|
||||
if (textFieldValue.text.isNotEmpty()) {
|
||||
onSearch(textFieldValue.text)
|
||||
}
|
||||
}
|
||||
),
|
||||
cursorBrush = SolidColor(colorPalette.text),
|
||||
decorationBox = { innerTextField ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.chevron_back),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.text),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
pop()
|
||||
focusRequester.freeFocus()
|
||||
}
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
) {
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = textFieldValue.text.isEmpty(),
|
||||
enter = fadeIn(tween(100)),
|
||||
exit = fadeOut(tween(100)),
|
||||
) {
|
||||
BasicText(
|
||||
text = "Enter a song, an album, an artist name...",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = typography.m.secondary,
|
||||
)
|
||||
}
|
||||
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.weight(1f)
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 64.dp)
|
||||
) {
|
||||
history?.forEach { searchQuery ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
indication = rememberRipple(bounded = true),
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
onSearch(searchQuery.query)
|
||||
}
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp, horizontal = 8.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.time),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.darkGray),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
|
||||
BasicText(
|
||||
text = searchQuery.query,
|
||||
style = typography.s.secondary,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.close),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.darkGray),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
Database.delete(searchQuery)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 8.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.arrow_forward),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.darkGray),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
textFieldValue = TextFieldValue(
|
||||
text = searchQuery.query,
|
||||
selection = TextRange(searchQuery.query.length)
|
||||
)
|
||||
}
|
||||
.rotate(225f)
|
||||
.padding(horizontal = 8.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OutcomeItem(
|
||||
outcome = searchSuggestions
|
||||
) { suggestions ->
|
||||
suggestions?.forEach { suggestion ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
indication = rememberRipple(bounded = true),
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
onSearch(suggestion)
|
||||
}
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp, horizontal = 8.dp)
|
||||
) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
|
||||
BasicText(
|
||||
text = suggestion,
|
||||
style = typography.s.secondary,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.arrow_forward),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(colorPalette.darkGray),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
textFieldValue = TextFieldValue(
|
||||
text = suggestion,
|
||||
selection = TextRange(suggestion.length)
|
||||
)
|
||||
}
|
||||
.rotate(225f)
|
||||
.padding(horizontal = 8.dp)
|
||||
.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package it.vfsfitvnm.vimusic.ui.screens
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import it.vfsfitvnm.route.Route0
|
||||
import it.vfsfitvnm.route.Route1
|
||||
|
||||
@Composable
|
||||
fun rememberIntentVideoRoute(intentVideoId: String?): Route1<String?> {
|
||||
val videoId = rememberSaveable {
|
||||
mutableStateOf(intentVideoId)
|
||||
}
|
||||
return remember {
|
||||
Route1("rememberIntentVideoRoute", videoId)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberAlbumRoute(): Route1<String?> {
|
||||
val browseId = rememberSaveable {
|
||||
mutableStateOf<String?>(null)
|
||||
}
|
||||
return remember {
|
||||
Route1("AlbumRoute", browseId)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberArtistRoute(): Route1<String?> {
|
||||
val browseId = rememberSaveable {
|
||||
mutableStateOf<String?>(null)
|
||||
}
|
||||
return remember {
|
||||
Route1("ArtistRoute", browseId)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberLocalPlaylistRoute(): Route1<Long?> {
|
||||
val playlistType = rememberSaveable {
|
||||
mutableStateOf<Long?>(null)
|
||||
}
|
||||
return remember {
|
||||
Route1("LocalPlaylistRoute", playlistType)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberSearchRoute(): Route1<String> {
|
||||
val initialTextInput = remember {
|
||||
mutableStateOf("")
|
||||
}
|
||||
return remember {
|
||||
Route1("SearchRoute", initialTextInput)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberCreatePlaylistRoute(): Route0 {
|
||||
return remember {
|
||||
Route0("CreatePlaylistRoute")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberSearchResultRoute(): Route1<String> {
|
||||
val searchQuery = rememberSaveable {
|
||||
mutableStateOf("")
|
||||
}
|
||||
return remember {
|
||||
Route1("SearchResultRoute", searchQuery)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberLyricsRoute(): Route0 {
|
||||
return remember {
|
||||
Route0("LyricsRoute")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user