Android 개발을 하면서 Retrofit을 활용하여 Api를 호출하고
{
...
...
"url": "https:// ....../media.mp4"
...
}
이 Json데이터를 받았다. 이 media를 제어하기 위해서 S MVVM 패턴의 ViewModel에서 상태를 관리하기로 했다.
이 단원은 미디어 파일 URL을 String으로 받는 것과 ApiServiceManager가 호출의 상태를 관리한다는 것이 특이점이니 할 줄 아신다면 넘어가시고, Hilt를 사용하였기 때문에 Retrofit 빌드는 Object를 생성하는 부분은 생략 되었기 때문에 유의하시길 바랍니다.
data class ApiResponse(
val title: String,
val file: String, // 미디어파일 URL
)
interface ApiService {
@GET("media.json")
suspend fun getMedia(): Response<ApiResponse>
}
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)
}
}
}
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))
}
}
}
}
sealed class MediaState {
object Loading: MediaState()
data class Success(val data: ApiResponse): MediaState()
data class Error(val message: String): MediaState()
}
@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()
}
}
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")
}
}
}
}
}
// 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")
}