Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ Splash screen

<img src="readme_res/splash_screen_20240703.gif" width="480"/>

Exoplayer video playback

![exoplayer](readme_res/exoplayer_20241115.gif)

### Quick overview

| Feature | Short description |
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/com/featuremodule/template/ui/AppNavBar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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/"
11 changes: 11 additions & 0 deletions core/src/main/java/com/featuremodule/core/util/ContextUtils.kt
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 1 addition & 1 deletion detekt-config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions feature/homeImpl/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ android {
dependencies {
implementation(projects.feature.homeApi)
implementation(projects.feature.featureAApi)

implementation(libs.bundles.exoplayer)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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(),
)
},
),
)
}
}
Original file line number Diff line number Diff line change
@@ -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<State, Event>() {
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()
}
}
Loading