diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index 920847a2..5f21e0f5 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel @@ -299,6 +300,7 @@ private fun MainNavigation() { } LaunchedEffect(Unit) { if (nowPlayingOpen) nowPlayingProgress = 1f } + val focusManager = LocalFocusManager.current val playerVisible = playback.item != null LaunchedEffect(playerVisible) { if (!playerVisible) { @@ -308,6 +310,9 @@ private fun MainNavigation() { return@LaunchedEffect } nowPlayingLauncher.openNowPlaying.collect { + focusManager.clearFocus() + contextMenuHost.dismissTransients() + infoSong = null showingQueue = false if (navController.currentDestination?.route in DETAIL_ROUTES) { navController.popBackStack(Routes.LIBRARY, inclusive = false) @@ -397,10 +402,14 @@ private fun MainNavigation() { onOpenLicenses = { navController.navigate(Routes.LICENSES) }, onEditServer = { id -> navController.navigate(Routes.editServer(id)) }, onBack = { navController.popBackStack() }, + dismissRequests = contextMenuHost.dismissRequests, ) } composable(Routes.DOWNLOADS) { - DownloadsRoute(onBack = { navController.popBackStack() }) + DownloadsRoute( + onBack = { navController.popBackStack() }, + dismissRequests = contextMenuHost.dismissRequests, + ) } composable(Routes.SEARCH) { SearchRoute( @@ -598,6 +607,7 @@ private fun MainNavigation() { AddSourceRoute( onSaved = { navController.popBackStack() }, onBack = { navController.popBackStack() }, + dismissRequests = contextMenuHost.dismissRequests, ) } composable( @@ -608,6 +618,7 @@ private fun MainNavigation() { onSaved = { navController.popBackStack() }, onBack = { navController.popBackStack() }, sourceId = entry.arguments?.getLong("sourceId"), + dismissRequests = contextMenuHost.dismissRequests, ) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt b/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt index 3a3beff6..89d4c9bc 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt @@ -32,7 +32,9 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -147,6 +149,11 @@ class ContextMenuHost { var actions by mutableStateOf(ItemActions()) private set + // Bumped when an external event (opening now playing) should close menu-like UI the host does + // not own, such as the library's list options sheet. + var dismissRequests by mutableIntStateOf(0) + private set + fun show( target: MenuTarget, actions: ItemActions, @@ -159,6 +166,21 @@ class ContextMenuHost { target = null actions = ItemActions() } + + fun dismissTransients() { + dismiss() + dismissRequests++ + } +} + +@Composable +internal fun DismissOnRequest( + dismissRequests: Int, + onDismiss: () -> Unit, +) { + LaunchedEffect(dismissRequests) { + if (dismissRequests > 0) onDismiss() + } } @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsScreen.kt b/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsScreen.kt index f946155c..521978ec 100644 --- a/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsScreen.kt @@ -49,6 +49,7 @@ import com.subtracks.data.model.DownloadList import com.subtracks.data.model.DownloadStatus import com.subtracks.data.model.DownloadedSong import com.subtracks.ui.components.DeleteDownloadsDialog +import com.subtracks.ui.components.DismissOnRequest import com.subtracks.ui.components.EmptyState import org.koin.compose.viewmodel.koinViewModel @@ -70,10 +71,12 @@ sealed interface DownloadDeleteTarget { @Composable fun DownloadsRoute( onBack: () -> Unit, + dismissRequests: Int = 0, viewModel: DownloadsViewModel = koinViewModel(), ) { val tree by viewModel.tree.collectAsStateWithLifecycle() var pending by remember { mutableStateOf(null) } + DismissOnRequest(dismissRequests) { pending = null } DownloadsScreen( tree = tree, loadEncoding = viewModel::encoding, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt index 9593d434..8ca94b52 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt @@ -39,6 +39,7 @@ import com.subtracks.playback.PlaybackController import com.subtracks.ui.components.CloseWhenDownloadsGone import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.DeleteDownloadsDialog +import com.subtracks.ui.components.DismissOnRequest import com.subtracks.ui.components.HeroDetailScaffold import com.subtracks.ui.components.HeroHeader import com.subtracks.ui.components.ItemActions @@ -73,6 +74,7 @@ fun AlbumDetailRoute( CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack) val scope = rememberCoroutineScope() var pendingDelete by remember { mutableStateOf(null) } + DismissOnRequest(contextMenuHost?.dismissRequests ?: 0) { pendingDelete = null } val playback by playbackController.state.collectAsStateWithLifecycle() val context = playback.context val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt index 098a8a8f..0114d0dc 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt @@ -89,6 +89,7 @@ import com.subtracks.ui.components.CloseWhenDownloadsGone import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.DeleteDownloadsDialog +import com.subtracks.ui.components.DismissOnRequest import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ListDownloadIndicator import com.subtracks.ui.components.MenuTarget @@ -159,6 +160,7 @@ fun ArtistDetailRoute( val albumDownloads by viewModel.albumDownloads.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() var pendingDelete by remember { mutableStateOf(null) } + DismissOnRequest(contextMenuHost?.dismissRequests ?: 0) { pendingDelete = null } val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } } val actions = ItemActions( 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 822277f2..07e5af19 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -119,6 +119,7 @@ import com.subtracks.data.source.StarType import com.subtracks.playback.PlaybackController 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 @@ -269,6 +270,7 @@ fun LibraryRoute( val downloads = downloadsState?.value ?: emptyMap() val scope = rememberCoroutineScope() var pendingDelete by remember { mutableStateOf(null) } + DismissOnRequest(contextMenuHost?.dismissRequests ?: 0) { pendingDelete = null } val itemActions = ItemActions( playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) }, @@ -339,6 +341,7 @@ fun LibraryRoute( albumDownloads = downloads.takeIf { listTab == LibraryListTab.Albums }.orEmpty(), artistDownloads = downloads.takeIf { listTab == LibraryListTab.Artists }.orEmpty(), playlistDownloads = downloads.takeIf { listTab == LibraryListTab.Playlists }.orEmpty(), + dismissRequests = contextMenuHost?.dismissRequests ?: 0, homeContent = { topInset, bottomInset -> HomeRoute( onAlbumClick = onAlbumClick, @@ -403,6 +406,7 @@ fun LibraryScreen( albumDownloads: Map = emptyMap(), artistDownloads: Map = emptyMap(), playlistDownloads: Map = emptyMap(), + dismissRequests: Int = 0, homeContent: @Composable (topInset: Dp, bottomInset: Dp) -> Unit = { _, _ -> }, modifier: Modifier = Modifier, ) { @@ -415,6 +419,8 @@ fun LibraryScreen( var searchActive by rememberSaveable { mutableStateOf(false) } val searchFocus = remember { FocusRequester() } + DismissOnRequest(dismissRequests) { showOptions = false } + LaunchedEffect(pagerState) { snapshotFlow { pagerState.settledPage }.collect { page -> onTabSelected(LibraryTab.entries[page]) diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt index ddc8dae3..1525a34d 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt @@ -28,6 +28,7 @@ import com.subtracks.playback.PlaybackController import com.subtracks.ui.components.CloseWhenDownloadsGone import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.DeleteDownloadsDialog +import com.subtracks.ui.components.DismissOnRequest import com.subtracks.ui.components.HeroDetailScaffold import com.subtracks.ui.components.HeroHeader import com.subtracks.ui.components.ItemActions @@ -60,6 +61,7 @@ fun PlaylistDetailRoute( CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack) val scope = rememberCoroutineScope() var pendingDelete by remember { mutableStateOf(null) } + DismissOnRequest(contextMenuHost?.dismissRequests ?: 0) { pendingDelete = null } val playback by playbackController.state.collectAsStateWithLifecycle() val context = playback.context val requestDownloadAction: (BulkDownloadAction) -> Unit = { action -> diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceScreen.kt index ba1c19d1..8b383c09 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/AddSourceScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.subtracks.R +import com.subtracks.ui.components.DismissOnRequest import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -55,6 +56,7 @@ fun AddSourceRoute( onSaved: () -> Unit, onBack: (() -> Unit)?, sourceId: Long? = null, + dismissRequests: Int = 0, viewModel: AddSourceViewModel = koinViewModel(key = sourceId?.toString() ?: "new") { parametersOf(sourceId) }, ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -69,6 +71,7 @@ fun AddSourceRoute( onSave = { viewModel.save(onSaved) }, onDelete = { viewModel.delete(onSaved) }, onBack = onBack, + dismissRequests = dismissRequests, ) } @@ -85,11 +88,13 @@ fun AddSourceScreen( onSave: () -> Unit, onDelete: () -> Unit, onBack: (() -> Unit)?, + dismissRequests: Int = 0, modifier: Modifier = Modifier, ) { val nameFocus = remember { FocusRequester() } val addressFocus = remember { FocusRequester() } var confirmingDelete by remember { mutableStateOf(false) } + DismissOnRequest(dismissRequests) { confirmingDelete = false } LaunchedEffect(state.nameError, state.addressError) { when { state.nameError -> nameFocus.requestFocus() diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt index 09a74a14..3abd7ba8 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt @@ -56,6 +56,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.subtracks.R import com.subtracks.data.model.Source import com.subtracks.data.prefs.StreamQuality +import com.subtracks.ui.components.DismissOnRequest import com.subtracks.ui.components.rememberViewportFill import org.koin.compose.viewmodel.koinViewModel @@ -71,6 +72,7 @@ fun SettingsRoute( onOpenLicenses: () -> Unit, onEditServer: (Long) -> Unit, onBack: () -> Unit, + dismissRequests: Int = 0, viewModel: SettingsViewModel = koinViewModel(), ) { val sources by viewModel.sources.collectAsStateWithLifecycle() @@ -105,6 +107,7 @@ fun SettingsRoute( onOpenDownloads = onOpenDownloads, onOpenLicenses = onOpenLicenses, onBack = onBack, + dismissRequests = dismissRequests, ) } @@ -133,9 +136,11 @@ fun SettingsScreen( onOpenDownloads: () -> Unit, onOpenLicenses: () -> Unit, onBack: () -> Unit, + dismissRequests: Int = 0, modifier: Modifier = Modifier, ) { var dialog by remember { mutableStateOf(null) } + DismissOnRequest(dismissRequests) { dialog = null } val listState = rememberLazyListState() val fill = rememberViewportFill(listState) diff --git a/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsTest.kt b/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsTest.kt index cc28a19a..eb42d76c 100644 --- a/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsTest.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onAllNodesWithText @@ -20,6 +21,7 @@ import com.subtracks.data.model.Artist import com.subtracks.data.model.Playlist import com.subtracks.data.prefs.ListQuery import com.subtracks.data.prefs.StarredFilter +import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.library.ALBUM_COVER_TAG import com.subtracks.ui.library.LibraryScreen import com.subtracks.ui.library.LibraryTab @@ -98,6 +100,44 @@ class LibraryOptionsTest { assertEquals(StarredFilter.Any, query.starred) } + @Test + fun theOptionsSheetClosesWhenTransientUiIsDismissed() { + val host = ContextMenuHost() + composeRule.setContent { + SubtracksTheme { + LibraryScreen( + selectedTab = LibraryTab.Albums, + onTabSelected = {}, + albums = remember { flowOf(PagingData.from(albums())) }, + artists = remember { flowOf(PagingData.empty()) }, + playlists = remember { flowOf(PagingData.empty()) }, + coverArt = { _, _ -> null }, + onAlbumClick = {}, + onArtistClick = {}, + onPlaylistClick = {}, + onSync = {}, + onOpenSettings = {}, + sortOptions = sortOptionsFor(LibraryTab.Albums), + starredSupported = true, + dismissRequests = host.dismissRequests, + ) + } + } + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodesWithTag(ALBUM_COVER_TAG).fetchSemanticsNodes().isNotEmpty() + } + + composeRule.onNodeWithContentDescription("List options").performClick() + composeRule.mainClock.advanceTimeBy(1_000) + composeRule.waitForIdle() + composeRule.onNodeWithText("Added").assertExists() + + composeRule.runOnIdle { host.dismissTransients() } + composeRule.waitForIdle() + + composeRule.onAllNodesWithText("Added").assertCountEquals(0) + } + @Test fun playSortsAreHiddenUntilTheServerReturnsPlayData() { assertEquals(