Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,11 @@ class UIControllerScopeImpl(
override fun closeCurrentMenu() {
menuStack.removeLastOrNull()?.also { lastWasClosed = true }
}

override fun closeAllMenus() {
if (menuStack.isNotEmpty()) {
menuStack.clear()
lastWasClosed = true
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 2 additions & 0 deletions ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt
Original file line number Diff line number Diff line change
Expand Up @@ -153,4 +153,6 @@ private object PreviewMenuScope : MenuScope {
override fun openMenu(menu: MenuContent) = Unit

override fun closeCurrentMenu() = Unit

override fun closeAllMenus() = Unit
}
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,10 @@ class TextTrackStyleMenuTest {
override fun closeCurrentMenu() {
menuStack.removeLastOrNull()
}

override fun closeAllMenus() {
menuStack.clear()
}
}

private class MockStyle(
Expand Down
6 changes: 3 additions & 3 deletions ui/src/main/java/com/theoplayer/android/ui/Menu.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ui/src/main/java/com/theoplayer/android/ui/UIController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ internal fun UIControllerImpl(
),
menuContainer = { menuContent ->
MenuSheet(
onDismiss = { scope.closeCurrentMenu() },
onDismiss = { scope.closeAllMenus() },
content = menuContent,
)
},
Expand Down
136 changes: 129 additions & 7 deletions ui/src/test/java/com/theoplayer/android/ui/MenuSheetTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -76,7 +77,72 @@ 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
@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
Expand All @@ -102,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"))
}
Expand Down Expand Up @@ -146,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"))
}
Expand All @@ -173,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),
Expand Down Expand Up @@ -435,6 +501,57 @@ class MenuSheetTest {
)
}

private fun setControllerAndOpenMenus(): UIControllerScope {
val theoplayerView =
object : THEOplayerView(composeRule.activity) {
private val nativePlayer =
mockk<com.theoplayer.android.api.player.Player>(relaxed = true) {
every { isPaused } returns false
every { isEnded } returns false
every { ads } returns
mockk<Ads>(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)
Expand All @@ -456,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"))
}
Expand All @@ -466,12 +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() {
closeAllMenusCalls++
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ class TextTrackStyleMenuTest {
override fun closeCurrentMenu() {
menuStack.removeLastOrNull()
}

override fun closeAllMenus() {
menuStack.clear()
}
}

private class PlayerBackedTHEOplayerView(
Expand Down
Loading