mirror of
https://github.com/androidx/media.git
synced 2025-04-30 06:46:50 +08:00
Add PlayPauseButtonState
to ui-compose
Provide a helper Composable for remembering the state instance and launching the listening coroutine that observes the changes in the Player Add an example to demo-compose of using PlayPauseButtonState inside a PlayPauseButton Composable. The smart State object has been deemed a preferred solution over collecting Flows due to queuing/timing/buffering limitations. Instead, it uses the new `Player.listen` suspending extension function to catch the relevant events. PiperOrigin-RevId: 691879975
This commit is contained in:
parent
f991e1f023
commit
676a3872a5
@ -57,6 +57,8 @@
|
||||
* IMA extension:
|
||||
* UI:
|
||||
* Add `PlayerSurface` Composable to `media3-ui-compose` module.
|
||||
* Add `PlayPauseButtonState` class and `rememberPlayPauseButtonState`
|
||||
Composable to `media3-ui-compose` module.
|
||||
* Downloads:
|
||||
* OkHttp Extension:
|
||||
* Cronet Extension:
|
||||
@ -74,6 +76,8 @@
|
||||
* Cast Extension:
|
||||
* Test Utilities:
|
||||
* Demo app:
|
||||
* Add `PlayPauseButton` Composable UI element to `demo-compose` utilizing
|
||||
`PlayPauseButtonState`.
|
||||
* Remove deprecated symbols:
|
||||
* Remove deprecated `AudioMixer.create()` method. Use
|
||||
`DefaultAudioMixer.Factory().create()` instead.
|
||||
|
@ -52,7 +52,6 @@ android {
|
||||
disable 'GoogleAppIndexingWarning','MissingTranslation'
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
compose true
|
||||
}
|
||||
composeOptions {
|
||||
@ -71,18 +70,16 @@ dependencies {
|
||||
implementation composeBom
|
||||
|
||||
implementation 'androidx.activity:activity-compose:1.9.0'
|
||||
implementation 'androidx.compose.foundation:foundation-android:1.6.7'
|
||||
implementation 'androidx.compose.material3:material3-android:1.2.1'
|
||||
implementation 'androidx.compose.foundation:foundation'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation 'androidx.compose.material:material-icons-extended'
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
implementation 'com.google.android.material:material:' + androidxMaterialVersion
|
||||
|
||||
implementation project(modulePrefix + 'lib-exoplayer')
|
||||
implementation project(modulePrefix + 'lib-ui-compose')
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
|
||||
// For detecting and debugging leaks only. LeakCanary is not needed for demo app to work.
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:' + leakCanaryVersion
|
||||
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:' + kotlinxCoroutinesVersion
|
||||
testImplementation 'org.robolectric:robolectric:' + robolectricVersion
|
||||
testImplementation project(modulePrefix + 'test-utils')
|
||||
|
||||
}
|
||||
|
20
demos/compose/lint.xml
Normal file
20
demos/compose/lint.xml
Normal file
@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copyright 2024 The Android Open Source Project
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<lint>
|
||||
<issue id="UnsafeOptInUsageError">
|
||||
<option name="opt-in" value="androidx.media3.common.util.UnstableApi" />
|
||||
</issue>
|
||||
</lint>
|
@ -19,15 +19,18 @@ import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.demo.compose.buttons.PlayPauseButton
|
||||
import androidx.media3.demo.compose.data.videos
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.compose.PlayerSurface
|
||||
@ -39,11 +42,6 @@ class MainActivity : ComponentActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
Surface {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exoPlayer = remember {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
@ -53,13 +51,15 @@ class MainActivity : ComponentActivity() {
|
||||
repeatMode = Player.REPEAT_MODE_ONE
|
||||
}
|
||||
}
|
||||
PlayerSurface(
|
||||
player = exoPlayer,
|
||||
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
)
|
||||
}
|
||||
}
|
||||
MediaPlayerScreen(player = exoPlayer, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaPlayerScreen(player: Player, modifier: Modifier = Modifier) {
|
||||
Box(modifier) {
|
||||
PlayerSurface(player = player, surfaceType = SURFACE_TYPE_SURFACE_VIEW)
|
||||
PlayPauseButton(player, Modifier.align(Alignment.Center).size(100.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2024 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.media3.demo.compose.buttons
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PauseCircle
|
||||
import androidx.compose.material.icons.filled.PlayCircle
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.demo.compose.R
|
||||
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
|
||||
|
||||
@Composable
|
||||
internal fun PlayPauseButton(player: Player, modifier: Modifier = Modifier) {
|
||||
val state = rememberPlayPauseButtonState(player)
|
||||
val icon = if (state.showPlay) Icons.Default.PlayCircle else Icons.Default.PauseCircle
|
||||
val contentDescription =
|
||||
if (state.showPlay) stringResource(R.string.playpause_button_play)
|
||||
else stringResource(R.string.playpause_button_pause)
|
||||
IconButton(onClick = state::onClick, modifier = modifier, enabled = state.isEnabled) {
|
||||
Icon(icon, contentDescription = contentDescription, modifier = modifier)
|
||||
}
|
||||
}
|
@ -15,12 +15,6 @@
|
||||
-->
|
||||
<resources>
|
||||
<string name="app_name">Media3 Compose Demo</string>
|
||||
<string name="current_playlist_name">Current playlist</string>
|
||||
<string name="open_player_content_description">Click to view your play list</string>
|
||||
<string name="added_media_item_format">Added %1$s to playlist</string>
|
||||
<string name="shuffle">Shuffle</string>
|
||||
<string name="play_button">Play</string>
|
||||
<string name="waiting_for_metadata">Waiting for playlist to load…</string>
|
||||
<string name="notification_permission_denied">
|
||||
"Without notification access the app can't warn about failed background operations"</string>
|
||||
<string name="playpause_button_play">Play</string>
|
||||
<string name="playpause_button_pause">Pause</string>
|
||||
</resources>
|
||||
|
@ -55,6 +55,11 @@ dependencies {
|
||||
|
||||
implementation 'androidx.compose.foundation:foundation'
|
||||
implementation 'androidx.core:core:' + androidxCoreVersion
|
||||
|
||||
testImplementation 'androidx.compose.ui:ui-test'
|
||||
testImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||
testImplementation project(modulePrefix + 'test-utils')
|
||||
testImplementation 'org.robolectric:robolectric:' + robolectricVersion
|
||||
}
|
||||
|
||||
ext {
|
||||
|
@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright 2024 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.media3.ui.compose.state
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.common.listen
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.common.util.Util.handlePauseButtonAction
|
||||
import androidx.media3.common.util.Util.handlePlayButtonAction
|
||||
import androidx.media3.common.util.Util.handlePlayPauseButtonAction
|
||||
import androidx.media3.common.util.Util.shouldEnablePlayPauseButton
|
||||
import androidx.media3.common.util.Util.shouldShowPlayButton
|
||||
|
||||
/**
|
||||
* Remembers the value of [PlayPauseButtonState] created based on the passed [Player] and launch a
|
||||
* coroutine to listen to [Player]'s changes. If the [Player] instance changes between compositions,
|
||||
* produce and remember a new value.
|
||||
*/
|
||||
@UnstableApi
|
||||
@Composable
|
||||
fun rememberPlayPauseButtonState(player: Player): PlayPauseButtonState {
|
||||
val playPauseButtonState = remember(player) { PlayPauseButtonState(player) }
|
||||
LaunchedEffect(player) { playPauseButtonState.observe() }
|
||||
return playPauseButtonState
|
||||
}
|
||||
|
||||
/**
|
||||
* State that converts the necessary information from the [Player] to correctly deal with a UI
|
||||
* component representing a PlayPause button.
|
||||
*
|
||||
* @property[isEnabled] determined by `isCommandAvailable(Player.COMMAND_PLAY_PAUSE)` and having
|
||||
* something in the [Timeline] to play
|
||||
* @property[showPlay] determined by [shouldShowPlayButton]
|
||||
*/
|
||||
@UnstableApi
|
||||
class PlayPauseButtonState(private val player: Player) {
|
||||
var isEnabled by mutableStateOf(shouldEnablePlayPauseButton(player))
|
||||
private set
|
||||
|
||||
var showPlay by mutableStateOf(shouldShowPlayButton(player))
|
||||
private set
|
||||
|
||||
/**
|
||||
* Handles the interaction with the PlayPause button according to the current state of the
|
||||
* [Player].
|
||||
*
|
||||
* The [Player] update that follows can take a form of [Player.play], [Player.pause],
|
||||
* [Player.prepare] or [Player.seekToDefaultPosition].
|
||||
*
|
||||
* @see [handlePlayButtonAction]
|
||||
* @see [handlePauseButtonAction]
|
||||
* @see [shouldShowPlayButton]
|
||||
*/
|
||||
fun onClick() {
|
||||
handlePlayPauseButtonAction(player)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to updates from [Player.Events] and listens to
|
||||
* * [Player.EVENT_PLAYBACK_STATE_CHANGED] and [Player.EVENT_PLAY_WHEN_READY_CHANGED] in order to
|
||||
* determine whether a play or a pause button should be presented on a UI element for playback
|
||||
* control.
|
||||
* * [Player.EVENT_AVAILABLE_COMMANDS_CHANGED] in order to determine whether the button should be
|
||||
* enabled, i.e. respond to user input.
|
||||
*/
|
||||
suspend fun observe(): Nothing =
|
||||
player.listen { events ->
|
||||
if (
|
||||
events.containsAny(
|
||||
Player.EVENT_PLAYBACK_STATE_CHANGED,
|
||||
Player.EVENT_PLAY_WHEN_READY_CHANGED,
|
||||
Player.EVENT_AVAILABLE_COMMANDS_CHANGED,
|
||||
)
|
||||
) {
|
||||
showPlay = shouldShowPlayButton(this)
|
||||
isEnabled = shouldEnablePlayPauseButton(this)
|
||||
}
|
||||
}
|
||||
}
|
@ -14,6 +14,22 @@
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<manifest package="androidx.media3.ui.compose.test">
|
||||
<manifest package="androidx.media3.ui.compose.test"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-sdk/>
|
||||
|
||||
<application>
|
||||
<!--
|
||||
Setting a base ComponentActivity as the test app's main activity.
|
||||
The androidx.compose.ui.test.junit4.createComposeRule() defaults to this
|
||||
activity, so in order for the runner to resolve it, it must be defined here.
|
||||
-->
|
||||
<activity android:name="androidx.activity.ComponentActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
@ -0,0 +1,206 @@
|
||||
/*
|
||||
* Copyright 2024 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.media3.ui.compose.state
|
||||
|
||||
import android.os.Looper
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.SimpleBasePlayer
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.collect.ImmutableList
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/** Unit test for [PlayPauseButtonState]. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PlayPauseButtonStateTest {
|
||||
|
||||
@get:Rule val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun playerIsBuffering_pausePlayer_playIconShowing() {
|
||||
val player = TestPlayer()
|
||||
player.playbackState = Player.STATE_BUFFERING
|
||||
player.play()
|
||||
|
||||
lateinit var state: PlayPauseButtonState
|
||||
composeTestRule.setContent { state = rememberPlayPauseButtonState(player = player) }
|
||||
|
||||
assertThat(state.showPlay).isFalse()
|
||||
|
||||
player.pause()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
assertThat(state.showPlay).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playerIsIdling_preparePlayer_pauseIconShowing() {
|
||||
val player = TestPlayer()
|
||||
player.playbackState = Player.STATE_IDLE
|
||||
player.play()
|
||||
|
||||
lateinit var state: PlayPauseButtonState
|
||||
composeTestRule.setContent { state = rememberPlayPauseButtonState(player = player) }
|
||||
|
||||
assertThat(state.showPlay).isTrue()
|
||||
|
||||
player.prepare()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
assertThat(state.showPlay).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addPlayPauseCommandToPlayer_buttonStateTogglesFromDisabledToEnabled() {
|
||||
val player = TestPlayer()
|
||||
player.playbackState = Player.STATE_READY
|
||||
player.play()
|
||||
player.removeCommands(Player.COMMAND_PLAY_PAUSE)
|
||||
|
||||
lateinit var state: PlayPauseButtonState
|
||||
composeTestRule.setContent { state = rememberPlayPauseButtonState(player = player) }
|
||||
|
||||
assertThat(state.isEnabled).isFalse()
|
||||
|
||||
player.addCommands(Player.COMMAND_PLAY_PAUSE)
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
assertThat(state.isEnabled).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playerInReadyState_buttonClicked_playerPaused() {
|
||||
val player = TestPlayer()
|
||||
player.playbackState = Player.STATE_READY
|
||||
player.play()
|
||||
|
||||
val state = PlayPauseButtonState(player)
|
||||
|
||||
assertThat(state.showPlay).isFalse()
|
||||
assertThat(player.playWhenReady).isTrue()
|
||||
|
||||
state.onClick() // Player pauses
|
||||
|
||||
assertThat(player.playWhenReady).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playerInEndedState_buttonClicked_playerBuffersAndPlays() {
|
||||
val player = TestPlayer()
|
||||
player.playbackState = Player.STATE_ENDED
|
||||
player.setPosition(456)
|
||||
val state = PlayPauseButtonState(player)
|
||||
|
||||
assertThat(state.showPlay).isTrue()
|
||||
|
||||
state.onClick() // Player seeks to default position and plays
|
||||
|
||||
assertThat(player.contentPosition).isEqualTo(0)
|
||||
assertThat(player.playWhenReady).isTrue()
|
||||
assertThat(player.playbackState).isEqualTo(Player.STATE_BUFFERING)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playerInIdleState_buttonClicked_playerBuffersAndPlays() {
|
||||
val player = TestPlayer()
|
||||
player.playbackState = Player.STATE_IDLE
|
||||
val state = PlayPauseButtonState(player)
|
||||
|
||||
assertThat(state.showPlay).isTrue() // Player not prepared, Play icon
|
||||
|
||||
state.onClick() // Player prepares and goes into buffering
|
||||
|
||||
assertThat(player.playWhenReady).isTrue()
|
||||
assertThat(player.playbackState).isEqualTo(Player.STATE_BUFFERING)
|
||||
}
|
||||
}
|
||||
|
||||
private class TestPlayer : SimpleBasePlayer(Looper.myLooper()!!) {
|
||||
private var state =
|
||||
State.Builder()
|
||||
.setAvailableCommands(Player.Commands.Builder().addAllCommands().build())
|
||||
.setPlaylist(ImmutableList.of(MediaItemData.Builder(/* uid= */ Any()).build()))
|
||||
.build()
|
||||
|
||||
override fun getState(): State {
|
||||
return state
|
||||
}
|
||||
|
||||
override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> {
|
||||
state =
|
||||
state
|
||||
.buildUpon()
|
||||
.setPlayWhenReady(playWhenReady, PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST)
|
||||
.build()
|
||||
return Futures.immediateVoidFuture()
|
||||
}
|
||||
|
||||
override fun handlePrepare(): ListenableFuture<*> {
|
||||
state =
|
||||
state
|
||||
.buildUpon()
|
||||
.setPlayerError(null)
|
||||
.setPlaybackState(if (state.timeline.isEmpty) STATE_ENDED else STATE_BUFFERING)
|
||||
.build()
|
||||
return Futures.immediateVoidFuture()
|
||||
}
|
||||
|
||||
override fun handleSeek(
|
||||
mediaItemIndex: Int,
|
||||
positionMs: Long,
|
||||
seekCommand: @Player.Command Int,
|
||||
): ListenableFuture<*> {
|
||||
state =
|
||||
state.buildUpon().setPlaybackState(STATE_BUFFERING).setContentPositionMs(positionMs).build()
|
||||
return Futures.immediateVoidFuture()
|
||||
}
|
||||
|
||||
fun setPlaybackState(playbackState: @Player.State Int) {
|
||||
state = state.buildUpon().setPlaybackState(playbackState).build()
|
||||
invalidateState()
|
||||
}
|
||||
|
||||
fun setPosition(positionMs: Long) {
|
||||
state = state.buildUpon().setContentPositionMs(positionMs).build()
|
||||
invalidateState()
|
||||
}
|
||||
|
||||
fun removeCommands(vararg commands: @Player.Command Int) {
|
||||
state =
|
||||
state
|
||||
.buildUpon()
|
||||
.setAvailableCommands(
|
||||
Player.Commands.Builder().addAllCommands().removeAll(*commands).build()
|
||||
)
|
||||
.build()
|
||||
invalidateState()
|
||||
}
|
||||
|
||||
fun addCommands(vararg commands: @Player.Command Int) {
|
||||
state =
|
||||
state
|
||||
.buildUpon()
|
||||
.setAvailableCommands(Player.Commands.Builder().addAll(*commands).build())
|
||||
.build()
|
||||
invalidateState()
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user