Add shared player controls boundary

This commit is contained in:
KhooLy 2026-07-13 15:37:40 +03:00
parent 42f905af62
commit fa4adcc5d8
4 changed files with 173 additions and 0 deletions

View file

@ -0,0 +1,54 @@
package com.fluxa.app.player
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import com.fluxa.app.shared.feature.player.PlaybackController
import com.fluxa.app.shared.feature.player.PlayerAction
import com.fluxa.app.shared.feature.player.PlayerContentUiModel
import com.fluxa.app.shared.feature.player.PlayerUiState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class AndroidPlaybackController(
private val player: ExoPlayer,
private val content: () -> PlayerContentUiModel?
) : PlaybackController, Player.Listener {
private val mutableState = MutableStateFlow(snapshot())
override val state: StateFlow<PlayerUiState> = mutableState.asStateFlow()
init {
player.addListener(this)
}
override suspend fun dispatch(action: PlayerAction) {
when (action) {
PlayerAction.PlayPause -> if (player.isPlaying) player.pause() else player.play()
is PlayerAction.SeekTo -> player.seekTo(action.positionMs)
is PlayerAction.SetVolume -> player.volume = action.volume.coerceIn(0f, 1f)
is PlayerAction.SetSubtitleEnabled -> Unit
PlayerAction.Stop -> player.stop()
}
mutableState.value = snapshot()
}
override fun onEvents(player: Player, events: Player.Events) {
mutableState.value = snapshot()
}
fun close() {
player.removeListener(this)
}
private fun snapshot(): PlayerUiState {
return PlayerUiState(
content = content(),
isPlaying = player.isPlaying,
isBuffering = player.playbackState == Player.STATE_BUFFERING,
positionMs = player.currentPosition.coerceAtLeast(0L),
durationMs = player.duration.coerceAtLeast(0L),
bufferedPositionMs = player.bufferedPosition.coerceAtLeast(0L),
volume = player.volume
)
}
}

View file

@ -0,0 +1,38 @@
package com.fluxa.app.shared.feature.player
import kotlinx.coroutines.flow.StateFlow
data class PlayerContentUiModel(
val id: String,
val type: String,
val title: String,
val subtitle: String,
val logoUrl: String?,
val backgroundUrl: String?,
val streamLabel: String
)
data class PlayerUiState(
val content: PlayerContentUiModel? = null,
val isPlaying: Boolean = false,
val isBuffering: Boolean = false,
val positionMs: Long = 0L,
val durationMs: Long = 0L,
val bufferedPositionMs: Long = 0L,
val volume: Float = 1f,
val subtitleEnabled: Boolean = false,
val errorKey: String? = null
)
sealed interface PlayerAction {
data object PlayPause : PlayerAction
data class SeekTo(val positionMs: Long) : PlayerAction
data class SetVolume(val volume: Float) : PlayerAction
data class SetSubtitleEnabled(val enabled: Boolean) : PlayerAction
data object Stop : PlayerAction
}
interface PlaybackController {
val state: StateFlow<PlayerUiState>
suspend fun dispatch(action: PlayerAction)
}

View file

@ -0,0 +1,68 @@
package com.fluxa.app.shared.feature.player
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.fluxa.app.common.AppStrings
import com.fluxa.app.ui.catalog.FluxaColors
@Composable
fun PlayerScreen(
state: PlayerUiState,
language: String?,
onAction: (PlayerAction) -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black)
) {
if (state.isBuffering) {
CircularProgressIndicator(
color = Color.White,
modifier = Modifier.align(Alignment.Center)
)
}
state.content?.let { content ->
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(FluxaColors.background.copy(alpha = 0.86f))
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(text = content.title, color = Color.White, fontWeight = FontWeight.Bold)
if (content.subtitle.isNotBlank()) {
Text(text = content.subtitle, color = Color.White.copy(alpha = 0.72f))
}
if (content.streamLabel.isNotBlank()) {
Text(text = content.streamLabel, color = Color.White.copy(alpha = 0.72f))
}
Slider(
value = state.positionMs.toFloat(),
onValueChange = { onAction(PlayerAction.SeekTo(it.toLong())) },
valueRange = 0f..state.durationMs.coerceAtLeast(1L).toFloat()
)
Button(onClick = { onAction(PlayerAction.PlayPause) }) {
Text(AppStrings.t(language, if (state.isPlaying) "common.pause" else "common.play"))
}
}
}
}
}

View file

@ -0,0 +1,13 @@
package com.fluxa.app.shared.feature.player
import kotlinx.coroutines.flow.StateFlow
class PlayerStore(
private val controller: PlaybackController
) {
val state: StateFlow<PlayerUiState> = controller.state
suspend fun dispatch(action: PlayerAction) {
controller.dispatch(action)
}
}