diff --git a/app/build.gradle b/app/build.gradle index c2e5985e..5328fbc2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -144,6 +144,8 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:2.1.4' // mockito testImplementation 'org.mockito:mockito-inline:2.21.0' + + implementation "com.airbnb.android:lottie-compose:5.0.2" testImplementation 'junit:junit:4.13.2' androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version" debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f9f74246..e409325f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -26,6 +26,12 @@ android:supportsRtl="true" android:theme="@style/Theme.DailyFilm" tools:targetApi="31"> + + - diff --git a/app/src/main/java/com/boostcamp/dailyfilm/presentation/calendar/CalendarActivity.kt b/app/src/main/java/com/boostcamp/dailyfilm/presentation/calendar/CalendarActivity.kt index 3a44bef9..b8485bfe 100644 --- a/app/src/main/java/com/boostcamp/dailyfilm/presentation/calendar/CalendarActivity.kt +++ b/app/src/main/java/com/boostcamp/dailyfilm/presentation/calendar/CalendarActivity.kt @@ -24,11 +24,10 @@ import com.boostcamp.dailyfilm.presentation.calendar.model.DateModel import com.boostcamp.dailyfilm.presentation.calendar.model.DateState import com.boostcamp.dailyfilm.presentation.login.LoginActivity import com.boostcamp.dailyfilm.presentation.playfilm.model.EditState -import com.boostcamp.dailyfilm.presentation.searchfilm.SearchFilmActivity import com.boostcamp.dailyfilm.presentation.searchfilm.SearchFilmComposeActivity import com.boostcamp.dailyfilm.presentation.selectvideo.SelectVideoActivity import com.boostcamp.dailyfilm.presentation.settings.compose.SettingComposeActivity -import com.boostcamp.dailyfilm.presentation.totalfilm.TotalFilmActivity +import com.boostcamp.dailyfilm.presentation.totalfilm.TotalFilmComposeActivity import com.boostcamp.dailyfilm.presentation.trimvideo.TrimVideoActivity import com.boostcamp.dailyfilm.presentation.uploadfilm.model.DateAndVideoModel import com.boostcamp.dailyfilm.presentation.util.network.NetworkManager @@ -145,7 +144,7 @@ class CalendarActivity : BaseActivity(R.layout.activity return@setOnMenuItemClickListener true } startActivity( - Intent(this@CalendarActivity, TotalFilmActivity::class.java).apply { + Intent(this@CalendarActivity, TotalFilmComposeActivity::class.java).apply { putParcelableArrayListExtra( KEY_FILM_ARRAY, ArrayList(viewModel.filmFlow.value) diff --git a/app/src/main/java/com/boostcamp/dailyfilm/presentation/totalfilm/TotalFilmComposeActivity.kt b/app/src/main/java/com/boostcamp/dailyfilm/presentation/totalfilm/TotalFilmComposeActivity.kt new file mode 100644 index 00000000..d26316a1 --- /dev/null +++ b/app/src/main/java/com/boostcamp/dailyfilm/presentation/totalfilm/TotalFilmComposeActivity.kt @@ -0,0 +1,322 @@ +package com.boostcamp.dailyfilm.presentation.totalfilm + +import android.app.Activity +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.boostcamp.dailyfilm.R +import com.google.android.exoplayer2.ExoPlayer +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.MediaItem.fromUri +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout +import com.google.android.exoplayer2.ui.StyledPlayerView +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieClipSpec +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.animateLottieCompositionAsState +import com.airbnb.lottie.compose.rememberLottieAnimatable +import com.airbnb.lottie.compose.rememberLottieComposition +import com.boostcamp.dailyfilm.presentation.ui.theme.blackBlur +import com.google.android.exoplayer2.Player + +@AndroidEntryPoint +class TotalFilmComposeActivity : ComponentActivity() { + private val viewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val exoPlayer = ExoPlayer.Builder(this).build().apply { + playWhenReady = true + + addListener(object : Player.Listener { + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + super.onMediaItemTransition(mediaItem, reason) + viewModel.filmArray?.get(currentMediaItemIndex)?.let { model -> + viewModel.setCurrentDateItem(model) + } + } + + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == ExoPlayer.STATE_ENDED) { + viewModel.changeEndState() + } + } + }) + } + setContent { + VideoPlayer(viewModel, exoPlayer) + PlayerControlView(viewModel) + } + } +} + +@Composable +fun VideoPlayer(viewModel: TotalFilmViewModel, exoPlayer: ExoPlayer) { + var videoUrl by rememberSaveable { mutableStateOf(null) } + val isMuted = viewModel.isMuted.collectAsStateWithLifecycle().value + val isEnded = viewModel.isEnded.collectAsStateWithLifecycle().value + val isSpeed = viewModel.isSpeed.collectAsStateWithLifecycle().value + if (isEnded) { + (LocalContext.current as Activity).finish() + } + + exoPlayer.apply { + LaunchedEffect(Unit) { + viewModel.loadVideos() + viewModel.downloadedVideoUri.collectLatest { + it?.let { videoURL -> + if (videoURL != Uri.EMPTY) { + addMediaItem(fromUri(videoURL)) + prepare() + videoUrl = videoURL + } else { + return@collectLatest + } + } + } + } + setPlaybackSpeed(isSpeed.speed) + volume = when (isMuted) { + true -> { + 0.0f + } + + false -> { + 0.5f + } + } + } + videoUrl?.let { VideoView(it, exoPlayer) } +} + +@Composable +fun VideoView(videoUrl: Uri, exoPlayer: ExoPlayer) { + + exoPlayer.currentMediaItem?.let { + val context = LocalContext.current + val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) + DisposableEffect( + AndroidView(factory = { + StyledPlayerView(context).apply { + player = exoPlayer + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM + useController = false + }.apply { + setOnClickListener { + if (exoPlayer.isPlaying) { + exoPlayer.pause() + } else { + exoPlayer.play() + } + } + } + }) + ) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_PAUSE -> { + exoPlayer.pause() + } + + Lifecycle.Event.ON_RESUME -> { + exoPlayer.play() + } + + else -> {} + } + } + val lifecycle = lifecycleOwner.value.lifecycle + lifecycle.addObserver(observer) + + onDispose { + exoPlayer.release() + lifecycle.removeObserver(observer) + } + } + } +} + +@Composable +fun PlayerControlView( + viewModel: TotalFilmViewModel, +) { + val isMuted = viewModel.isMuted.collectAsStateWithLifecycle().value + val curSpeed = viewModel.isSpeed.collectAsStateWithLifecycle().value + val isContentShowed = viewModel.isContentShowed.collectAsStateWithLifecycle().value + val dateModel = viewModel.currentDateItem.collectAsStateWithLifecycle().value + + val soundComposition by rememberLottieComposition( + LottieCompositionSpec.Asset(stringResource(R.string.lottie_sound)) + ) + val textComposition by rememberLottieComposition( + LottieCompositionSpec.RawRes(resId = R.raw.lottie_textstate) + ) + val soundAnimatable = rememberLottieAnimatable() + + var checked by remember { mutableStateOf(true) } + var isPlaying by remember { mutableStateOf(true) } + val progress by animateLottieCompositionAsState( + composition = textComposition, + restartOnPlay = false, + isPlaying = isPlaying, + speed = if (isContentShowed) 1f else -1f, + clipSpec = LottieClipSpec.Progress(0.25f, 0.67f) + ) + LaunchedEffect(isMuted) { + soundAnimatable.animate( + composition = soundComposition, + clipSpec = if (isMuted) { + LottieClipSpec.Progress(0.0f, 0.5f) + } else { + LottieClipSpec.Progress(0.5f, 1.0f) + }, + ) + } + LaunchedEffect(progress) { + if (progress == 0.67f) { + isPlaying = false + checked = true + } + if (progress == 0.25f && !checked) { + isPlaying = false + checked = false + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Transparent) + .padding(dimensionResource(id = R.dimen.normal_100)) + ) { + Box( + modifier = Modifier + .background(blackBlur, RoundedCornerShape(4.dp)) + .align(Alignment.TopStart) + .padding(start = 6.dp, end = 6.dp, top = 4.dp, bottom = 4.dp) + .height(dimensionResource(id = R.dimen.normal_175)) + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = stringResource( + R.string.date, dateModel!!.year, dateModel.month, dateModel.day + ), + color = Color.White, + fontSize = 16.sp + ) + } + + Row( + modifier = Modifier.align(Alignment.TopEnd), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.normal_100)) + ) { + Box( + modifier = Modifier + .background(blackBlur, RoundedCornerShape(4.dp)) + .padding(start = 6.dp, end = 6.dp, top = 4.dp, bottom = 4.dp) + .size(dimensionResource(id = R.dimen.normal_175)) + .clickable { viewModel.changeSpeedState() } + ) { + Image( + modifier = Modifier.align(Alignment.Center), + painter = painterResource(id = R.drawable.ic_fast), + contentDescription = stringResource(R.string.control_speed) + ) + } + Box( + modifier = Modifier + .background(blackBlur, RoundedCornerShape(4.dp)) + .padding(start = 6.dp, end = 6.dp, top = 4.dp, bottom = 4.dp) + .height(dimensionResource(id = R.dimen.normal_175)) + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = curSpeed.toString(), + color = Color.White, + fontSize = 16.sp + ) + } + LottieAnimation( + composition = soundComposition, + progress = soundAnimatable.progress, + modifier = Modifier + .background(blackBlur, RoundedCornerShape(4.dp)) + .padding(4.dp) + .size(dimensionResource(id = R.dimen.normal_175)) + .clickable { viewModel.changeMuteState() }) + } + + AnimatedVisibility( + visible = isContentShowed, + modifier = Modifier.align(Alignment.Center), + enter = fadeIn(), + exit = fadeOut() + ) { + Text( + text = dateModel!!.text ?: "", + modifier = Modifier + .background( + Color.Black.copy(alpha = 0.5f), RoundedCornerShape(4.dp) + ) + .padding(4.dp), + color = Color.White, + fontSize = 16.sp + ) + } + + LottieAnimation( + composition = textComposition, + progress = progress, + modifier = Modifier + .align(Alignment.BottomCenter) + .background(blackBlur, RoundedCornerShape(50.dp)) + .size(dimensionResource(id = R.dimen.large_200)) + .padding(4.dp) + .clickable { + isPlaying = true + viewModel.changeShowState() + } + ) + } +} diff --git a/app/src/main/java/com/boostcamp/dailyfilm/presentation/totalfilm/TotalFilmViewModel.kt b/app/src/main/java/com/boostcamp/dailyfilm/presentation/totalfilm/TotalFilmViewModel.kt index 69913a36..f87d05b6 100644 --- a/app/src/main/java/com/boostcamp/dailyfilm/presentation/totalfilm/TotalFilmViewModel.kt +++ b/app/src/main/java/com/boostcamp/dailyfilm/presentation/totalfilm/TotalFilmViewModel.kt @@ -30,32 +30,28 @@ class TotalFilmViewModel @Inject constructor( private val _downloadedVideoUri = MutableSharedFlow() val downloadedVideoUri: SharedFlow = _downloadedVideoUri.asSharedFlow() - private val _isContentShowed = MutableLiveData(true) - val isContentShowed: LiveData get() = _isContentShowed + private val _isContentShowed = MutableStateFlow(true) + val isContentShowed: StateFlow get() = _isContentShowed - private val _isMuted = MutableLiveData(false) - val isMuted: LiveData get() = _isMuted + private val _isMuted = MutableStateFlow(false) + val isMuted: StateFlow get() = _isMuted private val _isEnded = MutableStateFlow(false) val isEnded: StateFlow get() = _isEnded.asStateFlow() - private val _isSpeed = MutableLiveData(SpeedState.values()[speedIndex ?: 2]) - val isSpeed: LiveData get() = _isSpeed + private val _isSpeed = MutableStateFlow(SpeedState.values()[speedIndex ?: 2]) + val isSpeed: StateFlow get() = _isSpeed fun setCurrentDateItem(dateModel: DateModel) { _currentDateItem.value = dateModel } - init { - loadVideos() - } - fun changeShowState() { - _isContentShowed.value = _isContentShowed.value?.not() + _isContentShowed.value = _isContentShowed.value.not() } fun changeMuteState() { - _isMuted.value = _isMuted.value?.not() + _isMuted.value = _isMuted.value.not() } fun changeEndState() { @@ -74,7 +70,7 @@ class TotalFilmViewModel @Inject constructor( } } - private fun loadVideos() { + fun loadVideos() { viewModelScope.launch { filmArray?.forEach { dateModel -> yield() @@ -103,11 +99,13 @@ class TotalFilmViewModel @Inject constructor( } } } + is Result.Error -> {} } } } } + is Result.Error -> {} } } diff --git a/app/src/main/java/com/boostcamp/dailyfilm/presentation/totalfilm/ui/theme/Color.kt b/app/src/main/java/com/boostcamp/dailyfilm/presentation/totalfilm/ui/theme/Color.kt new file mode 100644 index 00000000..3127a86c --- /dev/null +++ b/app/src/main/java/com/boostcamp/dailyfilm/presentation/totalfilm/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.boostcamp.dailyfilm.presentation.totalfilm.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/boostcamp/dailyfilm/presentation/totalfilm/ui/theme/Theme.kt b/app/src/main/java/com/boostcamp/dailyfilm/presentation/totalfilm/ui/theme/Theme.kt new file mode 100644 index 00000000..e3b61347 --- /dev/null +++ b/app/src/main/java/com/boostcamp/dailyfilm/presentation/totalfilm/ui/theme/Theme.kt @@ -0,0 +1,70 @@ +package com.boostcamp.dailyfilm.presentation.totalfilm.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun DailyFilmTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/boostcamp/dailyfilm/presentation/totalfilm/ui/theme/Type.kt b/app/src/main/java/com/boostcamp/dailyfilm/presentation/totalfilm/ui/theme/Type.kt new file mode 100644 index 00000000..7f00efe9 --- /dev/null +++ b/app/src/main/java/com/boostcamp/dailyfilm/presentation/totalfilm/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.boostcamp.dailyfilm.presentation.totalfilm.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index a3d36b3f..37c506cf 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -38,4 +38,6 @@ DailyFilm Logo Google logo Sign in with Google + sound_lottie.json + control speed \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 9b52d544..71eda12d 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -39,4 +39,7 @@ DailyFilm Logo Google logo Sign in with Google + sound_lottie.json + control speed + \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 1eea83cb..31285191 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -38,4 +38,7 @@ DailyFilm Logo Google logo Sign in with Google + sound_lottie.json + control speed + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7f675de7..9338514c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -54,4 +54,6 @@ 설정 화면 TotalComposeActivity Failed Google Login + sound_lottie.json + 속도 조절 \ No newline at end of file