{behavior}/issue-{number}-{something}
- e.g. :
feature/issue-007-data-module
[{behavior}] {something}
- e.g. :
[FEATURE] 프로젝트 세팅
[ISSUE-{number}] {something}
- e.g. :
[ISSUE-007] 데이터 모듈 추가
- 문장 단위가 아닌, 단어 단위로 제목을 작성합니다.
feat - {something}
: 새로운 기능을 추가했을 때fix - {something}
: 기능 중 버그를 수정했을 때design - {something}
: 디자인 일부를 변경했을 때refactor - {something}
: 코드를 재정비 하였을 때chore - {something}
: 빌드 관련 업무를 수정하거나 패키지 매니저를 수정했을 때docs - {something}
: README와 같은 문서를 변경했을 때- 문장 단위가 아닌, 단어 단위로 제목을 작성합니다.
- android-style-guide의 코딩 컨벤션과 동일하게 진행합니다.
Clean Architecture
, MVVM
, Kotlin
, Hilt
, Coroutine
, Flow
, Firebase Realtime DB
, Firebase Storage
, Lifecycle
, Databinding
, Livedata
, ExoPlayer
, CameraX
, Timber
- 담당한 일
- Base Architecture 설계
- 영상 long click 시 처음 5초간 플레이
- 더 해야할 점
- 현재 영상 끝까지 재생되도록 구현되었는데, 5초간 재생으로 변경하기
- Clean Architecture 적용
- UseCase
- Video Model
data class Video(
val id: Int,
val uri: String,
val date: String
) {
companion object {
val EMPTY = Video(
id = 0,
uri = "",
date = ""
)
}
}
- 시연 영상
videorecorder.mp4
-
ExoPlayer 주요 컴포넌트 소개
- PlayerView: 비디오를 불러와 실제 UI에 뿌려줄 때 사용되는 UI 요소
- ExoPlayer: 미디어 플레이어 기능 제공
- MediaSource: ExoPlayer에서 재생될 미디어를 정의하고 제공하는 역할
- ProgressiveMediaSource: 일반 미디어 파일 형식
- DataSource: 영상 스트림을 읽을 수 있는 형태로 표현
-
Long Click Listener 적용
binding.itemNewsPlayerView.setOnClickListener {
itemClick(video)
}
binding.itemNewsPlayerView.setOnLongClickListener {
mediaSource = ProgressiveMediaSource.Factory(factory)
.createMediaSource(MediaItem.fromUri(Uri.parse(video.uri)))
exoPlayer.apply {
setMediaSource(mediaSource)
prepare()
play()
}
binding.itemNewsPlayerView.apply {
player = exoPlayer
useController = false // 자동재생이므로 controlview는 사용되지 않도록 적용
}
true
}
- background로 이동 시, player 재시작 준비
override fun onPause() {
super.onPause()
mainAdapter.exoPlayer.apply {
seekTo(0)
playWhenReady = false
}
}
화면전환
private fun itemClick(video: Video) {
val intent = Intent(this, PlayActivity::class.java)
intent.putExtra("video", video.uri)
startActivity(intent)
}
private fun deleteClick(video: Video) {
viewModel.deleteVideo(video)
}
녹화 목록 어답터 구현
class MainAdapter(
private val itemClick: (Video) -> Unit,
private val deleteClick: (Video) -> Unit,
) : ListAdapter<Video, MainAdapter.MainViewHolder>(diffUtil) {
inner class MainViewHolder(private val binding: ItemVideoBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(video: Video) {
binding.video = video
binding.cvVideoItem.setOnClickListener {
itemClick(video)
}
binding.btnDelete.setOnClickListener {
deleteClick(video)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
val binding = ItemVideoBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return MainViewHolder(binding)
}
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
holder.bind(getItem(position))
}
companion object {
val diffUtil = object : DiffUtil.ItemCallback<Video>() {
override fun areItemsTheSame(oldItem: Video, newItem: Video): Boolean {
return oldItem.date == newItem.date
}
override fun areContentsTheSame(oldItem: Video, newItem: Video): Boolean {
return oldItem.hashCode() == newItem.hashCode()
}
}
}
}
아쉬운점이나 더해야할점
- 페이징을 사용해서 리사이클러뷰 구현
- ui가 보기 좋지 않음
CameraX
라이브러리를 통해 전-후면 동영상 촬영 기능 구현- 애플리케이션 실행에 필요한 권한 요청 코드 구현
- 녹화 영상 갤러리 저장 및
Firebase Storage
에 저장 Firebase Realtime Database
의CRUD
구현
private fun captureVideo() {
val videoCapture = this.videoCapture ?: return
binding.videoCaptureButton.isEnabled = false
// 진행 중인 녹음이 있으면 중지, 현재 녹음을 해제한다.
// 캡처한 비디오 파일이 애플리케이션에서 사용할 준비가 됐을 때 알림을 받게 된다.
val curRecording = recording
if (curRecording != null) {
curRecording.stop()
recording = null
return
}
val name = SimpleDateFormat(FILENAME_FORMAT, Locale.KOREA)
.format(System.currentTimeMillis())
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")
}
}
val mediaStoreOutputOptions = MediaStoreOutputOptions
.Builder(contentResolver, MediaStore.Video.Media.EXTERNAL_CONTENT_URI)
.setContentValues(contentValues)
.build()
// videoCapture 의 레코더에 대한 출력 옵션을 구성하고 오디오 녹음 활성화
recording = videoCapture.output
.prepareRecording(this, mediaStoreOutputOptions)
.apply {
if (PermissionChecker.checkSelfPermission(
this@RecordActivity,
Manifest.permission.RECORD_AUDIO
) ==
PermissionChecker.PERMISSION_GRANTED
) {
withAudioEnabled()
}
}
.start(ContextCompat.getMainExecutor(this)) { recordEvent ->
when (recordEvent) {
is VideoRecordEvent.Start -> {
binding.videoCaptureButton.apply {
text = "stop capture"
isEnabled = true
}
}
is VideoRecordEvent.Finalize -> {
if (!recordEvent.hasError()) {
val msg = "Video capture succeeded: ${recordEvent.outputResults.outputUri}"
Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
} else {
recording?.close()
recording = null
}
binding.videoCaptureButton.apply {
text = "start capture"
isEnabled = true
}
}
}
}
}
private fun startCamera() {
// 카메라 생명 주기를 현재 라이프사이클 오너에게 연결하는데 사용된다.
// CameraX 는 생명 주기를 인식하므로 카메라를 열고 닫는 작업이 필요하지 않다.
cameraProviderFuture = ProcessCameraProvider.getInstance(this)
cameraProviderFuture.addListener({
// 애플리케이션 프로세스 내에서 카메라의 생명주기를 라이프사이클 오너에 바인딩하는데 사용된다.
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
// Preview 클래스 생성
val preview: Preview = Preview.Builder()
.build()
.also {
// 유즈케이스(Preview)와 previewView 를 연결
it.setSurfaceProvider(binding.previewView.surfaceProvider)
}
// Recorder 클래스 생성
val recorder = Recorder.Builder()
.setQualitySelector(QualitySelector.from(Quality.HIGHEST))
.build()
videoCapture = VideoCapture.withOutput(recorder)
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
try {
cameraProvider.unbindAll() // 이전에 바인딩된 유즈케이스 해제
cameraProvider
.bindToLifecycle(
this as LifecycleOwner,
cameraSelector,
preview,
videoCapture
) // 라이프 사이클 연결
} catch (e: Exception) {
Toast.makeText(this, "Use case binding failed $e", Toast.LENGTH_SHORT).show()
}
}, ContextCompat.getMainExecutor(this))
}
역할 : 비디오 플레이 화면 구현
-
다른 미디어 플레이어 API와의 비교
- MediaPlayer
- MediaPlayer 보다 커스터마이징이 매우 용이.
- 배터리 소모측면에서, 비디오 재생시 소비되는 전력이 거의 비슷함.
- Jetpack Media3
- Jetpack Media3에서의 비디오 재생은 ExoPlayer를 사용.
- MediaSession 다루기가 쉽지만, 현재 앱에선 동영상 재생 기능만 필요하므로 MediaSession 불필요.
- MediaPlayer
-
동영상 플레이어 객체 생성
- ExoPlayer 인터페이스를 다목적으로 편리하게 구현한 SimpleExoPlayer가 Deprecated 되어, ExoPlayer를 빌더 패턴을 이용하여 객체 생성.
-
동영상 플레이어 객체 생성
- Android API 수준 24 이상
- 멀티 윈도우를 지원하므로, 분활 윈도우 모드로 활성화되지 않으므로 onStart에서 초기화.
- Android API 수준 24 이하
- 리소스 포착시까지 기다려야 하므로, onResume에서초기화
- Android API 수준 24 이상
-
재생될 미디어 항목
- 미디어 파일의 URI 를 사용함.
fun initPlay(){
player = ExoPlayer.Builder(this).build()
playerView.player = player
binding.playControlView.player = player
player!!.also {
it.setMediaItem(mediaItem)
it.playWhenReady = playWhenReady
it.seekTo(currentWindow, playbackPosition)
it.prepare()
}
}
override fun onStart() {
if (Util.SDK_INT >= 24) {
initPlay()
}
super.onStart()
}
override fun onResume() {
if ((Util.SDK_INT < 24 || player == null)) {
initPlay()
}
super.onResume()
}
- 동영상 플레이어 객체의 해제
- Android API 수준 24 이상
- 멀티 윈도우 지원으로 인해 onStop 호출이 보장된다. 멀티 윈도우 모드에서 한 앱이 포커스를 가질 경우, 그 외의 모든 앱은 일시중지 됨. 이때 일시중지 상태의 동영상 플레이 Activity도 다른 윈도우에서 완전하게 보일 수 있어서 onStop에서 객체 해제.
- Android API 수준 24 이하
- onStop의 호출이 보장되지 않음. 그러므로 onPause 에서 플레이어 해제
- Android API 수준 24 이상
- 동영상 플레이어 객체 해체시 권장사항
- 앱이 활동 수명 주기의 다양한 상태에서의 기존 재생 정보 유지 ex) 앱의 회전, 포그라운드 상태 -> 백그라운드 상태 -> 포그라운드 상태 , 다른 앱 시작 후 앱 실행 등의 경우의 재생 위치
private fun releasePlayer() {
player?.run {
playbackPosition = this.currentPosition
currentWindow = this.currentMediaItemIndex
playWhenReady = this.playWhenReady
release()
}
player = null
}
override fun onPause() {
super.onPause()
if (Util.SDK_INT < 24) {
releasePlayer()
}
}
override fun onStop() {
super.onStop()
if (Util.SDK_INT >= 24) {
releasePlayer()
}
}
- 컨트롤러
- ExoPlayer의 기본 controller 레이아웃 커스터마이징.
- 아쉬웠던 점
- Media3와 ExoPlayer 를 스스로 비교해보지 못한 점.
- 남은 일
- 동영상 목록 리스트에서 보여질 동영상 썸네일 생성 기능 구현