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 = 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(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>, artists: Flow>, playlists: Flow>, 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 = emptyMap(), sortOptions: List = 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 = emptyMap(), artistDownloads: Map = emptyMap(), playlistDownloads: Map = emptyMap(), dismissals: Flow = 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, 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() } 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, ) } } } }