Android(Kotlin), 오디오파일 상태관리를 위한 SeekBar(Compose 사용)

이도현·2023년 10월 19일
0

Android 공부

목록 보기
27/30

0. 개요

Android 개발을 하면서 Retrofit을 활용하여 Api를 호출하고

{
	...
    ...
    "url": "https:// ....../media.mp4"
    ...
}

이 Json데이터를 받았다. 이 media를 제어하기 위해서 S MVVM 패턴의 ViewModel에서 상태를 관리하기로 했다.

1. API 호출

이 단원은 미디어 파일 URL을 String으로 받는 것과 ApiServiceManager가 호출의 상태를 관리한다는 것이 특이점이니 할 줄 아신다면 넘어가시고, Hilt를 사용하였기 때문에 Retrofit 빌드는 Object를 생성하는 부분은 생략 되었기 때문에 유의하시길 바랍니다.

1) ApiResponse

data class ApiResponse(
    val title: String,
    val file: String, // 미디어파일 URL

)

2) ApiService

interface ApiService {
    @GET("media.json")
    suspend fun getMedia(): Response<ApiResponse>
}

3) ApiServiceManager

class ApiServiceManager @Inject constructor (
    private val apiService: ApiService
){

    suspend fun getMedia(): Result<ApiResponse>{
        return try {
            val response = ApiService.getMedia()
            if (response.isSuccessful) {
                Result.success(response.body()!!)
            } else {
                Result.failure(Exception(response.message()))
            }
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

2. 미디어 상태관리를 위한 Seekbar 구현

class CommonView(){
	@Composable
    fun MediaSeekBar(
    	currentPosition: Float // 현재 재생 위치 변수
        maxPostion: Float, // 현재 재생위치 확인을 위한 전체 재생 길이 변수
        onSeek: (Float) -> Unit, // 재생 구간 선택을 위한 함수
        onPlayPause: () -> Unit, // 재생/멈춤을 위한 함수
        onStop: () -> Unit, // 재생상태 초기화를 위한 함수
        isPlaying: Boolean // 재생 여부를 확인 하기위한 변수
    ){
    	Row(
            modifier = Modifier.fillMaxWidth().padding(16.dp),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.SpaceBetween
        ) {
            // SeekBar (Slider)
            Slider( // 전체 재생 구간에서 현재 재생위치를 보여주고 재생위치를 바꿀 수 있음.
                value = currentPosition, 
                onValueChange = onSeek,
                valueRange = 0f..maxPosition,
                steps = 0,
                modifier = Modifier.weight(1f).padding(horizontal = 16.dp)
            )

            // Play/Pause Button
            Button(onClick = onPlayPause) { // Play/Pause 상태 관리 버튼
                // Draw Play/Pause icon using Canvas
                Canvas(modifier = Modifier.size(24.dp)) {
                    if (isPlaying) {
                        // Draw Pause icon: two vertical bars
                        drawRect(color = Color.Black, size = Size(8f, 24f), topLeft = Offset(0f, 0f))
                        drawRect(color = Color.Black, size = Size(8f, 24f), topLeft = Offset(16f, 0f))
                    } else {
                        // Draw Play icon: a triangle
                        drawPath(
                            color = Color.Black,
                            path = androidx.compose.ui.graphics.Path().apply {
                                moveTo(0f, 0f)
                                lineTo(24f, 12f)
                                lineTo(0f, 24f)
                                close()
                            }
                        )
                    }
                }
            }

            // Stop Button
            Button(onClick = onStop) {
                // Draw Stop icon using Canvas: a square
                Canvas(modifier = Modifier.size(24.dp)) {
                    drawRect(color = Color.Black, size = Size(24f, 24f))
                }
            }
    
    
    }
}

3. SeekBar를 위한 ViewModel

1) MediaState

  • 호출한 미디어 상태 체크를 위한 클래스
  • sealed class를 사용한 이유: 제한된 클래스 계층을 나타내면서, 완전성을 보장하고, 유연하게 데이터를 표현할 수 있기 때문이다. 이로써 상태를 명확하게 표현하게 해주고, 처리할 상황을 정의하는데 도움이된다.
sealed class MediaState {
    object Loading: MediaState()
    data class Success(val data: ApiResponse): MediaState()
    data class Error(val message: String): MediaState()
}

2) ViewModel

@HiltViewModel // Hilt를 활용하여 ViewModel에 의존성 주입
class MyViewModel @Inject construnctor(
	private val apiServiceManager: ApiServiceManager
): ViewModel(){
	private val _mediaData = MutableLiveData<MediaState> 
    val media: LiveData<MediaState> = _mediaData
    
    private val _currentPosition = MutableLiveData<Float>(0f)
    val currentPosition: LiveData<Float> = _currentPosition
    
    private val _maxPosition = MutableLiveData<Float>(0f)
    val maxPosition: LiveData<Float> = _maxPosition
	
    private val _isPlaying = MutableLiveData<Boolean>(false)
    val isPlaying: LiveData<Boolean> get() = _isPlaying 
    
    fun fetchSong() {  // APi 호출
        viewModelScope.launch {
            val result = apiServiceManager.getData()
            _mediaData.value = when  {
                result.isSuccess -> MediaState.Success(result.getOrThrow())
                result.isFailure -> MediaState.Error(result.exceptionOrNull()?.localizedMessage ?: "An unknown error occurred!")
                else -> MediaState.Error("An unexpected error occurred!")
            }
        }
    }
    
    fun updateCurrentPosition(newPosition: Float) {
    	_currentPosition.value = newPosition
    }

	private val mediaPlayer: MediaPlayer by lazy { // Android에서 오디오나 비디오를 재생하기 위한 클래스
        MediaPlayer().apply {
            setOnCompletionListener {
                _currentPosition.value = 0f
            }
        }
    }

    fun playMedia(url: String) {
        viewModelScope.launch {
            try {
                if (mediaPlayer.isPlaying) {
                    mediaPlayer.stop()
                    mediaPlayer.reset()
                }
                mediaPlayer.setDataSource(url)
                mediaPlayer.prepare()
                mediaPlayer.start()
                _maxPosition.value = mediaPlayer.duration.toFloat()
                _isPlaying.value = true
                updateSeekBar()
            } catch (e: IOException) {
                _mediaData.value = MediaState.Error("Failed to play the media: ${e.localizedMessage}")
            }
        }
    }

    private fun updateSeekBar() {
        viewModelScope.launch {
            while (mediaPlayer.isPlaying) {
                updateCurrentPosition(mediaPlayer.currentPosition.toFloat())
                delay(1000)  // update every second
            }
        }
    }

    fun seekTo(position: Float) {
        mediaPlayer.seekTo(position.toInt())
        _currentPosition.value = position
    }

    fun stopMedia() {
        if (mediaPlayer.isPlaying) {
            mediaPlayer.stop()
            mediaPlayer.reset()
        }
        _isPlaying.value = false
        _currentPosition.value = 0f
    }

    override fun onCleared() {
        super.onCleared()
        mediaPlayer.release()
    }
}

4. View에 Seekbar와 ViewModel 변수 선언

class MediaPlayView {
    @Composable
    fun MediaPlayScreen(viewModel: MyViewModel) {
        val mediaState by viewModel.mediaData.observeAsState(MediaState.Loading)

         DisplaySongState(mediaState) {
            viewModel.fetchSong()
        }


        when (val currentMediaState = MediaState) {
            is MediaState.Success -> {
                val mediaData =  currentMedaState.data
                Log.d("mediaData", "$mediaData")
                val currentPosition = viewModel.currentPosition.value ?: 0f  // 현재 재생 위치
                Log.d("DEBUG", "Current Position: $currentPosition")
                val isPlaying by viewModel.isPlaying.observeAsState(false)

				MediaContentView(songData, navController, viewModel)
            }
            else -> {}  // 다른 상태들은 DisplaySongState에서 처리되므로 여기서는 아무 것도 하지 않습니다.
        }

        LaunchedEffect(key1 = viewModel) {
            viewModel.fetchSong()
        }
    }

    @Composable
    fun MediaContentView(mediaData: ApiResponse, viewModel: SongViewModel) {
        val isPlaying by viewModel.isPlaying.observeAsState(false)
        val currentPosition by viewModel.currentPosition.observeAsState(0f)
        val maxPosition by viewModel.maxPosition.observeAsState(0f)
        

        Column(
            modifier = Modifier.fillMaxSize().padding(16.dp),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {

            // 곡 정보
            Text(text = mediaData.title, style = MaterialTheme.typography.h5, modifier = Modifier.padding(top = 8.dp))
            
            //SeekBar
            CommonView().MediaSeekBar(
                currentPosition = currentPosition,
                maxPosition = maxPosition,
                onSeek = { newPosition ->
                    viewModel.seekTo(newPosition)
                },
                onPlayPause = {
                    if (isPlaying) {
                        viewModel.stopMedia()
                    } else {
                        viewModel.playMedia(mediaData.file)
                    }
                },
                onStop = {
                    viewModel.stopMusic()
                },
                isPlaying = isPlaying
            )

            Spacer(modifier = Modifier.height(16.dp))

        }
    }
    
    
    @Composable
    fun DisplayMediaState(mediaState: MediaState, onRetry: () -> Unit) {
        when (mediaState) {
            is mediaState.Loading -> {
                // 로딩 UI...
            }
            is mediaState.Success -> {
                // 성공 UI...
            }
            is mediaState.Error -> {
                // 에러 UI...
                Button(onClick = onRetry) {
                    Text("Retry")
                }
            }
        }
    }
}

5. implementation

// gradle.gradle.kts(module:app)
dependencies{
  	  implementation(project(":data"))
  
      //Compose
      implementation("androidx.compose.ui:ui:1.5.3")
      implementation("androidx.compose.material:material:1.5.3")
      implementation("androidx.compose.ui:ui-tooling:1.5.3")
      implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
      implementation ("androidx.navigation:navigation-compose:2.5.3")
      implementation ("androidx.compose.runtime:runtime-livedata:1.3.1")
      implementation("androidx.activity:activity-compose:1.8.0")
      implementation ("io.coil-kt:coil-compose:1.4.0")

      val lifecycle_versions = "2.4.1"
      //lifecycle
      implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_versions")
      implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_versions")
      implementation ("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_versions")


      //hilt
      implementation("com.google.dagger:hilt-android:2.44")
      kapt("com.google.dagger:hilt-android-compiler:2.44")
}
profile
좋은 지식 나누어요

0개의 댓글