diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index f885733e..7fdbfbc8 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -289,7 +289,7 @@ private fun MainNavigation() { MiniPlayer( state = playback, coverArt = miniArt, - artwork = rememberArtworkColors(miniArt), + artwork = artwork, onExpand = { settleNowPlaying(true) }, onPlayPause = playbackController::togglePlayPause, onNext = playbackController::next, diff --git a/app/src/main/kotlin/com/subtracks/ui/components/Components.kt b/app/src/main/kotlin/com/subtracks/ui/components/Components.kt index b3b1a252..6b6ea5f9 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/Components.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/Components.kt @@ -172,17 +172,29 @@ fun LoadingState(modifier: Modifier = Modifier) { fun EmptyState( text: String, modifier: Modifier = Modifier, + actionLabel: String? = null, + onAction: (() -> Unit)? = null, ) { Box( modifier = modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center, ) { - Text( - text = text, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + if (actionLabel != null && onAction != null) { + Button(onClick = onAction) { + Text(actionLabel) + } + } + } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt index 84a284f0..05597751 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt @@ -44,6 +44,7 @@ fun AlbumsContent( onClearFilters: () -> Unit = {}, resetKey: Any? = null, topInset: Dp = 0.dp, + onSync: () -> Unit = {}, modifier: Modifier = Modifier, ) { when { @@ -55,7 +56,12 @@ fun AlbumsContent( if (filtered) { FilteredEmptyState(onClearFilters, modifier) } else { - EmptyState("No albums yet.\nSync with your server to fill your library.", modifier) + EmptyState( + text = "No albums yet.\nSync with your server to fill your library.", + modifier = modifier, + actionLabel = "Sync", + onAction = onSync, + ) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt index c2f17400..052a01d1 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt @@ -42,6 +42,7 @@ fun ArtistsContent( onClearFilters: () -> Unit = {}, resetKey: Any? = null, topInset: Dp = 0.dp, + onSync: () -> Unit = {}, modifier: Modifier = Modifier, ) { when { @@ -53,7 +54,7 @@ fun ArtistsContent( if (filtered) { FilteredEmptyState(onClearFilters, modifier) } else { - EmptyState("No artists yet.", modifier) + EmptyState("No artists yet.", modifier, actionLabel = "Sync", onAction = onSync) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index 1b2323dd..93d64f7f 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -13,11 +13,11 @@ 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.imePadding 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 @@ -90,6 +90,7 @@ 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.layout.positionInRoot import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.Constraints @@ -302,16 +303,16 @@ fun LibraryScreen( if (pagerState.currentPage != selectedTab.ordinal) { pagerState.animateScrollToPage(selectedTab.ordinal) } - searchActive = search.isNotEmpty() + if (search.isNotEmpty()) searchActive = true } val density = LocalDensity.current val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() } val filtersActive = listQuery.starred != StarredFilter.Any || search.isNotEmpty() - var tabBarHeightPx by remember { mutableFloatStateOf(0f) } + 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 + if (searchActive) SEARCH_BAR_CLEARANCE else FAB_CLEARANCE + val listBottomInset = tabBarHeight + BOTTOM_CLEARANCE val headerColor = artwork?.let(::playerSurfaceColor) ?: MaterialTheme.colorScheme.background Box( @@ -335,7 +336,7 @@ fun LibraryScreen( PullToRefreshDefaults.Indicator( state = pullToRefreshState, isRefreshing = syncing, - modifier = Modifier.align(Alignment.TopCenter), + modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding(), containerColor = headerColor, color = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.primary, ) @@ -353,6 +354,7 @@ fun LibraryScreen( onClearFilters = onClearFilters, resetKey = resetKey, topInset = listTopInset, + onSync = onSync, ) } @@ -366,6 +368,7 @@ fun LibraryScreen( onClearFilters = onClearFilters, resetKey = resetKey, topInset = listTopInset, + onSync = onSync, ) } @@ -380,6 +383,7 @@ fun LibraryScreen( onClearFilters = onClearFilters, resetKey = resetKey, topInset = listTopInset, + onSync = onSync, ) } @@ -393,6 +397,7 @@ fun LibraryScreen( onClearFilters = onClearFilters, resetKey = resetKey, topInset = listTopInset, + onSync = onSync, ) } } @@ -444,7 +449,6 @@ fun LibraryScreen( Modifier .align(Alignment.BottomCenter) .fillMaxWidth() - .imePadding() .padding(horizontal = 12.dp, vertical = 8.dp), ) } @@ -618,8 +622,8 @@ private fun ListOptionsSheet( } } -private val FAB_CLEARANCE = 80.dp -private val SEARCH_BAR_CLEARANCE = 80.dp +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 @@ -768,47 +772,53 @@ private fun TabButton( 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) + var tabRootX by remember { mutableFloatStateOf(0f) } + var contentRootX by remember { mutableFloatStateOf(0f) } + val contentLeft = tabLeft + (contentRootX - tabRootX) + val clipStart = indicatorLeft?.minus(contentLeft) + val clipEnd = indicatorRight?.minus(contentLeft) Box( modifier = modifier - .onGloballyPositioned { tabLeft = it.boundsInParent().left } - .clickable(onClick = onClick) + .onGloballyPositioned { + tabLeft = it.boundsInParent().left + tabRootX = it.positionInRoot().x + }.clickable(onClick = onClick) .minimumInteractiveComponentSize() .padding(start = 8.dp, end = 12.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) { + Box(Modifier.onGloballyPositioned { contentRootX = it.positionInRoot().x }) { + 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, selectedContent) + ) { + 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) } - } else { - TabContent(tab, progress, MaterialTheme.colorScheme.onBackground) } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt index 45753303..cb32733a 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt @@ -41,6 +41,7 @@ fun PlaylistsContent( onClearFilters: () -> Unit = {}, resetKey: Any? = null, topInset: Dp = 0.dp, + onSync: () -> Unit = {}, modifier: Modifier = Modifier, ) { when { @@ -52,7 +53,7 @@ fun PlaylistsContent( if (filtered) { FilteredEmptyState(onClearFilters, modifier) } else { - EmptyState("No playlists yet.", modifier) + EmptyState("No playlists yet.", modifier, actionLabel = "Sync", onAction = onSync) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt index b0b862da..8a018262 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt @@ -53,6 +53,7 @@ fun SongsContent( onClearFilters: () -> Unit = {}, resetKey: Any? = null, topInset: Dp = 0.dp, + onSync: () -> Unit = {}, modifier: Modifier = Modifier, ) { when { @@ -64,7 +65,7 @@ fun SongsContent( if (filtered) { FilteredEmptyState(onClearFilters, modifier) } else { - EmptyState("No songs yet.", modifier) + EmptyState("No songs yet.", modifier, actionLabel = "Sync", onAction = onSync) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt index 915daa3f..d2daa890 100644 --- a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt +++ b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt @@ -182,12 +182,13 @@ private fun blendHue( fun rememberArtworkColors( ref: CoverArtRef?, durationMillis: Int = ARTWORK_THEME_TRANSITION_MS, -): ArtworkColors { +): ArtworkColors? { val cached = remember(ref?.cacheKey) { ref?.cacheKey?.let(ArtworkSeedCache::cached) } val seeds by rememberArtworkSeed(ref) val effective = seeds ?: cached val target = remember(effective) { effective?.let { (primary, secondary) -> artworkColorsFromSeeds(primary, secondary) } } - return rememberAnimatedArtworkColors(target, durationMillis) + val colors = rememberAnimatedArtworkColors(target, durationMillis) + return if (target == null) null else colors } @Composable diff --git a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt index 87db599f..5709cf9b 100644 --- a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt @@ -147,49 +147,18 @@ class AppScreenshotTest { } @Test - fun libraryWithMiniPlayer() { - composeRule.setContent { - SubtracksTheme { - val artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)) - Column(Modifier.fillMaxSize()) { - LibraryScreen( - selectedTab = LibraryTab.Albums, - onTabSelected = {}, - albums = remember { flowOf(PagingData.from(Fixtures.albums)) }.collectAsLazyPagingItems(), - artists = remember { flowOf(PagingData.from(Fixtures.artists)) }.collectAsLazyPagingItems(), - songs = remember { flowOf(PagingData.from(Fixtures.songItems)) }.collectAsLazyPagingItems(), - playlists = remember { flowOf(PagingData.from(Fixtures.playlists)) }.collectAsLazyPagingItems(), - coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } }, - onAlbumClick = {}, - onArtistClick = {}, - onPlaylistClick = {}, - onSongClick = {}, - onSync = {}, - onOpenSettings = {}, - artwork = artwork, - modifier = Modifier.weight(1f), - ) - MiniPlayer( - state = Fixtures.playbackState(), - coverArt = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"), - artwork = artwork, - onExpand = {}, - onPlayPause = {}, - onNext = {}, - progressInset = true, - ) - } - } - } - awaitTag(ALBUM_COVER_TAG) - composeRule.onRoot().captureRoboImage("src/test/screenshots/library_with_mini_player.png") - } + fun libraryWithMiniPlayer() = libraryWithMiniPlayerShot(Color.rgb(120, 80, 200), "library_with_mini_player.png") @Test - fun libraryWithMiniPlayerWarm() { + fun libraryWithMiniPlayerWarm() = libraryWithMiniPlayerShot(Color.rgb(222, 150, 30), "library_with_mini_player_warm.png") + + private fun libraryWithMiniPlayerShot( + seed: Int, + file: String, + ) { composeRule.setContent { SubtracksTheme { - val artwork = artworkColorsFromSeed(Color.rgb(222, 150, 30)) + val artwork = artworkColorsFromSeed(seed) Column(Modifier.fillMaxSize()) { LibraryScreen( selectedTab = LibraryTab.Albums, @@ -221,7 +190,7 @@ class AppScreenshotTest { } } awaitTag(ALBUM_COVER_TAG) - composeRule.onRoot().captureRoboImage("src/test/screenshots/library_with_mini_player_warm.png") + composeRule.onRoot().captureRoboImage("src/test/screenshots/$file") } @Test diff --git a/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt b/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt index 3af64cb0..bb56b81f 100644 --- a/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt @@ -4,11 +4,15 @@ import androidx.activity.ComponentActivity import androidx.compose.runtime.remember import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeDown +import androidx.paging.LoadState +import androidx.paging.LoadStates import androidx.paging.PagingData import androidx.paging.compose.collectAsLazyPagingItems import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -72,6 +76,44 @@ class LibraryScreenTest { assertTrue("the settings control should open settings", settingsOpened) } + @Test + fun anEmptyTabOffersSync() { + var synced = false + composeRule.setContent { + SubtracksTheme { + LibraryScreen( + selectedTab = LibraryTab.Albums, + onTabSelected = {}, + albums = remember { flowOf(PagingData.empty(settledLoadStates)) }.collectAsLazyPagingItems(), + artists = remember { flowOf(PagingData.empty()) }.collectAsLazyPagingItems(), + songs = remember { flowOf(PagingData.empty()) }.collectAsLazyPagingItems(), + playlists = remember { flowOf(PagingData.empty()) }.collectAsLazyPagingItems(), + coverArt = { _, _ -> null }, + onAlbumClick = {}, + onArtistClick = {}, + onPlaylistClick = {}, + onSongClick = {}, + onSync = { synced = true }, + onOpenSettings = {}, + ) + } + } + + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodesWithText("Sync").fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNodeWithText("Sync").performClick() + + assertTrue("an empty library should offer a sync", synced) + } + + private val settledLoadStates = + LoadStates( + refresh = LoadState.NotLoading(endOfPaginationReached = true), + prepend = LoadState.NotLoading(endOfPaginationReached = true), + append = LoadState.NotLoading(endOfPaginationReached = true), + ) + private fun albums() = (1..60).map { index -> Album(