diff --git a/app/src/main/java/com/fluxa/app/player/AndroidPlaybackController.kt b/app/src/main/java/com/fluxa/app/player/AndroidPlaybackController.kt new file mode 100644 index 0000000..e21fca1 --- /dev/null +++ b/app/src/main/java/com/fluxa/app/player/AndroidPlaybackController.kt @@ -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 = 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 + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerContracts.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerContracts.kt new file mode 100644 index 0000000..c4d405f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerContracts.kt @@ -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 + suspend fun dispatch(action: PlayerAction) +} diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerScreen.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerScreen.kt new file mode 100644 index 0000000..268c12a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerScreen.kt @@ -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")) + } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerStore.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerStore.kt new file mode 100644 index 0000000..ebecb78 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerStore.kt @@ -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 = controller.state + + suspend fun dispatch(action: PlayerAction) { + controller.dispatch(action) + } +}