Initial commit

This commit is contained in:
vfsfitvnm
2022-06-02 18:59:18 +02:00
commit 1e673ad582
160 changed files with 10800 additions and 0 deletions

View File

@@ -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)
)
}
}
}
}
}

View File

@@ -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)
)
}
}
}

View File

@@ -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)
)
}
}

View File

@@ -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)
}
)
}
}
}
}
}

View File

@@ -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
)
)
}
}
)
)
}
}
}
}
}

View File

@@ -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)
)
}
}

View File

@@ -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)
)
}
}
}
}
}
}
}
}

View File

@@ -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")
}
}