diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt index 2f07d66..3352650 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt @@ -220,6 +220,9 @@ interface Database { @Query("SELECT loudnessDb FROM Format WHERE songId = :songId") fun loudnessDb(songId: String): Flow + @Query("SELECT * FROM Song WHERE title LIKE :query OR artistsText LIKE :query") + fun search(query: String): Flow> + @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(format: Format) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/HomeScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/HomeScreen.kt index 89a3a6e..be01e99 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/HomeScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/HomeScreen.kt @@ -11,6 +11,8 @@ import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.models.SearchQuery import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold +import it.vfsfitvnm.vimusic.ui.screens.search.SearchScreen +import it.vfsfitvnm.vimusic.ui.screens.settings.SettingsScreen import it.vfsfitvnm.vimusic.ui.views.PlaylistsTab import it.vfsfitvnm.vimusic.ui.views.SongsTab import it.vfsfitvnm.vimusic.utils.homeScreenTabIndexKey diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchScreen.kt deleted file mode 100644 index 790044f..0000000 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchScreen.kt +++ /dev/null @@ -1,400 +0,0 @@ -package it.vfsfitvnm.vimusic.ui.screens - -import android.net.Uri -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.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.calculateEndPadding -import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -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.material.ripple.rememberRipple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.paint -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.platform.LocalLayoutDirection -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 androidx.core.net.toUri -import it.vfsfitvnm.route.RouteHandler -import it.vfsfitvnm.vimusic.Database -import it.vfsfitvnm.vimusic.R -import it.vfsfitvnm.vimusic.models.SearchQuery -import it.vfsfitvnm.vimusic.query -import it.vfsfitvnm.vimusic.ui.components.TopAppBar -import it.vfsfitvnm.vimusic.ui.styling.Dimensions -import it.vfsfitvnm.vimusic.ui.components.themed.LoadingOrError -import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance -import it.vfsfitvnm.vimusic.utils.medium -import it.vfsfitvnm.vimusic.utils.secondary -import it.vfsfitvnm.youtubemusic.YouTube -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.withContext - -@ExperimentalAnimationApi -@Composable -fun SearchScreen(initialTextInput: String, onSearch: (String) -> Unit, onUri: (Uri) -> Unit) { - RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() - - host { - val (colorPalette, typography) = LocalAppearance.current - val layoutDirection = LocalLayoutDirection.current - val paddingValues = WindowInsets.systemBars.asPaddingValues() - - val timeIconPainter = painterResource(R.drawable.time) - val closeIconPainter = painterResource(R.drawable.close) - val arrowForwardIconPainter = painterResource(R.drawable.arrow_forward) - val rippleIndication = rememberRipple(bounded = true) - - var textFieldValue by rememberSaveable( - initialTextInput, - stateSaver = TextFieldValue.Saver - ) { - mutableStateOf( - TextFieldValue( - text = initialTextInput, - selection = TextRange(initialTextInput.length) - ) - ) - } - - val focusRequester = remember { - FocusRequester() - } - - val searchSuggestionsResult by produceState?>?>( - initialValue = null, - key1 = textFieldValue - ) { - value = if (textFieldValue.text.isNotEmpty()) { - withContext(Dispatchers.IO) { - YouTube.getSearchSuggestions(textFieldValue.text) - } - } else { - null - } - } - - val history by remember(textFieldValue.text) { - Database.queries("%${textFieldValue.text}%").distinctUntilChanged { old, new -> - old.size == new.size - } - }.collectAsState(initial = emptyList(), context = Dispatchers.IO) - - val isOpenableUrl = remember(textFieldValue.text) { - listOf( - "https://www.youtube.com/watch?", - "https://music.youtube.com/watch?", - "https://m.youtube.com/watch?", - "https://www.youtube.com/playlist?", - "https://music.youtube.com/playlist?", - "https://m.youtube.com/playlist?", - "https://youtu.be/", - ).any(textFieldValue.text::startsWith) - } - - LaunchedEffect(Unit) { - delay(300) - focusRequester.requestFocus() - } - - Column( - modifier = Modifier - .fillMaxSize() - .imePadding() - .padding( - start = paddingValues.calculateStartPadding(layoutDirection), - end = paddingValues.calculateEndPadding(layoutDirection), - top = paddingValues.calculateTopPadding(), - ) - ) { - 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) { - 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() - } - - Box( - modifier = Modifier - .clickable { - textFieldValue = TextFieldValue() - } - .padding(horizontal = 14.dp, vertical = 6.dp) - .background( - color = colorPalette.background1, - shape = CircleShape - ) - .size(28.dp) - ) { - Image( - painter = painterResource(R.drawable.close), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.textSecondary), - modifier = Modifier - .align(Alignment.Center) - .size(14.dp) - ) - } - } - }, - modifier = Modifier - .weight(1f) - .focusRequester(focusRequester) - ) - } - - if (isOpenableUrl) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable( - indication = rippleIndication, - interactionSource = remember { MutableInteractionSource() }, - onClick = { onUri(textFieldValue.text.toUri()) } - ) - .fillMaxWidth() - .background(colorPalette.background1) - .padding(vertical = 16.dp, horizontal = 8.dp) - ) { - Image( - painter = painterResource(R.drawable.link), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.textDisabled), - modifier = Modifier - .padding(horizontal = 8.dp) - .size(20.dp) - ) - - BasicText( - text = "Open URL", - style = typography.s.secondary, - modifier = Modifier - .padding(horizontal = 8.dp) - .weight(1f) - ) - } - } - - LazyColumn( - contentPadding = PaddingValues( - bottom = Dimensions.collapsedPlayer + paddingValues.calculateBottomPadding() - ) - ) { - items( - items = history, - key = SearchQuery::id - ) { searchQuery -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable( - indication = rippleIndication, - interactionSource = remember { MutableInteractionSource() }, - onClick = { onSearch(searchQuery.query) } - ) - .fillMaxWidth() - .padding(vertical = 16.dp, horizontal = 8.dp) - ) { - Spacer( - modifier = Modifier - .padding(horizontal = 8.dp) - .size(20.dp) - .paint( - painter = timeIconPainter, - colorFilter = ColorFilter.tint(colorPalette.textDisabled) - ) - ) - - BasicText( - text = searchQuery.query, - style = typography.s.secondary, - modifier = Modifier - .padding(horizontal = 8.dp) - .weight(1f) - ) - - Spacer( - modifier = Modifier - .clickable { - query { - Database.delete(searchQuery) - } - } - .padding(horizontal = 8.dp) - .size(20.dp) - .paint( - painter = closeIconPainter, - colorFilter = ColorFilter.tint(colorPalette.textDisabled) - ) - ) - - Spacer( - modifier = Modifier - .clickable { - textFieldValue = TextFieldValue( - text = searchQuery.query, - selection = TextRange(searchQuery.query.length) - ) - } - .rotate(225f) - .padding(horizontal = 8.dp) - .size(20.dp) - .paint( - painter = arrowForwardIconPainter, - colorFilter = ColorFilter.tint(colorPalette.textDisabled) - ) - ) - } - } - - searchSuggestionsResult?.getOrNull()?.let { suggestions -> - items(items = suggestions) { suggestion -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable( - indication = rippleIndication, - interactionSource = remember { MutableInteractionSource() }, - onClick = { 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) - ) - - Spacer( - modifier = Modifier - .clickable { - textFieldValue = TextFieldValue( - text = suggestion, - selection = TextRange(suggestion.length) - ) - } - .rotate(225f) - .padding(horizontal = 8.dp) - .size(22.dp) - .paint( - painter = arrowForwardIconPainter, - colorFilter = ColorFilter.tint(colorPalette.textDisabled) - ) - ) - } - } - } ?: searchSuggestionsResult?.exceptionOrNull()?.let { throwable -> - item { - LoadingOrError(errorMessage = throwable.javaClass.canonicalName) {} - } - } - } - } - } - } -} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/LibrarySearchTab.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/LibrarySearchTab.kt new file mode 100644 index 0000000..5ffcf39 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/LibrarySearchTab.kt @@ -0,0 +1,145 @@ +package it.vfsfitvnm.vimusic.ui.screens.search + +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.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.compose.viewModel +import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues +import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder +import it.vfsfitvnm.vimusic.models.DetailedSong +import it.vfsfitvnm.vimusic.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu +import it.vfsfitvnm.vimusic.ui.styling.Dimensions +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.ui.styling.px +import it.vfsfitvnm.vimusic.ui.views.SongItem +import it.vfsfitvnm.vimusic.utils.align +import it.vfsfitvnm.vimusic.utils.asMediaItem +import it.vfsfitvnm.vimusic.utils.forcePlay +import it.vfsfitvnm.vimusic.utils.medium +import it.vfsfitvnm.vimusic.utils.secondary +import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint + +@ExperimentalFoundationApi +@ExperimentalAnimationApi +@Composable +fun LibrarySearchTab( + textFieldValue: TextFieldValue, + onTextFieldValueChanged: (TextFieldValue) -> Unit, + viewModel: LibrarySearchTabViewModel = viewModel( + key = textFieldValue.text, + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return LibrarySearchTabViewModel(textFieldValue.text) as T + } + } + ) +) { + val (colorPalette, typography) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + val thumbnailSize = Dimensions.thumbnails.song.px + + LazyColumn( + contentPadding = LocalPlayerAwarePaddingValues.current, + modifier = Modifier + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0 + ) { + Header( + titleContent = { + BasicTextField( + value = textFieldValue, + onValueChange = onTextFieldValueChanged, + textStyle = typography.xxl.medium.align(TextAlign.End), + singleLine = true, + maxLines = 1, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + cursorBrush = SolidColor(colorPalette.text), + decorationBox = { innerTextField -> + Box { + androidx.compose.animation.AnimatedVisibility( + visible = textFieldValue.text.isEmpty(), + enter = fadeIn(tween(200)), + exit = fadeOut(tween(200)), + modifier = Modifier + .align(Alignment.CenterEnd) + ) { + BasicText( + text = "Enter a name", + maxLines = 1, + style = typography.xxl.secondary + ) + } + + innerTextField() + } + } + ) + }, + actionsContent = { + BasicText( + text = "Clear", + style = typography.xxs.medium, + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .clickable(enabled = textFieldValue.text.isNotEmpty()) { + onTextFieldValueChanged(TextFieldValue()) + } + .background(colorPalette.background2) + .padding(all = 8.dp) + .padding(horizontal = 8.dp) + ) + } + ) + } + + items( + items = viewModel.items, + key = DetailedSong::id, + ) { song -> + SongItem( + song = song, + thumbnailSize = thumbnailSize, + onClick = { + val mediaItem = song.asMediaItem + binder?.stopRadio() + binder?.player?.forcePlay(mediaItem) + binder?.setupRadio( + NavigationEndpoint.Endpoint.Watch(videoId = mediaItem.mediaId) + ) + }, + menuContent = { InHistoryMediaItemMenu(song = song) }, + modifier = Modifier + .animateItemPlacement() + ) + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/LibrarySearchTabViewModel.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/LibrarySearchTabViewModel.kt new file mode 100644 index 0000000..4572e75 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/LibrarySearchTabViewModel.kt @@ -0,0 +1,25 @@ +package it.vfsfitvnm.vimusic.ui.screens.search + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.models.DetailedSong +import kotlinx.coroutines.launch + +class LibrarySearchTabViewModel(text: String) : ViewModel() { + var items by mutableStateOf(emptyList()) + private set + + init { + if (text.isNotEmpty()) { + viewModelScope.launch { + Database.search("%$text%").collect { + items = it + } + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/OnlineSearchTab.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/OnlineSearchTab.kt new file mode 100644 index 0000000..9e24905 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/OnlineSearchTab.kt @@ -0,0 +1,298 @@ +package it.vfsfitvnm.vimusic.ui.screens.search + +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.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +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.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.paint +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.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.compose.viewModel +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.models.SearchQuery +import it.vfsfitvnm.vimusic.query +import it.vfsfitvnm.vimusic.ui.components.themed.Header +import it.vfsfitvnm.vimusic.ui.components.themed.LoadingOrError +import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance +import it.vfsfitvnm.vimusic.utils.align +import it.vfsfitvnm.vimusic.utils.medium +import it.vfsfitvnm.vimusic.utils.secondary +import kotlinx.coroutines.delay + +@Composable +fun OnlineSearchTab( + textFieldValue: TextFieldValue, + onTextFieldValueChanged: (TextFieldValue) -> Unit, + isOpenableUrl: Boolean, + onSearch: (String) -> Unit, + onUri: () -> Unit, + viewModel: OnlineSearchTabViewModel = viewModel( + key = textFieldValue.text, + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return OnlineSearchTabViewModel(textFieldValue.text) as T + } + } + ) +) { + val (colorPalette, typography) = LocalAppearance.current + + val timeIconPainter = painterResource(R.drawable.time) + val closeIconPainter = painterResource(R.drawable.close) + val arrowForwardIconPainter = painterResource(R.drawable.arrow_forward) + val rippleIndication = rememberRipple(bounded = true) + + val focusRequester = remember { + FocusRequester() + } + + LaunchedEffect(Unit) { + delay(300) + focusRequester.requestFocus() + } + + LazyColumn( + contentPadding = LocalPlayerAwarePaddingValues.current, + modifier = Modifier + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0 + ) { + Header( + titleContent = { + BasicTextField( + value = textFieldValue, + onValueChange = onTextFieldValueChanged, + textStyle = typography.xxl.medium.align(TextAlign.End), + 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 -> + Box { + androidx.compose.animation.AnimatedVisibility( + visible = textFieldValue.text.isEmpty(), + enter = fadeIn(tween(200)), + exit = fadeOut(tween(200)), + modifier = Modifier + .align(Alignment.CenterEnd) + ) { + BasicText( + text = "Enter a name", + maxLines = 1, + style = typography.xxl.secondary + ) + } + + innerTextField() + } + }, + modifier = Modifier + .focusRequester(focusRequester) + ) + }, + actionsContent = { + BasicText( + text = if (isOpenableUrl) "Open url" else "Search", + style = typography.xxs.medium, + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .clickable(enabled = textFieldValue.text.isNotEmpty()) { + if (isOpenableUrl) onUri() else onSearch(textFieldValue.text) + } + .background(colorPalette.background2) + .padding(all = 8.dp) + .padding(horizontal = 8.dp) + ) + + Spacer( + modifier = Modifier + .weight(1f) + ) + + BasicText( + text = "Clear", + style = typography.xxs.medium, + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .clickable(enabled = textFieldValue.text.isNotEmpty()) { + onTextFieldValueChanged(TextFieldValue()) + } + .background(colorPalette.background2) + .padding(all = 8.dp) + .padding(horizontal = 8.dp) + ) + } + ) + } + + items( + items = viewModel.history, + key = SearchQuery::id + ) { searchQuery -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable( + indication = rippleIndication, + interactionSource = remember { MutableInteractionSource() }, + onClick = { onSearch(searchQuery.query) } + ) + .fillMaxWidth() + .padding(all = 16.dp) + ) { + Spacer( + modifier = Modifier + .padding(horizontal = 8.dp) + .size(20.dp) + .paint( + painter = timeIconPainter, + colorFilter = ColorFilter.tint(colorPalette.textDisabled) + ) + ) + + BasicText( + text = searchQuery.query, + style = typography.s.secondary, + modifier = Modifier + .padding(horizontal = 8.dp) + .weight(1f) + ) + + Spacer( + modifier = Modifier + .clickable { + query { + Database.delete(searchQuery) + } + } + .padding(horizontal = 8.dp) + .size(20.dp) + .paint( + painter = closeIconPainter, + colorFilter = ColorFilter.tint(colorPalette.textDisabled) + ) + ) + + Spacer( + modifier = Modifier + .clickable { + onTextFieldValueChanged( + TextFieldValue( + text = searchQuery.query, + selection = TextRange(searchQuery.query.length) + ) + ) + } + .rotate(225f) + .padding(horizontal = 8.dp) + .size(20.dp) + .paint( + painter = arrowForwardIconPainter, + colorFilter = ColorFilter.tint(colorPalette.textDisabled) + ) + ) + } + } + + viewModel.suggestionsResult?.getOrNull()?.let { suggestions -> + items(items = suggestions) { suggestion -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable( + indication = rippleIndication, + interactionSource = remember { MutableInteractionSource() }, + onClick = { onSearch(suggestion) } + ) + .fillMaxWidth() + .padding(all = 16.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) + ) + + Spacer( + modifier = Modifier + .clickable { + onTextFieldValueChanged( + TextFieldValue( + text = suggestion, + selection = TextRange(suggestion.length) + ) + ) + } + .rotate(225f) + .padding(horizontal = 8.dp) + .size(22.dp) + .paint( + painter = arrowForwardIconPainter, + colorFilter = ColorFilter.tint(colorPalette.textDisabled) + ) + ) + } + } + } ?: viewModel.suggestionsResult?.exceptionOrNull()?.let { throwable -> + item { + LoadingOrError(errorMessage = throwable.javaClass.canonicalName) {} + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/OnlineSearchTabViewModel.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/OnlineSearchTabViewModel.kt new file mode 100644 index 0000000..c334cc7 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/OnlineSearchTabViewModel.kt @@ -0,0 +1,36 @@ +package it.vfsfitvnm.vimusic.ui.screens.search + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.models.SearchQuery +import it.vfsfitvnm.youtubemusic.YouTube +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +class OnlineSearchTabViewModel(text: String) : ViewModel() { + var history by mutableStateOf(emptyList()) + private set + + var suggestionsResult by mutableStateOf?>?>(null) + private set + + init { + viewModelScope.launch { + Database.queries("%$text%").distinctUntilChanged { old, new -> + old.size == new.size + }.collect { + history = it + } + } + + if (text.isNotEmpty()) { + viewModelScope.launch { + suggestionsResult = YouTube.getSearchSuggestions(text) + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/SearchScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/SearchScreen.kt new file mode 100644 index 0000000..13d8b31 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/search/SearchScreen.kt @@ -0,0 +1,89 @@ +package it.vfsfitvnm.vimusic.ui.screens.search + +import android.net.Uri +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.core.net.toUri +import it.vfsfitvnm.route.RouteHandler +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold +import it.vfsfitvnm.vimusic.ui.screens.globalRoutes + +@ExperimentalFoundationApi +@ExperimentalAnimationApi +@Composable +fun SearchScreen(initialTextInput: String, onSearch: (String) -> Unit, onUri: (Uri) -> Unit) { + val saveableStateHolder = rememberSaveableStateHolder() + + val (tabIndex, onTabChanged) = rememberSaveable { + mutableStateOf(0) + } + + val (textFieldValue, onTextFieldValueChanged) = rememberSaveable( + initialTextInput, + stateSaver = TextFieldValue.Saver + ) { + mutableStateOf( + TextFieldValue( + text = initialTextInput, + selection = TextRange(initialTextInput.length) + ) + ) + } + + RouteHandler(listenToGlobalEmitter = true) { + globalRoutes() + + host { + val isOpenableUrl = remember(textFieldValue.text) { + listOf( + "https://www.youtube.com/watch?", + "https://music.youtube.com/watch?", + "https://m.youtube.com/watch?", + "https://www.youtube.com/playlist?", + "https://music.youtube.com/playlist?", + "https://m.youtube.com/playlist?", + "https://youtu.be/", + ).any(textFieldValue.text::startsWith) + } + + Scaffold( + topIconButtonId = R.drawable.chevron_back, + onTopIconButtonClick = pop, + tabIndex = tabIndex, + onTabChanged = onTabChanged, + tabColumnContent = { Item -> + Item(0, "Online", R.drawable.globe) + Item(1, "Library", R.drawable.library) + } + ) { currentTabIndex -> + saveableStateHolder.SaveableStateProvider(currentTabIndex) { + when (currentTabIndex) { + 0 -> OnlineSearchTab( + textFieldValue = textFieldValue, + onTextFieldValueChanged = onTextFieldValueChanged, + isOpenableUrl = isOpenableUrl, + onSearch = onSearch, + onUri = { + if (isOpenableUrl) { + onUri(textFieldValue.text.toUri()) + } + } + ) + 1 -> LibrarySearchTab( + textFieldValue = textFieldValue, + onTextFieldValueChanged = onTextFieldValueChanged + ) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/AboutTab.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/AboutTab.kt index c5ed846..27951c1 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/AboutTab.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/AboutTab.kt @@ -14,9 +14,6 @@ import androidx.compose.ui.platform.LocalUriHandler import it.vfsfitvnm.vimusic.BuildConfig import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues import it.vfsfitvnm.vimusic.ui.components.themed.Header -import it.vfsfitvnm.vimusic.ui.screens.SettingsEntry -import it.vfsfitvnm.vimusic.ui.screens.SettingsEntryGroupText -import it.vfsfitvnm.vimusic.ui.screens.SettingsGroupSpacer import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.secondary diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/AppearanceSettingsTab.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/AppearanceSettingsTab.kt index 352ef21..842f644 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/AppearanceSettingsTab.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/AppearanceSettingsTab.kt @@ -16,10 +16,6 @@ import it.vfsfitvnm.vimusic.enums.ColorPaletteMode import it.vfsfitvnm.vimusic.enums.ColorPaletteName import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness import it.vfsfitvnm.vimusic.ui.components.themed.Header -import it.vfsfitvnm.vimusic.ui.screens.EnumValueSelectorSettingsEntry -import it.vfsfitvnm.vimusic.ui.screens.SettingsEntryGroupText -import it.vfsfitvnm.vimusic.ui.screens.SettingsGroupSpacer -import it.vfsfitvnm.vimusic.ui.screens.SwitchSettingEntry import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.colorPaletteModeKey import it.vfsfitvnm.vimusic.utils.colorPaletteNameKey diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/CacheSettingsTab.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/CacheSettingsTab.kt index 0ced60e..6694760 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/CacheSettingsTab.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/CacheSettingsTab.kt @@ -22,10 +22,6 @@ import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.enums.CoilDiskCacheMaxSize import it.vfsfitvnm.vimusic.enums.ExoPlayerDiskCacheMaxSize import it.vfsfitvnm.vimusic.ui.components.themed.Header -import it.vfsfitvnm.vimusic.ui.screens.EnumValueSelectorSettingsEntry -import it.vfsfitvnm.vimusic.ui.screens.SettingsDescription -import it.vfsfitvnm.vimusic.ui.screens.SettingsEntryGroupText -import it.vfsfitvnm.vimusic.ui.screens.SettingsGroupSpacer import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.coilDiskCacheMaxSizeKey import it.vfsfitvnm.vimusic.utils.exoPlayerDiskCacheMaxSizeKey diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/OtherSettingsTab.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/OtherSettingsTab.kt index 9ec669a..716f69f 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/OtherSettingsTab.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/OtherSettingsTab.kt @@ -33,11 +33,6 @@ import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.service.PlayerService import it.vfsfitvnm.vimusic.ui.components.themed.ConfirmationDialog import it.vfsfitvnm.vimusic.ui.components.themed.Header -import it.vfsfitvnm.vimusic.ui.screens.SettingsDescription -import it.vfsfitvnm.vimusic.ui.screens.SettingsEntry -import it.vfsfitvnm.vimusic.ui.screens.SettingsEntryGroupText -import it.vfsfitvnm.vimusic.ui.screens.SettingsGroupSpacer -import it.vfsfitvnm.vimusic.ui.screens.SwitchSettingEntry import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.intent import it.vfsfitvnm.vimusic.utils.isIgnoringBatteryOptimizations diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/PlayerSettingsTab.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/PlayerSettingsTab.kt index 8be656f..fcf907c 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/PlayerSettingsTab.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/PlayerSettingsTab.kt @@ -20,10 +20,6 @@ import androidx.compose.ui.platform.LocalContext import it.vfsfitvnm.vimusic.LocalPlayerAwarePaddingValues import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.ui.components.themed.Header -import it.vfsfitvnm.vimusic.ui.screens.SettingsEntry -import it.vfsfitvnm.vimusic.ui.screens.SettingsEntryGroupText -import it.vfsfitvnm.vimusic.ui.screens.SettingsGroupSpacer -import it.vfsfitvnm.vimusic.ui.screens.SwitchSettingEntry import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.persistentQueueKey import it.vfsfitvnm.vimusic.utils.rememberPreference diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SettingsScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/SettingsScreen.kt similarity index 98% rename from app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SettingsScreen.kt rename to app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/SettingsScreen.kt index 0e0755d..a6e4037 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SettingsScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/settings/SettingsScreen.kt @@ -1,4 +1,4 @@ -package it.vfsfitvnm.vimusic.ui.screens +package it.vfsfitvnm.vimusic.ui.screens.settings import androidx.compose.animation.* import androidx.compose.foundation.* @@ -19,6 +19,7 @@ import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.ui.components.themed.Scaffold import it.vfsfitvnm.vimusic.ui.components.themed.Switch import it.vfsfitvnm.vimusic.ui.components.themed.ValueSelectorDialog +import it.vfsfitvnm.vimusic.ui.screens.globalRoutes import it.vfsfitvnm.vimusic.ui.screens.settings.* import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.utils.* diff --git a/app/src/main/res/drawable/globe.xml b/app/src/main/res/drawable/globe.xml new file mode 100644 index 0000000..10a3b37 --- /dev/null +++ b/app/src/main/res/drawable/globe.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/library.xml b/app/src/main/res/drawable/library.xml new file mode 100644 index 0000000..1105723 --- /dev/null +++ b/app/src/main/res/drawable/library.xml @@ -0,0 +1,24 @@ + + + + + + + +