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