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.
This commit is contained in:
austinried
2026-10-04 00:56:53 +00:00
parent 9f2e14a9f9
commit 1b83a5ff93
10 changed files with 99 additions and 1 deletions
@@ -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,
)
}
}
@@ -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)
@@ -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<DownloadDeleteTarget?>(null) }
DismissOnRequest(dismissRequests) { pending = null }
DownloadsScreen(
tree = tree,
loadEncoding = viewModel::encoding,
@@ -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<Long?>(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) } }
@@ -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<PendingDownloadDelete?>(null) }
DismissOnRequest(contextMenuHost?.dismissRequests ?: 0) { pendingDelete = null }
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
val actions =
ItemActions(
@@ -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<PendingDownloadDelete?>(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<String, ListDownloadStatus> = emptyMap(),
artistDownloads: Map<String, ListDownloadStatus> = emptyMap(),
playlistDownloads: Map<String, ListDownloadStatus> = 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])
@@ -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<Long?>(null) }
DismissOnRequest(contextMenuHost?.dismissRequests ?: 0) { pendingDelete = null }
val playback by playbackController.state.collectAsStateWithLifecycle()
val context = playback.context
val requestDownloadAction: (BulkDownloadAction) -> Unit = { action ->
@@ -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()
@@ -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<SettingsDialog?>(null) }
DismissOnRequest(dismissRequests) { dialog = null }
val listState = rememberLazyListState()
val fill = rememberViewportFill(listState)
@@ -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<Artist>()) },
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
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(