mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user