mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
1136 lines
48 KiB
Kotlin
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,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|