Skip to content

Commit 86f7dce

Browse files
MGaetan89StaehliJ
andauthored
Improve the multi player showcase (#478)
Co-authored-by: Joaquim Stähli <[email protected]>
1 parent 99e2300 commit 86f7dce

File tree

7 files changed

+208
-63
lines changed

7 files changed

+208
-63
lines changed

pillarbox-demo/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ dependencies {
7575
implementation(libs.androidx.lifecycle.viewmodel)
7676
implementation(libs.androidx.lifecycle.viewmodel.compose)
7777
implementation(libs.androidx.lifecycle.viewmodel.ktx)
78+
implementation(libs.androidx.media)
7879
implementation(libs.androidx.media3.common)
7980
implementation(libs.androidx.media3.exoplayer)
8081
implementation(libs.androidx.media3.session)

pillarbox-demo/src/main/AndroidManifest.xml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
33
xmlns:tools="http://schemas.android.com/tools">
44

5+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
56
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
6-
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
7+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
8+
79
<application
810
android:name=".DemoApplication"
911
android:allowBackup="true"
@@ -26,8 +28,8 @@
2628

2729
<activity
2830
android:name=".MainActivity"
29-
android:theme="@style/Theme.PillarboxDemo"
30-
android:exported="true">
31+
android:exported="true"
32+
android:theme="@style/Theme.PillarboxDemo">
3133
<intent-filter>
3234
<action android:name="android.intent.action.MAIN" />
3335

pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/controls/PlayerControls.kt

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,12 @@ import androidx.compose.ui.graphics.Color
2020
import androidx.media3.common.Player
2121
import ch.srgssr.pillarbox.ui.extension.currentMediaMetadataAsState
2222

23-
private val controlsBackgroundColor = Color.Black.copy(0.5f)
24-
2523
/**
2624
* Player controls
2725
*
2826
* @param player The [Player] to interact with.
2927
* @param modifier The modifier to be applied to the layout.
28+
* @param backgroundColor The background color to apply behind the controls.
3029
* @param interactionSource The interaction source of the slider.
3130
* @param content The content to display under the slider.
3231
* @receiver
@@ -35,14 +34,13 @@ private val controlsBackgroundColor = Color.Black.copy(0.5f)
3534
fun PlayerControls(
3635
player: Player,
3736
modifier: Modifier = Modifier,
37+
backgroundColor: Color = Color.Black.copy(0.5f),
3838
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
3939
content: @Composable ColumnScope.() -> Unit,
4040
) {
4141
val mediaMetadata by player.currentMediaMetadataAsState()
4242
Box(
43-
modifier = modifier.then(
44-
Modifier.background(color = controlsBackgroundColor)
45-
),
43+
modifier = modifier.background(color = backgroundColor),
4644
contentAlignment = Alignment.Center
4745
) {
4846
Text(

pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/MultiPlayerShowcase.kt

Lines changed: 64 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -5,88 +5,66 @@
55
package ch.srgssr.pillarbox.demo.ui.showcases.misc
66

77
import android.content.res.Configuration
8+
import androidx.compose.foundation.clickable
9+
import androidx.compose.foundation.interaction.MutableInteractionSource
810
import androidx.compose.foundation.layout.Column
911
import androidx.compose.foundation.layout.Row
12+
import androidx.compose.foundation.layout.fillMaxSize
1013
import androidx.compose.foundation.layout.fillMaxWidth
1114
import androidx.compose.foundation.layout.padding
1215
import androidx.compose.material3.Button
1316
import androidx.compose.material3.MaterialTheme
1417
import androidx.compose.material3.Text
1518
import androidx.compose.runtime.Composable
16-
import androidx.compose.runtime.DisposableEffect
19+
import androidx.compose.runtime.collectAsState
1720
import androidx.compose.runtime.getValue
1821
import androidx.compose.runtime.movableContentOf
19-
import androidx.compose.runtime.mutableStateOf
2022
import androidx.compose.runtime.remember
21-
import androidx.compose.runtime.setValue
2223
import androidx.compose.ui.Alignment
2324
import androidx.compose.ui.Modifier
25+
import androidx.compose.ui.draw.drawWithContent
26+
import androidx.compose.ui.graphics.Color
2427
import androidx.compose.ui.platform.LocalConfiguration
25-
import androidx.compose.ui.platform.LocalContext
26-
import androidx.lifecycle.compose.LifecycleResumeEffect
28+
import androidx.lifecycle.viewmodel.compose.viewModel
2729
import androidx.media3.common.Player
28-
import ch.srgssr.pillarbox.demo.shared.data.DemoItem
29-
import ch.srgssr.pillarbox.demo.shared.di.PlayerModule
30-
import ch.srgssr.pillarbox.demo.ui.player.PlayerView
30+
import ch.srgssr.pillarbox.demo.ui.player.controls.PlayerControls
3131
import ch.srgssr.pillarbox.demo.ui.theme.paddings
32+
import ch.srgssr.pillarbox.ui.widget.player.PlayerSurface
3233

3334
/**
34-
* Demo of 2 player swapping view
35+
* Demo displaying two players, that can be swapped.
36+
* At any given moment, there's always only one player with sound active.
3537
*/
3638
@Composable
3739
fun MultiPlayerShowcase() {
38-
var swapLeftRight by remember {
39-
mutableStateOf(false)
40-
}
41-
val context = LocalContext.current
42-
val playerOne = remember {
43-
PlayerModule.provideDefaultPlayer(context).apply {
44-
repeatMode = Player.REPEAT_MODE_ONE
45-
setMediaItem(DemoItem.LiveVideo.toMediaItem())
46-
prepare()
47-
}
48-
}
49-
val playerTwo = remember {
50-
PlayerModule.provideDefaultPlayer(context).apply {
51-
repeatMode = Player.REPEAT_MODE_ONE
52-
setMediaItem(DemoItem.DvrVideo.toMediaItem())
53-
prepare()
54-
}
55-
}
56-
DisposableEffect(Unit) {
57-
onDispose {
58-
playerOne.release()
59-
playerTwo.release()
60-
}
61-
}
62-
LifecycleResumeEffect(Unit) {
63-
playerOne.play()
64-
playerTwo.play()
65-
onPauseOrDispose {
66-
playerOne.pause()
67-
playerTwo.pause()
68-
}
69-
}
40+
val multiPlayerViewModel = viewModel<MultiPlayerViewModel>()
41+
val activePlayer by multiPlayerViewModel.activePlayer.collectAsState()
42+
val playerOne by multiPlayerViewModel.playerOne.collectAsState()
43+
val playerTwo by multiPlayerViewModel.playerTwo.collectAsState()
44+
7045
Column(horizontalAlignment = Alignment.CenterHorizontally) {
71-
Button(onClick = { swapLeftRight = !swapLeftRight }) {
46+
Button(onClick = multiPlayerViewModel::swapPlayers) {
7247
Text(text = "Swap players")
7348
}
49+
7450
val players = remember {
7551
movableContentOf {
76-
PlayerView(
77-
modifier = Modifier
78-
.weight(1.0f)
79-
.padding(MaterialTheme.paddings.mini),
80-
player = if (swapLeftRight) playerTwo else playerOne,
52+
ActivablePlayer(
53+
player = playerOne,
54+
isActive = activePlayer == playerOne,
55+
modifier = Modifier.weight(1f),
56+
onClick = { multiPlayerViewModel.setActivePlayer(playerOne) },
8157
)
82-
PlayerView(
83-
modifier = Modifier
84-
.weight(1.0f)
85-
.padding(MaterialTheme.paddings.mini),
86-
player = if (swapLeftRight) playerOne else playerTwo,
58+
59+
ActivablePlayer(
60+
player = playerTwo,
61+
isActive = activePlayer == playerTwo,
62+
modifier = Modifier.weight(1f),
63+
onClick = { multiPlayerViewModel.setActivePlayer(playerTwo) },
8764
)
8865
}
8966
}
67+
9068
if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE) {
9169
Row(modifier = Modifier.fillMaxWidth()) {
9270
players()
@@ -98,3 +76,37 @@ fun MultiPlayerShowcase() {
9876
}
9977
}
10078
}
79+
80+
@Composable
81+
private fun ActivablePlayer(
82+
player: Player,
83+
isActive: Boolean,
84+
modifier: Modifier = Modifier,
85+
onClick: () -> Unit,
86+
) {
87+
PlayerSurface(
88+
modifier = modifier
89+
.padding(MaterialTheme.paddings.mini)
90+
.clickable(
91+
interactionSource = remember { MutableInteractionSource() },
92+
indication = null,
93+
enabled = !isActive,
94+
onClick = onClick,
95+
),
96+
player = player,
97+
) {
98+
val inactivePlayerOverlay = Modifier.drawWithContent {
99+
drawContent()
100+
drawRect(Color.LightGray.copy(alpha = 0.7f))
101+
}
102+
103+
PlayerControls(
104+
player = player,
105+
modifier = Modifier
106+
.fillMaxSize()
107+
.then(if (isActive) Modifier else inactivePlayerOverlay),
108+
backgroundColor = Color.Unspecified,
109+
content = {},
110+
)
111+
}
112+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/*
2+
* Copyright (c) SRG SSR. All rights reserved.
3+
* License information is available from the LICENSE file.
4+
*/
5+
package ch.srgssr.pillarbox.demo.ui.showcases.misc
6+
7+
import android.app.Application
8+
import androidx.lifecycle.AndroidViewModel
9+
import androidx.lifecycle.viewModelScope
10+
import androidx.media3.common.C
11+
import androidx.media3.common.Player
12+
import androidx.media3.session.MediaSession
13+
import androidx.media3.ui.PlayerNotificationManager
14+
import ch.srgssr.pillarbox.demo.shared.data.DemoItem
15+
import ch.srgssr.pillarbox.demo.shared.di.PlayerModule
16+
import ch.srgssr.pillarbox.player.PillarboxPlayer
17+
import ch.srgssr.pillarbox.player.extension.disableAudioTrack
18+
import ch.srgssr.pillarbox.player.notification.PillarboxMediaDescriptionAdapter
19+
import kotlinx.coroutines.flow.MutableStateFlow
20+
import kotlinx.coroutines.flow.SharingStarted
21+
import kotlinx.coroutines.flow.StateFlow
22+
import kotlinx.coroutines.flow.map
23+
import kotlinx.coroutines.flow.stateIn
24+
import kotlinx.coroutines.flow.update
25+
26+
/**
27+
* The [ViewModel][androidx.lifecycle.ViewModel] for the [MultiPlayerShowcase].
28+
*
29+
* @param application The running [Application].
30+
*/
31+
class MultiPlayerViewModel(application: Application) : AndroidViewModel(application) {
32+
private val notificationManager = PlayerNotificationManager.Builder(application, NOTIFICATION_ID, CHANNEL_ID)
33+
.setChannelNameResourceId(androidx.media3.session.R.string.default_notification_channel_name)
34+
.setMediaDescriptionAdapter(PillarboxMediaDescriptionAdapter(null, application))
35+
.build()
36+
private val mediaSession: MediaSession
37+
38+
private val _playerOne = PlayerModule.provideDefaultPlayer(application).apply {
39+
repeatMode = Player.REPEAT_MODE_ONE
40+
setMediaItem(DemoItem.LiveVideo.toMediaItem())
41+
prepare()
42+
play()
43+
}
44+
private val _playerTwo = PlayerModule.provideDefaultPlayer(application).apply {
45+
repeatMode = Player.REPEAT_MODE_ONE
46+
setMediaItem(DemoItem.DvrVideo.toMediaItem())
47+
prepare()
48+
play()
49+
}
50+
51+
private val _activePlayer = MutableStateFlow(_playerOne)
52+
private val swapPlayers = MutableStateFlow(false)
53+
54+
/**
55+
* The currently active player.
56+
*/
57+
val activePlayer: StateFlow<PillarboxPlayer> = _activePlayer
58+
59+
/**
60+
* The first player to display.
61+
*/
62+
val playerOne = swapPlayers.map { swapPlayers ->
63+
if (swapPlayers) _playerTwo else _playerOne
64+
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), _playerOne)
65+
66+
/**
67+
* The second play to display.
68+
*/
69+
val playerTwo = swapPlayers.map { swapPlayers ->
70+
if (swapPlayers) _playerOne else _playerTwo
71+
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), _playerTwo)
72+
73+
init {
74+
mediaSession = MediaSession.Builder(application, _playerTwo)
75+
.setId("MultiPlayerSession")
76+
.build()
77+
notificationManager.setMediaSessionToken(mediaSession.sessionCompatToken)
78+
setActivePlayer(_playerOne)
79+
}
80+
81+
/**
82+
* Set the currently active player.
83+
*
84+
* @param activePlayer The new active player.
85+
*/
86+
fun setActivePlayer(activePlayer: PillarboxPlayer) {
87+
val oldActivePlayer = mediaSession.player as PillarboxPlayer
88+
_activePlayer.update { activePlayer }
89+
mediaSession.player = activePlayer
90+
notificationManager.setPlayer(activePlayer)
91+
92+
oldActivePlayer.disableAudioTrack()
93+
oldActivePlayer.trackSelectionParameters = oldActivePlayer.trackSelectionParameters.buildUpon().setTrackTypeDisabled(
94+
C.TRACK_TYPE_AUDIO,
95+
true
96+
).build()
97+
oldActivePlayer.trackingEnabled = false
98+
oldActivePlayer.setHandleAudioFocus(false)
99+
oldActivePlayer.setHandleAudioBecomingNoisy(false)
100+
101+
activePlayer.trackSelectionParameters = activePlayer.trackSelectionParameters.buildUpon().setTrackTypeDisabled(
102+
C.TRACK_TYPE_AUDIO,
103+
false
104+
).build()
105+
activePlayer.trackingEnabled = true
106+
activePlayer.setHandleAudioFocus(true)
107+
activePlayer.setHandleAudioBecomingNoisy(true)
108+
}
109+
110+
/**
111+
* Swap the two players.
112+
*/
113+
fun swapPlayers() {
114+
swapPlayers.update { !it }
115+
}
116+
117+
override fun onCleared() {
118+
notificationManager.setPlayer(null)
119+
mediaSession.release()
120+
121+
_playerOne.release()
122+
_playerTwo.release()
123+
}
124+
125+
private companion object {
126+
private const val NOTIFICATION_ID = 42
127+
private const val CHANNEL_ID = "DemoChannel"
128+
}
129+
}

pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/UpdatableMediaItemViewModel.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import androidx.lifecycle.AndroidViewModel
99
import androidx.lifecycle.viewModelScope
1010
import androidx.media3.common.MediaItem
1111
import androidx.media3.common.MediaMetadata
12+
import androidx.media3.session.MediaSession
1213
import androidx.media3.ui.PlayerNotificationManager
1314
import ch.srgssr.pillarbox.core.business.SRGMediaItemBuilder
1415
import ch.srgssr.pillarbox.demo.shared.data.DemoItem
@@ -38,6 +39,7 @@ class UpdatableMediaItemViewModel(application: Application) : AndroidViewModel(a
3839
private val timer: Timer
3940
private val baseTitle = "Update title"
4041
private var counter = 0
42+
private val mediaSession = MediaSession.Builder(application, player).build()
4143

4244
init {
4345
player.prepare()
@@ -48,6 +50,7 @@ class UpdatableMediaItemViewModel(application: Application) : AndroidViewModel(a
4850
.setMediaDescriptionAdapter(PillarboxMediaDescriptionAdapter(context = application, pendingIntent = null))
4951
.build()
5052
notificationManager.setPlayer(player)
53+
notificationManager.setMediaSessionToken(mediaSession.sessionCompatToken)
5154

5255
timer = timer(name = "update-item", period = 3.seconds.inWholeMilliseconds) {
5356
viewModelScope.launch(Dispatchers.Main) {
@@ -91,6 +94,7 @@ class UpdatableMediaItemViewModel(application: Application) : AndroidViewModel(a
9194
super.onCleared()
9295
timer.cancel()
9396
notificationManager.setPlayer(null)
97+
mediaSession.release()
9498
player.release()
9599
}
96100

0 commit comments

Comments
 (0)