From ad67c11af947d1d5b7f135fd0b2c49fc9ac1df61 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 22 Oct 2026 15:18:31 +0200 Subject: [PATCH 1/3] Close all menus when tapping outside the mobile menu sheet Tapping the scrim only closed the top menu, so from a submenu it went back one level instead of dismissing the sheet. Add MenuScope.closeAllMenus() and use it for the scrim; Back and the back arrow still close one level. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/MenuScope.kt | 5 +- .../android/ui/UIControllerScope.kt | 7 + .../android/ui/UIControllerScopeImplTest.kt | 47 +++++++ .../java/com/theoplayer/android/ui/tv/Menu.kt | 2 + .../android/ui/tv/TextTrackStyleMenuTest.kt | 4 + .../java/com/theoplayer/android/ui/Menu.kt | 6 +- .../com/theoplayer/android/ui/UIController.kt | 2 +- .../theoplayer/android/ui/MenuSheetTest.kt | 120 ++++++++++++++++++ .../android/ui/TextTrackStyleMenuTest.kt | 4 + 9 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/UIControllerScopeImplTest.kt diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/MenuScope.kt b/ui-common/src/main/java/com/theoplayer/android/ui/MenuScope.kt index 52ec3a57..c5d36e9a 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/MenuScope.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/MenuScope.kt @@ -34,6 +34,9 @@ interface MenuScope { */ fun openMenu(menu: MenuContent) - /** Close the currently open menu. */ + /** Close the currently open menu, returning to the previous menu (if any). */ fun closeCurrentMenu() + + /** Close all open menus, including the current menu and all menus below it. */ + fun closeAllMenus() } diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/UIControllerScope.kt b/ui-common/src/main/java/com/theoplayer/android/ui/UIControllerScope.kt index 4f201fc6..8004ae49 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/UIControllerScope.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/UIControllerScope.kt @@ -86,4 +86,11 @@ class UIControllerScopeImpl( override fun closeCurrentMenu() { menuStack.removeLastOrNull()?.also { lastWasClosed = true } } + + override fun closeAllMenus() { + if (menuStack.isNotEmpty()) { + menuStack.clear() + lastWasClosed = true + } + } } diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/UIControllerScopeImplTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/UIControllerScopeImplTest.kt new file mode 100644 index 00000000..a5afae9c --- /dev/null +++ b/ui-common/src/test/java/com/theoplayer/android/ui/UIControllerScopeImplTest.kt @@ -0,0 +1,47 @@ +package com.theoplayer.android.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(InternalUiApi::class) +class UIControllerScopeImplTest { + @Test + fun `closeAllMenus closes all open menus`() { + val scope = UIControllerScopeImpl(theoplayerView = null, providedPlayer = null) + scope.openMenu {} + scope.openMenu {} + scope.openMenu {} + + scope.closeAllMenus() + + assertNull(scope.currentMenu) + assertTrue(scope.menuIds.isEmpty()) + assertTrue(scope.lastWasClosed) + } + + @Test + fun `closeAllMenus on empty stack is a no-op`() { + val scope = UIControllerScopeImpl(theoplayerView = null, providedPlayer = null) + + scope.closeAllMenus() + + assertNull(scope.currentMenu) + assertTrue(scope.menuIds.isEmpty()) + assertFalse(scope.lastWasClosed) + } + + @Test + fun `closeCurrentMenu only closes the top menu`() { + val scope = UIControllerScopeImpl(theoplayerView = null, providedPlayer = null) + scope.openMenu {} + scope.openMenu {} + + scope.closeCurrentMenu() + + assertEquals(1, scope.menuIds.size) + assertTrue(scope.lastWasClosed) + } +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt index 6b310a48..f7013ae7 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt @@ -153,4 +153,6 @@ private object PreviewMenuScope : MenuScope { override fun openMenu(menu: MenuContent) = Unit override fun closeCurrentMenu() = Unit + + override fun closeAllMenus() = Unit } diff --git a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/TextTrackStyleMenuTest.kt b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/TextTrackStyleMenuTest.kt index e6af3a40..ea827ffc 100644 --- a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/TextTrackStyleMenuTest.kt +++ b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/TextTrackStyleMenuTest.kt @@ -242,6 +242,10 @@ class TextTrackStyleMenuTest { override fun closeCurrentMenu() { menuStack.removeLastOrNull() } + + override fun closeAllMenus() { + menuStack.clear() + } } private class MockStyle( diff --git a/ui/src/main/java/com/theoplayer/android/ui/Menu.kt b/ui/src/main/java/com/theoplayer/android/ui/Menu.kt index 89d64709..508b79f5 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Menu.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Menu.kt @@ -22,8 +22,8 @@ import com.theoplayer.android.ui.common.R as CommonR * * The menu has a heading at the top, with a back button and a title text. Inside a [UIController] * it is rendered in the shared menu sheet: a bottom sheet on compact players, and a side sheet at - * the end of the player on wide players (at least 600dp). Clicking outside the sheet closes the - * menu. + * the end of the player on wide players (at least 600dp). Clicking outside the sheet closes all + * open menus. * * @param modifier the [Modifier] to be applied to this menu * @param title the title @@ -56,7 +56,7 @@ fun MenuScope.Menu( * The menu has a heading at the top, with a back button and a title text, followed by actions that * are aligned to the end of the heading. Inside a [UIController] it is rendered in the shared menu * sheet: a bottom sheet on compact players, and a side sheet at the end of the player on wide - * players (at least 600dp). Clicking outside the sheet closes the menu. + * players (at least 600dp). Clicking outside the sheet closes all open menus. * * @param modifier the [Modifier] to be applied to this menu * @param title the title diff --git a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt index a9903133..8efc294f 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt @@ -235,7 +235,7 @@ internal fun UIControllerImpl( ), menuContainer = { menuContent -> MenuSheet( - onDismiss = { scope.closeCurrentMenu() }, + onDismiss = { scope.closeAllMenus() }, content = menuContent, ) }, diff --git a/ui/src/test/java/com/theoplayer/android/ui/MenuSheetTest.kt b/ui/src/test/java/com/theoplayer/android/ui/MenuSheetTest.kt index 4707c35b..02757682 100644 --- a/ui/src/test/java/com/theoplayer/android/ui/MenuSheetTest.kt +++ b/ui/src/test/java/com/theoplayer/android/ui/MenuSheetTest.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onParent import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performTouchInput @@ -79,6 +80,70 @@ class MenuSheetTest { assertEquals(1, scope.closeCalls) } + @Test + @Config(qualifiers = "w900dp-h400dp") + fun tapOutsideSheetClosesAllMenus() { + setControllerAndOpenMenus() + composeRule.waitForIdle() + pushSubmenu() + composeRule.waitForIdle() + assertEquals(1, sheetCount()) + assertTrue( + composeRule + .onAllNodesWithTag("submenu-content", useUnmergedTree = true) + .fetchSemanticsNodes() + .isNotEmpty() + ) + + // Tapping the scrim to the left of the 400dp side sheet closes all + // menus, not just the top submenu. + composeRule.onNodeWithTag("menu-sheet").onParent().performTouchInput { + click(Offset(with(composeRule.density) { 200.dp.toPx() }, centerY)) + } + composeRule.waitForIdle() + + assertEquals(0, sheetCount()) + assertTrue( + composeRule + .onAllNodesWithTag("menu-content", useUnmergedTree = true) + .fetchSemanticsNodes() + .isEmpty() + ) + assertTrue( + composeRule + .onAllNodesWithTag("submenu-content", useUnmergedTree = true) + .fetchSemanticsNodes() + .isEmpty() + ) + } + + @Test + @Config(qualifiers = "w900dp-h400dp") + fun backFromSubmenuReturnsToParentMenu() { + setControllerAndOpenMenus() + composeRule.waitForIdle() + pushSubmenu() + composeRule.waitForIdle() + + // Back only closes the top menu, returning to the parent menu. + composeRule.activity.onBackPressedDispatcher.onBackPressed() + composeRule.waitForIdle() + + assertEquals(1, sheetCount()) + assertTrue( + composeRule + .onAllNodesWithTag("menu-content", useUnmergedTree = true) + .fetchSemanticsNodes() + .isNotEmpty() + ) + assertTrue( + composeRule + .onAllNodesWithTag("submenu-content", useUnmergedTree = true) + .fetchSemanticsNodes() + .isEmpty() + ) + } + @Test @Config(qualifiers = "w400dp-h800dp") fun menuIsBottomSheetOnCompactPlayer() { @@ -435,6 +500,57 @@ class MenuSheetTest { ) } + private fun setControllerAndOpenMenus(): UIControllerScope { + val theoplayerView = + object : THEOplayerView(composeRule.activity) { + private val nativePlayer = + mockk(relaxed = true) { + every { isPaused } returns false + every { isEnded } returns false + every { ads } returns + mockk(relaxed = true) { every { isPlaying } returns false } + } + + override fun getPlayer() = nativePlayer + } + var controllerScope: UIControllerScope? = null + composeRule.setContent { + UIController( + modifier = Modifier.fillMaxSize(), + theoplayerView = theoplayerView, + centerOverlay = { controllerScope = this }, + ) + } + composeRule.waitForIdle() + composeRule.runOnIdle { + controllerScope!!.openMenu { + Menu(title = { Text("Menu") }) { + Box( + Modifier.fillMaxWidth().height(100.dp).testTag("menu-content").clickable { + openMenu { + Menu(title = { Text("Submenu") }) { + Box( + Modifier.fillMaxWidth() + .height(50.dp) + .testTag("submenu-content") + ) + } + } + } + ) + } + } + } + return controllerScope!! + } + + // Invoke the click semantics action directly: under Robolectric the + // sheet's clipped Surface is opaque to touch hit-testing, so a real + // tap would fall through to the scrim. + private fun pushSubmenu() { + composeRule.onNodeWithTag("menu-content").performSemanticsAction(SemanticsActions.OnClick) + } + private fun boundsOf(tag: String) = composeRule .onAllNodesWithTag(tag, useUnmergedTree = true) @@ -473,6 +589,10 @@ class MenuSheetTest { override fun closeCurrentMenu() { closeCalls++ } + + override fun closeAllMenus() { + closeCalls++ + } } private fun mockAudioTrack( diff --git a/ui/src/test/java/com/theoplayer/android/ui/TextTrackStyleMenuTest.kt b/ui/src/test/java/com/theoplayer/android/ui/TextTrackStyleMenuTest.kt index 06cbe54c..e9076b0f 100644 --- a/ui/src/test/java/com/theoplayer/android/ui/TextTrackStyleMenuTest.kt +++ b/ui/src/test/java/com/theoplayer/android/ui/TextTrackStyleMenuTest.kt @@ -152,6 +152,10 @@ class TextTrackStyleMenuTest { override fun closeCurrentMenu() { menuStack.removeLastOrNull() } + + override fun closeAllMenus() { + menuStack.clear() + } } private class PlayerBackedTHEOplayerView( From 76dbff606baae043f94a54ddfde90f4ecdebe020 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 22 Oct 2026 15:19:23 +0200 Subject: [PATCH 2/3] Update changelog Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d152894..fe020256 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ * 🚀 `UIController` now handles keys: pressing any key shows the controls (which helps on tablets with a keyboard), media keys (play/pause, fast-forward and rewind) control playback even while the controls are hidden, and Back closes an open menu. Use the new `PlayerAnimations.controlsHideDelay` to configure how long the controls stay visible after user interaction. ([#122](https://github.com/THEOplayer/android-ui/pull/122), [#124](https://github.com/THEOplayer/android-ui/pull/124)) * 🚀 `UIController` now moves subtitles up or down while the controls are visible, so they don't render behind the top or bottom chrome. Moving subtitles up requires THEOplayer 9.5.0 or higher. ([#129](https://github.com/THEOplayer/android-ui/pull/129)) * 🚀 Added `TextTrackStyleMenu` to change the style of subtitles, such as the font family, font color and background color. In `DefaultUI`, open it from the subtitles menu. Use `rememberTextTrackStyleState` to build a custom menu. Changing the font family requires THEOplayer 9.9.0 or higher. ([#130](https://github.com/THEOplayer/android-ui/issues/130), [#143](https://github.com/THEOplayer/android-ui/pull/143)) +* 🚀 Added `MenuScope.closeAllMenus()` to close all open menus at once. On mobile, tapping outside the menu sheet now closes all open menus, instead of only going back to the previous menu. ([#144](https://github.com/THEOplayer/android-ui/pull/144)) * 🐛 Fixed `AudioTrackList`, `SubtitleTrackList`, `QualityList` and `PlaybackRateList` not scrolling to the selected item, and not announcing it to accessibility services. ([#127](https://github.com/THEOplayer/android-ui/pull/127)) * 🐛 Fixed `UIController` and `DefaultUI` not keeping the player at the aspect ratio of the video (or 16:9 before the video is loaded). ([#140](https://github.com/THEOplayer/android-ui/pull/140)) * 🐛 Fixed menus losing their scroll position (and on Android TV, their focused item) when returning from a submenu. ([#143](https://github.com/THEOplayer/android-ui/pull/143)) From 9bb676db75a71c37a87e1d65398773c154157f48 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 22 Oct 2026 15:34:53 +0200 Subject: [PATCH 3/3] Count closeCurrentMenu and closeAllMenus calls separately in MenuSheetTest Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/MenuSheetTest.kt | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/ui/src/test/java/com/theoplayer/android/ui/MenuSheetTest.kt b/ui/src/test/java/com/theoplayer/android/ui/MenuSheetTest.kt index 02757682..e6c9e7d8 100644 --- a/ui/src/test/java/com/theoplayer/android/ui/MenuSheetTest.kt +++ b/ui/src/test/java/com/theoplayer/android/ui/MenuSheetTest.kt @@ -77,7 +77,8 @@ class MenuSheetTest { composeRule.onRoot().performTouchInput { click(Offset(with(composeRule.density) { 200.dp.toPx() }, centerY)) } - assertEquals(1, scope.closeCalls) + assertEquals(1, scope.closeAllMenusCalls) + assertEquals(0, scope.closeCurrentMenuCalls) } @Test @@ -167,7 +168,7 @@ class MenuSheetTest { fun menuSheetIsCappedAtSixtyPercentOnCompactPlayer() { val scope = TestMenuScope() composeRule.setContent { - MenuSheet(onDismiss = { scope.closeCurrentMenu() }) { + MenuSheet(onDismiss = { scope.closeAllMenus() }) { scope.Menu(title = { Text("Menu") }) { Box(Modifier.fillMaxWidth().height(2000.dp).testTag("menu-content")) } @@ -211,7 +212,7 @@ class MenuSheetTest { val scope = TestMenuScope() composeRule.setContent { CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { - MenuSheet(onDismiss = { scope.closeCurrentMenu() }) { + MenuSheet(onDismiss = { scope.closeAllMenus() }) { scope.Menu(title = { Text("Menu") }) { Box(Modifier.fillMaxWidth().height(100.dp).testTag("menu-content")) } @@ -238,7 +239,7 @@ class MenuSheetTest { textTracks = listOf(mockSubtitleTrack()), ) composeRule.setContent { - MenuSheet(onDismiss = { scope.closeCurrentMenu() }) { + MenuSheet(onDismiss = { scope.closeAllMenus() }) { scope.LanguageMenu( audioTrackState = rememberAudioTrackState(player), subtitleTrackState = rememberSubtitleTrackState(player), @@ -572,7 +573,7 @@ class MenuSheetTest { private fun setMenuContent(scope: TestMenuScope) { composeRule.setContent { - MenuSheet(onDismiss = { scope.closeCurrentMenu() }) { + MenuSheet(onDismiss = { scope.closeAllMenus() }) { scope.Menu(modifier = Modifier.testTag("menu-root"), title = { Text("Menu") }) { Box(Modifier.fillMaxWidth().height(100.dp).testTag("menu-content")) } @@ -582,16 +583,17 @@ class MenuSheetTest { @OptIn(InternalUiApi::class) private class TestMenuScope : MenuScope { - var closeCalls = 0 + var closeCurrentMenuCalls = 0 + var closeAllMenusCalls = 0 override fun openMenu(menu: MenuContent) = Unit override fun closeCurrentMenu() { - closeCalls++ + closeCurrentMenuCalls++ } override fun closeAllMenus() { - closeCalls++ + closeAllMenusCalls++ } }