mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
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
This commit is contained in:
@@ -48,6 +48,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
@@ -122,6 +123,13 @@ fun SubtracksRoot(root: RootViewModel = koinViewModel()) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MainNavigation() {
|
private fun MainNavigation() {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
val currentRoute =
|
||||||
|
navController
|
||||||
|
.currentBackStackEntryAsState()
|
||||||
|
.value
|
||||||
|
?.destination
|
||||||
|
?.route
|
||||||
|
val tabBarVisible = currentRoute == Routes.LIBRARY
|
||||||
val playbackController = koinInject<PlaybackController>()
|
val playbackController = koinInject<PlaybackController>()
|
||||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||||
var showingQueue by rememberSaveable { mutableStateOf(false) }
|
var showingQueue by rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -285,6 +293,7 @@ private fun MainNavigation() {
|
|||||||
onExpand = { settleNowPlaying(true) },
|
onExpand = { settleNowPlaying(true) },
|
||||||
onPlayPause = playbackController::togglePlayPause,
|
onPlayPause = playbackController::togglePlayPause,
|
||||||
onNext = playbackController::next,
|
onNext = playbackController::next,
|
||||||
|
progressInset = tabBarVisible,
|
||||||
onExpandDrag = { dragUpPx ->
|
onExpandDrag = { dragUpPx ->
|
||||||
nowPlayingOpen = true
|
nowPlayingOpen = true
|
||||||
settleJob?.cancel()
|
settleJob?.cancel()
|
||||||
|
|||||||
@@ -38,8 +38,6 @@ import androidx.compose.material.icons.rounded.Person
|
|||||||
import androidx.compose.material.icons.rounded.Remove
|
import androidx.compose.material.icons.rounded.Remove
|
||||||
import androidx.compose.material.icons.rounded.Search
|
import androidx.compose.material.icons.rounded.Search
|
||||||
import androidx.compose.material.icons.rounded.Settings
|
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.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.FilterChipDefaults
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
@@ -55,6 +53,7 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.material3.TextField
|
import androidx.compose.material3.TextField
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -310,7 +309,7 @@ fun LibraryScreen(
|
|||||||
var tabBarHeightPx by remember { mutableFloatStateOf(0f) }
|
var tabBarHeightPx by remember { mutableFloatStateOf(0f) }
|
||||||
val tabBarHeight = with(density) { tabBarHeightPx.toDp() }
|
val tabBarHeight = with(density) { tabBarHeightPx.toDp() }
|
||||||
val listTopInset = statusBarTop
|
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
|
val headerColor = artwork?.let(::playerSurfaceColor) ?: MaterialTheme.colorScheme.background
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
@@ -325,6 +324,11 @@ fun LibraryScreen(
|
|||||||
) { page ->
|
) { page ->
|
||||||
val pageTab = LibraryTab.entries[page]
|
val pageTab = LibraryTab.entries[page]
|
||||||
val resetKey = resetKeys[pageTab]
|
val resetKey = resetKeys[pageTab]
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = syncing,
|
||||||
|
onRefresh = onSync,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
when (pageTab) {
|
when (pageTab) {
|
||||||
LibraryTab.Albums -> {
|
LibraryTab.Albums -> {
|
||||||
AlbumsContent(
|
AlbumsContent(
|
||||||
@@ -380,6 +384,7 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(
|
Spacer(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -394,6 +399,7 @@ fun LibraryScreen(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!searchActive) {
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -406,12 +412,11 @@ fun LibraryScreen(
|
|||||||
LibraryTabs(
|
LibraryTabs(
|
||||||
pagerState = pagerState,
|
pagerState = pagerState,
|
||||||
onTabSelected = onTabSelected,
|
onTabSelected = onTabSelected,
|
||||||
syncing = syncing,
|
|
||||||
onSync = onSync,
|
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (searchActive) {
|
if (searchActive) {
|
||||||
SearchField(
|
SearchField(
|
||||||
@@ -427,7 +432,7 @@ fun LibraryScreen(
|
|||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.imePadding()
|
.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(
|
private fun LibraryTabs(
|
||||||
pagerState: PagerState,
|
pagerState: PagerState,
|
||||||
onTabSelected: (LibraryTab) -> Unit,
|
onTabSelected: (LibraryTab) -> Unit,
|
||||||
syncing: Boolean,
|
|
||||||
onSync: () -> Unit,
|
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
artwork: ArtworkColors?,
|
artwork: ArtworkColors?,
|
||||||
) {
|
) {
|
||||||
@@ -727,19 +730,6 @@ private fun LibraryTabs(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.weight(1f))
|
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) {
|
IconButton(onClick = onOpenSettings) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Settings,
|
imageVector = Icons.Rounded.Settings,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.subtracks.ui.playback
|
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.Canvas
|
||||||
import androidx.compose.foundation.basicMarquee
|
import androidx.compose.foundation.basicMarquee
|
||||||
import androidx.compose.foundation.clickable
|
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.ArtworkTheme
|
||||||
import com.subtracks.ui.theme.playerSurfaceColor
|
import com.subtracks.ui.theme.playerSurfaceColor
|
||||||
|
|
||||||
|
private val PROGRESS_INSET = 8.dp
|
||||||
|
private const val PROGRESS_INSET_MS = 260
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MiniPlayer(
|
fun MiniPlayer(
|
||||||
state: PlaybackState,
|
state: PlaybackState,
|
||||||
@@ -52,6 +57,7 @@ fun MiniPlayer(
|
|||||||
onExpand: () -> Unit,
|
onExpand: () -> Unit,
|
||||||
onPlayPause: () -> Unit,
|
onPlayPause: () -> Unit,
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
|
progressInset: Boolean = false,
|
||||||
onExpandDrag: (Float) -> Unit = {},
|
onExpandDrag: (Float) -> Unit = {},
|
||||||
onExpandRelease: (Float) -> Unit = {},
|
onExpandRelease: (Float) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -59,6 +65,16 @@ fun MiniPlayer(
|
|||||||
val item = state.item ?: return
|
val item = state.item ?: return
|
||||||
var dragUpPx by remember { mutableFloatStateOf(0f) }
|
var dragUpPx by remember { mutableFloatStateOf(0f) }
|
||||||
val progress = if (state.durationMs > 0) (state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f) else 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) {
|
ArtworkTheme(artwork) {
|
||||||
Surface(
|
Surface(
|
||||||
@@ -93,7 +109,9 @@ fun MiniPlayer(
|
|||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(3.dp),
|
.padding(horizontal = progressInsetDp)
|
||||||
|
.height(3.dp)
|
||||||
|
.clip(RoundedCornerShape(progressCornerDp)),
|
||||||
)
|
)
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
|
|||||||
@@ -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_SURFACE_SATURATION_DAMPING = 0.4f
|
||||||
private const val WARM_HUE_FULL = 90f
|
private const val WARM_HUE_FULL = 90f
|
||||||
private const val WARM_HUE_NONE = 150f
|
private const val WARM_HUE_NONE = 150f
|
||||||
|
private const val WARM_HUE_WRAP_START = 330f
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class ArtworkColors(
|
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)
|
return Color.hsl(hue, (saturation * surfaceSaturationFactor(hue)).coerceIn(0f, 1f), LIBRARY_SURFACE_LIGHTNESS)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun surfaceSaturationFactor(hue: Float): Float {
|
internal fun surfaceSaturationFactor(hue: Float): Float {
|
||||||
val warm = ((WARM_HUE_NONE - hue) / (WARM_HUE_NONE - WARM_HUE_FULL)).coerceIn(0f, 1f)
|
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
|
return 1f + (WARM_SURFACE_SATURATION_DAMPING - 1f) * warm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ class AppScreenshotTest {
|
|||||||
onExpand = {},
|
onExpand = {},
|
||||||
onPlayPause = {},
|
onPlayPause = {},
|
||||||
onNext = {},
|
onNext = {},
|
||||||
|
progressInset = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,6 +215,7 @@ class AppScreenshotTest {
|
|||||||
onExpand = {},
|
onExpand = {},
|
||||||
onPlayPause = {},
|
onPlayPause = {},
|
||||||
onNext = {},
|
onNext = {},
|
||||||
|
progressInset = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ package com.subtracks.ui
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
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.onNodeWithContentDescription
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
import androidx.compose.ui.test.performClick
|
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.PagingData
|
||||||
import androidx.paging.compose.collectAsLazyPagingItems
|
import androidx.paging.compose.collectAsLazyPagingItems
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
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.Artist
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.SongListItem
|
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.LibraryScreen
|
||||||
import com.subtracks.ui.library.LibraryTab
|
import com.subtracks.ui.library.LibraryTab
|
||||||
import com.subtracks.ui.theme.SubtracksTheme
|
import com.subtracks.ui.theme.SubtracksTheme
|
||||||
@@ -32,7 +37,7 @@ class LibraryScreenTest {
|
|||||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun theSyncAndSettingsControlsAreReachable() {
|
fun pullingDownRefreshesAndSettingsOpens() {
|
||||||
var synced = false
|
var synced = false
|
||||||
var settingsOpened = false
|
var settingsOpened = false
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
@@ -40,7 +45,7 @@ class LibraryScreenTest {
|
|||||||
LibraryScreen(
|
LibraryScreen(
|
||||||
selectedTab = LibraryTab.Albums,
|
selectedTab = LibraryTab.Albums,
|
||||||
onTabSelected = {},
|
onTabSelected = {},
|
||||||
albums = remember { flowOf(PagingData.empty<Album>()) }.collectAsLazyPagingItems(),
|
albums = remember { flowOf(PagingData.from(albums())) }.collectAsLazyPagingItems(),
|
||||||
artists = remember { flowOf(PagingData.empty<Artist>()) }.collectAsLazyPagingItems(),
|
artists = remember { flowOf(PagingData.empty<Artist>()) }.collectAsLazyPagingItems(),
|
||||||
songs = remember { flowOf(PagingData.empty<SongListItem>()) }.collectAsLazyPagingItems(),
|
songs = remember { flowOf(PagingData.empty<SongListItem>()) }.collectAsLazyPagingItems(),
|
||||||
playlists = remember { flowOf(PagingData.empty<Playlist>()) }.collectAsLazyPagingItems(),
|
playlists = remember { flowOf(PagingData.empty<Playlist>()) }.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()
|
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)
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ class ArtworkColorsTest {
|
|||||||
assertTrue(playerSurfaceColor(warm).luminance() < 0.1f)
|
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(
|
private fun contrast(
|
||||||
a: Color,
|
a: Color,
|
||||||
b: Color,
|
b: Color,
|
||||||
|
|||||||
Reference in New Issue
Block a user