diff --git a/.editorconfig b/.editorconfig index 03e7ebb..5784868 100644 --- a/.editorconfig +++ b/.editorconfig @@ -60,7 +60,6 @@ ij_kotlin_extends_list_wrap = normal ij_kotlin_field_annotation_wrap = split_into_lines ij_kotlin_finally_on_new_line = false ij_kotlin_if_rparen_on_new_line = true -ij_kotlin_import_nested_classes = true ij_kotlin_imports_layout = *,java.**,javax.**,kotlin.**,^ ij_kotlin_insert_whitespaces_in_simple_one_line_method = true ij_kotlin_keep_blank_lines_before_right_brace = 0 diff --git a/README.md b/README.md index 9d2b2ce..2d708b0 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ Splash screen +Exoplayer video playback + +![exoplayer](readme_res/exoplayer_20241115.gif) + ### Quick overview | Feature | Short description | diff --git a/app/src/main/java/com/featuremodule/template/ui/AppNavBar.kt b/app/src/main/java/com/featuremodule/template/ui/AppNavBar.kt index 83ea8eb..7f5ef95 100644 --- a/app/src/main/java/com/featuremodule/template/ui/AppNavBar.kt +++ b/app/src/main/java/com/featuremodule/template/ui/AppNavBar.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hierarchy +import com.featuremodule.core.navigation.HIDE_NAV_BAR import com.featuremodule.core.navigation.NavBarItems /** @@ -24,6 +25,8 @@ internal fun AppNavBar( openNavBarRoute: (route: String, isSelected: Boolean) -> Unit, currentDestination: NavDestination?, ) { + if (currentDestination?.route.orEmpty().contains(HIDE_NAV_BAR)) return + NavigationBar { NavBarItems.entries.forEach { item -> val isSelected = currentDestination?.hierarchy diff --git a/core/src/main/java/com/featuremodule/core/navigation/NavUtils.kt b/core/src/main/java/com/featuremodule/core/navigation/NavUtils.kt new file mode 100644 index 0000000..ec45cc2 --- /dev/null +++ b/core/src/main/java/com/featuremodule/core/navigation/NavUtils.kt @@ -0,0 +1,4 @@ +package com.featuremodule.core.navigation + +/** Appended to the start of a route to sign that NavigationBar should be hidden */ +const val HIDE_NAV_BAR = "hide_nav_bar/" diff --git a/core/src/main/java/com/featuremodule/core/util/ContextUtils.kt b/core/src/main/java/com/featuremodule/core/util/ContextUtils.kt new file mode 100644 index 0000000..3af26a8 --- /dev/null +++ b/core/src/main/java/com/featuremodule/core/util/ContextUtils.kt @@ -0,0 +1,11 @@ +package com.featuremodule.core.util + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper + +tailrec fun Context.getActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.getActivity() + else -> null +} diff --git a/core/src/main/java/com/featuremodule/core/util/Utils.kt b/core/src/main/java/com/featuremodule/core/util/FlowUtils.kt similarity index 100% rename from core/src/main/java/com/featuremodule/core/util/Utils.kt rename to core/src/main/java/com/featuremodule/core/util/FlowUtils.kt diff --git a/detekt-config.yml b/detekt-config.yml index 76740b4..ccbeec5 100644 --- a/detekt-config.yml +++ b/detekt-config.yml @@ -366,7 +366,7 @@ naming: packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' TopLevelPropertyNaming: active: true - constantPattern: '[A-Z][A-Za-z0-9]*' + constantPattern: '[A-Za-z][_A-Za-z0-9]*' propertyPattern: '[A-Za-z][_A-Za-z0-9]*' privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' VariableMaxLength: diff --git a/feature/homeImpl/build.gradle.kts b/feature/homeImpl/build.gradle.kts index b5d7a9e..37148a3 100644 --- a/feature/homeImpl/build.gradle.kts +++ b/feature/homeImpl/build.gradle.kts @@ -9,4 +9,6 @@ android { dependencies { implementation(projects.feature.homeApi) implementation(projects.feature.featureAApi) + + implementation(libs.bundles.exoplayer) } diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/HomeGraphEntry.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/HomeGraphEntry.kt index e39fdc9..a966086 100644 --- a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/HomeGraphEntry.kt +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/HomeGraphEntry.kt @@ -2,11 +2,25 @@ package com.featuremodule.homeImpl import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable +import com.featuremodule.core.navigation.HIDE_NAV_BAR import com.featuremodule.homeApi.HomeDestination +import com.featuremodule.homeImpl.exoplayer.ExoplayerScreen import com.featuremodule.homeImpl.ui.HomeScreen fun NavGraphBuilder.registerHome() { composable(HomeDestination.ROUTE) { backStackEntry -> HomeScreen(route = backStackEntry.destination.route) } + + composable(InternalRoutes.ExoplayerDestination.ROUTE) { + ExoplayerScreen() + } +} + +internal sealed class InternalRoutes { + object ExoplayerDestination { + const val ROUTE = HIDE_NAV_BAR + "exoplayer" + + fun constructRoute() = ROUTE + } } diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/exoplayer/ExoplayerContract.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/exoplayer/ExoplayerContract.kt new file mode 100644 index 0000000..2ff9f3a --- /dev/null +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/exoplayer/ExoplayerContract.kt @@ -0,0 +1,22 @@ +package com.featuremodule.homeImpl.exoplayer + +import androidx.media3.exoplayer.ExoPlayer +import com.featuremodule.core.ui.UiEvent +import com.featuremodule.core.ui.UiState + +internal data class State( + val exoplayer: ExoPlayer, + val overlayState: OverlayState = OverlayState(), +) : UiState + +internal data class OverlayState( + val showPlayButton: Boolean = false, + val title: String = "", + val contentPosition: Long = 0, + val contentDuration: Long = 0, +) + +internal sealed interface Event : UiEvent { + data object OnPlayPauseClick : Event + data class OnSeekFinished(val position: Long) : Event +} diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/exoplayer/ExoplayerModule.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/exoplayer/ExoplayerModule.kt new file mode 100644 index 0000000..b6d1776 --- /dev/null +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/exoplayer/ExoplayerModule.kt @@ -0,0 +1,18 @@ +package com.featuremodule.homeImpl.exoplayer + +import android.content.Context +import androidx.media3.exoplayer.ExoPlayer +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.scopes.ViewModelScoped + +@Module +@InstallIn(ViewModelComponent::class) +class ExoplayerModule { + @ViewModelScoped + @Provides + fun provideExoplayer(@ApplicationContext context: Context) = ExoPlayer.Builder(context).build() +} diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/exoplayer/ExoplayerScreen.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/exoplayer/ExoplayerScreen.kt new file mode 100644 index 0000000..494560d --- /dev/null +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/exoplayer/ExoplayerScreen.kt @@ -0,0 +1,115 @@ +package com.featuremodule.homeImpl.exoplayer + +import androidx.annotation.OptIn +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.fillMaxSize +import androidx.compose.foundation.layout.size +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.saveable.rememberSaveable +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.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerView +import com.featuremodule.core.util.getActivity + +@Composable +internal fun ExoplayerScreen(viewModel: ExoplayerVM = hiltViewModel()) { + val context = LocalContext.current + val state by viewModel.state.collectAsStateWithLifecycle() + + // Hide system bars just for this dialog + DisposableEffect(Unit) { + val window = context.getActivity()?.window ?: return@DisposableEffect onDispose {} + val insetsController = WindowCompat.getInsetsController(window, window.decorView) + + insetsController.apply { + hide(WindowInsetsCompat.Type.systemBars()) + systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + + onDispose { + insetsController.apply { + show(WindowInsetsCompat.Type.systemBars()) + systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT + } + } + } + + ExoplayerScreen( + state = state, + postEvent = viewModel::postEvent, + ) +} + +@OptIn(UnstableApi::class) +@Composable +private fun ExoplayerScreen(state: State, postEvent: (Event) -> Unit) { + var overlayVisibility by rememberSaveable { mutableStateOf(true) } + var videoSize by remember { mutableStateOf(IntSize(0, 0)) } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.DarkGray) + // Indication is not needed and it does not look good + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + overlayVisibility = !overlayVisibility + }, + contentAlignment = Alignment.Center, + ) { + AndroidView( + factory = { viewContext -> + PlayerView(viewContext).apply { + useController = false + setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS) + background = context.getDrawable(android.R.color.black) + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + + player = state.exoplayer + } + }, + modifier = Modifier.onSizeChanged { intSize -> + videoSize = intSize + }, + ) + + Overlay( + state = state.overlayState, + isVisible = overlayVisibility, + onPlayPauseClick = { postEvent(Event.OnPlayPauseClick) }, + onSeek = { postEvent(Event.OnSeekFinished(it)) }, + modifier = Modifier.size( + with(LocalDensity.current) { + DpSize( + width = videoSize.width.toDp(), + height = videoSize.height.toDp(), + ) + }, + ), + ) + } +} diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/exoplayer/ExoplayerVM.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/exoplayer/ExoplayerVM.kt new file mode 100644 index 0000000..4ef0a87 --- /dev/null +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/exoplayer/ExoplayerVM.kt @@ -0,0 +1,127 @@ +package com.featuremodule.homeImpl.exoplayer + +import android.content.ContentResolver +import android.net.Uri +import androidx.annotation.OptIn +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.common.util.Util +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.SeekParameters +import com.featuremodule.core.ui.BaseVM +import com.featuremodule.homeImpl.R +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import javax.inject.Inject + +@OptIn(UnstableApi::class) +@HiltViewModel +internal class ExoplayerVM @Inject constructor( + private val exoplayer: ExoPlayer, +) : BaseVM() { + private var progressUpdateJob: Job? = null + + private val playerEventListener = object : Player.Listener { + override fun onEvents(player: Player, events: Player.Events) { + var updatedState = state.value.overlayState + + if (events.containsAny( + Player.EVENT_PLAY_WHEN_READY_CHANGED, + Player.EVENT_PLAYBACK_STATE_CHANGED, + Player.EVENT_PLAYBACK_SUPPRESSION_REASON_CHANGED, + ) + ) { + updatedState = updatedState.copy( + showPlayButton = Util.shouldShowPlayButton(exoplayer), + ) + } + + if (events.contains(Player.EVENT_IS_PLAYING_CHANGED)) { + if (player.isPlaying) { + startProgressUpdateJob() + } else { + stopProgressUpdateJob() + updatedState = updatedState.copy( + contentPosition = player.currentPosition, + contentDuration = player.contentDuration.coerceAtLeast(0L), + ) + } + } + + setState { copy(overlayState = updatedState) } + } + } + + init { + with(exoplayer) { + setMediaItem( + MediaItem.fromUri( + Uri.Builder() + .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) + .path(R.raw.fumo_sail.toString()) + .build(), + ), + ) + // Slow but exact, can be adjusted if needed + setSeekParameters(SeekParameters.EXACT) + prepare() + playWhenReady = true + + addListener(playerEventListener) + } + } + + @Suppress("MagicNumber") + @Synchronized + private fun startProgressUpdateJob() { + // if job is null, this will not trigger too + if (progressUpdateJob?.isActive == true) return + + progressUpdateJob = launch { + while (isActive) { + setState { + val newOverlay = overlayState.copy( + contentPosition = exoplayer.currentPosition, + contentDuration = exoplayer.contentDuration.coerceAtLeast(0L), + ) + copy(overlayState = newOverlay) + } + delay(1000L - exoplayer.currentPosition % 1000L) + } + } + } + + @Synchronized + private fun stopProgressUpdateJob() { + progressUpdateJob?.cancel() + progressUpdateJob = null + } + + override fun initialState() = State( + exoplayer = exoplayer, + overlayState = OverlayState(title = "fumo_sail"), + ) + + override fun handleEvent(event: Event) { + when (event) { + Event.OnPlayPauseClick -> { + Util.handlePlayPauseButtonAction(exoplayer) + } + + is Event.OnSeekFinished -> onSeek(event.position) + } + } + + private fun onSeek(position: Long) { + exoplayer.seekTo(position) + setState { copy(overlayState = overlayState.copy(contentPosition = position)) } + } + + override fun onCleared() { + exoplayer.removeListener(playerEventListener) + exoplayer.release() + } +} diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/exoplayer/Overlay.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/exoplayer/Overlay.kt new file mode 100644 index 0000000..7de98a7 --- /dev/null +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/exoplayer/Overlay.kt @@ -0,0 +1,156 @@ +package com.featuremodule.homeImpl.exoplayer + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.featuremodule.homeImpl.R +import kotlin.time.Duration.Companion.milliseconds + +@Composable +internal fun Overlay( + state: OverlayState, + isVisible: Boolean, + onPlayPauseClick: () -> Unit, + onSeek: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = isVisible, + enter = fadeIn(), + exit = fadeOut(), + ) { + Column( + modifier = modifier.background(Color.Black.copy(alpha = 0.5f)), + verticalArrangement = Arrangement.SpaceBetween, + ) { + TopPart( + title = state.title, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + ) + + CenterPart( + showPlayButton = state.showPlayButton, + onPlayPauseClick = onPlayPauseClick, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + + BottomPart( + contentPosition = state.contentPosition, + contentDuration = state.contentDuration, + onSeek = onSeek, + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + .padding(top = 8.dp, start = 8.dp, end = 8.dp), + ) + } + } +} + +@Composable +private fun TopPart(title: String, modifier: Modifier = Modifier) = Row(modifier = modifier) { + Text( + text = title, + color = Color.White, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(8.dp), + ) +} + +@Composable +private fun CenterPart( + showPlayButton: Boolean, + onPlayPauseClick: () -> Unit, + modifier: Modifier = Modifier, +) = Row(modifier = modifier) { + IconButton( + onClick = onPlayPauseClick, + colors = IconButtonDefaults.iconButtonColors( + contentColor = Color.White, + ), + ) { + if (showPlayButton) { + Icon(painterResource(id = R.drawable.play), contentDescription = null) + } else { + Icon(painterResource(id = R.drawable.pause), contentDescription = null) + } + } +} + +@Composable +private fun BottomPart( + contentPosition: Long, + contentDuration: Long, + onSeek: (Long) -> Unit, + modifier: Modifier = Modifier, +) = Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, +) { + var isValueChanging by remember { mutableStateOf(false) } + var seekPosition by remember { mutableFloatStateOf(0f) } + + Text( + text = "${formatMs(contentPosition)}/${formatMs(contentDuration)}", + color = Color.White, + fontSize = 12.sp, + modifier = Modifier.padding(end = 8.dp), + ) + + Slider( + value = if (isValueChanging) { + seekPosition + } else { + contentPosition.toFloat() + }, + onValueChange = { + seekPosition = it + isValueChanging = true + }, + valueRange = 0f..contentDuration.toFloat(), + modifier = Modifier.fillMaxWidth(), + onValueChangeFinished = { + onSeek(seekPosition.toLong()) + isValueChanging = false + }, + ) +} + +private fun formatMs(ms: Long): String { + return ms.milliseconds.toComponents { hours, minutes, seconds, _ -> + val secondsString = "%02d".format(seconds) + if (hours == 0L) { + "$minutes:$secondsString" + } else { + val minutesString = "%02d".format(minutes) + "$hours:$minutesString:$secondsString" + } + } +} diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeContract.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeContract.kt index d9e0b1e..e5bc0c6 100644 --- a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeContract.kt +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeContract.kt @@ -7,4 +7,5 @@ internal data object State : UiState internal sealed interface Event : UiEvent { data object NavigateToFeatureA : Event + data object NavigateToExoplayer : Event } diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeScreen.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeScreen.kt index 672979b..2b7a3e5 100644 --- a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeScreen.kt +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeScreen.kt @@ -1,17 +1,45 @@ package com.featuremodule.homeImpl.ui import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @Composable internal fun HomeScreen(route: String?, viewModel: HomeVM = hiltViewModel()) { - Column { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { Text(text = route.toString()) - Button(onClick = { viewModel.postEvent(Event.NavigateToFeatureA) }) { - Text(text = "Pass number") + + Column( + modifier = Modifier.width(IntrinsicSize.Max), + ) { + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { viewModel.postEvent(Event.NavigateToFeatureA) }, + ) { + Text(text = "Pass number") + } + + Spacer(modifier = Modifier.height(24.dp)) + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { viewModel.postEvent(Event.NavigateToExoplayer) }, + ) { + Text(text = "Exoplayer") + } } } } diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeVM.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeVM.kt index 2cfa27a..e274dd8 100644 --- a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeVM.kt +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeVM.kt @@ -5,6 +5,7 @@ import com.featuremodule.core.navigation.NavCommand import com.featuremodule.core.navigation.NavManager import com.featuremodule.core.ui.BaseVM import com.featuremodule.featureAApi.FeatureADestination +import com.featuremodule.homeImpl.InternalRoutes import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlin.random.Random @@ -26,6 +27,12 @@ internal class HomeVM @Inject constructor( NavCommand.Forward(FeatureADestination.constructRoute(randomInt)), ) } + + Event.NavigateToExoplayer -> launch { + navManager.navigate( + NavCommand.Forward(InternalRoutes.ExoplayerDestination.constructRoute()), + ) + } } } } diff --git a/feature/homeImpl/src/main/res/drawable/pause.xml b/feature/homeImpl/src/main/res/drawable/pause.xml new file mode 100644 index 0000000..b266200 --- /dev/null +++ b/feature/homeImpl/src/main/res/drawable/pause.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/feature/homeImpl/src/main/res/drawable/play.xml b/feature/homeImpl/src/main/res/drawable/play.xml new file mode 100644 index 0000000..c710715 --- /dev/null +++ b/feature/homeImpl/src/main/res/drawable/play.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/feature/homeImpl/src/main/res/raw/fumo_sail.mp4 b/feature/homeImpl/src/main/res/raw/fumo_sail.mp4 new file mode 100644 index 0000000..cfe3b9f Binary files /dev/null and b/feature/homeImpl/src/main/res/raw/fumo_sail.mp4 differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 63049e3..4eb3191 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ moshi = "1.15.1" collections-immutable = "0.3.7" glide-compose = "1.0.0-beta01" leakcanary = "2.14" +media3 = "1.4.1" # Versions used for android{} setup sdk-compile = "34" @@ -68,6 +69,9 @@ moshi-kotlin-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", ver glide-compose = { module = "com.github.bumptech.glide:compose", version.ref = "glide-compose" } +media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } +media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" } + # Testing junit = { module = "junit:junit", version.ref = "junit" } androidx-junit = { module = "androidx.test.ext:junit", version.ref = "junit-androidx" } @@ -83,6 +87,7 @@ leakcanary = { module = "com.squareup.leakcanary:leakcanary-android", version.re compose = ["androidx-lifecycle-runtime-compose", "compose-ui", "compose-ui-graphics", "compose-ui-tooling-preview", "compose-material3", "compose-runtime"] network = ["retrofit", "retrofit-converter-moshi", "moshi"] +exoplayer = ["media3-exoplayer", "media3-ui"] [plugins] kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/readme_res/exoplayer_20241115.gif b/readme_res/exoplayer_20241115.gif new file mode 100644 index 0000000..d96f9c8 Binary files /dev/null and b/readme_res/exoplayer_20241115.gif differ