From e031e92e2d153a6ed08f7e4c52eb8f94602c233c Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:46:59 +0000 Subject: [PATCH] pull to refresh, hide the bar while searching, animate the progress inset, circular warm damping - replace the library sync button with pull-to-refresh on every tab - hide the tab bar while searching so only the search field sits above the keyboard - animate the mini player progress bar between full bleed and 8dp inset rounded ends as the tab bar comes and goes - make the warm-hue saturation damping continuous across the 0/360 hue wrap --- .../main/kotlin/com/subtracks/ui/AppRoot.kt | 9 + .../com/subtracks/ui/library/LibraryScreen.kt | 160 ++++++++---------- .../com/subtracks/ui/playback/MiniPlayer.kt | 20 ++- .../com/subtracks/ui/theme/ArtworkTheme.kt | 12 +- .../com/subtracks/ui/AppScreenshotTest.kt | 2 + .../com/subtracks/ui/LibraryScreenTest.kt | 37 +++- .../subtracks/ui/theme/ArtworkColorsTest.kt | 7 + 7 files changed, 155 insertions(+), 92 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index ebd8a51e..f885733e 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -48,6 +48,7 @@ import androidx.lifecycle.viewModelScope import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.subtracks.data.repo.SourceRepository @@ -122,6 +123,13 @@ fun SubtracksRoot(root: RootViewModel = koinViewModel()) { @Composable private fun MainNavigation() { val navController = rememberNavController() + val currentRoute = + navController + .currentBackStackEntryAsState() + .value + ?.destination + ?.route + val tabBarVisible = currentRoute == Routes.LIBRARY val playbackController = koinInject() val playback by playbackController.state.collectAsStateWithLifecycle() var showingQueue by rememberSaveable { mutableStateOf(false) } @@ -285,6 +293,7 @@ private fun MainNavigation() { onExpand = { settleNowPlaying(true) }, onPlayPause = playbackController::togglePlayPause, onNext = playbackController::next, + progressInset = tabBarVisible, onExpandDrag = { dragUpPx -> nowPlayingOpen = true settleJob?.cancel() 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 a502644e..a63086da 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -38,8 +38,6 @@ 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.material.icons.rounded.Sync -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults @@ -55,6 +53,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -310,7 +309,7 @@ fun LibraryScreen( var tabBarHeightPx by remember { mutableFloatStateOf(0f) } val tabBarHeight = with(density) { tabBarHeightPx.toDp() } val listTopInset = statusBarTop - val listBottomInset = tabBarHeight + if (searchActive) SEARCH_BAR_CLEARANCE else FAB_CLEARANCE + val listBottomInset = if (searchActive) SEARCH_BAR_CLEARANCE else tabBarHeight + FAB_CLEARANCE val headerColor = artwork?.let(::playerSurfaceColor) ?: MaterialTheme.colorScheme.background Box( @@ -325,58 +324,64 @@ fun LibraryScreen( ) { page -> val pageTab = LibraryTab.entries[page] val resetKey = resetKeys[pageTab] - when (pageTab) { - LibraryTab.Albums -> { - AlbumsContent( - albums, - coverArt, - listBottomInset, - onAlbumClick, - filtered = filtersActive, - onClearFilters = onClearFilters, - resetKey = resetKey, - topInset = listTopInset, - ) - } + PullToRefreshBox( + isRefreshing = syncing, + onRefresh = onSync, + modifier = Modifier.fillMaxSize(), + ) { + when (pageTab) { + LibraryTab.Albums -> { + AlbumsContent( + albums, + coverArt, + listBottomInset, + onAlbumClick, + filtered = filtersActive, + onClearFilters = onClearFilters, + resetKey = resetKey, + topInset = listTopInset, + ) + } - LibraryTab.Artists -> { - ArtistsContent( - artists, - coverArt, - listBottomInset, - onArtistClick, - filtered = filtersActive, - onClearFilters = onClearFilters, - resetKey = resetKey, - topInset = listTopInset, - ) - } + LibraryTab.Artists -> { + ArtistsContent( + artists, + coverArt, + listBottomInset, + onArtistClick, + filtered = filtersActive, + onClearFilters = onClearFilters, + resetKey = resetKey, + topInset = listTopInset, + ) + } - LibraryTab.Songs -> { - SongsContent( - songs, - coverArt, - listBottomInset, - onSongClick, - playingSongId, - filtered = filtersActive, - onClearFilters = onClearFilters, - resetKey = resetKey, - topInset = listTopInset, - ) - } + LibraryTab.Songs -> { + SongsContent( + songs, + coverArt, + listBottomInset, + onSongClick, + playingSongId, + filtered = filtersActive, + onClearFilters = onClearFilters, + resetKey = resetKey, + topInset = listTopInset, + ) + } - LibraryTab.Playlists -> { - PlaylistsContent( - playlists, - coverArt, - listBottomInset, - onPlaylistClick, - filtered = filtersActive, - onClearFilters = onClearFilters, - resetKey = resetKey, - topInset = listTopInset, - ) + LibraryTab.Playlists -> { + PlaylistsContent( + playlists, + coverArt, + listBottomInset, + onPlaylistClick, + filtered = filtersActive, + onClearFilters = onClearFilters, + resetKey = resetKey, + topInset = listTopInset, + ) + } } } } @@ -394,23 +399,23 @@ fun LibraryScreen( ), ) - Column( - modifier = - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .background(headerColor) - .onSizeChanged { tabBarHeightPx = it.height.toFloat() } - .padding(bottom = bottomInset), - ) { - LibraryTabs( - pagerState = pagerState, - onTabSelected = onTabSelected, - syncing = syncing, - onSync = onSync, - onOpenSettings = onOpenSettings, - artwork = artwork, - ) + if (!searchActive) { + Column( + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .background(headerColor) + .onSizeChanged { tabBarHeightPx = it.height.toFloat() } + .padding(bottom = bottomInset), + ) { + LibraryTabs( + pagerState = pagerState, + onTabSelected = onTabSelected, + onOpenSettings = onOpenSettings, + artwork = artwork, + ) + } } if (searchActive) { @@ -427,7 +432,7 @@ fun LibraryScreen( .align(Alignment.BottomCenter) .fillMaxWidth() .imePadding() - .padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = tabBarHeight + 8.dp), + .padding(horizontal = 12.dp, vertical = 8.dp), ) } @@ -642,8 +647,6 @@ private fun SearchField( private fun LibraryTabs( pagerState: PagerState, onTabSelected: (LibraryTab) -> Unit, - syncing: Boolean, - onSync: () -> Unit, onOpenSettings: () -> Unit, artwork: ArtworkColors?, ) { @@ -727,19 +730,6 @@ private fun LibraryTabs( ) } Spacer(Modifier.weight(1f)) - if (syncing) { - CircularProgressIndicator( - modifier = Modifier.padding(end = 4.dp).size(20.dp), - strokeWidth = 2.dp, - ) - } - IconButton(onClick = onSync) { - Icon( - imageVector = Icons.Rounded.Sync, - contentDescription = "Sync", - tint = MaterialTheme.colorScheme.onBackground, - ) - } IconButton(onClick = onOpenSettings) { Icon( imageVector = Icons.Rounded.Settings, diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/MiniPlayer.kt b/app/src/main/kotlin/com/subtracks/ui/playback/MiniPlayer.kt index a70f1e91..cceaae3f 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/MiniPlayer.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/MiniPlayer.kt @@ -1,5 +1,7 @@ package com.subtracks.ui.playback +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.clickable @@ -44,6 +46,9 @@ import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkTheme import com.subtracks.ui.theme.playerSurfaceColor +private val PROGRESS_INSET = 8.dp +private const val PROGRESS_INSET_MS = 260 + @Composable fun MiniPlayer( state: PlaybackState, @@ -52,6 +57,7 @@ fun MiniPlayer( onExpand: () -> Unit, onPlayPause: () -> Unit, onNext: () -> Unit, + progressInset: Boolean = false, onExpandDrag: (Float) -> Unit = {}, onExpandRelease: (Float) -> Unit = {}, modifier: Modifier = Modifier, @@ -59,6 +65,16 @@ fun MiniPlayer( val item = state.item ?: return var dragUpPx by remember { mutableFloatStateOf(0f) } val progress = if (state.durationMs > 0) (state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f) else 0f + val progressInsetDp by animateDpAsState( + targetValue = if (progressInset) PROGRESS_INSET else 0.dp, + animationSpec = tween(PROGRESS_INSET_MS), + label = "progressInset", + ) + val progressCornerDp by animateDpAsState( + targetValue = if (progressInset) PROGRESS_INSET / 2 else 0.dp, + animationSpec = tween(PROGRESS_INSET_MS), + label = "progressCorner", + ) ArtworkTheme(artwork) { Surface( @@ -93,7 +109,9 @@ fun MiniPlayer( modifier = Modifier .fillMaxWidth() - .height(3.dp), + .padding(horizontal = progressInsetDp) + .height(3.dp) + .clip(RoundedCornerShape(progressCornerDp)), ) Row( modifier = 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 b9b3310c..915daa3f 100644 --- a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt +++ b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkTheme.kt @@ -37,6 +37,7 @@ private const val LIBRARY_SURFACE_LIGHTNESS = 0.10f private const val WARM_SURFACE_SATURATION_DAMPING = 0.4f private const val WARM_HUE_FULL = 90f private const val WARM_HUE_NONE = 150f +private const val WARM_HUE_WRAP_START = 330f @Immutable data class ArtworkColors( @@ -221,8 +222,15 @@ fun librarySurfaceColor(artwork: ArtworkColors): Color { return Color.hsl(hue, (saturation * surfaceSaturationFactor(hue)).coerceIn(0f, 1f), LIBRARY_SURFACE_LIGHTNESS) } -private fun surfaceSaturationFactor(hue: Float): Float { - val warm = ((WARM_HUE_NONE - hue) / (WARM_HUE_NONE - WARM_HUE_FULL)).coerceIn(0f, 1f) +internal fun surfaceSaturationFactor(hue: Float): Float { + val h = hue.mod(360f) + val warm = + when { + h <= WARM_HUE_FULL -> 1f + h < WARM_HUE_NONE -> (WARM_HUE_NONE - h) / (WARM_HUE_NONE - WARM_HUE_FULL) + h >= WARM_HUE_WRAP_START -> (h - WARM_HUE_WRAP_START) / (360f - WARM_HUE_WRAP_START) + else -> 0f + } return 1f + (WARM_SURFACE_SATURATION_DAMPING - 1f) * warm } diff --git a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt index 79f2b869..87db599f 100644 --- a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt @@ -176,6 +176,7 @@ class AppScreenshotTest { onExpand = {}, onPlayPause = {}, onNext = {}, + progressInset = true, ) } } @@ -214,6 +215,7 @@ class AppScreenshotTest { onExpand = {}, onPlayPause = {}, onNext = {}, + progressInset = true, ) } } diff --git a/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt b/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt index ce8ce4ab..3af64cb0 100644 --- a/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt @@ -3,8 +3,12 @@ package com.subtracks.ui 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.onNodeWithContentDescription +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.PagingData import androidx.paging.compose.collectAsLazyPagingItems import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -13,6 +17,7 @@ import com.subtracks.data.model.Album import com.subtracks.data.model.Artist import com.subtracks.data.model.Playlist import com.subtracks.data.model.SongListItem +import com.subtracks.ui.library.ALBUM_COVER_TAG import com.subtracks.ui.library.LibraryScreen import com.subtracks.ui.library.LibraryTab import com.subtracks.ui.theme.SubtracksTheme @@ -32,7 +37,7 @@ class LibraryScreenTest { val composeRule = createAndroidComposeRule() @Test - fun theSyncAndSettingsControlsAreReachable() { + fun pullingDownRefreshesAndSettingsOpens() { var synced = false var settingsOpened = false composeRule.setContent { @@ -40,7 +45,7 @@ class LibraryScreenTest { LibraryScreen( selectedTab = LibraryTab.Albums, onTabSelected = {}, - albums = remember { flowOf(PagingData.empty()) }.collectAsLazyPagingItems(), + albums = remember { flowOf(PagingData.from(albums())) }.collectAsLazyPagingItems(), artists = remember { flowOf(PagingData.empty()) }.collectAsLazyPagingItems(), songs = remember { flowOf(PagingData.empty()) }.collectAsLazyPagingItems(), playlists = remember { flowOf(PagingData.empty()) }.collectAsLazyPagingItems(), @@ -55,10 +60,34 @@ class LibraryScreenTest { } } - composeRule.onNodeWithContentDescription("Sync").performClick() + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodesWithTag(ALBUM_COVER_TAG).fetchSemanticsNodes().isNotEmpty() + } + composeRule.onRoot().performTouchInput { swipeDown(durationMillis = 600) } + composeRule.waitForIdle() + composeRule.onNodeWithContentDescription("Settings").performClick() - assertTrue("the sync control should trigger a sync", synced) + assertTrue("pulling down should trigger a sync", synced) assertTrue("the settings control should open settings", settingsOpened) } + + private fun albums() = + (1..60).map { index -> + Album( + sourceId = 1, + id = "al-$index", + artistId = "ar-1", + name = "Album $index", + albumArtist = "Artist", + created = 0, + coverArt = null, + genre = null, + year = null, + starred = null, + songCount = 1, + frequentRank = null, + recentRank = null, + ) + } } diff --git a/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkColorsTest.kt b/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkColorsTest.kt index e3931245..7d1419a4 100644 --- a/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkColorsTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkColorsTest.kt @@ -65,6 +65,13 @@ class ArtworkColorsTest { assertTrue(playerSurfaceColor(warm).luminance() < 0.1f) } + @Test + fun warmSaturationDampingIsContinuousAroundTheHueCircle() { + assertEquals(surfaceSaturationFactor(0f), surfaceSaturationFactor(359.9f), 0.01f) + assertTrue(surfaceSaturationFactor(60f) < surfaceSaturationFactor(240f)) + assertTrue(surfaceSaturationFactor(359f) < surfaceSaturationFactor(330f)) + } + private fun contrast( a: Color, b: Color,