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.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@@ -299,6 +300,7 @@ private fun MainNavigation() {
} }
LaunchedEffect(Unit) { if (nowPlayingOpen) nowPlayingProgress = 1f } LaunchedEffect(Unit) { if (nowPlayingOpen) nowPlayingProgress = 1f }
val focusManager = LocalFocusManager.current
val playerVisible = playback.item != null val playerVisible = playback.item != null
LaunchedEffect(playerVisible) { LaunchedEffect(playerVisible) {
if (!playerVisible) { if (!playerVisible) {
@@ -308,6 +310,9 @@ private fun MainNavigation() {
return@LaunchedEffect return@LaunchedEffect
} }
nowPlayingLauncher.openNowPlaying.collect { nowPlayingLauncher.openNowPlaying.collect {
focusManager.clearFocus()
contextMenuHost.dismissTransients()
infoSong = null
showingQueue = false showingQueue = false
if (navController.currentDestination?.route in DETAIL_ROUTES) { if (navController.currentDestination?.route in DETAIL_ROUTES) {
navController.popBackStack(Routes.LIBRARY, inclusive = false) navController.popBackStack(Routes.LIBRARY, inclusive = false)
@@ -397,10 +402,14 @@ private fun MainNavigation() {
onOpenLicenses = { navController.navigate(Routes.LICENSES) }, onOpenLicenses = { navController.navigate(Routes.LICENSES) },
onEditServer = { id -> navController.navigate(Routes.editServer(id)) }, onEditServer = { id -> navController.navigate(Routes.editServer(id)) },
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
dismissRequests = contextMenuHost.dismissRequests,
) )
} }
composable(Routes.DOWNLOADS) { composable(Routes.DOWNLOADS) {
DownloadsRoute(onBack = { navController.popBackStack() }) DownloadsRoute(
onBack = { navController.popBackStack() },
dismissRequests = contextMenuHost.dismissRequests,
)
} }
composable(Routes.SEARCH) { composable(Routes.SEARCH) {
SearchRoute( SearchRoute(
@@ -598,6 +607,7 @@ private fun MainNavigation() {
AddSourceRoute( AddSourceRoute(
onSaved = { navController.popBackStack() }, onSaved = { navController.popBackStack() },
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
dismissRequests = contextMenuHost.dismissRequests,
) )
} }
composable( composable(
@@ -608,6 +618,7 @@ private fun MainNavigation() {
onSaved = { navController.popBackStack() }, onSaved = { navController.popBackStack() },
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
sourceId = entry.arguments?.getLong("sourceId"), 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.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -147,6 +149,11 @@ class ContextMenuHost {
var actions by mutableStateOf(ItemActions()) var actions by mutableStateOf(ItemActions())
private set 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( fun show(
target: MenuTarget, target: MenuTarget,
actions: ItemActions, actions: ItemActions,
@@ -159,6 +166,21 @@ class ContextMenuHost {
target = null target = null
actions = ItemActions() actions = ItemActions()
} }
fun dismissTransients() {
dismiss()
dismissRequests++
}
}
@Composable
internal fun DismissOnRequest(
dismissRequests: Int,
onDismiss: () -> Unit,
) {
LaunchedEffect(dismissRequests) {
if (dismissRequests > 0) onDismiss()
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -49,6 +49,7 @@ import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.DownloadStatus import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.DownloadedSong import com.subtracks.data.model.DownloadedSong
import com.subtracks.ui.components.DeleteDownloadsDialog import com.subtracks.ui.components.DeleteDownloadsDialog
import com.subtracks.ui.components.DismissOnRequest
import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.EmptyState
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@@ -70,10 +71,12 @@ sealed interface DownloadDeleteTarget {
@Composable @Composable
fun DownloadsRoute( fun DownloadsRoute(
onBack: () -> Unit, onBack: () -> Unit,
dismissRequests: Int = 0,
viewModel: DownloadsViewModel = koinViewModel(), viewModel: DownloadsViewModel = koinViewModel(),
) { ) {
val tree by viewModel.tree.collectAsStateWithLifecycle() val tree by viewModel.tree.collectAsStateWithLifecycle()
var pending by remember { mutableStateOf<DownloadDeleteTarget?>(null) } var pending by remember { mutableStateOf<DownloadDeleteTarget?>(null) }
DismissOnRequest(dismissRequests) { pending = null }
DownloadsScreen( DownloadsScreen(
tree = tree, tree = tree,
loadEncoding = viewModel::encoding, loadEncoding = viewModel::encoding,
@@ -39,6 +39,7 @@ import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.CloseWhenDownloadsGone import com.subtracks.ui.components.CloseWhenDownloadsGone
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.DeleteDownloadsDialog import com.subtracks.ui.components.DeleteDownloadsDialog
import com.subtracks.ui.components.DismissOnRequest
import com.subtracks.ui.components.HeroDetailScaffold import com.subtracks.ui.components.HeroDetailScaffold
import com.subtracks.ui.components.HeroHeader import com.subtracks.ui.components.HeroHeader
import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ItemActions
@@ -73,6 +74,7 @@ fun AlbumDetailRoute(
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack) CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var pendingDelete by remember { mutableStateOf<Long?>(null) } var pendingDelete by remember { mutableStateOf<Long?>(null) }
DismissOnRequest(contextMenuHost?.dismissRequests ?: 0) { pendingDelete = null }
val playback by playbackController.state.collectAsStateWithLifecycle() val playback by playbackController.state.collectAsStateWithLifecycle()
val context = playback.context val context = playback.context
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } } 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.ContextMenuHost
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.DeleteDownloadsDialog import com.subtracks.ui.components.DeleteDownloadsDialog
import com.subtracks.ui.components.DismissOnRequest
import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.ListDownloadIndicator import com.subtracks.ui.components.ListDownloadIndicator
import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.MenuTarget
@@ -159,6 +160,7 @@ fun ArtistDetailRoute(
val albumDownloads by viewModel.albumDownloads.collectAsStateWithLifecycle() val albumDownloads by viewModel.albumDownloads.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) } var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
DismissOnRequest(contextMenuHost?.dismissRequests ?: 0) { pendingDelete = null }
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } } val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
val actions = val actions =
ItemActions( ItemActions(
@@ -119,6 +119,7 @@ import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.DeleteDownloadsDialog import com.subtracks.ui.components.DeleteDownloadsDialog
import com.subtracks.ui.components.DismissOnRequest
import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.PendingDownloadDelete import com.subtracks.ui.components.PendingDownloadDelete
@@ -269,6 +270,7 @@ fun LibraryRoute(
val downloads = downloadsState?.value ?: emptyMap() val downloads = downloadsState?.value ?: emptyMap()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) } var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
DismissOnRequest(contextMenuHost?.dismissRequests ?: 0) { pendingDelete = null }
val itemActions = val itemActions =
ItemActions( ItemActions(
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) }, playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
@@ -339,6 +341,7 @@ fun LibraryRoute(
albumDownloads = downloads.takeIf { listTab == LibraryListTab.Albums }.orEmpty(), albumDownloads = downloads.takeIf { listTab == LibraryListTab.Albums }.orEmpty(),
artistDownloads = downloads.takeIf { listTab == LibraryListTab.Artists }.orEmpty(), artistDownloads = downloads.takeIf { listTab == LibraryListTab.Artists }.orEmpty(),
playlistDownloads = downloads.takeIf { listTab == LibraryListTab.Playlists }.orEmpty(), playlistDownloads = downloads.takeIf { listTab == LibraryListTab.Playlists }.orEmpty(),
dismissRequests = contextMenuHost?.dismissRequests ?: 0,
homeContent = { topInset, bottomInset -> homeContent = { topInset, bottomInset ->
HomeRoute( HomeRoute(
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
@@ -403,6 +406,7 @@ fun LibraryScreen(
albumDownloads: Map<String, ListDownloadStatus> = emptyMap(), albumDownloads: Map<String, ListDownloadStatus> = emptyMap(),
artistDownloads: Map<String, ListDownloadStatus> = emptyMap(), artistDownloads: Map<String, ListDownloadStatus> = emptyMap(),
playlistDownloads: Map<String, ListDownloadStatus> = emptyMap(), playlistDownloads: Map<String, ListDownloadStatus> = emptyMap(),
dismissRequests: Int = 0,
homeContent: @Composable (topInset: Dp, bottomInset: Dp) -> Unit = { _, _ -> }, homeContent: @Composable (topInset: Dp, bottomInset: Dp) -> Unit = { _, _ -> },
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -415,6 +419,8 @@ fun LibraryScreen(
var searchActive by rememberSaveable { mutableStateOf(false) } var searchActive by rememberSaveable { mutableStateOf(false) }
val searchFocus = remember { FocusRequester() } val searchFocus = remember { FocusRequester() }
DismissOnRequest(dismissRequests) { showOptions = false }
LaunchedEffect(pagerState) { LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage }.collect { page -> snapshotFlow { pagerState.settledPage }.collect { page ->
onTabSelected(LibraryTab.entries[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.CloseWhenDownloadsGone
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.DeleteDownloadsDialog import com.subtracks.ui.components.DeleteDownloadsDialog
import com.subtracks.ui.components.DismissOnRequest
import com.subtracks.ui.components.HeroDetailScaffold import com.subtracks.ui.components.HeroDetailScaffold
import com.subtracks.ui.components.HeroHeader import com.subtracks.ui.components.HeroHeader
import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ItemActions
@@ -60,6 +61,7 @@ fun PlaylistDetailRoute(
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack) CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var pendingDelete by remember { mutableStateOf<Long?>(null) } var pendingDelete by remember { mutableStateOf<Long?>(null) }
DismissOnRequest(contextMenuHost?.dismissRequests ?: 0) { pendingDelete = null }
val playback by playbackController.state.collectAsStateWithLifecycle() val playback by playbackController.state.collectAsStateWithLifecycle()
val context = playback.context val context = playback.context
val requestDownloadAction: (BulkDownloadAction) -> Unit = { action -> val requestDownloadAction: (BulkDownloadAction) -> Unit = { action ->
@@ -47,6 +47,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.R import com.subtracks.R
import com.subtracks.ui.components.DismissOnRequest
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@@ -55,6 +56,7 @@ fun AddSourceRoute(
onSaved: () -> Unit, onSaved: () -> Unit,
onBack: (() -> Unit)?, onBack: (() -> Unit)?,
sourceId: Long? = null, sourceId: Long? = null,
dismissRequests: Int = 0,
viewModel: AddSourceViewModel = koinViewModel(key = sourceId?.toString() ?: "new") { parametersOf(sourceId) }, viewModel: AddSourceViewModel = koinViewModel(key = sourceId?.toString() ?: "new") { parametersOf(sourceId) },
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
@@ -69,6 +71,7 @@ fun AddSourceRoute(
onSave = { viewModel.save(onSaved) }, onSave = { viewModel.save(onSaved) },
onDelete = { viewModel.delete(onSaved) }, onDelete = { viewModel.delete(onSaved) },
onBack = onBack, onBack = onBack,
dismissRequests = dismissRequests,
) )
} }
@@ -85,11 +88,13 @@ fun AddSourceScreen(
onSave: () -> Unit, onSave: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onBack: (() -> Unit)?, onBack: (() -> Unit)?,
dismissRequests: Int = 0,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val nameFocus = remember { FocusRequester() } val nameFocus = remember { FocusRequester() }
val addressFocus = remember { FocusRequester() } val addressFocus = remember { FocusRequester() }
var confirmingDelete by remember { mutableStateOf(false) } var confirmingDelete by remember { mutableStateOf(false) }
DismissOnRequest(dismissRequests) { confirmingDelete = false }
LaunchedEffect(state.nameError, state.addressError) { LaunchedEffect(state.nameError, state.addressError) {
when { when {
state.nameError -> nameFocus.requestFocus() state.nameError -> nameFocus.requestFocus()
@@ -56,6 +56,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.R import com.subtracks.R
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.prefs.StreamQuality import com.subtracks.data.prefs.StreamQuality
import com.subtracks.ui.components.DismissOnRequest
import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.components.rememberViewportFill
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@@ -71,6 +72,7 @@ fun SettingsRoute(
onOpenLicenses: () -> Unit, onOpenLicenses: () -> Unit,
onEditServer: (Long) -> Unit, onEditServer: (Long) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
dismissRequests: Int = 0,
viewModel: SettingsViewModel = koinViewModel(), viewModel: SettingsViewModel = koinViewModel(),
) { ) {
val sources by viewModel.sources.collectAsStateWithLifecycle() val sources by viewModel.sources.collectAsStateWithLifecycle()
@@ -105,6 +107,7 @@ fun SettingsRoute(
onOpenDownloads = onOpenDownloads, onOpenDownloads = onOpenDownloads,
onOpenLicenses = onOpenLicenses, onOpenLicenses = onOpenLicenses,
onBack = onBack, onBack = onBack,
dismissRequests = dismissRequests,
) )
} }
@@ -133,9 +136,11 @@ fun SettingsScreen(
onOpenDownloads: () -> Unit, onOpenDownloads: () -> Unit,
onOpenLicenses: () -> Unit, onOpenLicenses: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
dismissRequests: Int = 0,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var dialog by remember { mutableStateOf<SettingsDialog?>(null) } var dialog by remember { mutableStateOf<SettingsDialog?>(null) }
DismissOnRequest(dismissRequests) { dialog = null }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val fill = rememberViewportFill(listState) val fill = rememberViewportFill(listState)
@@ -5,6 +5,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertCountEquals
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.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText 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.model.Playlist
import com.subtracks.data.prefs.ListQuery import com.subtracks.data.prefs.ListQuery
import com.subtracks.data.prefs.StarredFilter 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.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
@@ -98,6 +100,44 @@ class LibraryOptionsTest {
assertEquals(StarredFilter.Any, query.starred) 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 @Test
fun playSortsAreHiddenUntilTheServerReturnsPlayData() { fun playSortsAreHiddenUntilTheServerReturnsPlayData() {
assertEquals( assertEquals(