Files
subtracks/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt
T

1136 lines
48 KiB
Kotlin

package com.subtracks.ui.library
import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.PlaylistPlay
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Album
import androidx.compose.material.icons.rounded.ArrowDownward
import androidx.compose.material.icons.rounded.ArrowUpward
import androidx.compose.material.icons.rounded.CloudOff
import androidx.compose.material.icons.rounded.Download
import androidx.compose.material.icons.rounded.DownloadDone
import androidx.compose.material.icons.rounded.FilterAltOff
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.Remove
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.ClipOp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.boundsInParent
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import com.subtracks.R
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.DownloadProgress
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.Playlist
import com.subtracks.data.prefs.AlbumSort
import com.subtracks.data.prefs.ArtistSort
import com.subtracks.data.prefs.LibraryListTab
import com.subtracks.data.prefs.ListQuery
import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.StarredFilter
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.DeleteDownloadsDialog
import com.subtracks.ui.components.DismissOnRequest
import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.PendingDownloadDelete
import com.subtracks.ui.components.SearchField
import com.subtracks.ui.components.bulkRef
import com.subtracks.ui.components.statusBarScrim
import com.subtracks.ui.home.HomeRoute
import com.subtracks.ui.home.HomeSection
import com.subtracks.ui.home.OfflineListKind
import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.librarySurfaceColor
import com.subtracks.ui.theme.playerSurfaceColor
import com.subtracks.ui.theme.rememberArtworkColors
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import kotlin.math.abs
import kotlin.math.roundToInt
enum class LibraryTab(
@param:StringRes val label: Int,
val icon: ImageVector,
) {
Home(R.string.navigation_tabs_home, Icons.Rounded.Home),
Albums(R.string.resources_album_name, Icons.Rounded.Album),
Artists(R.string.resources_artist_name, Icons.Rounded.Person),
Playlists(R.string.resources_playlist_name, Icons.AutoMirrored.Rounded.PlaylistPlay),
}
data class SortOption(
val value: String,
@param:StringRes val label: Int,
val descendingByDefault: Boolean = false,
)
fun LibraryTab.listTab(): LibraryListTab? = LibraryListTab.entries.firstOrNull { it.name == name }
internal fun libraryTabFor(name: String?): LibraryTab = LibraryTab.entries.firstOrNull { it.name == name } ?: LibraryTab.Home
fun sortOptionsFor(
tab: LibraryTab,
play: PlaySortAvailability = PlaySortAvailability(frequent = false, recent = false),
): List<SortOption> =
when (tab) {
LibraryTab.Albums -> {
buildList {
add(SortOption(AlbumSort.Name.name, R.string.resources_sort_by_name))
add(SortOption(AlbumSort.Artist.name, R.string.resources_sort_by_artist))
add(SortOption(AlbumSort.Year.name, R.string.resources_sort_by_year, descendingByDefault = true))
add(SortOption(AlbumSort.Added.name, R.string.resources_sort_by_added, descendingByDefault = true))
add(SortOption(AlbumSort.Starred.name, R.string.resources_filter_starred, descendingByDefault = true))
if (play.frequent) {
add(
SortOption(AlbumSort.Frequent.name, R.string.resources_sort_by_frequently_played, descendingByDefault = true),
)
}
if (play.recent) {
add(
SortOption(AlbumSort.Recent.name, R.string.resources_sort_by_recently_played, descendingByDefault = true),
)
}
}
}
LibraryTab.Artists -> {
buildList {
add(SortOption(ArtistSort.Name.name, R.string.resources_sort_by_name))
add(SortOption(ArtistSort.AlbumCount.name, R.string.resources_album_name, descendingByDefault = true))
add(SortOption(ArtistSort.Starred.name, R.string.resources_filter_starred, descendingByDefault = true))
if (play.frequent) {
add(
SortOption(ArtistSort.Frequent.name, R.string.resources_sort_by_frequently_played, descendingByDefault = true),
)
}
if (play.recent) {
add(
SortOption(ArtistSort.Recent.name, R.string.resources_sort_by_recently_played, descendingByDefault = true),
)
}
}
}
LibraryTab.Playlists -> {
listOf(
SortOption(PlaylistSort.Name.name, R.string.resources_sort_by_name),
SortOption(PlaylistSort.Added.name, R.string.resources_sort_by_added, descendingByDefault = true),
SortOption(PlaylistSort.Updated.name, R.string.resources_sort_by_updated, descendingByDefault = true),
)
}
LibraryTab.Home -> {
emptyList()
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LibraryRoute(
onAlbumClick: (Album) -> Unit,
onArtistClick: (Artist) -> Unit,
onPlaylistClick: (Playlist) -> Unit,
onOpenSettings: () -> Unit,
onOpenDownloads: () -> Unit = {},
onOpenSearch: () -> Unit = {},
onViewAlbum: (String) -> Unit,
onViewArtist: (String) -> Unit,
onHomeMore: (HomeSection) -> Unit = {},
onHomeDownloaded: (OfflineListKind) -> Unit = {},
onGenreClick: (String) -> Unit = {},
onDecadeClick: (Long) -> Unit = {},
contextMenuHost: ContextMenuHost? = null,
setStar: (StarType, String, Boolean) -> Unit,
bottomInset: Dp,
viewModel: LibraryViewModel = koinViewModel(),
playbackController: PlaybackController = koinInject(),
) {
// Wait for the stored tab before composing: the pager latches its initial page, so starting on
// the default and scrolling to the stored one would flash and, worse, report the default back.
val selectedTab = viewModel.selectedTab.collectAsStateWithLifecycle().value ?: return
var previousTabName by remember { mutableStateOf(selectedTab.name) }
val previousTab = LibraryTab.entries.firstOrNull { it.name == previousTabName } ?: LibraryTab.Albums
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
val refreshing by viewModel.refreshing.collectAsStateWithLifecycle()
val listTab = selectedTab.listTab()
LaunchedEffect(selectedTab) {
if (previousTab != selectedTab) {
previousTab.listTab()?.let { viewModel.setSearch(it, "") }
previousTabName = selectedTab.name
}
}
val listQueryState = listTab?.let { viewModel.listQuery(it).collectAsStateWithLifecycle() }
val listQuery = listQueryState?.value ?: ListQuery("")
val offline by viewModel.offline.collectAsStateWithLifecycle()
val downloadProgress by viewModel.downloadProgress.collectAsStateWithLifecycle()
val playSort by viewModel.playSortAvailability.collectAsStateWithLifecycle()
val displayQuery = if (offline) listQuery.copy(downloaded = true) else listQuery
val searchState = listTab?.let { viewModel.search(it).collectAsStateWithLifecycle() }
val search = searchState?.value.orEmpty()
val resetKeys =
LibraryTab.entries
.mapNotNull { tab ->
val tabList = tab.listTab() ?: return@mapNotNull null
val query by viewModel.listQuery(tabList).collectAsStateWithLifecycle()
val term by viewModel.search(tabList).collectAsStateWithLifecycle()
tab to "${query.sort}|${query.descending}|${query.starred}|${query.downloaded || offline}|$term"
}.toMap()
val playback by playbackController.state.collectAsStateWithLifecycle()
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true), fallbackName = playback.item?.title)
val downloadsState = listTab?.let { viewModel.downloads(it).collectAsStateWithLifecycle() }
val downloads = downloadsState?.value ?: emptyMap()
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
val itemActions =
ItemActions(
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
bulkDownload = { target, action ->
target.bulkRef()?.let { (list, refId) ->
if (action == BulkDownloadAction.Delete) {
scope.launch {
pendingDelete =
PendingDownloadDelete(target.title, list, refId, viewModel.downloadedBytes(list, refId))
}
} else {
viewModel.onDownloadAction(list, refId, action)
}
}
},
setStar = setStar,
viewAlbum = onViewAlbum,
viewArtist = onViewArtist,
)
LibraryScreen(
selectedTab = selectedTab,
onTabSelected = { tab -> if (viewModel.selectedTab.value != tab) viewModel.selectTab(tab) },
syncing = syncing,
refreshing = refreshing,
bottomInset = bottomInset,
artwork = artwork,
albums = viewModel.albums,
artists = viewModel.artists,
playlists = viewModel.playlists,
coverArt = viewModel::coverArt,
artistArt = { viewModel.artistArt(it) },
onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick,
onPlaylistClick = onPlaylistClick,
onSync = viewModel::sync,
onOpenSettings = onOpenSettings,
onOpenDownloads = onOpenDownloads,
onOpenSearch = onOpenSearch,
onItemLongClick = {
focusManager.clearFocus()
contextMenuHost?.show(it, itemActions)
},
listQuery = displayQuery,
resetKeys = resetKeys,
offline = offline,
onExitOffline = { viewModel.setOffline(false) },
downloadProgress = downloadProgress,
sortOptions = sortOptionsFor(selectedTab, playSort),
starredSupported = listTab?.supportsStarred == true,
onSortChange = { sort -> listTab?.let { viewModel.setListQuery(it, listQuery.copy(sort = sort)) } },
onToggleSortDirection = {
listTab?.let { viewModel.setListQuery(it, listQuery.copy(descending = !listQuery.descending)) }
},
onCycleStarred = {
val next =
when (listQuery.starred) {
StarredFilter.Any -> StarredFilter.Starred
StarredFilter.Starred -> StarredFilter.NotStarred
StarredFilter.NotStarred -> StarredFilter.Any
}
listTab?.let { viewModel.setListQuery(it, listQuery.copy(starred = next)) }
},
onClearFilters = {
listTab?.let {
viewModel.setListQuery(it, listQuery.copy(starred = StarredFilter.Any, downloaded = false))
viewModel.setSearch(it, "")
}
},
search = search,
onSearchChange = { value -> listTab?.let { viewModel.setSearch(it, value) } },
onToggleDownloaded = {
listTab?.let { viewModel.setListQuery(it, listQuery.copy(downloaded = !listQuery.downloaded)) }
},
albumDownloads = downloads.takeIf { listTab == LibraryListTab.Albums }.orEmpty(),
artistDownloads = downloads.takeIf { listTab == LibraryListTab.Artists }.orEmpty(),
playlistDownloads = downloads.takeIf { listTab == LibraryListTab.Playlists }.orEmpty(),
dismissals = contextMenuHost?.dismissals ?: emptyFlow(),
homeContent = { topInset, bottomInset ->
HomeRoute(
onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick,
onPlaylistClick = onPlaylistClick,
onViewAlbum = onViewAlbum,
onViewArtist = onViewArtist,
onMore = onHomeMore,
onDownloadedMore = onHomeDownloaded,
onGenreClick = onGenreClick,
onDecadeClick = onDecadeClick,
contextMenuHost = contextMenuHost,
setStar = setStar,
topInset = topInset,
bottomInset = bottomInset,
)
},
)
pendingDelete?.let { pending ->
DeleteDownloadsDialog(
name = pending.name,
bytes = pending.bytes,
onConfirm = { viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) },
onDismiss = { pendingDelete = null },
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LibraryScreen(
selectedTab: LibraryTab,
onTabSelected: (LibraryTab) -> Unit,
albums: Flow<PagingData<Album>>,
artists: Flow<PagingData<Artist>>,
playlists: Flow<PagingData<Playlist>>,
coverArt: (String?, Boolean) -> CoverArtRef?,
onAlbumClick: (Album) -> Unit,
onArtistClick: (Artist) -> Unit,
onPlaylistClick: (Playlist) -> Unit,
onSync: () -> Unit,
onOpenSettings: () -> Unit,
modifier: Modifier = Modifier,
onOpenDownloads: () -> Unit = {},
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
onOpenSearch: () -> Unit = {},
offline: Boolean = false,
onExitOffline: () -> Unit = {},
downloadProgress: DownloadProgress? = null,
onItemLongClick: (MenuTarget) -> Unit = {},
syncing: Boolean = false,
refreshing: Boolean = false,
bottomInset: Dp = 0.dp,
listQuery: ListQuery = ListQuery(""),
resetKeys: Map<LibraryTab, Any?> = emptyMap(),
sortOptions: List<SortOption> = emptyList(),
starredSupported: Boolean = false,
onSortChange: (String) -> Unit = {},
onToggleSortDirection: () -> Unit = {},
onCycleStarred: () -> Unit = {},
onToggleDownloaded: () -> Unit = {},
onClearFilters: () -> Unit = {},
search: String = "",
onSearchChange: (String) -> Unit = {},
artwork: ArtworkColors? = null,
albumDownloads: Map<String, ListDownloadStatus> = emptyMap(),
artistDownloads: Map<String, ListDownloadStatus> = emptyMap(),
playlistDownloads: Map<String, ListDownloadStatus> = emptyMap(),
dismissals: Flow<Unit> = emptyFlow(),
homeContent: @Composable (topInset: Dp, bottomInset: Dp) -> Unit = { _, _ -> },
) {
val pagerState =
rememberPagerState(
initialPage = selectedTab.ordinal,
pageCount = { LibraryTab.entries.size },
)
var showOptions by rememberSaveable { mutableStateOf(false) }
var searchActive by rememberSaveable { mutableStateOf(false) }
var topRequest by remember { mutableIntStateOf(0) }
val searchFocus = remember { FocusRequester() }
val closeSearch = {
searchActive = false
onSearchChange("")
}
// Search mode is left open so the typed query survives; MainNavigation dismisses the keyboard.
DismissOnRequest(dismissals) { showOptions = false }
BackHandler(enabled = searchActive, onBack = closeSearch)
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage }.collect { page ->
onTabSelected(LibraryTab.entries[page])
}
}
LaunchedEffect(selectedTab) {
if (pagerState.currentPage != selectedTab.ordinal) {
pagerState.animateScrollToPage(selectedTab.ordinal)
}
if (selectedTab == LibraryTab.Home) {
searchActive = false
} else if (search.isNotEmpty()) {
searchActive = true
}
}
val density = LocalDensity.current
val focusManager = LocalFocusManager.current
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
val filtersActive = listQuery.starred != StarredFilter.Any || listQuery.downloaded || search.isNotEmpty()
var tabBarHeightPx by remember { mutableFloatStateOf(with(density) { (TAB_BAR_CONTENT_HEIGHT + bottomInset).toPx() }) }
val tabBarHeight = with(density) { tabBarHeightPx.toDp() }
val listTopInset = statusBarTop
val listBottomInset = tabBarHeight + BOTTOM_CLEARANCE
val headerColor = artwork?.let(::playerSurfaceColor) ?: MaterialTheme.colorScheme.background
val listOptionsLabel = stringResource(R.string.list_options)
Box(
modifier =
modifier
.fillMaxSize()
.background(artwork?.let(::librarySurfaceColor) ?: MaterialTheme.colorScheme.background),
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
beyondViewportPageCount = 1,
) { page ->
val pageTab = LibraryTab.entries[page]
val resetKey = resetKeys[pageTab]
val scrollToTopKey = if (pageTab == selectedTab) topRequest else null
val pullToRefreshState = rememberPullToRefreshState()
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = onSync,
state = pullToRefreshState,
indicator = {
PullToRefreshDefaults.Indicator(
state = pullToRefreshState,
isRefreshing = refreshing,
modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding(),
containerColor = headerColor,
color = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.primary,
)
},
modifier = Modifier.fillMaxSize(),
) {
when (pageTab) {
LibraryTab.Home -> {
homeContent(listTopInset, listBottomInset)
}
LibraryTab.Albums -> {
AlbumsContent(
albums.collectAsLazyPagingItems(),
coverArt,
listBottomInset,
{
focusManager.clearFocus()
onAlbumClick(it)
},
onLongClick = onItemLongClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
topInset = listTopInset,
onSync = onSync,
downloadStatuses = albumDownloads,
scrollToTopKey = scrollToTopKey,
)
}
LibraryTab.Artists -> {
ArtistsContent(
artists.collectAsLazyPagingItems(),
coverArt,
listBottomInset,
{
focusManager.clearFocus()
onArtistClick(it)
},
onLongClick = onItemLongClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
topInset = listTopInset,
onSync = onSync,
downloadStatuses = artistDownloads,
scrollToTopKey = scrollToTopKey,
artistArt = artistArt,
)
}
LibraryTab.Playlists -> {
PlaylistsContent(
playlists.collectAsLazyPagingItems(),
coverArt,
listBottomInset,
{
focusManager.clearFocus()
onPlaylistClick(it)
},
onLongClick = onItemLongClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
topInset = listTopInset,
onSync = onSync,
downloadStatuses = playlistDownloads,
scrollToTopKey = scrollToTopKey,
)
}
}
}
}
Spacer(
modifier =
Modifier
.align(Alignment.TopStart)
.statusBarScrim(),
)
if (!searchActive) {
Column(
modifier =
Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.background(headerColor)
.onSizeChanged { tabBarHeightPx = it.height.toFloat() }
.padding(bottom = bottomInset),
) {
LibraryTabs(
pagerState = pagerState,
onTabSelected = { tab ->
if (tab == selectedTab) topRequest++ else onTabSelected(tab)
},
onOpenSettings = onOpenSettings,
offline = offline,
onExitOffline = onExitOffline,
downloadProgress = downloadProgress,
onOpenDownloads = onOpenDownloads,
artwork = artwork,
syncing = syncing,
)
}
}
if (searchActive) {
SearchField(
value = search,
onValueChange = onSearchChange,
onClose = closeSearch,
focusRequester = searchFocus,
modifier =
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
if (!searchActive) {
val searchFab = selectedTab == LibraryTab.Home
FloatingActionButton(
onClick = { if (searchFab) onOpenSearch() else showOptions = true },
containerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.onBackground,
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 12.dp),
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = tabBarHeight + 16.dp),
) {
if (searchFab) {
Icon(Icons.Rounded.Search, contentDescription = stringResource(R.string.search))
} else {
Box(Modifier.size(28.dp), contentAlignment = Alignment.Center) {
ListOptionsGlyph(
modifier =
Modifier
.size(24.dp)
.semantics { contentDescription = listOptionsLabel },
)
if (filtersActive) {
Box(
modifier =
Modifier
.align(Alignment.TopEnd)
.offset(x = 4.dp, y = (-4).dp)
.size(8.dp)
.clip(CircleShape)
.background(artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.primary),
)
}
}
}
}
}
}
if (showOptions) {
ModalBottomSheet(
onDismissRequest = { showOptions = false },
dragHandle = null,
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
ListOptionsSheet(
listQuery = listQuery,
sortOptions = sortOptions,
starredSupported = starredSupported,
canClearFilters = filtersActive,
onSortChange = onSortChange,
onToggleSortDirection = onToggleSortDirection,
onCycleStarred = onCycleStarred,
onToggleDownloaded = onToggleDownloaded,
onClearFilters = onClearFilters,
onSearch = {
showOptions = false
searchActive = true
},
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ListOptionsSheet(
listQuery: ListQuery,
sortOptions: List<SortOption>,
starredSupported: Boolean,
canClearFilters: Boolean,
onSortChange: (String) -> Unit,
onToggleSortDirection: () -> Unit,
onCycleStarred: () -> Unit,
onToggleDownloaded: () -> Unit,
onClearFilters: () -> Unit,
onSearch: () -> Unit,
) {
Column(Modifier.fillMaxWidth().padding(bottom = 24.dp)) {
val naturalDescending = sortOptions.firstOrNull { it.value == listQuery.sort }?.descendingByDefault == true
val descending = if (naturalDescending) !listQuery.descending else listQuery.descending
Surface(
onClick = onSearch,
shape = RoundedCornerShape(8.dp),
color = Color.Transparent,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 12.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
) {
Icon(Icons.Rounded.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.search_this_list), color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 12.dp),
) {
Text(
text = stringResource(R.string.sort_by),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onToggleSortDirection) {
Icon(
imageVector = if (descending) Icons.Rounded.ArrowDownward else Icons.Rounded.ArrowUpward,
contentDescription =
if (descending) {
stringResource(
R.string.sort_descending,
)
} else {
stringResource(R.string.sort_ascending)
},
)
}
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
sortOptions.forEach { option ->
FilterChip(
selected = listQuery.sort == option.value,
onClick = {
if (listQuery.sort == option.value) onToggleSortDirection() else onSortChange(option.value)
},
label = { Text(stringResource(option.label)) },
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 16.dp),
) {
Text(
text = stringResource(R.string.filters),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = onClearFilters,
enabled = canClearFilters,
) {
Icon(Icons.Rounded.FilterAltOff, contentDescription = stringResource(R.string.components_clear_filters))
}
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
FilterChip(
selected = listQuery.downloaded,
onClick = onToggleDownloaded,
label = { Text(stringResource(R.string.filter_downloaded)) },
leadingIcon =
if (listQuery.downloaded) {
{
Icon(
Icons.Rounded.DownloadDone,
contentDescription = null,
modifier = Modifier.size(FilterChipDefaults.IconSize),
)
}
} else {
null
},
)
if (starredSupported) {
FilterChip(
selected = listQuery.starred != StarredFilter.Any,
onClick = onCycleStarred,
label = { Text(stringResource(R.string.resources_filter_starred)) },
leadingIcon =
when (listQuery.starred) {
StarredFilter.Any -> {
null
}
StarredFilter.Starred -> {
{
Icon(
Icons.Rounded.Add,
contentDescription = null,
modifier = Modifier.size(FilterChipDefaults.IconSize),
)
}
}
StarredFilter.NotStarred -> {
{
Icon(
Icons.Rounded.Remove,
contentDescription = null,
modifier = Modifier.size(FilterChipDefaults.IconSize),
)
}
}
},
)
}
}
}
}
private val BOTTOM_CLEARANCE = 80.dp
private val TAB_BAR_CONTENT_HEIGHT = 52.dp
private val TAB_ICON_SIZE = 24.dp
private val TAB_VERTICAL_PADDING = 6.dp
private val TOUCH_TARGET = 48.dp
private const val LIST_OPTIONS_WEIGHT = 0.11f
private val LIST_OPTIONS_BAR_WIDTHS = listOf(1f, 0.62f, 0.34f)
@Composable
private fun ListOptionsGlyph(modifier: Modifier = Modifier) {
val color = LocalContentColor.current
Canvas(modifier) {
val thickness = size.height * LIST_OPTIONS_WEIGHT
val gap = size.height / (LIST_OPTIONS_BAR_WIDTHS.size + 1)
LIST_OPTIONS_BAR_WIDTHS.forEachIndexed { index, widthFraction ->
val y = gap * (index + 1)
drawLine(
color = color,
start = Offset(0f, y),
end = Offset(size.width * widthFraction, y),
strokeWidth = thickness,
cap = StrokeCap.Round,
)
}
}
}
@Suppress("FrequentlyChangingValue")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun LibraryTabs(
pagerState: PagerState,
onTabSelected: (LibraryTab) -> Unit,
onOpenSettings: () -> Unit,
offline: Boolean = false,
onExitOffline: () -> Unit = {},
downloadProgress: DownloadProgress? = null,
onOpenDownloads: () -> Unit = {},
artwork: ArtworkColors?,
syncing: Boolean = false,
) {
val indicatorStretch = 18.dp
val density = LocalDensity.current
val bounds = remember { mutableStateMapOf<Int, Rect>() }
val page = pagerState.currentPage
val fraction = pagerState.currentPageOffsetFraction
val raw = abs(fraction).coerceIn(0f, 1f)
val transition = raw * raw * (3f - 2f * raw)
var travel by remember { mutableIntStateOf(1) }
LaunchedEffect(Unit) {
var previous = pagerState.currentPage + pagerState.currentPageOffsetFraction
snapshotFlow { pagerState.currentPage + pagerState.currentPageOffsetFraction }.collect { current ->
if (current != previous) {
travel = if (current > previous) 1 else -1
previous = current
}
}
}
val current = bounds[page]
val neighbour = current?.let { bounds[if (fraction >= 0f) page + 1 else page - 1] }
val step = if (neighbour != null) transition else 0f
val stretch = with(density) { indicatorStretch.toPx() } * step
val leading = stretch * 2f
val trailing = stretch * 0.25f
val leftExtra = if (travel > 0) trailing else leading
val rightExtra = if (travel > 0) leading else trailing
val indicatorLeft = current?.let { it.left + ((neighbour?.left ?: it.left) - it.left) * step - leftExtra }
val indicatorRight = current?.let { it.right + ((neighbour?.right ?: it.right) - it.right) * step + rightExtra }
val headerColor = artwork?.let(::playerSurfaceColor) ?: MaterialTheme.colorScheme.background
val indicatorColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.onBackground
val tabContentHeightPx = with(density) { (TAB_ICON_SIZE + TAB_VERTICAL_PADDING * 2).toPx() }
Box(Modifier.fillMaxWidth()) {
Box(
modifier =
Modifier
.fillMaxWidth()
.background(headerColor)
.padding(horizontal = 8.dp, vertical = 2.dp),
) {
Box(
modifier =
Modifier.drawBehind {
if (current != null && indicatorLeft != null && indicatorRight != null && indicatorRight > indicatorLeft) {
val height = tabContentHeightPx.coerceAtMost(current.height)
drawRoundRect(
color = indicatorColor,
topLeft = Offset(indicatorLeft, current.top + (current.height - height) / 2f),
size = Size(indicatorRight - indicatorLeft, height),
cornerRadius = CornerRadius(8.dp.toPx()),
)
}
},
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
LibraryTab.entries.forEachIndexed { index, tab ->
TabButton(
tab = tab,
artwork = artwork,
progress =
when (index) {
page -> 1f - transition
page + (if (fraction >= 0f) 1 else -1) -> transition
else -> 0f
},
indicatorLeft = indicatorLeft,
indicatorRight = indicatorRight,
onClick = { onTabSelected(tab) },
modifier =
Modifier.onGloballyPositioned { coordinates ->
bounds[index] = coordinates.boundsInParent()
},
)
}
Spacer(Modifier.weight(1f))
if (downloadProgress != null) {
IconButton(onClick = onOpenDownloads) {
DownloadProgressIcon(
progress = downloadProgress,
color = MaterialTheme.colorScheme.primary,
)
}
} else if (offline) {
IconButton(onClick = onExitOffline) {
Icon(
imageVector = Icons.Rounded.CloudOff,
contentDescription = stringResource(R.string.offline_mode_status),
tint = MaterialTheme.colorScheme.onBackground,
)
}
}
IconButton(onClick = onOpenSettings) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = stringResource(R.string.navigation_tabs_settings),
tint = MaterialTheme.colorScheme.onBackground,
)
if (syncing) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
}
}
}
@Composable
private fun DownloadProgressIcon(
progress: DownloadProgress,
color: Color,
) {
val description = stringResource(R.string.downloads_in_progress)
Box(
modifier = Modifier.size(28.dp).semantics { contentDescription = description },
contentAlignment = Alignment.Center,
) {
val fraction = progress.fraction
if (fraction != null) {
CircularProgressIndicator(
progress = { fraction },
color = color,
trackColor = MaterialTheme.colorScheme.surfaceContainerHighest,
strokeWidth = 2.5.dp,
modifier = Modifier.fillMaxSize(),
)
} else {
CircularProgressIndicator(
color = color,
strokeWidth = 2.5.dp,
modifier = Modifier.fillMaxSize(),
)
}
Icon(
imageVector = Icons.Rounded.Download,
contentDescription = null,
tint = color,
modifier = Modifier.size(16.dp),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TabButton(
tab: LibraryTab,
artwork: ArtworkColors?,
progress: Float,
indicatorLeft: Float?,
indicatorRight: Float?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val selectedContent = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.background
val paddingStart = with(LocalDensity.current) { 8.dp.toPx() }
var tabLeft by remember { mutableFloatStateOf(0f) }
val clipStart = indicatorLeft?.minus(tabLeft + paddingStart)
val clipEnd = indicatorRight?.minus(tabLeft + paddingStart)
Box(
modifier =
modifier
.onGloballyPositioned { tabLeft = it.boundsInParent().left }
.clickable(onClick = onClick)
.height(TOUCH_TARGET),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier.padding(start = 8.dp, end = 16.dp, top = TAB_VERTICAL_PADDING, bottom = TAB_VERTICAL_PADDING),
) {
if (clipStart != null && clipEnd != null) {
Box(
modifier =
Modifier.drawWithContent {
val hole =
Path().apply {
addRect(Rect(clipStart, 0f, clipEnd, size.height))
}
clipPath(hole, clipOp = ClipOp.Difference) {
this@drawWithContent.drawContent()
}
},
) {
TabContent(tab, progress, MaterialTheme.colorScheme.onBackground)
}
Box(
modifier =
Modifier
.clearAndSetSemantics {}
.drawWithContent {
clipRect(clipStart, 0f, clipEnd, size.height) {
this@drawWithContent.drawContent()
}
},
) {
TabContent(tab, progress, selectedContent)
}
} else {
TabContent(tab, progress, MaterialTheme.colorScheme.onBackground)
}
}
}
}
@Composable
private fun TabContent(
tab: LibraryTab,
progress: Float,
color: Color,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = tab.icon,
contentDescription = stringResource(tab.label),
tint = color,
modifier =
Modifier.size(TAB_ICON_SIZE).graphicsLayer {
if (tab == LibraryTab.Playlists) {
scaleX = 1.12f
scaleY = 1.12f
translationX = 1.5.dp.toPx()
}
},
)
Box(
modifier =
Modifier
.clipToBounds()
.clearAndSetSemantics {}
.layout { measurable, _ ->
val placeable = measurable.measure(Constraints())
val width = (placeable.width * progress).roundToInt()
layout(width, placeable.height) {
placeable.place(0, 0)
}
},
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(tab.label),
style = MaterialTheme.typography.titleSmall,
color = color,
maxLines = 1,
softWrap = false,
)
}
}
}
}