From 1b83a5ff939611946452822235e293a561f17409 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:56:25 +0000 Subject: [PATCH] now playing: dismiss transient UI when the notification opens it Tapping the media notification opened now playing over whatever was on screen, leaving the item context menu, the library options sheet, the keyboard and any open dialog above it. On a now-playing request, clear focus, dismiss the shared menus and close each route's dialog state. --- .../main/kotlin/com/subtracks/ui/AppRoot.kt | 13 +++++- .../ui/components/ItemContextMenu.kt | 22 ++++++++++ .../subtracks/ui/downloads/DownloadsScreen.kt | 3 ++ .../subtracks/ui/library/AlbumDetailScreen.kt | 2 + .../ui/library/ArtistDetailScreen.kt | 2 + .../com/subtracks/ui/library/LibraryScreen.kt | 6 +++ .../ui/library/PlaylistDetailScreen.kt | 2 + .../subtracks/ui/settings/AddSourceScreen.kt | 5 +++ .../subtracks/ui/settings/SettingsScreen.kt | 5 +++ .../com/subtracks/ui/LibraryOptionsTest.kt | 40 +++++++++++++++++++ 10 files changed, 99 insertions(+), 1 deletion(-) 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(