KakaoTalk_20221022_172230003.mp4
- 촬영버튼을 통해 영상을 녹화하여 파이어베이스에 업데이트 합니다.
- 영상은 RecyclerView Scroll에 따라 미리보기 재생이 시작됩니다.
- 영상이 재생될 때 클릭하면 landScape 모드인 비디오화면으로 이동합니다.
- 아키텍처 설계
- Domain Layer는 Repository interface와 UseCase interface을 포함하고 있으며 인터페이스의 구현체는 Data Layer에서 구현하였습니다.
-
Presentation Layer
- UI과 관련된 작업으로 구성되어있습니다.
- 대표적으로 Activity, Fragment, ViewModel이 있습니다.
-
Domain Layer
- 비지니스 로직에서 수행되어져야할 행동들을 Interface로 정의하고 제공됩니다.
- Presentation Layer에 제공되는 비지니스 모델이 포함됩니다.
-
Data Layer
- Domain Layer에서 정의된 Interface(Repository, Usecase)의 구현체와 DataSource가 존재합니다.
- Hilt에 의해 인터페이스의 구현체가 제공됩니다.
- DataSource는 Local과 Remote로 나뉘어 각각 Room Database와 Firebase에 접근합니다.
-
Gradle
- KTS를 통해 gradle를 Kotlin Script로 구성했습니다.
-
Version Catalog
- Library와 Plugin의 버전을 관리합니다.
- 영상 업로드 (Firebase Storage, Firebase Firestore)
- 영상 삭제 (Firebase Storage, Firebase Firestore)
- 영상 리스트 가져오기 (Firebase Firestore, Paging3)
- 결과값을 FirebaseResponse로 매핑 (data-domain)
- FirebaseResponse를 UiState로 매핑 (presentation)
- UiState에 따라 리스트 업데이트 or 프로그레스바 노출 or 에러 메시지 노출
//Video
data class Video(
val name: String = "",
val publishedAt: Long = 0L,
val uri: String = ""
)
//FirebaseResponse
data class FirebaseResponse<T>(
val state : FirebaseState,
val result : T? = null
)
enum class FirebaseState{
SUCCESS,
FAILURE,
}
//safeFirebaseCall
suspend fun <T> safeSetCall(callFunction: () -> Task<T>): FirebaseResponse<Nothing> {
return try {
callFunction.invoke().await()
FirebaseResponse(FirebaseState.SUCCESS)
} catch (e: Exception) {
Timber.e("safeSetCall: 실패 $e")
FirebaseResponse(FirebaseState.FAILURE)
}
}
//VideoDataSourceImpl
override suspend fun uploadVideoStorage(video: Video): FirebaseResponse<Nothing> {
return safeSetCall {
firebaseStorage.reference.child(video.name).putFile(Uri.parse(video.uri))
}
}
//VideoListState
sealed class VideoListState {
object Failure : VideoListState()
object Loading : VideoListState()
object Update : VideoListState()
}
//MainViewModel
fun uploadVideoList(video: Video) {
viewModelScope.launch {
_videoState.emit(VideoListState.Loading)
when (uploadVideoUseCase(video).state) {
FirebaseState.SUCCESS -> {
_videoState.emit(VideoListState.Update)
}
FirebaseState.FAILURE -> {
_videoState.emit(VideoListState.Failure)
}
}
}
}
//MainActivity
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
mainViewModel.videoState.collectLatest {
when (it) {
VideoListState.Loading -> {
binding.progressBar.isVisible = true
}
VideoListState.Update -> {
binding.progressBar.isVisible = false
adapter.refresh()
}
VideoListState.Failure -> {
binding.progressBar.isVisible = false
Snackbar.make(binding.root, "오류가 발생했습니다.", Snackbar.LENGTH_SHORT).show()
}
}
}
}
}
2022-10-21.1.19.12.mov
- 비디오 플레이 화면 구현
- 5초간 영상 재생
KakaoTalk_20221021_011747867.mp4
- scroll에 따라 exoPlayer가 동작하도록 만들기 위해서 Custom View를 구현했습니다.
//CustomExoPlayerRecyclerView
fun releasePlayer() {
removePlayerView()
player?.run {
removeListener(exoPlayerListener)
release()
}
player = null
}
//Activity
override fun onStop() {
super.onStop()
binding.videoRecyclerView.releasePlayer()
}
- CustomExoPlayerRecyclerView로부터 구현한 release 메소드를 액티비티 라이프싸이클에 따라 동작하도록 만들었습니다.
//CustomExoPlayerRecyclerView
private fun initializePlayer(video: Video) {
if (player == null) {
player = ExoPlayer.Builder(context)
.build()
.also { exoPlayer ->
exoPlayerView?.player = exoPlayer
exoPlayer.setMediaItem(MediaItem.fromUri(video.uri))
exoPlayerListener = playerStateListener()
exoPlayer.addListener(exoPlayerListener)
exoPlayer.playWhenReady = true
exoPlayer.seekTo(0)
exoPlayer.prepare()
}
} else {
player?.also { exoPlayer ->
exoPlayerView?.player = exoPlayer
exoPlayer.setMediaItem(MediaItem.fromUri(video.uri))
exoPlayer.playWhenReady = true
exoPlayer.seekTo(0)
exoPlayer.prepare()
}
}
}
//Activity
override fun onResume() {
super.onResume()
lifecycleScope.launch{
delay(3000)
binding.videoRecyclerView.playCurrentPosition(false)
}
}
- CustomExoPlayerRecyclerView로부터 구현한 initialize 메소드를 액티비티 라이프싸이클에 따라 동작하도록 만들었습니다.
- 비디오 녹화 화면 구현
change.webm
private fun changeFacing() {
binding.changeLensButton.setOnClickListener {
defaultCameraFacing = if (defaultCameraFacing == CameraSelector.DEFAULT_FRONT_CAMERA) {
CameraSelector.DEFAULT_BACK_CAMERA
} else {
CameraSelector.DEFAULT_FRONT_CAMERA
}
try {
startCamera(defaultCameraFacing)
} catch (exc: Exception) {
// Do nothing
}
}
}
private fun startCamera(defaultCameraFacing: CameraSelector) {
val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
cameraProviderFuture.addListener({
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
val preview = Preview.Builder()
.build()
.also {
it.setSurfaceProvider(binding.viewFinder.surfaceProvider)
}
val recorder = Recorder.Builder()
// 화질 설정 (비디오)
.setQualitySelector(QualitySelector.from(Quality.HD))
.build()
videoCapture = VideoCapture.withOutput(recorder)
val cameraSelector = defaultCameraFacing
try {
cameraProvider.unbindAll()
// video 추가
cameraProvider.bindToLifecycle(
this, cameraSelector, preview, videoCapture
)
} catch (exc: Exception) {
Log.e(TAG, "startCamera fail: ", exc )
}
}, ContextCompat.getMainExecutor(this)
)
}
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, name)
put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4")
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
put(MediaStore.Video.Media.RELATIVE_PATH,
"Movies/CameraX-Video")
}
}
// uri 생성
val currentUri = contentResolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentValues)
feat/<branch name>
- e.g)
feat/Base Architecture
[prefix]: <commit content>
-
e.g)
feat: DAO 개발완료
-
e.g)
fix: room crash 수정
-
e.g)
refactor: MVVM 아키텍처 구조 리팩토링
[prefix] 작업할 내용
-
e.g)
[feat] base architecture 생성
-
e.g)
[fix] room crash 수정
-
e.g)
[refactor] Sensor구조 일부 수정
-
브랜치를 생성하기 전, github issue를 생성해주세요.
-
branch 명의 issue number는 해당 issue Number로 지정합니다.
[Issue-#number] PR 내용
- e.g)
[Issue-#7] Timer 추가