diff --git a/app/src/main/java/dev/khaled/leanstream/player/Player.kt b/app/src/main/java/dev/khaled/leanstream/player/Player.kt index 5475d1a..699d705 100644 --- a/app/src/main/java/dev/khaled/leanstream/player/Player.kt +++ b/app/src/main/java/dev/khaled/leanstream/player/Player.kt @@ -1,8 +1,9 @@ package dev.khaled.leanstream.player +import android.content.Context +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -12,32 +13,62 @@ import androidx.media3.common.MediaItem import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerView import dev.khaled.leanstream.channels.Channel +import dev.khaled.leanstream.player.controller.PlayerController @Composable fun Player(channel: Channel, navigateBack: () -> Unit) { - val exoPlayer = ExoPlayer.Builder(LocalContext.current).build() + val exoPlayer = rememberExoPlayer(LocalContext.current) val mediaSource = remember { MediaItem.fromUri(channel.url) } LaunchedEffect(Unit) { exoPlayer.setMediaItem(mediaSource) exoPlayer.prepare() - exoPlayer.playWhenReady = true } - DisposableEffect(Unit) { - onDispose { exoPlayer.release() } - } + BackHandler(onBack = navigateBack) + + AndroidView(modifier = Modifier.fillMaxSize(), factory = { + PlayerView(it).apply { + player = exoPlayer + useController = false + } + }, update = { it.player = exoPlayer }, onRelease = { exoPlayer.release() }) - AndroidView( + PlayerController( modifier = Modifier.fillMaxSize(), - factory = { - PlayerView(it).apply { - player = exoPlayer - useController = false - } - }, + channel = channel, + player = exoPlayer, + backHandler = navigateBack ) +} + + +@Composable +private fun rememberExoPlayer(context: Context) = remember { + ExoPlayer.Builder(context).build().apply { + playWhenReady = true + } +} -} \ No newline at end of file +//private fun Modifier.dPadEvents( +// exoPlayer: ExoPlayer, +// videoPlayerState: VideoPlayerState, +// pulseState: VideoPlayerPulseState +//): Modifier = this.handleDPadKeyEvents( +// onLeft = { +// exoPlayer.seekBack() +// pulseState.setType(BACK) +// }, +// onRight = { +// exoPlayer.seekForward() +// pulseState.setType(FORWARD) +// }, +// onUp = { videoPlayerState.showControls() }, +// onDown = { videoPlayerState.showControls() }, +// onEnter = { +// exoPlayer.pause() +// videoPlayerState.showControls() +// } +//) \ No newline at end of file diff --git a/app/src/main/java/dev/khaled/leanstream/player/controller/ChannelInfo.kt b/app/src/main/java/dev/khaled/leanstream/player/controller/ChannelInfo.kt new file mode 100644 index 0000000..b3d4b9f --- /dev/null +++ b/app/src/main/java/dev/khaled/leanstream/player/controller/ChannelInfo.kt @@ -0,0 +1,46 @@ +package dev.khaled.leanstream.player.controller + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.BrokenImage +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import dev.khaled.leanstream.channels.Channel + +@Composable +fun ChannelInfo(channel: Channel) { + Card { + Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + text = channel.title ?: channel.url, + style = MaterialTheme.typography.titleLarge, + ) + Spacer(modifier = Modifier.width(8.dp)) + AsyncImage( + model = channel.icon, + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .border(2.dp, MaterialTheme.colorScheme.primary, CircleShape), + contentDescription = null, + contentScale = ContentScale.FillBounds, + error = rememberVectorPainter(Icons.Rounded.BrokenImage), + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/khaled/leanstream/player/controller/ExtraControls.kt b/app/src/main/java/dev/khaled/leanstream/player/controller/ExtraControls.kt new file mode 100644 index 0000000..8fc69c4 --- /dev/null +++ b/app/src/main/java/dev/khaled/leanstream/player/controller/ExtraControls.kt @@ -0,0 +1,24 @@ +package dev.khaled.leanstream.player.controller + +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowBackIosNew +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable + +@Composable +fun ExtraControls( + backHandler: () -> Unit, +// toggleFullScreen: () -> Unit, +) { + Row { + FilledTonalIconButton(onClick = backHandler) { + Icon(Icons.Rounded.ArrowBackIosNew, contentDescription = null) + } +// FilledTonalIconButton(onClick = toggleFullScreen) { +// Icon(Icons.Rounded.Fullscreen, contentDescription = null) +// } + } +} + diff --git a/app/src/main/java/dev/khaled/leanstream/player/controller/PlayPauseToggle.kt b/app/src/main/java/dev/khaled/leanstream/player/controller/PlayPauseToggle.kt new file mode 100644 index 0000000..c4df143 --- /dev/null +++ b/app/src/main/java/dev/khaled/leanstream/player/controller/PlayPauseToggle.kt @@ -0,0 +1,28 @@ +package dev.khaled.leanstream.player.controller + +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun PlayPauseToggle( + modifier: Modifier = Modifier, isPlaying: Boolean, error: String?, onToggle: () -> Unit, +) { + //TODO Show error message somehow?? + FilledTonalIconButton(modifier = modifier.size(80.dp), onClick = onToggle) { + Icon( + when { + error != null -> Icons.Rounded.ErrorOutline + isPlaying -> Icons.Rounded.Pause + else -> Icons.Rounded.PlayArrow + }, contentDescription = null, modifier = Modifier.size(80.dp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/khaled/leanstream/player/controller/PlayerController.kt b/app/src/main/java/dev/khaled/leanstream/player/controller/PlayerController.kt new file mode 100644 index 0000000..f7da271 --- /dev/null +++ b/app/src/main/java/dev/khaled/leanstream/player/controller/PlayerController.kt @@ -0,0 +1,147 @@ +package dev.khaled.leanstream.player.controller + +import android.content.pm.ActivityInfo +import android.os.Handler +import android.os.Looper +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.Player.STATE_BUFFERING +import androidx.media3.exoplayer.ExoPlayer +import dev.khaled.leanstream.channels.Channel +import dev.khaled.leanstream.conditional +import dev.khaled.leanstream.isRunningOnTV +import dev.khaled.leanstream.ui.ScreenOrientation + +@Composable +fun PlayerController( + modifier: Modifier = Modifier, + channel: Channel, + player: ExoPlayer, + backHandler: () -> Unit, +) { + + + val isRunningOnTV = isRunningOnTV(LocalContext.current) + + var controllerVisible by remember { mutableStateOf(true) } + + var isPlaying by remember { mutableStateOf(true) } + var isBuffering by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + + + val handler = remember { Handler(Looper.getMainLooper()) } + val controllerVisibilityRunnable = + remember { Runnable { if (controllerVisible) controllerVisible = false } } + + fun triggerHideController() = run { + handler.removeCallbacks(controllerVisibilityRunnable) + handler.postDelayed(controllerVisibilityRunnable, 3000) + } + + DisposableEffect(Unit) { + val listener = object : Player.Listener { + override fun onIsPlayingChanged(playing: Boolean) { + super.onIsPlayingChanged(playing) + isPlaying = playing + if (isPlaying) error = null + if (error == null) triggerHideController() + } + + override fun onPlaybackStateChanged(playbackState: Int) { + super.onPlaybackStateChanged(playbackState) + isBuffering = playbackState == STATE_BUFFERING + controllerVisible = true + } + + override fun onPlayerError(exception: PlaybackException) { + super.onPlayerError(exception) + error = exception.message + controllerVisible = true + } + } + + player.addListener(listener) + onDispose { + player.removeListener(listener) + } + } + + + ScreenOrientation(orientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) + + Box(modifier = Modifier + .fillMaxSize() + .conditional(!controllerVisible) { + clickable( + interactionSource = MutableInteractionSource(), + indication = null, + onClick = { + controllerVisible = true + triggerHideController() + }) + }) + + + + AnimatedVisibility( + modifier = modifier.fillMaxSize(), visible = controllerVisible, + ) { + Box(modifier = Modifier + .fillMaxSize() + .conditional(!isRunningOnTV) { + clickable { controllerVisible = false } + } + .background(Color.Black.copy(alpha = 0.3f))) { + + Box(modifier = Modifier.align(Alignment.Center)) { + if (isBuffering) CircularProgressIndicator() + else PlayPauseToggle(isPlaying = isPlaying, error = error) { + if (error != null) { + error = null + return@PlayPauseToggle player.prepare() + } + + if (isPlaying) player.pause() + else player.play() + triggerHideController() + } + } + + Row( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(16.dp) + ) { + if (!isRunningOnTV) ExtraControls(backHandler) + + Spacer(modifier = Modifier.weight(1f)) + + ChannelInfo(channel = channel) + } + } + } +} + + diff --git a/app/src/main/java/dev/khaled/leanstream/ui/ScreenOrientation.kt b/app/src/main/java/dev/khaled/leanstream/ui/ScreenOrientation.kt new file mode 100644 index 0000000..95b734b --- /dev/null +++ b/app/src/main/java/dev/khaled/leanstream/ui/ScreenOrientation.kt @@ -0,0 +1,28 @@ +package dev.khaled.leanstream.ui + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalContext + +@Composable +fun ScreenOrientation(orientation: Int) { + val context = LocalContext.current + DisposableEffect(orientation) { + val activity = context.findActivity() ?: return@DisposableEffect onDispose {} + val originalOrientation = activity.requestedOrientation + activity.requestedOrientation = orientation + onDispose { + // restore original orientation when view disappears + activity.requestedOrientation = originalOrientation + } + } +} + +fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +}