mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
feat(library): enhance library UI with new tab titles, filtering options, and improved state management
This commit is contained in:
parent
5b442a93a9
commit
cf9476ab08
@ -108,6 +108,7 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
jvm()
|
jvm()
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
val commonMain by getting {
|
val commonMain by getting {
|
||||||
|
|||||||
@ -25,6 +25,22 @@
|
|||||||
<string name="search_clear_history">Clear all history</string>
|
<string name="search_clear_history">Clear all history</string>
|
||||||
<string name="search_filter_placeholder">Search...</string>
|
<string name="search_filter_placeholder">Search...</string>
|
||||||
|
|
||||||
|
<!-- Library toolbar -->
|
||||||
|
<string name="library_screen_title">Your Library</string>
|
||||||
|
<string name="library_tab_playlists">Playlists</string>
|
||||||
|
<string name="library_tab_albums">Albums</string>
|
||||||
|
<string name="library_tab_artists">Artists</string>
|
||||||
|
<string name="library_tab_local_tracks">Local Tracks</string>
|
||||||
|
<string name="library_tab_downloads">Downloads</string>
|
||||||
|
<string name="library_filter_playlists">Search saved playlists…</string>
|
||||||
|
<string name="library_filter_albums">Search saved albums…</string>
|
||||||
|
<string name="library_filter_artists">Search saved artists…</string>
|
||||||
|
<string name="library_filter_local_tracks">Search folders or tracks…</string>
|
||||||
|
<string name="library_action_search">Filter library</string>
|
||||||
|
<string name="library_action_close_search">Close filter</string>
|
||||||
|
<string name="library_action_create_playlist">Create playlist</string>
|
||||||
|
<string name="library_action_refresh_local_tracks">Refresh local tracks</string>
|
||||||
|
|
||||||
<string name="accessibility_selected">Selected</string>
|
<string name="accessibility_selected">Selected</string>
|
||||||
|
|
||||||
<string name="settings_screen_title">Settings</string>
|
<string name="settings_screen_title">Settings</string>
|
||||||
|
|||||||
@ -24,15 +24,8 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import org.spotube.millennium.composables.MillenniumIcon
|
|
||||||
import org.spotube.millennium.composables.MillenniumIconButton
|
|
||||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
|
||||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
|
||||||
import com.composeunstyled.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@ -44,16 +37,21 @@ import androidx.compose.ui.layout.ContentScale
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import com.composeunstyled.Text
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
|
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
|
||||||
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
||||||
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2
|
||||||
import org.jetbrains.compose.resources.DrawableResource
|
import org.jetbrains.compose.resources.DrawableResource
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CollectionView(
|
fun CollectionView(
|
||||||
|
|||||||
@ -17,102 +17,192 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.modules.library
|
package dev.krtirtho.spotube.modules.library
|
||||||
|
|
||||||
|
|
||||||
import org.spotube.millennium.composables.MillenniumIcon
|
|
||||||
import org.spotube.millennium.composables.MillenniumButton
|
|
||||||
import org.spotube.millennium.composables.MillenniumButtonStyle
|
|
||||||
import org.spotube.millennium.composables.MillenniumIconButton
|
|
||||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import com.composeunstyled.Text
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.composeunstyled.Text
|
||||||
|
import com.composeunstyled.currentWindowWidthBreakpoint
|
||||||
|
import dev.krtirtho.spotube.getPlatform
|
||||||
|
import dev.krtirtho.spotube.isDesktop
|
||||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||||
import dev.krtirtho.spotube.modules.downloads.DownloadsScreen
|
import dev.krtirtho.spotube.modules.downloads.DownloadsScreen
|
||||||
import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsScreen
|
import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsScreen
|
||||||
import dev.krtirtho.spotube.modules.library.artist.LibraryArtistsScreen
|
import dev.krtirtho.spotube.modules.library.artist.LibraryArtistsScreen
|
||||||
import dev.krtirtho.spotube.modules.library.local_tracks.LibraryLocalTracksScreen
|
import dev.krtirtho.spotube.modules.library.local_tracks.LibraryLocalTracksScreen
|
||||||
import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsScreen
|
import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsScreen
|
||||||
import dev.krtirtho.spotube.modules.shell.AppShellViewModel
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
|
||||||
|
import org.jetbrains.compose.resources.StringResource
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.spotube.millennium.adaptive.WindowBreakpoints
|
||||||
|
import org.spotube.millennium.composables.MillenniumButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
|
import org.spotube.millennium.composables.MillenniumTextField
|
||||||
|
import spotube.composeapp.generated.resources.*
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LibraryScreen(
|
fun LibraryScreen(libraryState: LibraryState = koinInject()) {
|
||||||
libraryState: LibraryState = koinInject(),
|
val state by libraryState.uiState.collectAsStateWithLifecycle()
|
||||||
appShellViewModel: AppShellViewModel = koinViewModel()
|
val expandedDesktop = getPlatform().isDesktop() &&
|
||||||
) {
|
(currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded)
|
||||||
val currentTab by libraryState.currentTab.collectAsState()
|
val focusManager = LocalFocusManager.current
|
||||||
val searchMode by libraryState.searchMode.collectAsState()
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
val isLargeScreen = appShellViewModel.useSidebar()
|
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
Column {
|
|
||||||
ApplicationMainBar(
|
ApplicationMainBar(
|
||||||
backButton = false,
|
backButton = false,
|
||||||
title = {
|
title = { LibraryToolbarTitle(state, expandedDesktop) },
|
||||||
if (!isLargeScreen) Text("Your Library")
|
|
||||||
},
|
|
||||||
actions = {
|
actions = {
|
||||||
if (!isLargeScreen) {
|
LibraryToolbarActions(
|
||||||
MillenniumIconButton(
|
state = state,
|
||||||
icon = Iconsax.IconsaxSearchBroken,
|
expandedDesktop = expandedDesktop,
|
||||||
contentDescription = "Search",
|
onOpenSearch = libraryState::openSearch,
|
||||||
onClick = {
|
onCloseSearch = {
|
||||||
libraryState.setSearchMode(!searchMode)
|
focusManager.clearFocus()
|
||||||
|
keyboard?.hide()
|
||||||
|
libraryState.closeSearch()
|
||||||
},
|
},
|
||||||
style = MillenniumIconButtonStyle.Quiet)
|
onCreatePlaylist = libraryState::showCreatePlaylist,
|
||||||
}
|
onRefresh = libraryState::refreshLocalTracks
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (!isLargeScreen)
|
if (!expandedDesktop) {
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(horizontal = 12.dp)
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
LazyRow(
|
LazyRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
|
||||||
items(LibraryTab.entries.size) { index ->
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
val tab = LibraryTab.entries[index]
|
) {
|
||||||
|
items(LibraryTab.entries) { tab ->
|
||||||
MillenniumButton(
|
MillenniumButton(
|
||||||
onClick = { libraryState.onTabSelected(tab) },
|
onClick = { libraryState.onTabSelected(tab) },
|
||||||
style = if (currentTab == tab) {
|
style = if (state.currentTab == tab) MillenniumButtonStyle.Primary else MillenniumButtonStyle.Secondary
|
||||||
MillenniumButtonStyle.Primary
|
) { Text(stringResource(tab.titleResource)) }
|
||||||
} else {
|
|
||||||
MillenniumButtonStyle.Secondary
|
|
||||||
}) {
|
|
||||||
Text(tab.title)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||||
}
|
when (state.currentTab) {
|
||||||
}
|
LibraryTab.Playlists -> LibraryPlaylistsScreen(
|
||||||
|
showCreatePlaylist = state.showCreatePlaylist,
|
||||||
|
onDismissCreatePlaylist = libraryState::dismissCreatePlaylist
|
||||||
|
)
|
||||||
|
|
||||||
Column(
|
LibraryTab.Albums -> LibraryAlbumsScreen()
|
||||||
modifier = Modifier
|
LibraryTab.Artists -> LibraryArtistsScreen()
|
||||||
.weight(1f)
|
LibraryTab.LocalTracks -> LibraryLocalTracksScreen()
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
) {
|
|
||||||
when (currentTab) {
|
|
||||||
LibraryTab.Playlists -> LibraryPlaylistsScreen(searchMode = isLargeScreen || searchMode)
|
|
||||||
LibraryTab.Albums -> LibraryAlbumsScreen(searchMode = isLargeScreen || searchMode)
|
|
||||||
LibraryTab.Artists -> LibraryArtistsScreen(searchMode = isLargeScreen || searchMode)
|
|
||||||
LibraryTab.LocalTracks -> LibraryLocalTracksScreen(searchMode = isLargeScreen || searchMode)
|
|
||||||
LibraryTab.Downloads -> DownloadsScreen()
|
LibraryTab.Downloads -> DownloadsScreen()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun LibraryToolbarTitle(state: LibraryToolbarUiState, expandedDesktop: Boolean) {
|
||||||
|
if (!expandedDesktop && state.searchMode) {
|
||||||
|
LibraryFilterField(
|
||||||
|
state.searchState,
|
||||||
|
state.currentTab.filterPlaceholder,
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
autoFocus = true
|
||||||
|
)
|
||||||
|
} else if (!expandedDesktop) {
|
||||||
|
Text(stringResource(Res.string.library_screen_title))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun RowScope.LibraryToolbarActions(
|
||||||
|
state: LibraryToolbarUiState,
|
||||||
|
expandedDesktop: Boolean,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
|
onCloseSearch: () -> Unit,
|
||||||
|
onCreatePlaylist: () -> Unit,
|
||||||
|
onRefresh: () -> Unit
|
||||||
|
) {
|
||||||
|
if (!expandedDesktop && state.searchMode) {
|
||||||
|
MillenniumIconButton(
|
||||||
|
icon = Iconsax.IconsaxCloseSquare,
|
||||||
|
contentDescription = stringResource(Res.string.library_action_close_search),
|
||||||
|
onClick = onCloseSearch,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if (state.canFilter) {
|
||||||
|
if (expandedDesktop) {
|
||||||
|
LibraryFilterField(
|
||||||
|
state.searchState,
|
||||||
|
state.currentTab.filterPlaceholder,
|
||||||
|
Modifier.width(300.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
MillenniumIconButton(
|
||||||
|
icon = Iconsax.IconsaxSearchBroken,
|
||||||
|
contentDescription = stringResource(Res.string.library_action_search),
|
||||||
|
onClick = onOpenSearch,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.canCreatePlaylist) {
|
||||||
|
MillenniumIconButton(
|
||||||
|
icon = Iconsax.IconsaxAddSquare,
|
||||||
|
contentDescription = stringResource(Res.string.library_action_create_playlist),
|
||||||
|
onClick = onCreatePlaylist,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.canRefresh) {
|
||||||
|
MillenniumIconButton(
|
||||||
|
icon = Iconsax.IconsaxRefreshRight,
|
||||||
|
contentDescription = stringResource(Res.string.library_action_refresh_local_tracks),
|
||||||
|
onClick = onRefresh,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LibraryFilterField(
|
||||||
|
state: TextFieldState,
|
||||||
|
placeholder: StringResource,
|
||||||
|
modifier: Modifier,
|
||||||
|
autoFocus: Boolean = false
|
||||||
|
) {
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(autoFocus) {
|
||||||
|
if (autoFocus) focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
MillenniumTextField(
|
||||||
|
state = state,
|
||||||
|
placeholder = stringResource(placeholder),
|
||||||
|
minimumHeight = 36.dp,
|
||||||
|
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp),
|
||||||
|
modifier = modifier.padding(vertical = 4.dp).focusRequester(focusRequester)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -17,17 +17,90 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.modules.library
|
package dev.krtirtho.spotube.modules.library
|
||||||
|
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
|
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
|
||||||
class LibraryState {
|
data class LibraryToolbarUiState(
|
||||||
val searchMode = MutableStateFlow(false)
|
val currentTab: LibraryTab = LibraryTab.Playlists,
|
||||||
|
val searchMode: Boolean = false,
|
||||||
|
val searchState: TextFieldState = TextFieldState(),
|
||||||
|
val canFilter: Boolean = true,
|
||||||
|
val canCreatePlaylist: Boolean = true,
|
||||||
|
val canRefresh: Boolean = false,
|
||||||
|
val showCreatePlaylist: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Shared with the sidebar for the app lifetime; each library tab retains its own filter. */
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class LibraryState : ViewModel() {
|
||||||
|
private val searchMode = MutableStateFlow(false)
|
||||||
val currentTab = MutableStateFlow(LibraryTab.Playlists)
|
val currentTab = MutableStateFlow(LibraryTab.Playlists)
|
||||||
|
private val searchStates = LibraryTab.entries.associateWith { TextFieldState() }
|
||||||
|
private val createPlaylistVisible = MutableStateFlow(false)
|
||||||
|
private val refreshRequests = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||||
|
val localRefreshRequests = refreshRequests.asSharedFlow()
|
||||||
|
|
||||||
|
val uiState = combine(
|
||||||
|
currentTab.flatMapLatest { tab -> queryFlow(tab).map { query -> tab to query } },
|
||||||
|
searchMode,
|
||||||
|
createPlaylistVisible
|
||||||
|
) { (tab, query), searching, creating ->
|
||||||
|
LibraryToolbarUiState(
|
||||||
|
currentTab = tab,
|
||||||
|
searchMode = tab != LibraryTab.Downloads && (searching || query.isNotEmpty()),
|
||||||
|
searchState = searchStates.getValue(tab),
|
||||||
|
canFilter = tab != LibraryTab.Downloads,
|
||||||
|
canCreatePlaylist = tab == LibraryTab.Playlists,
|
||||||
|
canRefresh = tab == LibraryTab.LocalTracks,
|
||||||
|
showCreatePlaylist = tab == LibraryTab.Playlists && creating
|
||||||
|
)
|
||||||
|
}.stateIn(
|
||||||
|
viewModelScope, SharingStarted.Eagerly,
|
||||||
|
LibraryToolbarUiState(searchState = searchStates.getValue(LibraryTab.Playlists))
|
||||||
|
)
|
||||||
|
|
||||||
|
fun queryFlow(tab: LibraryTab) = snapshotFlow { searchStates.getValue(tab).text.toString() }
|
||||||
|
|
||||||
|
fun setQuery(tab: LibraryTab, query: String) {
|
||||||
|
searchStates.getValue(tab).setTextAndPlaceCursorAtEnd(query)
|
||||||
|
}
|
||||||
|
|
||||||
fun onTabSelected(tab: LibraryTab) {
|
fun onTabSelected(tab: LibraryTab) {
|
||||||
currentTab.value = tab
|
currentTab.value = tab
|
||||||
|
createPlaylistVisible.value = false
|
||||||
|
if (tab == LibraryTab.Downloads) searchMode.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSearchMode(enabled: Boolean) {
|
fun openSearch() {
|
||||||
searchMode.value = enabled
|
if (currentTab.value != LibraryTab.Downloads) searchMode.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeSearch() {
|
||||||
|
setQuery(currentTab.value, "")
|
||||||
|
searchMode.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showCreatePlaylist() {
|
||||||
|
if (currentTab.value == LibraryTab.Playlists) createPlaylistVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissCreatePlaylist() {
|
||||||
|
createPlaylistVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshLocalTracks() {
|
||||||
|
refreshRequests.tryEmit(Unit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,11 +24,31 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
|
|||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicDashboard
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicDashboard
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary
|
||||||
import dev.krtirtho.spotube.resources.iconsax.User
|
import dev.krtirtho.spotube.resources.iconsax.User
|
||||||
|
import org.jetbrains.compose.resources.StringResource
|
||||||
|
import spotube.composeapp.generated.resources.*
|
||||||
|
|
||||||
enum class LibraryTab(val title: String, val icon: ImageVector) {
|
enum class LibraryTab(val title: String, val icon: ImageVector) {
|
||||||
Playlists("Playlists", Iconsax.IconsaxMusicDashboard),
|
Playlists("Playlists", Iconsax.IconsaxMusicDashboard),
|
||||||
Albums("Albums", Iconsax.IconsaxCd),
|
Albums("Albums", Iconsax.IconsaxCd),
|
||||||
Artists("Artists", Iconsax.User),
|
Artists("Artists", Iconsax.User),
|
||||||
LocalTracks("Local Tracks", Iconsax.IconsaxMusicLibrary),
|
LocalTracks("Local Tracks", Iconsax.IconsaxMusicLibrary),
|
||||||
Downloads("Downloads", Iconsax.IconsaxDirectboxReceive),
|
Downloads("Downloads", Iconsax.IconsaxDirectboxReceive);
|
||||||
|
|
||||||
|
val titleResource: StringResource
|
||||||
|
get() = when (this) {
|
||||||
|
Playlists -> Res.string.library_tab_playlists
|
||||||
|
Albums -> Res.string.library_tab_albums
|
||||||
|
Artists -> Res.string.library_tab_artists
|
||||||
|
LocalTracks -> Res.string.library_tab_local_tracks
|
||||||
|
Downloads -> Res.string.library_tab_downloads
|
||||||
|
}
|
||||||
|
|
||||||
|
val filterPlaceholder: StringResource
|
||||||
|
get() = when (this) {
|
||||||
|
Playlists -> Res.string.library_filter_playlists
|
||||||
|
Albums -> Res.string.library_filter_albums
|
||||||
|
Artists -> Res.string.library_filter_artists
|
||||||
|
LocalTracks -> Res.string.library_filter_local_tracks
|
||||||
|
Downloads -> Res.string.search_filter_placeholder
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,17 +18,12 @@
|
|||||||
package dev.krtirtho.spotube.modules.library.album
|
package dev.krtirtho.spotube.modules.library.album
|
||||||
|
|
||||||
|
|
||||||
import org.spotube.millennium.composables.MillenniumIcon
|
|
||||||
import org.spotube.millennium.composables.MillenniumTextField
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
@ -43,35 +38,24 @@ import androidx.compose.runtime.snapshotFlow
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dev.krtirtho.spotube.core.ui.component.AlbumCard
|
import dev.krtirtho.spotube.core.ui.component.AlbumCard
|
||||||
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
|
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
|
||||||
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
||||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
||||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.drop
|
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LibraryAlbumsScreen(
|
fun LibraryAlbumsScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
searchMode: Boolean = false,
|
|
||||||
viewModel: LibraryAlbumsViewModel = koinViewModel()
|
viewModel: LibraryAlbumsViewModel = koinViewModel()
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val gridState = rememberLazyGridState()
|
val gridState = rememberLazyGridState()
|
||||||
|
|
||||||
val searchState = rememberTextFieldState((state as? LibraryAlbumsState.Data)?.query ?: "")
|
|
||||||
LaunchedEffect(searchState) {
|
|
||||||
snapshotFlow { searchState.text.toString() }
|
|
||||||
.drop(1)
|
|
||||||
.collect(viewModel::onQueryChange)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(gridState) {
|
LaunchedEffect(gridState) {
|
||||||
snapshotFlow { gridState.layoutInfo }
|
snapshotFlow { gridState.layoutInfo }
|
||||||
@ -93,15 +77,6 @@ fun LibraryAlbumsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
AnimatedVisibility(searchMode) {
|
|
||||||
MillenniumTextField(
|
|
||||||
state = searchState,
|
|
||||||
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
placeholder = "Search saved albums..."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
when (state) {
|
when (state) {
|
||||||
is LibraryAlbumsState.Loading -> {
|
is LibraryAlbumsState.Loading -> {
|
||||||
val shellBottomInset = LocalAppShellBottomInset.current
|
val shellBottomInset = LocalAppShellBottomInset.current
|
||||||
|
|||||||
@ -23,14 +23,17 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
|||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||||
|
import dev.krtirtho.spotube.modules.library.LibraryState
|
||||||
|
import dev.krtirtho.spotube.modules.library.LibraryTab
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
|
|
||||||
@ -46,7 +49,8 @@ sealed interface LibraryAlbumsState {
|
|||||||
data class Loaded(
|
data class Loaded(
|
||||||
override val query: String = "",
|
override val query: String = "",
|
||||||
override val allItems: List<MetadataAlbum.Detailed> = emptyList(),
|
override val allItems: List<MetadataAlbum.Detailed> = emptyList(),
|
||||||
override val nextPagination: PaginationStrategy? = null) : Data {
|
override val nextPagination: PaginationStrategy? = null
|
||||||
|
) : Data {
|
||||||
override val items: List<MetadataAlbum.Detailed>
|
override val items: List<MetadataAlbum.Detailed>
|
||||||
get() = if (query.isNotBlank()) {
|
get() = if (query.isNotBlank()) {
|
||||||
allItems.filter { album ->
|
allItems.filter { album ->
|
||||||
@ -62,13 +66,15 @@ sealed interface LibraryAlbumsState {
|
|||||||
fun toLoadingMore(): LoadingMore = LoadingMore(
|
fun toLoadingMore(): LoadingMore = LoadingMore(
|
||||||
query = query,
|
query = query,
|
||||||
allItems = allItems,
|
allItems = allItems,
|
||||||
nextPagination = nextPagination)
|
nextPagination = nextPagination
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class LoadingMore(
|
data class LoadingMore(
|
||||||
override val query: String = "",
|
override val query: String = "",
|
||||||
override val allItems: List<MetadataAlbum.Detailed> = emptyList(),
|
override val allItems: List<MetadataAlbum.Detailed> = emptyList(),
|
||||||
override val nextPagination: PaginationStrategy? = null) : Data {
|
override val nextPagination: PaginationStrategy? = null
|
||||||
|
) : Data {
|
||||||
override val items: List<MetadataAlbum.Detailed>
|
override val items: List<MetadataAlbum.Detailed>
|
||||||
get() = if (query.isNotBlank()) {
|
get() = if (query.isNotBlank()) {
|
||||||
allItems.filter { album ->
|
allItems.filter { album ->
|
||||||
@ -88,11 +94,21 @@ sealed interface LibraryAlbumsState {
|
|||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class LibraryAlbumsViewModel(
|
class LibraryAlbumsViewModel(
|
||||||
private val repository: LibraryRepository) : ViewModel(), KoinComponent {
|
private val repository: LibraryRepository,
|
||||||
|
private val libraryState: LibraryState
|
||||||
|
) : ViewModel(), KoinComponent {
|
||||||
private val logger by injectLogger<LibraryAlbumsViewModel>()
|
private val logger by injectLogger<LibraryAlbumsViewModel>()
|
||||||
|
|
||||||
private val _state = MutableStateFlow<LibraryAlbumsState>(LibraryAlbumsState.Loading)
|
private val _state = MutableStateFlow<LibraryAlbumsState>(LibraryAlbumsState.Loading)
|
||||||
val uiState: StateFlow<LibraryAlbumsState> = _state.asStateFlow()
|
val uiState: StateFlow<LibraryAlbumsState> = combine(
|
||||||
|
_state, libraryState.queryFlow(LibraryTab.Albums)
|
||||||
|
) { state, query ->
|
||||||
|
when (state) {
|
||||||
|
is LibraryAlbumsState.Data.Loaded -> state.copy(query = query)
|
||||||
|
is LibraryAlbumsState.Data.LoadingMore -> state.copy(query = query)
|
||||||
|
else -> state
|
||||||
|
}
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryAlbumsState.Loading)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@ -113,7 +129,8 @@ class LibraryAlbumsViewModel(
|
|||||||
val result = repository.savedAlbums()
|
val result = repository.savedAlbums()
|
||||||
_state.value = LibraryAlbumsState.Data.Loaded(
|
_state.value = LibraryAlbumsState.Data.Loaded(
|
||||||
allItems = result?.items ?: emptyList(),
|
allItems = result?.items ?: emptyList(),
|
||||||
nextPagination = result?.nextPagination)
|
nextPagination = result?.nextPagination
|
||||||
|
)
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
logger.e(e) { "Failed to load albums" }
|
logger.e(e) { "Failed to load albums" }
|
||||||
_state.value = LibraryAlbumsState.Error(e.message ?: "Unknown error")
|
_state.value = LibraryAlbumsState.Error(e.message ?: "Unknown error")
|
||||||
@ -127,7 +144,8 @@ class LibraryAlbumsViewModel(
|
|||||||
_state.value = LibraryAlbumsState.Data.Loaded(
|
_state.value = LibraryAlbumsState.Data.Loaded(
|
||||||
query = currentState.query,
|
query = currentState.query,
|
||||||
allItems = currentState.allItems + (result?.items ?: emptyList()),
|
allItems = currentState.allItems + (result?.items ?: emptyList()),
|
||||||
nextPagination = result?.nextPagination)
|
nextPagination = result?.nextPagination
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
logger.e(e) { "Failed to load more albums" }
|
logger.e(e) { "Failed to load more albums" }
|
||||||
@ -135,13 +153,7 @@ class LibraryAlbumsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onQueryChange(query: String) {
|
fun onQueryChange(query: String) {
|
||||||
val currentState = _state.value
|
libraryState.setQuery(LibraryTab.Albums, query)
|
||||||
if (currentState is LibraryAlbumsState.Data) {
|
|
||||||
_state.value = when (currentState) {
|
|
||||||
is LibraryAlbumsState.Data.Loaded -> currentState.copy(query = query)
|
|
||||||
is LibraryAlbumsState.Data.LoadingMore -> currentState.copy(query = query)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
|
|||||||
@ -18,23 +18,16 @@
|
|||||||
package dev.krtirtho.spotube.modules.library.artist
|
package dev.krtirtho.spotube.modules.library.artist
|
||||||
|
|
||||||
|
|
||||||
import org.spotube.millennium.composables.MillenniumIcon
|
|
||||||
import org.spotube.millennium.composables.MillenniumTextField
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
|
|
||||||
import com.composeunstyled.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@ -43,34 +36,25 @@ import androidx.compose.runtime.snapshotFlow
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.composeunstyled.Text
|
||||||
import dev.krtirtho.spotube.core.ui.component.ArtistCard
|
import dev.krtirtho.spotube.core.ui.component.ArtistCard
|
||||||
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
|
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
|
||||||
import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard
|
import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard
|
||||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
||||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.drop
|
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LibraryArtistsScreen(
|
fun LibraryArtistsScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
searchMode: Boolean = false,
|
viewModel: LibraryArtistsViewModel = koinViewModel()
|
||||||
viewModel: LibraryArtistsViewModel = koinViewModel()) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val gridState = rememberLazyGridState()
|
val gridState = rememberLazyGridState()
|
||||||
|
|
||||||
val searchState = rememberTextFieldState((state as? LibraryArtistsState.Data)?.query ?: "")
|
|
||||||
LaunchedEffect(searchState) {
|
|
||||||
snapshotFlow { searchState.text.toString() }
|
|
||||||
.drop(1)
|
|
||||||
.collect(viewModel::onQueryChange)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(gridState) {
|
LaunchedEffect(gridState) {
|
||||||
snapshotFlow { gridState.layoutInfo }
|
snapshotFlow { gridState.layoutInfo }
|
||||||
@ -92,14 +76,6 @@ fun LibraryArtistsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
AnimatedVisibility(searchMode){
|
|
||||||
MillenniumTextField(
|
|
||||||
state = searchState,
|
|
||||||
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
placeholder = "Search saved artists...")
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
when (state) {
|
when (state) {
|
||||||
is LibraryArtistsState.Loading -> {
|
is LibraryArtistsState.Loading -> {
|
||||||
val shellBottomInset = LocalAppShellBottomInset.current
|
val shellBottomInset = LocalAppShellBottomInset.current
|
||||||
@ -111,13 +87,15 @@ fun LibraryArtistsScreen(
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
items(8) {
|
items(8) {
|
||||||
SkeletonTree(true) {
|
SkeletonTree(true) {
|
||||||
AvatarCard(
|
AvatarCard(
|
||||||
title = "Artist Name",
|
title = "Artist Name",
|
||||||
subtitle = "Artist",
|
subtitle = "Artist",
|
||||||
imageURL = "https://placehold.co/600x400")
|
imageURL = "https://placehold.co/600x400"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -152,11 +130,13 @@ fun LibraryArtistsScreen(
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
items(dataState.items, key = { it.id }) { artist ->
|
items(dataState.items, key = { it.id }) { artist ->
|
||||||
ArtistCard(
|
ArtistCard(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
modifier = Modifier.fillMaxWidth())
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// if (state is LibraryArtistsState.Data.LoadingMore) {
|
// if (state is LibraryArtistsState.Data.LoadingMore) {
|
||||||
@ -165,7 +145,8 @@ fun LibraryArtistsScreen(
|
|||||||
AvatarCard(
|
AvatarCard(
|
||||||
title = "Artist Name",
|
title = "Artist Name",
|
||||||
subtitle = "Artist",
|
subtitle = "Artist",
|
||||||
imageURL = "https://placehold.co/400x400")
|
imageURL = "https://placehold.co/400x400"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// }
|
// }
|
||||||
|
|||||||
@ -23,14 +23,17 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
|||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||||
|
import dev.krtirtho.spotube.modules.library.LibraryState
|
||||||
|
import dev.krtirtho.spotube.modules.library.LibraryTab
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
|
|
||||||
@ -46,7 +49,8 @@ sealed interface LibraryArtistsState {
|
|||||||
data class Loaded(
|
data class Loaded(
|
||||||
override val query: String = "",
|
override val query: String = "",
|
||||||
override val allItems: List<MetadataArtist.Basic> = emptyList(),
|
override val allItems: List<MetadataArtist.Basic> = emptyList(),
|
||||||
override val nextPagination: PaginationStrategy? = null) : Data {
|
override val nextPagination: PaginationStrategy? = null
|
||||||
|
) : Data {
|
||||||
override val items: List<MetadataArtist.Basic>
|
override val items: List<MetadataArtist.Basic>
|
||||||
get() = if (query.isNotBlank()) {
|
get() = if (query.isNotBlank()) {
|
||||||
allItems.filter { artist ->
|
allItems.filter { artist ->
|
||||||
@ -59,13 +63,15 @@ sealed interface LibraryArtistsState {
|
|||||||
fun toLoadingMore(): LoadingMore = LoadingMore(
|
fun toLoadingMore(): LoadingMore = LoadingMore(
|
||||||
query = query,
|
query = query,
|
||||||
allItems = allItems,
|
allItems = allItems,
|
||||||
nextPagination = nextPagination)
|
nextPagination = nextPagination
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class LoadingMore(
|
data class LoadingMore(
|
||||||
override val query: String = "",
|
override val query: String = "",
|
||||||
override val allItems: List<MetadataArtist.Basic> = emptyList(),
|
override val allItems: List<MetadataArtist.Basic> = emptyList(),
|
||||||
override val nextPagination: PaginationStrategy? = null) : Data {
|
override val nextPagination: PaginationStrategy? = null
|
||||||
|
) : Data {
|
||||||
override val items: List<MetadataArtist.Basic>
|
override val items: List<MetadataArtist.Basic>
|
||||||
get() = if (query.isNotBlank()) {
|
get() = if (query.isNotBlank()) {
|
||||||
allItems.filter { artist ->
|
allItems.filter { artist ->
|
||||||
@ -82,11 +88,21 @@ sealed interface LibraryArtistsState {
|
|||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class LibraryArtistsViewModel(
|
class LibraryArtistsViewModel(
|
||||||
private val repository: LibraryRepository) : ViewModel(), KoinComponent {
|
private val repository: LibraryRepository,
|
||||||
|
private val libraryState: LibraryState
|
||||||
|
) : ViewModel(), KoinComponent {
|
||||||
private val logger by injectLogger<LibraryArtistsViewModel>()
|
private val logger by injectLogger<LibraryArtistsViewModel>()
|
||||||
|
|
||||||
private val _state = MutableStateFlow<LibraryArtistsState>(LibraryArtistsState.Loading)
|
private val _state = MutableStateFlow<LibraryArtistsState>(LibraryArtistsState.Loading)
|
||||||
val uiState: StateFlow<LibraryArtistsState> = _state.asStateFlow()
|
val uiState: StateFlow<LibraryArtistsState> = combine(
|
||||||
|
_state, libraryState.queryFlow(LibraryTab.Artists)
|
||||||
|
) { state, query ->
|
||||||
|
when (state) {
|
||||||
|
is LibraryArtistsState.Data.Loaded -> state.copy(query = query)
|
||||||
|
is LibraryArtistsState.Data.LoadingMore -> state.copy(query = query)
|
||||||
|
else -> state
|
||||||
|
}
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryArtistsState.Loading)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@ -111,7 +127,8 @@ class LibraryArtistsViewModel(
|
|||||||
id = it.id,
|
id = it.id,
|
||||||
name = it.name,
|
name = it.name,
|
||||||
thumbnails = it.thumbnails,
|
thumbnails = it.thumbnails,
|
||||||
externalUri = it.externalUri)
|
externalUri = it.externalUri
|
||||||
|
)
|
||||||
} ?: emptyList(),
|
} ?: emptyList(),
|
||||||
nextPagination = result?.nextPagination)
|
nextPagination = result?.nextPagination)
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
@ -129,12 +146,14 @@ class LibraryArtistsViewModel(
|
|||||||
id = it.id,
|
id = it.id,
|
||||||
name = it.name,
|
name = it.name,
|
||||||
thumbnails = it.thumbnails,
|
thumbnails = it.thumbnails,
|
||||||
externalUri = it.externalUri)
|
externalUri = it.externalUri
|
||||||
|
)
|
||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
_state.value = LibraryArtistsState.Data.Loaded(
|
_state.value = LibraryArtistsState.Data.Loaded(
|
||||||
query = currentState.query,
|
query = currentState.query,
|
||||||
allItems = currentState.allItems + newItems,
|
allItems = currentState.allItems + newItems,
|
||||||
nextPagination = result?.nextPagination)
|
nextPagination = result?.nextPagination
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
logger.e(e) { "Failed to load more artists" }
|
logger.e(e) { "Failed to load more artists" }
|
||||||
@ -142,13 +161,7 @@ class LibraryArtistsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onQueryChange(query: String) {
|
fun onQueryChange(query: String) {
|
||||||
val currentState = _state.value
|
libraryState.setQuery(LibraryTab.Artists, query)
|
||||||
if (currentState is LibraryArtistsState.Data) {
|
|
||||||
_state.value = when (currentState) {
|
|
||||||
is LibraryArtistsState.Data.Loaded -> currentState.copy(query = query)
|
|
||||||
is LibraryArtistsState.Data.LoadingMore -> currentState.copy(query = query)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
|
|||||||
@ -25,8 +25,6 @@ import org.spotube.millennium.composables.MillenniumIconButton
|
|||||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
import org.spotube.millennium.composables.MillenniumIndeterminateProgress
|
import org.spotube.millennium.composables.MillenniumIndeterminateProgress
|
||||||
import org.spotube.millennium.composables.MillenniumListTile
|
import org.spotube.millennium.composables.MillenniumListTile
|
||||||
import org.spotube.millennium.composables.MillenniumTextField
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@ -38,7 +36,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
|
||||||
|
|
||||||
import com.composeunstyled.Text
|
import com.composeunstyled.Text
|
||||||
|
|
||||||
@ -46,28 +43,24 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.snapshotFlow
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.flow.drop
|
|
||||||
import dev.krtirtho.spotube.PlatformType
|
import dev.krtirtho.spotube.PlatformType
|
||||||
import dev.krtirtho.spotube.getPlatform
|
import dev.krtirtho.spotube.getPlatform
|
||||||
import dev.krtirtho.spotube.modules.library.local_tracks.media.rememberLocalMediaPermissionState
|
import dev.krtirtho.spotube.modules.library.local_tracks.media.rememberLocalMediaPermissionState
|
||||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||||
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
|
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderOpen
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderOpen
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Play
|
import dev.krtirtho.spotube.resources.iconsax.Play
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean = false) {
|
fun LibraryLocalTracksScreen(modifier: Modifier = Modifier) {
|
||||||
val viewModel = koinViewModel<LibraryLocalTracksViewModel>()
|
val viewModel = koinViewModel<LibraryLocalTracksViewModel>()
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val permissionState = rememberLocalMediaPermissionState()
|
val permissionState = rememberLocalMediaPermissionState()
|
||||||
@ -85,53 +78,25 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean
|
|||||||
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
|
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
|
||||||
}
|
}
|
||||||
|
|
||||||
val searchState = rememberTextFieldState(state.query)
|
|
||||||
LaunchedEffect(searchState) {
|
|
||||||
snapshotFlow { searchState.text.toString() }
|
|
||||||
.drop(1)
|
|
||||||
.collect(viewModel::onQueryChange)
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(modifier = modifier.fillMaxWidth()) {
|
Box(modifier = modifier.fillMaxWidth()) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter),
|
modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
AnimatedVisibility(searchMode) {
|
) {
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
MillenniumIcon(
|
|
||||||
imageVector = Iconsax.IconsaxFilterSearch,
|
|
||||||
contentDescription = "Search")
|
|
||||||
MillenniumTextField(
|
|
||||||
state = searchState,
|
|
||||||
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
placeholder = if (state.currentFolderPath == null) {
|
|
||||||
"Search folders or tracks"
|
|
||||||
} else {
|
|
||||||
"Search tracks in folder"
|
|
||||||
})
|
|
||||||
MillenniumIconButton(
|
|
||||||
icon = Iconsax.IconsaxRefreshRight,
|
|
||||||
contentDescription = "Refresh",
|
|
||||||
onClick = { viewModel.refresh() },
|
|
||||||
style = MillenniumIconButtonStyle.Quiet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.currentFolderPath != null) {
|
if (state.currentFolderPath != null) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween) {
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
MillenniumIconButton(
|
MillenniumIconButton(
|
||||||
icon = Iconsax.ArrowLeft3,
|
icon = Iconsax.ArrowLeft3,
|
||||||
contentDescription = "Back",
|
contentDescription = "Back",
|
||||||
onClick = viewModel::navigateUp,
|
onClick = viewModel::navigateUp,
|
||||||
style = MillenniumIconButtonStyle.Quiet)
|
style = MillenniumIconButtonStyle.Quiet
|
||||||
|
)
|
||||||
Text(viewModel.currentFolder()?.name ?: "Folder")
|
Text(viewModel.currentFolder()?.name ?: "Folder")
|
||||||
}
|
}
|
||||||
MillenniumIconButton(
|
MillenniumIconButton(
|
||||||
@ -140,7 +105,8 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean
|
|||||||
onClick = {
|
onClick = {
|
||||||
viewModel.currentFolder()?.let { viewModel.playFolder(it.path) }
|
viewModel.currentFolder()?.let { viewModel.playFolder(it.path) }
|
||||||
},
|
},
|
||||||
style = MillenniumIconButtonStyle.Quiet)
|
style = MillenniumIconButtonStyle.Quiet
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -151,11 +117,13 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean
|
|||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
Text("Allow audio permission to show local tracks")
|
Text("Allow audio permission to show local tracks")
|
||||||
MillenniumButton(
|
MillenniumButton(
|
||||||
onClick = permissionState.requestPermission,
|
onClick = permissionState.requestPermission,
|
||||||
style = MillenniumButtonStyle.Quiet) {
|
style = MillenniumButtonStyle.Quiet
|
||||||
|
) {
|
||||||
Text("Grant access")
|
Text("Grant access")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -212,7 +180,8 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean
|
|||||||
icon = Iconsax.Play,
|
icon = Iconsax.Play,
|
||||||
contentDescription = "Play",
|
contentDescription = "Play",
|
||||||
onClick = { viewModel.playTrack(track.path) },
|
onClick = { viewModel.playTrack(track.path) },
|
||||||
style = MillenniumIconButtonStyle.Quiet)
|
style = MillenniumIconButtonStyle.Quiet
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onClick = { viewModel.playTrack(track.path) })
|
onClick = { viewModel.playTrack(track.path) })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,13 +21,17 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
|
import dev.krtirtho.spotube.modules.library.LibraryState
|
||||||
|
import dev.krtirtho.spotube.modules.library.LibraryTab
|
||||||
import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaFolder
|
import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaFolder
|
||||||
import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator
|
import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator
|
||||||
import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaTrack
|
import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaTrack
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class LibraryLocalTracksState(
|
data class LibraryLocalTracksState(
|
||||||
@ -35,22 +39,32 @@ data class LibraryLocalTracksState(
|
|||||||
val folders: List<LocalMediaFolder> = emptyList(),
|
val folders: List<LocalMediaFolder> = emptyList(),
|
||||||
val currentFolderPath: String? = null,
|
val currentFolderPath: String? = null,
|
||||||
val query: String = "",
|
val query: String = "",
|
||||||
val error: String? = null)
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
class LibraryLocalTracksViewModel(
|
class LibraryLocalTracksViewModel(
|
||||||
private val coordinator: LocalMediaLibraryCoordinator,
|
private val coordinator: LocalMediaLibraryCoordinator,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue) : ViewModel() {
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
|
private val libraryState: LibraryState
|
||||||
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(LibraryLocalTracksState())
|
private val _state = MutableStateFlow(LibraryLocalTracksState())
|
||||||
val state: StateFlow<LibraryLocalTracksState> = _state.asStateFlow()
|
val state: StateFlow<LibraryLocalTracksState> = combine(
|
||||||
|
_state, libraryState.queryFlow(LibraryTab.LocalTracks)
|
||||||
|
) { state, query -> state.copy(query = query) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryLocalTracksState())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
libraryState.localRefreshRequests.collect { refresh() }
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
coordinator.cacheState
|
coordinator.cacheState
|
||||||
.collect { cache ->
|
.collect { cache ->
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
folders = cache.folders,
|
folders = cache.folders,
|
||||||
error = null)
|
error = null
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -60,7 +74,7 @@ class LibraryLocalTracksViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onQueryChange(query: String) {
|
fun onQueryChange(query: String) {
|
||||||
_state.update { it.copy(query = query) }
|
libraryState.setQuery(LibraryTab.LocalTracks, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun openFolder(folderPath: String) {
|
fun openFolder(folderPath: String) {
|
||||||
@ -87,7 +101,7 @@ class LibraryLocalTracksViewModel(
|
|||||||
|
|
||||||
fun playFolder(folderPath: String) {
|
fun playFolder(folderPath: String) {
|
||||||
val folder = _state.value.folders.firstOrNull { it.path == folderPath } ?: return
|
val folder = _state.value.folders.firstOrNull { it.path == folderPath } ?: return
|
||||||
val tracks = filterTracks(folder.tracks, _state.value.query)
|
val tracks = filterTracks(folder.tracks, state.value.query)
|
||||||
if (tracks.isEmpty()) return
|
if (tracks.isEmpty()) return
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@ -95,13 +109,14 @@ class LibraryLocalTracksViewModel(
|
|||||||
entries = tracks.map { it.toQueueEntry() },
|
entries = tracks.map { it.toQueueEntry() },
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = null)
|
collectionEntry = null
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playTrack(trackPath: String) {
|
fun playTrack(trackPath: String) {
|
||||||
val folder = currentFolder() ?: return
|
val folder = currentFolder() ?: return
|
||||||
val tracks = filterTracks(folder.tracks, _state.value.query)
|
val tracks = filterTracks(folder.tracks, state.value.query)
|
||||||
val selectedIndex = tracks.indexOfFirst { it.path == trackPath }
|
val selectedIndex = tracks.indexOfFirst { it.path == trackPath }
|
||||||
if (selectedIndex < 0) return
|
if (selectedIndex < 0) return
|
||||||
|
|
||||||
@ -110,7 +125,8 @@ class LibraryLocalTracksViewModel(
|
|||||||
entries = tracks.map { it.toQueueEntry() },
|
entries = tracks.map { it.toQueueEntry() },
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = selectedIndex,
|
startPosition = selectedIndex,
|
||||||
collectionEntry = null)
|
collectionEntry = null
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -168,6 +184,7 @@ class LibraryLocalTracksViewModel(
|
|||||||
duration = durationMs,
|
duration = durationMs,
|
||||||
album = album,
|
album = album,
|
||||||
coverBytes = coverBytes,
|
coverBytes = coverBytes,
|
||||||
url = path)
|
url = path
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,68 +18,45 @@
|
|||||||
package dev.krtirtho.spotube.modules.library.playlist
|
package dev.krtirtho.spotube.modules.library.playlist
|
||||||
|
|
||||||
|
|
||||||
import org.spotube.millennium.composables.MillenniumIcon
|
|
||||||
import org.spotube.millennium.composables.MillenniumIconButton
|
|
||||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
|
||||||
import org.spotube.millennium.composables.MillenniumTextField
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
|
|
||||||
import com.composeunstyled.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.composeunstyled.Text
|
||||||
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
|
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
|
||||||
import dev.krtirtho.spotube.core.ui.component.LikedTracksCard
|
import dev.krtirtho.spotube.core.ui.component.LikedTracksCard
|
||||||
import dev.krtirtho.spotube.core.ui.component.PlaylistCard
|
import dev.krtirtho.spotube.core.ui.component.PlaylistCard
|
||||||
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
||||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
||||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.drop
|
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LibraryPlaylistsScreen(
|
fun LibraryPlaylistsScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
searchMode: Boolean = false,
|
showCreatePlaylist: Boolean = false,
|
||||||
viewModel: LibraryPlaylistsViewModel = koinViewModel()) {
|
onDismissCreatePlaylist: () -> Unit = {},
|
||||||
|
viewModel: LibraryPlaylistsViewModel = koinViewModel()
|
||||||
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val gridState = rememberLazyGridState()
|
val gridState = rememberLazyGridState()
|
||||||
var showCreatePlaylist by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val searchState = rememberTextFieldState((state as? LibraryPlaylistsState.Data)?.query ?: "")
|
|
||||||
LaunchedEffect(searchState) {
|
|
||||||
snapshotFlow { searchState.text.toString() }
|
|
||||||
.drop(1)
|
|
||||||
.collect(viewModel::onQueryChange)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(gridState) {
|
LaunchedEffect(gridState) {
|
||||||
snapshotFlow { gridState.layoutInfo }
|
snapshotFlow { gridState.layoutInfo }
|
||||||
@ -101,26 +78,6 @@ fun LibraryPlaylistsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = searchMode,
|
|
||||||
modifier = Modifier.weight(1f)) {
|
|
||||||
MillenniumTextField(
|
|
||||||
state = searchState,
|
|
||||||
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
placeholder = "Search saved playlists...")
|
|
||||||
}
|
|
||||||
MillenniumIconButton(
|
|
||||||
icon = Iconsax.IconsaxAddSquare,
|
|
||||||
contentDescription = "Create Playlist",
|
|
||||||
onClick = { showCreatePlaylist = true },
|
|
||||||
modifier = Modifier.size(48.dp))
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
when (state) {
|
when (state) {
|
||||||
is LibraryPlaylistsState.Loading -> {
|
is LibraryPlaylistsState.Loading -> {
|
||||||
val shellBottomInset = LocalAppShellBottomInset.current
|
val shellBottomInset = LocalAppShellBottomInset.current
|
||||||
@ -139,7 +96,8 @@ fun LibraryPlaylistsScreen(
|
|||||||
PlayableCard(
|
PlayableCard(
|
||||||
title = "Playlist Name",
|
title = "Playlist Name",
|
||||||
subtitle = "By Artist",
|
subtitle = "By Artist",
|
||||||
imageURL = "https://placehold.co/600x400")
|
imageURL = "https://placehold.co/600x400"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -195,7 +153,8 @@ fun LibraryPlaylistsScreen(
|
|||||||
PlayableCard(
|
PlayableCard(
|
||||||
title = "Playlist Name",
|
title = "Playlist Name",
|
||||||
subtitle = "By Artist",
|
subtitle = "By Artist",
|
||||||
imageURL = "https://placehold.co/600x400")
|
imageURL = "https://placehold.co/600x400"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -206,7 +165,7 @@ fun LibraryPlaylistsScreen(
|
|||||||
|
|
||||||
PlaylistFormSheet(
|
PlaylistFormSheet(
|
||||||
visible = showCreatePlaylist,
|
visible = showCreatePlaylist,
|
||||||
onDismiss = { showCreatePlaylist = false },
|
onDismiss = onDismissCreatePlaylist,
|
||||||
onSubmit = { data ->
|
onSubmit = { data ->
|
||||||
viewModel.createPlaylist(
|
viewModel.createPlaylist(
|
||||||
name = data.name,
|
name = data.name,
|
||||||
@ -214,7 +173,8 @@ fun LibraryPlaylistsScreen(
|
|||||||
isPublic = data.isPublic,
|
isPublic = data.isPublic,
|
||||||
isCollaborating = data.isCollaborating,
|
isCollaborating = data.isCollaborating,
|
||||||
imageBase64 = data.imageBase64,
|
imageBase64 = data.imageBase64,
|
||||||
trackIds = emptyList())
|
trackIds = emptyList()
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,14 +23,17 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStra
|
|||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||||
|
import dev.krtirtho.spotube.modules.library.LibraryState
|
||||||
|
import dev.krtirtho.spotube.modules.library.LibraryTab
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
|
|
||||||
@ -46,7 +49,8 @@ sealed interface LibraryPlaylistsState {
|
|||||||
data class Loaded(
|
data class Loaded(
|
||||||
override val query: String = "",
|
override val query: String = "",
|
||||||
override val allItems: List<MetadataPlaylist> = emptyList(),
|
override val allItems: List<MetadataPlaylist> = emptyList(),
|
||||||
override val nextPagination: PaginationStrategy? = null) : Data {
|
override val nextPagination: PaginationStrategy? = null
|
||||||
|
) : Data {
|
||||||
override val items: List<MetadataPlaylist>
|
override val items: List<MetadataPlaylist>
|
||||||
get() = if (query.isNotBlank()) {
|
get() = if (query.isNotBlank()) {
|
||||||
allItems.filter {
|
allItems.filter {
|
||||||
@ -60,13 +64,15 @@ sealed interface LibraryPlaylistsState {
|
|||||||
fun toLoadingMore(): LoadingMore = LoadingMore(
|
fun toLoadingMore(): LoadingMore = LoadingMore(
|
||||||
query = query,
|
query = query,
|
||||||
allItems = allItems,
|
allItems = allItems,
|
||||||
nextPagination = nextPagination)
|
nextPagination = nextPagination
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class LoadingMore(
|
data class LoadingMore(
|
||||||
override val query: String = "",
|
override val query: String = "",
|
||||||
override val allItems: List<MetadataPlaylist> = emptyList(),
|
override val allItems: List<MetadataPlaylist> = emptyList(),
|
||||||
override val nextPagination: PaginationStrategy? = null) : Data {
|
override val nextPagination: PaginationStrategy? = null
|
||||||
|
) : Data {
|
||||||
override val items: List<MetadataPlaylist>
|
override val items: List<MetadataPlaylist>
|
||||||
get() = if (query.isNotBlank()) {
|
get() = if (query.isNotBlank()) {
|
||||||
allItems.filter {
|
allItems.filter {
|
||||||
@ -84,11 +90,21 @@ sealed interface LibraryPlaylistsState {
|
|||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class LibraryPlaylistsViewModel(
|
class LibraryPlaylistsViewModel(
|
||||||
private val repository: LibraryRepository) : ViewModel(), KoinComponent {
|
private val repository: LibraryRepository,
|
||||||
|
private val libraryState: LibraryState
|
||||||
|
) : ViewModel(), KoinComponent {
|
||||||
private val logger by injectLogger<LibraryPlaylistsViewModel>()
|
private val logger by injectLogger<LibraryPlaylistsViewModel>()
|
||||||
|
|
||||||
private val _state = MutableStateFlow<LibraryPlaylistsState>(LibraryPlaylistsState.Loading)
|
private val _state = MutableStateFlow<LibraryPlaylistsState>(LibraryPlaylistsState.Loading)
|
||||||
val uiState: StateFlow<LibraryPlaylistsState> = _state.asStateFlow()
|
val uiState: StateFlow<LibraryPlaylistsState> = combine(
|
||||||
|
_state, libraryState.queryFlow(LibraryTab.Playlists)
|
||||||
|
) { state, query ->
|
||||||
|
when (state) {
|
||||||
|
is LibraryPlaylistsState.Data.Loaded -> state.copy(query = query)
|
||||||
|
is LibraryPlaylistsState.Data.LoadingMore -> state.copy(query = query)
|
||||||
|
else -> state
|
||||||
|
}
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryPlaylistsState.Loading)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@ -109,7 +125,8 @@ class LibraryPlaylistsViewModel(
|
|||||||
val result = repository.savedPlaylists()
|
val result = repository.savedPlaylists()
|
||||||
_state.value = LibraryPlaylistsState.Data.Loaded(
|
_state.value = LibraryPlaylistsState.Data.Loaded(
|
||||||
allItems = result?.items ?: emptyList(),
|
allItems = result?.items ?: emptyList(),
|
||||||
nextPagination = result?.nextPagination)
|
nextPagination = result?.nextPagination
|
||||||
|
)
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
logger.e(e) { "Failed to load playlists" }
|
logger.e(e) { "Failed to load playlists" }
|
||||||
_state.value = LibraryPlaylistsState.Error(e.message ?: "Unknown error")
|
_state.value = LibraryPlaylistsState.Error(e.message ?: "Unknown error")
|
||||||
@ -123,7 +140,8 @@ class LibraryPlaylistsViewModel(
|
|||||||
_state.value = LibraryPlaylistsState.Data.Loaded(
|
_state.value = LibraryPlaylistsState.Data.Loaded(
|
||||||
query = currentState.query,
|
query = currentState.query,
|
||||||
allItems = currentState.allItems + (result?.items ?: emptyList()),
|
allItems = currentState.allItems + (result?.items ?: emptyList()),
|
||||||
nextPagination = result?.nextPagination)
|
nextPagination = result?.nextPagination
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
logger.e(e) { "Failed to load more playlists" }
|
logger.e(e) { "Failed to load more playlists" }
|
||||||
@ -131,13 +149,7 @@ class LibraryPlaylistsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onQueryChange(query: String) {
|
fun onQueryChange(query: String) {
|
||||||
val currentState = _state.value
|
libraryState.setQuery(LibraryTab.Playlists, query)
|
||||||
if (currentState is LibraryPlaylistsState.Data) {
|
|
||||||
_state.value = when (currentState) {
|
|
||||||
is LibraryPlaylistsState.Data.Loaded -> currentState.copy(query = query)
|
|
||||||
is LibraryPlaylistsState.Data.LoadingMore -> currentState.copy(query = query)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
@ -153,7 +165,8 @@ class LibraryPlaylistsViewModel(
|
|||||||
isPublic: Boolean,
|
isPublic: Boolean,
|
||||||
isCollaborating: Boolean,
|
isCollaborating: Boolean,
|
||||||
imageBase64: String,
|
imageBase64: String,
|
||||||
trackIds: List<String>) {
|
trackIds: List<String>
|
||||||
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
repository.createPlaylist(
|
repository.createPlaylist(
|
||||||
@ -162,7 +175,8 @@ class LibraryPlaylistsViewModel(
|
|||||||
isPublic = isPublic,
|
isPublic = isPublic,
|
||||||
isCollaborating = isCollaborating,
|
isCollaborating = isCollaborating,
|
||||||
imageBase64 = imageBase64,
|
imageBase64 = imageBase64,
|
||||||
trackIds = trackIds)
|
trackIds = trackIds
|
||||||
|
)
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
logger.e(e) { "Failed to create playlist" }
|
logger.e(e) { "Failed to create playlist" }
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
|
|||||||
@ -45,10 +45,13 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
@ -77,12 +80,14 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.composeunstyled.currentWindowWidthBreakpoint
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser
|
||||||
|
import dev.krtirtho.spotube.Platform
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.jam.JamRole
|
import dev.krtirtho.spotube.core.jam.JamRole
|
||||||
@ -103,6 +108,8 @@ import dev.krtirtho.spotube.core.ui.component.UserCard
|
|||||||
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
||||||
import dev.krtirtho.spotube.core.ui.component.dragScrollable
|
import dev.krtirtho.spotube.core.ui.component.dragScrollable
|
||||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
||||||
|
import dev.krtirtho.spotube.getPlatform
|
||||||
|
import dev.krtirtho.spotube.isDesktop
|
||||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||||
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||||
@ -121,6 +128,7 @@ import kotlinx.coroutines.flow.map
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import org.spotube.millennium.adaptive.WindowBreakpoints
|
||||||
|
|
||||||
private val GridMinCellSize = 180.dp
|
private val GridMinCellSize = 180.dp
|
||||||
|
|
||||||
@ -153,6 +161,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
emptyList()
|
emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val isExpanded =
|
||||||
|
currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded && getPlatform().isDesktop()
|
||||||
|
|
||||||
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
|
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
|
||||||
val trackIds = blacklistedTracks.map { it.id }.toSet()
|
val trackIds = blacklistedTracks.map { it.id }.toSet()
|
||||||
val artistIds = blacklistedArtists.map { it.id }.toSet()
|
val artistIds = blacklistedArtists.map { it.id }.toSet()
|
||||||
@ -287,16 +298,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MillenniumTheme.colors.background)
|
.background(MillenniumTheme.colors.background)
|
||||||
|
.windowInsetsPadding(WindowInsets.statusBars)
|
||||||
) {
|
) {
|
||||||
ApplicationMainBar(backButton = false)
|
if (!isExpanded) {
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
SearchBar(
|
SearchBar(
|
||||||
query = state.query,
|
query = state.query,
|
||||||
onQueryChange = viewModel::onQueryChange,
|
onQueryChange = viewModel::onQueryChange,
|
||||||
@ -308,7 +312,34 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
onClearAllRecentSearches = {
|
onClearAllRecentSearches = {
|
||||||
viewModel.clearAllRecentSearches()
|
viewModel.clearAllRecentSearches()
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ApplicationMainBar(backButton = false)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
if (isExpanded) {
|
||||||
|
SearchBar(
|
||||||
|
query = state.query,
|
||||||
|
onQueryChange = viewModel::onQueryChange,
|
||||||
|
recentSearchSuggestions = state.recentSearchSuggestions,
|
||||||
|
onRecentSearchClick = { search ->
|
||||||
|
viewModel.applyRecentSearch(search)
|
||||||
|
focusManager.clearFocus()
|
||||||
|
},
|
||||||
|
onClearAllRecentSearches = {
|
||||||
|
viewModel.clearAllRecentSearches()
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (state.supportedSearchTypes.isNotEmpty()) {
|
if (state.supportedSearchTypes.isNotEmpty()) {
|
||||||
SearchTabs(
|
SearchTabs(
|
||||||
|
|||||||
@ -0,0 +1,191 @@
|
|||||||
|
/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package dev.krtirtho.spotube.modules.library
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
|
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||||
|
import androidx.compose.runtime.snapshots.Snapshot
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.ImageComposeScene
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.semantics.SemanticsNode
|
||||||
|
import androidx.compose.ui.semantics.SemanticsProperties
|
||||||
|
import androidx.compose.ui.semantics.getOrNull
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.use
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme
|
||||||
|
import dev.krtirtho.spotube.modules.settings.Theme
|
||||||
|
import dev.krtirtho.spotube.modules.settings.UserSettings
|
||||||
|
import java.io.File
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.spotube.millennium.composables.MillenniumTopAppBar
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class LibraryToolbarTest {
|
||||||
|
private lateinit var library: LibraryState
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||||
|
library = LibraryState()
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun cleanup() {
|
||||||
|
library.viewModelScope.cancel()
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun closingSearchClearsTheActiveFilter() = runTest {
|
||||||
|
library.openSearch()
|
||||||
|
library.uiState.value.searchState.setTextAndPlaceCursorAtEnd("Muse")
|
||||||
|
Snapshot.sendApplyNotifications()
|
||||||
|
runCurrent()
|
||||||
|
assertTrue(library.uiState.value.searchMode)
|
||||||
|
assertEquals("Muse", library.queryFlow(LibraryTab.Playlists).first())
|
||||||
|
|
||||||
|
library.closeSearch()
|
||||||
|
Snapshot.sendApplyNotifications()
|
||||||
|
runCurrent()
|
||||||
|
assertFalse(library.uiState.value.searchMode)
|
||||||
|
assertEquals("", library.uiState.value.searchState.text.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun eachTabRetainsItsOwnFilterAndActions() = runTest {
|
||||||
|
library.setQuery(LibraryTab.Playlists, "Rock")
|
||||||
|
library.onTabSelected(LibraryTab.Albums)
|
||||||
|
library.setQuery(LibraryTab.Albums, "Jazz")
|
||||||
|
Snapshot.sendApplyNotifications()
|
||||||
|
runCurrent()
|
||||||
|
assertEquals("Jazz", library.uiState.value.searchState.text.toString())
|
||||||
|
assertFalse(library.uiState.value.canCreatePlaylist)
|
||||||
|
|
||||||
|
library.onTabSelected(LibraryTab.Playlists)
|
||||||
|
runCurrent()
|
||||||
|
assertEquals("Rock", library.uiState.value.searchState.text.toString())
|
||||||
|
assertTrue(library.uiState.value.canCreatePlaylist)
|
||||||
|
library.showCreatePlaylist()
|
||||||
|
runCurrent()
|
||||||
|
assertTrue(library.uiState.value.showCreatePlaylist)
|
||||||
|
|
||||||
|
library.onTabSelected(LibraryTab.LocalTracks)
|
||||||
|
runCurrent()
|
||||||
|
assertTrue(library.uiState.value.canRefresh)
|
||||||
|
assertFalse(library.uiState.value.showCreatePlaylist)
|
||||||
|
library.onTabSelected(LibraryTab.Downloads)
|
||||||
|
runCurrent()
|
||||||
|
assertFalse(library.uiState.value.canFilter)
|
||||||
|
assertFalse(library.uiState.value.searchMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anInlineDesktopQueryRemainsVisibleWhenTheLayoutBecomesCompact() = runTest {
|
||||||
|
library.setQuery(LibraryTab.Artists, "Radiohead")
|
||||||
|
library.onTabSelected(LibraryTab.Artists)
|
||||||
|
Snapshot.sendApplyNotifications()
|
||||||
|
runCurrent()
|
||||||
|
assertTrue(library.uiState.value.searchMode)
|
||||||
|
assertEquals("Radiohead", library.uiState.value.searchState.text.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
@Test
|
||||||
|
fun renderExpandedCompactAndSearchToolbars() {
|
||||||
|
val output = File("build/reports/library-toolbar").apply { mkdirs() }
|
||||||
|
for (tab in listOf(LibraryTab.Playlists, LibraryTab.Albums, LibraryTab.LocalTracks)) {
|
||||||
|
for ((mode, width) in listOf(
|
||||||
|
"desktop" to 575,
|
||||||
|
"mobile" to 360,
|
||||||
|
"search" to 360,
|
||||||
|
"compact-desktop" to 392,
|
||||||
|
"compact-desktop-search" to 392
|
||||||
|
)) {
|
||||||
|
val expandedDesktop = mode == "desktop"
|
||||||
|
val searching = mode.contains("search")
|
||||||
|
val state = LibraryToolbarUiState(
|
||||||
|
currentTab = tab,
|
||||||
|
searchMode = searching,
|
||||||
|
searchState = TextFieldState(if (searching) "Muse" else ""),
|
||||||
|
canCreatePlaylist = tab == LibraryTab.Playlists,
|
||||||
|
canRefresh = tab == LibraryTab.LocalTracks
|
||||||
|
)
|
||||||
|
ImageComposeScene(width, 88) {
|
||||||
|
SpotubeTheme(UserSettings(theme = Theme.DARK)) {
|
||||||
|
MillenniumTopAppBar(
|
||||||
|
title = { LibraryToolbarTitle(state, expandedDesktop) },
|
||||||
|
actions = {
|
||||||
|
LibraryToolbarActions(state, expandedDesktop, {}, {}, {}, {})
|
||||||
|
if (expandedDesktop || mode.startsWith("compact-desktop")) Spacer(
|
||||||
|
Modifier.width(132.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.use { scene ->
|
||||||
|
repeat(8) { scene.render().close() }
|
||||||
|
fun flatten(node: SemanticsNode): List<SemanticsNode> =
|
||||||
|
listOf(node) + node.children.flatMap(::flatten)
|
||||||
|
|
||||||
|
val nodes =
|
||||||
|
scene.semanticsOwners.flatMap { flatten(it.unmergedRootSemanticsNode) }
|
||||||
|
val descriptions = nodes.flatMap {
|
||||||
|
it.config.getOrNull(SemanticsProperties.ContentDescription).orEmpty()
|
||||||
|
}
|
||||||
|
val textFields =
|
||||||
|
nodes.filter { it.config.getOrNull(SemanticsProperties.EditableText) != null }
|
||||||
|
assertEquals(if (expandedDesktop || searching) 1 else 0, textFields.size)
|
||||||
|
if (searching) {
|
||||||
|
assertTrue("Close filter" in descriptions)
|
||||||
|
assertFalse("Create playlist" in descriptions)
|
||||||
|
assertFalse("Refresh local tracks" in descriptions)
|
||||||
|
assertEquals(
|
||||||
|
"Muse",
|
||||||
|
textFields.single().config[SemanticsProperties.EditableText].text
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
assertEquals(tab == LibraryTab.Playlists, "Create playlist" in descriptions)
|
||||||
|
assertEquals(
|
||||||
|
tab == LibraryTab.LocalTracks,
|
||||||
|
"Refresh local tracks" in descriptions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
textFields.forEach { node ->
|
||||||
|
assertTrue(
|
||||||
|
node.boundsInRoot.left >= 0f && node.boundsInRoot.right <= width,
|
||||||
|
"Filter must fit beside desktop window controls"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
scene.render().use { image ->
|
||||||
|
assertNotNull(image.encodeToData()).use {
|
||||||
|
File(
|
||||||
|
output,
|
||||||
|
"${tab.name}-$mode.png"
|
||||||
|
).writeBytes(it.bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,9 +19,10 @@
|
|||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
kotlin.daemon.jvmargs=-Xmx3072M
|
kotlin.daemon.jvmargs=-Xmx3072M
|
||||||
kotlin.mpp.stability.nowarn=true
|
kotlin.mpp.stability.nowarn=true
|
||||||
|
kotlin.incremental=true
|
||||||
|
|
||||||
#Gradle
|
#Gradle
|
||||||
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g
|
||||||
org.gradle.configuration-cache=true
|
org.gradle.configuration-cache=true
|
||||||
org.gradle.caching=true
|
org.gradle.caching=true
|
||||||
|
|
||||||
@ -32,3 +33,5 @@ android.useAndroidX=true
|
|||||||
android.builtInKotlin=false
|
android.builtInKotlin=false
|
||||||
android.newDsl=false
|
android.newDsl=false
|
||||||
android.sourceset.disallowProvider=false
|
android.sourceset.disallowProvider=false
|
||||||
|
|
||||||
|
org.gradle.vfs.watch=true
|
||||||
Loading…
Reference in New Issue
Block a user