diff --git a/app/build.gradle b/app/build.gradle index 4090a90..d999929 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -162,8 +162,6 @@ dependencies { implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4" - implementation "androidx.media3:media3-ui:1.4.1" implementation "androidx.media3:media3-session:1.4.1" implementation "androidx.media3:media3-exoplayer:1.4.1" - implementation "androidx.media3:media3-datasource-okhttp:1.4.1" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1f76c4f..9fc7474 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -182,6 +182,15 @@ android:foregroundServiceType="mediaPlayback" android:stopWithTask="false" /> + + + + + + + private val playlist: ArrayList ) : BottomSheetDialogFragment() { private lateinit var loadingDialog: LoadingDialog @@ -24,6 +46,10 @@ class AudioContentPlayerFragment( private val viewModel: AudioContentPlayerViewModel by viewModel() + private var mediaController: MediaController? = null + private val handler = Handler(Looper.getMainLooper()) + private var isUserSeeking = false + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog @@ -65,6 +91,14 @@ class AudioContentPlayerFragment( setupView() bindData() + connectPlayerService() + } + + override fun onDestroyView() { + mediaController?.release() + mediaController = null + handler.removeCallbacksAndMessages(null) + super.onDestroyView() } private fun setupView() { @@ -88,4 +122,196 @@ class AudioContentPlayerFragment( } } } + + private fun connectPlayerService() { + context?.let { + if (!SharedPreferenceManager.isPlayerServiceRunning) { + startPlayerService(context = it) + } + + view?.postDelayed({ + connectToMediaSession(it) + }, 500) + } + } + + private fun startPlayerService(context: Context) { + val serviceIntent = Intent(context, AudioContentPlayerService::class.java) + context.startService(serviceIntent) + } + + private fun connectToMediaSession(context: Context) { + val componentName = ComponentName(context, AudioContentPlayerService::class.java) + val sessionToken = SessionToken(context, componentName) + val mediaControllerFuture = MediaController.Builder(context, sessionToken).buildAsync() + mediaControllerFuture.addListener( + { + mediaController = mediaControllerFuture.get() + setupMediaController() + updatePlayerUI() + startUpdatingUI() + }, + ContextCompat.getMainExecutor(context) + ) + } + + private fun setupMediaController() { + if (mediaController == null) { + Toast.makeText( + requireContext(), + "플레이어를 실행하지 못했습니다.\n다시 시도해 주세요.", + Toast.LENGTH_LONG + ).show() + dismiss() + return + } + + mediaController!!.addListener(object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + mediaController?.let { + when (playbackState) { + Player.STATE_READY -> { + binding.sbProgress.max = it.duration.toInt() + binding.tvTotalTime.text = Utils.convertDurationToString( + it.duration.toInt() + ) + } + + Player.STATE_ENDED -> it.seekTo(0) + else -> {} + } + } + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + updateMediaMetadata(mediaItem?.mediaMetadata) + updateTimeUI() + } + + override fun onIsLoadingChanged(isLoading: Boolean) { + viewModel.setLoading(isLoading) + } + + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + binding.ivPlayOrPause.setImageResource( + if (playWhenReady) { + R.drawable.ic_player_pause + } else { + R.drawable.ic_player_play + } + ) + } + }) + + if (playlist.isNotEmpty()) { + val extras = Bundle().apply { + putParcelableArrayList( + Constants.EXTRA_AUDIO_CONTENT_PLAYLIST, + playlist + ) + } + val sessionCommand = SessionCommand("UPDATE_PLAYLIST", Bundle.EMPTY) + mediaController!!.sendCustomCommand(sessionCommand, extras) + } + } + + private fun updatePlayerUI() { + binding.ivPlayOrPause.setOnClickListener { + mediaController?.let { + if (it.playWhenReady) { + it.pause() + } else { + it.play() + } + } + } + + binding.ivSkipForward.setOnClickListener { + mediaController?.let { + val sessionCommand = SessionCommand( + "PLAY_NEXT_CONTENT", + Bundle.EMPTY + ) + it.sendCustomCommand(sessionCommand, Bundle.EMPTY) + } + } + + binding.ivSkipBack.setOnClickListener { + mediaController?.let { + val sessionCommand = SessionCommand( + "PLAY_PREVIOUS_CONTENT", + Bundle.EMPTY + ) + it.sendCustomCommand(sessionCommand, Bundle.EMPTY) + } + } + + binding.sbProgress.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged( + seekBar: SeekBar?, + progress: Int, + fromUser: Boolean + ) { + if (fromUser) { + isUserSeeking = true + } + } + + override fun onStartTrackingTouch(p0: SeekBar?) { + isUserSeeking = true + } + + override fun onStopTrackingTouch(seekBar: SeekBar?) { + isUserSeeking = false + seekBar?.progress?.let { progress -> + mediaController?.seekTo(progress.toLong()) + } + } + + }) + + updateMediaMetadata(mediaController?.currentMediaItem?.mediaMetadata) + updateTimeUI() + } + + private fun updateMediaMetadata(metadata: MediaMetadata?) { + metadata?.let { + binding.tvTitle.text = it.title + binding.tvCreatorNickname.text = it.artist + + binding.ivCreatorProfile.load( + it.extras?.getString(Constants.EXTRA_AUDIO_CONTENT_CREATOR_PROFILE_IMAGE) + ) { + crossfade(true) + placeholder(R.drawable.ic_place_holder) + transformations(CircleCropTransformation()) + } + + binding.ivCover.load(it.artworkUri) { + crossfade(true) + placeholder(R.drawable.ic_place_holder) + transformations(RoundedCornersTransformation(4f)) + } + } + } + + private fun updateTimeUI() { + mediaController?.let { + val currentPosition = it.currentPosition + binding.sbProgress.progress = currentPosition.toInt() + binding.tvProgressTime.text = Utils.convertDurationToString(currentPosition.toInt()) + } + } + + private fun startUpdatingUI() { + handler.post(object : Runnable { + override fun run() { + if (mediaController?.isPlaying == true && !isUserSeeking) { + updateTimeUI() + } + + handler.postDelayed(this, 500) + } + }) + } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerService.kt b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerService.kt new file mode 100644 index 0000000..8dfb1e5 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerService.kt @@ -0,0 +1,275 @@ +package kr.co.vividnext.sodalive.audio_content.player + +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import androidx.core.os.BundleCompat +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.media3.extractor.DefaultExtractorsFactory +import androidx.media3.extractor.ts.AdtsExtractor +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import com.orhanobut.logger.Logger +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.schedulers.Schedulers +import kr.co.vividnext.sodalive.audio_content.playlist.detail.AudioContentPlaylistContent +import kr.co.vividnext.sodalive.common.Constants +import kr.co.vividnext.sodalive.common.SharedPreferenceManager +import kr.co.vividnext.sodalive.common.Utils +import org.koin.android.ext.android.inject + +@UnstableApi +class AudioContentPlayerService : MediaSessionService() { + + private val repository: AudioContentGenerateUrlRepository by inject() + + private var playlistManager: AudioContentPlaylistManager? = null + private var mediaSession: MediaSession? = null + private var player: ExoPlayer? = null + + val compositeDisposable = CompositeDisposable() + + override fun onCreate() { + super.onCreate() + + try { + SharedPreferenceManager.isPlayerServiceRunning = true + + initPlayer() + initMediaSession() + } catch (e: Exception) { + onStopService() + return + } + } + + override fun onDestroy() { + mediaSession?.run { + player.release() + release() + mediaSession = null + } + + compositeDisposable.dispose() + SharedPreferenceManager.isPlayerServiceRunning = false + super.onDestroy() + } + + override fun onTaskRemoved(rootIntent: Intent?) { + mediaSession?.run { + if ( + !player.playWhenReady || + player.mediaItemCount == 0 || + player.playbackState == Player.STATE_ENDED + ) { + onStopService() + } + } + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession + + private fun initPlayer() { + player = ExoPlayer.Builder(this).build() + player!!.addListener(object : Player.Listener { + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + super.onMediaItemTransition(mediaItem, reason) + player?.play() + } + + override fun onPlaybackStateChanged(playbackState: Int) { + super.onPlaybackStateChanged(playbackState) + if (playbackState == Player.STATE_IDLE) { + onStopService() + } else if (playbackState == Player.STATE_ENDED) { + if (playlistManager!!.hasNextContent()) { + playNextContent() + } else { + onStopService() + } + } + } + }) + } + + private fun initMediaSession() { + mediaSession = MediaSession.Builder(this, player!!) + .setCallback(object : MediaSession.Callback { + override fun onConnect( + session: MediaSession, + controller: MediaSession.ControllerInfo + ): MediaSession.ConnectionResult { + val allowedCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS + .buildUpon() + .add(SessionCommand("UPDATE_PLAYLIST", Bundle.EMPTY)) + .add(SessionCommand("PLAY_NEXT_CONTENT", Bundle.EMPTY)) + .add(SessionCommand("PLAY_PREVIOUS_CONTENT", Bundle.EMPTY)) + .add(SessionCommand("SEEK_TO", Bundle.EMPTY)) + .build() + + return MediaSession.ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands(allowedCommands) + .build() + } + + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle + ): ListenableFuture { + return when (customCommand.customAction) { + "UPDATE_PLAYLIST" -> { + val playlist = BundleCompat.getParcelableArrayList( + args, + Constants.EXTRA_AUDIO_CONTENT_PLAYLIST, + AudioContentPlaylistContent::class.java + ) + + if (playlist != null) { + playlistManager = AudioContentPlaylistManager(playlist) + playNextContent() + } + + Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + + "PLAY_NEXT_CONTENT" -> { + playNextContent() + Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + + "PLAY_PREVIOUS_CONTENT" -> { + playPreviousContent() + Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + + else -> super.onCustomCommand(session, controller, customCommand, args) + } + } + }) + .build() + } + + private fun onStopService() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + stopForeground(true) + } + + stopSelf() + } + + private fun playNextContent() { + val content = playlistManager?.moveToNext() + + if (content != null) { + generateUrl( + content.id, + onSuccess = { urlGenerateSuccess(content, it) }, + onFailure = { + if (playlistManager!!.hasNextContent()) { + playNextContent() + } else { + onStopService() + } + } + ) + } + } + + private fun urlGenerateSuccess( + content: AudioContentPlaylistContent, + contentUrl: String + ) { + val extras = Bundle().apply { + putString( + Constants.EXTRA_AUDIO_CONTENT_CREATOR_PROFILE_IMAGE, + content.creatorProfileUrl + ) + } + + val mediaMetadata = MediaMetadata.Builder() + .setTitle(content.title) + .setArtist(content.creatorNickname) + .setArtworkUri(Uri.parse(content.coverUrl)) + .setExtras(extras) + .setDurationMs(Utils.convertStringToDuration(content.duration)) + .build() + + val mediaItem = MediaItem.Builder() + .setUri(Uri.parse(contentUrl)) + .setMediaMetadata(mediaMetadata) + .build() + + val extractorFactory = DefaultExtractorsFactory().setAdtsExtractorFlags( + AdtsExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING + ) + val mediaSource = ProgressiveMediaSource.Factory( + DefaultDataSource.Factory(this), + extractorFactory + ).createMediaSource(mediaItem) + + player?.setMediaSource(mediaSource) + player?.prepare() + } + + private fun playPreviousContent() { + val content = playlistManager?.moveToPrevious() + + if (content != null) { + generateUrl( + content.id, + onSuccess = { urlGenerateSuccess(content, it) }, + onFailure = { + if (playlistManager!!.hasNextContent()) { + playNextContent() + } else { + onStopService() + } + } + ) + } + } + + private fun generateUrl(contentId: Long, onSuccess: (String) -> Unit, onFailure: () -> Unit) { + if (contentId <= 0) { + onFailure() + } + + compositeDisposable.add( + repository.generateUrl( + contentId = contentId, + token = "Bearer ${SharedPreferenceManager.token}" + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + if (it.success && it.data != null) { + onSuccess(it.data.contentUrl) + } else { + onFailure() + } + }, + { + it.message?.let { message -> Logger.e(message) } + onFailure() + } + ) + ) + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerViewModel.kt index 14144bf..ddf5d7b 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerViewModel.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerViewModel.kt @@ -19,43 +19,7 @@ class AudioContentPlayerViewModel( val isLoading: LiveData get() = _isLoading - fun generateUrl(contentId: Long, onSuccess: (String) -> Unit, onFailure: () -> Unit) { - if (contentId <= 0) { - onFailure() - } - - _isLoading.value = true - compositeDisposable.add( - repository.generateUrl( - contentId = contentId, - token = "Bearer ${SharedPreferenceManager.token}" - ) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { - if (it.success && it.data != null) { - onSuccess(it.data.contentUrl) - } else { - if (it.message != null) { - _toastLiveData.postValue(it.message) - } else { - _toastLiveData.postValue( - "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." - ) - } - - onFailure() - } - _isLoading.value = false - }, - { - _isLoading.value = false - it.message?.let { message -> Logger.e(message) } - _toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.") - onFailure() - } - ) - ) + fun setLoading(loading: Boolean) { + _isLoading.value = loading } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlaylistManager.kt b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlaylistManager.kt new file mode 100644 index 0000000..cd1c373 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlaylistManager.kt @@ -0,0 +1,27 @@ +package kr.co.vividnext.sodalive.audio_content.player + +import kr.co.vividnext.sodalive.audio_content.playlist.detail.AudioContentPlaylistContent + +class AudioContentPlaylistManager(private val playlist: List) { + private var currentIndex = -1 + + fun moveToNext(): AudioContentPlaylistContent? { + if (playlist.isNotEmpty()) { + currentIndex = if (currentIndex + 1 >= playlist.size) 0 else currentIndex + 1 + return playlist[currentIndex] + } + return null + } + + fun moveToPrevious(): AudioContentPlaylistContent? { + if (playlist.isNotEmpty()) { + currentIndex = if (currentIndex - 1 < 0) playlist.size - 1 else currentIndex - 1 + return playlist[currentIndex] + } + return null + } + + fun hasNextContent(): Boolean { + return currentIndex + 1 < playlist.size + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/playlist/create/AudioContentPlaylistCreateActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/playlist/create/AudioContentPlaylistCreateActivity.kt index 5ef4ffe..9521108 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/playlist/create/AudioContentPlaylistCreateActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/playlist/create/AudioContentPlaylistCreateActivity.kt @@ -40,7 +40,8 @@ class AudioContentPlaylistCreateActivity : BaseActivity( ActivityAudioContentPlaylistDetailBinding::inflate ) { @@ -33,7 +35,7 @@ class AudioContentPlaylistDetailActivity : BaseActivity