parent
40e82a3796
commit
316c4399ce
|
@ -162,8 +162,6 @@ dependencies {
|
||||||
|
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
|
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-session:1.4.1"
|
||||||
implementation "androidx.media3:media3-exoplayer:1.4.1"
|
implementation "androidx.media3:media3-exoplayer:1.4.1"
|
||||||
implementation "androidx.media3:media3-datasource-okhttp:1.4.1"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -182,6 +182,15 @@
|
||||||
android:foregroundServiceType="mediaPlayback"
|
android:foregroundServiceType="mediaPlayback"
|
||||||
android:stopWithTask="false" />
|
android:stopWithTask="false" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".audio_content.player.AudioContentPlayerService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaPlayback">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
<!-- [START firebase_service] -->
|
<!-- [START firebase_service] -->
|
||||||
<service
|
<service
|
||||||
android:name=".fcm.SodaFirebaseMessagingService"
|
android:name=".fcm.SodaFirebaseMessagingService"
|
||||||
|
|
|
@ -1,22 +1,44 @@
|
||||||
package kr.co.vividnext.sodalive.audio_content.player
|
package kr.co.vividnext.sodalive.audio_content.player
|
||||||
|
|
||||||
import android.app.Dialog
|
import android.app.Dialog
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.widget.SeekBar
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.MediaMetadata
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.session.MediaController
|
||||||
|
import androidx.media3.session.SessionCommand
|
||||||
|
import androidx.media3.session.SessionToken
|
||||||
|
import coil.load
|
||||||
|
import coil.transform.CircleCropTransformation
|
||||||
|
import coil.transform.RoundedCornersTransformation
|
||||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||||
|
import kr.co.vividnext.sodalive.R
|
||||||
import kr.co.vividnext.sodalive.audio_content.playlist.detail.AudioContentPlaylistContent
|
import kr.co.vividnext.sodalive.audio_content.playlist.detail.AudioContentPlaylistContent
|
||||||
|
import kr.co.vividnext.sodalive.common.Constants
|
||||||
import kr.co.vividnext.sodalive.common.LoadingDialog
|
import kr.co.vividnext.sodalive.common.LoadingDialog
|
||||||
|
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||||
|
import kr.co.vividnext.sodalive.common.Utils
|
||||||
import kr.co.vividnext.sodalive.databinding.FragmentAudioContentPlayerBinding
|
import kr.co.vividnext.sodalive.databinding.FragmentAudioContentPlayerBinding
|
||||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
|
|
||||||
|
@UnstableApi
|
||||||
class AudioContentPlayerFragment(
|
class AudioContentPlayerFragment(
|
||||||
private val screenWidth: Int,
|
private val screenWidth: Int,
|
||||||
private val playlist: List<AudioContentPlaylistContent>
|
private val playlist: ArrayList<AudioContentPlaylistContent>
|
||||||
) : BottomSheetDialogFragment() {
|
) : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
private lateinit var loadingDialog: LoadingDialog
|
private lateinit var loadingDialog: LoadingDialog
|
||||||
|
@ -24,6 +46,10 @@ class AudioContentPlayerFragment(
|
||||||
|
|
||||||
private val viewModel: AudioContentPlayerViewModel by viewModel()
|
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 {
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog
|
val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog
|
||||||
|
|
||||||
|
@ -65,6 +91,14 @@ class AudioContentPlayerFragment(
|
||||||
|
|
||||||
setupView()
|
setupView()
|
||||||
bindData()
|
bindData()
|
||||||
|
connectPlayerService()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
mediaController?.release()
|
||||||
|
mediaController = null
|
||||||
|
handler.removeCallbacksAndMessages(null)
|
||||||
|
super.onDestroyView()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupView() {
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<SessionResult> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -19,43 +19,7 @@ class AudioContentPlayerViewModel(
|
||||||
val isLoading: LiveData<Boolean>
|
val isLoading: LiveData<Boolean>
|
||||||
get() = _isLoading
|
get() = _isLoading
|
||||||
|
|
||||||
fun generateUrl(contentId: Long, onSuccess: (String) -> Unit, onFailure: () -> Unit) {
|
fun setLoading(loading: Boolean) {
|
||||||
if (contentId <= 0) {
|
_isLoading.value = loading
|
||||||
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()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<AudioContentPlaylistContent>) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
|
@ -40,7 +40,8 @@ class AudioContentPlaylistCreateActivity : BaseActivity<ActivityAudioContentPlay
|
||||||
category = item.themeStr,
|
category = item.themeStr,
|
||||||
coverUrl = item.coverImageUrl,
|
coverUrl = item.coverImageUrl,
|
||||||
duration = item.duration ?: "00:00:00",
|
duration = item.duration ?: "00:00:00",
|
||||||
creatorNickname = item.creatorNickname
|
creatorNickname = item.creatorNickname,
|
||||||
|
creatorProfileUrl = ""
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return@PlaylistAddContentDialogFragment true
|
return@PlaylistAddContentDialogFragment true
|
||||||
|
@ -54,7 +55,8 @@ class AudioContentPlaylistCreateActivity : BaseActivity<ActivityAudioContentPlay
|
||||||
category = item.themeStr,
|
category = item.themeStr,
|
||||||
coverUrl = item.coverImageUrl,
|
coverUrl = item.coverImageUrl,
|
||||||
duration = item.duration ?: "00:00:00",
|
duration = item.duration ?: "00:00:00",
|
||||||
creatorNickname = item.creatorNickname
|
creatorNickname = item.creatorNickname,
|
||||||
|
creatorProfileUrl = ""
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return@PlaylistAddContentDialogFragment true
|
return@PlaylistAddContentDialogFragment true
|
||||||
|
|
|
@ -8,6 +8,7 @@ import android.view.View
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import coil.load
|
import coil.load
|
||||||
|
@ -21,6 +22,7 @@ import kr.co.vividnext.sodalive.databinding.ActivityAudioContentPlaylistDetailBi
|
||||||
import kr.co.vividnext.sodalive.extensions.dpToPx
|
import kr.co.vividnext.sodalive.extensions.dpToPx
|
||||||
import org.koin.android.ext.android.inject
|
import org.koin.android.ext.android.inject
|
||||||
|
|
||||||
|
@UnstableApi
|
||||||
class AudioContentPlaylistDetailActivity : BaseActivity<ActivityAudioContentPlaylistDetailBinding>(
|
class AudioContentPlaylistDetailActivity : BaseActivity<ActivityAudioContentPlaylistDetailBinding>(
|
||||||
ActivityAudioContentPlaylistDetailBinding::inflate
|
ActivityAudioContentPlaylistDetailBinding::inflate
|
||||||
) {
|
) {
|
||||||
|
@ -33,7 +35,7 @@ class AudioContentPlaylistDetailActivity : BaseActivity<ActivityAudioContentPlay
|
||||||
private var playlistId: Long = 0
|
private var playlistId: Long = 0
|
||||||
|
|
||||||
private val playerFragment: AudioContentPlayerFragment by lazy {
|
private val playerFragment: AudioContentPlayerFragment by lazy {
|
||||||
AudioContentPlayerFragment(screenWidth, contentList)
|
AudioContentPlayerFragment(screenWidth, ArrayList(contentList))
|
||||||
}
|
}
|
||||||
|
|
||||||
private val modifyPlaylistResult = registerForActivityResult(
|
private val modifyPlaylistResult = registerForActivityResult(
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
package kr.co.vividnext.sodalive.audio_content.playlist.detail
|
package kr.co.vividnext.sodalive.audio_content.playlist.detail
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
import androidx.annotation.Keep
|
import androidx.annotation.Keep
|
||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
@Keep
|
@Keep
|
||||||
data class GetPlaylistDetailResponse(
|
data class GetPlaylistDetailResponse(
|
||||||
|
@ -15,11 +17,13 @@ data class GetPlaylistDetailResponse(
|
||||||
)
|
)
|
||||||
|
|
||||||
@Keep
|
@Keep
|
||||||
|
@Parcelize
|
||||||
data class AudioContentPlaylistContent(
|
data class AudioContentPlaylistContent(
|
||||||
@SerializedName("id") val id: Long,
|
@SerializedName("id") val id: Long,
|
||||||
@SerializedName("title") val title: String,
|
@SerializedName("title") val title: String,
|
||||||
@SerializedName("category") val category: String,
|
@SerializedName("category") val category: String,
|
||||||
@SerializedName("coverUrl") val coverUrl: String,
|
@SerializedName("coverUrl") val coverUrl: String,
|
||||||
@SerializedName("duration") val duration: String,
|
@SerializedName("duration") val duration: String,
|
||||||
@SerializedName("creatorNickname") val creatorNickname: String
|
@SerializedName("creatorNickname") val creatorNickname: String,
|
||||||
)
|
@SerializedName("creatorProfileUrl") val creatorProfileUrl: String
|
||||||
|
) : Parcelable
|
||||||
|
|
|
@ -46,7 +46,8 @@ class AudioContentPlaylistModifyActivity : BaseActivity<ActivityAudioContentPlay
|
||||||
category = item.themeStr,
|
category = item.themeStr,
|
||||||
coverUrl = item.coverImageUrl,
|
coverUrl = item.coverImageUrl,
|
||||||
duration = item.duration ?: "00:00:00",
|
duration = item.duration ?: "00:00:00",
|
||||||
creatorNickname = item.creatorNickname
|
creatorNickname = item.creatorNickname,
|
||||||
|
creatorProfileUrl = ""
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return@PlaylistAddContentDialogFragment true
|
return@PlaylistAddContentDialogFragment true
|
||||||
|
@ -60,7 +61,8 @@ class AudioContentPlaylistModifyActivity : BaseActivity<ActivityAudioContentPlay
|
||||||
category = item.themeStr,
|
category = item.themeStr,
|
||||||
coverUrl = item.coverImageUrl,
|
coverUrl = item.coverImageUrl,
|
||||||
duration = item.duration ?: "00:00:00",
|
duration = item.duration ?: "00:00:00",
|
||||||
creatorNickname = item.creatorNickname
|
creatorNickname = item.creatorNickname,
|
||||||
|
creatorProfileUrl = ""
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return@PlaylistAddContentDialogFragment true
|
return@PlaylistAddContentDialogFragment true
|
||||||
|
|
|
@ -15,6 +15,7 @@ object Constants {
|
||||||
const val PREF_IS_CONTENT_PLAY_LOOP = "pref_is_content_play_loop"
|
const val PREF_IS_CONTENT_PLAY_LOOP = "pref_is_content_play_loop"
|
||||||
const val PREF_IS_ADULT_CONTENT_VISIBLE = "pref_is_adult_content_visible"
|
const val PREF_IS_ADULT_CONTENT_VISIBLE = "pref_is_adult_content_visible"
|
||||||
const val PREF_IS_FOLLOWED_CREATOR_LIVE = "pref_is_followed_creator_live"
|
const val PREF_IS_FOLLOWED_CREATOR_LIVE = "pref_is_followed_creator_live"
|
||||||
|
const val PREF_IS_PLAYER_SERVICE_RUNNING = "pref_is_player_service_running"
|
||||||
const val PREF_NOT_SHOWING_EVENT_POPUP_ID = "pref_not_showing_event_popup_id"
|
const val PREF_NOT_SHOWING_EVENT_POPUP_ID = "pref_not_showing_event_popup_id"
|
||||||
const val PREF_IS_VIEWED_ON_BOARDING_TUTORIAL = "pref_is_viewed_on_boarding_tutorial"
|
const val PREF_IS_VIEWED_ON_BOARDING_TUTORIAL = "pref_is_viewed_on_boarding_tutorial"
|
||||||
|
|
||||||
|
@ -57,11 +58,13 @@ object Constants {
|
||||||
const val EXTRA_AUDIO_CONTENT_LOADING = "audio_content_loading"
|
const val EXTRA_AUDIO_CONTENT_LOADING = "audio_content_loading"
|
||||||
const val EXTRA_AUDIO_CONTENT_CREATOR_ID = "audio_content_creator_id"
|
const val EXTRA_AUDIO_CONTENT_CREATOR_ID = "audio_content_creator_id"
|
||||||
const val EXTRA_AUDIO_CONTENT_CREATOR_NICKNAME = "audio_content_creator_nickname"
|
const val EXTRA_AUDIO_CONTENT_CREATOR_NICKNAME = "audio_content_creator_nickname"
|
||||||
|
const val EXTRA_AUDIO_CONTENT_CREATOR_PROFILE_IMAGE = "audio_content_creator_profile_image"
|
||||||
const val EXTRA_AUDIO_CONTENT_CURATION_ID = "extra_audio_content_curation_id"
|
const val EXTRA_AUDIO_CONTENT_CURATION_ID = "extra_audio_content_curation_id"
|
||||||
const val EXTRA_AUDIO_CONTENT_CURATION_TITLE = "extra_audio_content_curation_title"
|
const val EXTRA_AUDIO_CONTENT_CURATION_TITLE = "extra_audio_content_curation_title"
|
||||||
const val EXTRA_AUDIO_CONTENT_NEXT_ACTION = "audio_content_next_action"
|
const val EXTRA_AUDIO_CONTENT_NEXT_ACTION = "audio_content_next_action"
|
||||||
const val EXTRA_AUDIO_CONTENT_ALERT_PREVIEW = "audio_content_alert_preview"
|
const val EXTRA_AUDIO_CONTENT_ALERT_PREVIEW = "audio_content_alert_preview"
|
||||||
const val EXTRA_AUDIO_CONTENT_COVER_IMAGE_URL = "audio_content_cover_image_url"
|
const val EXTRA_AUDIO_CONTENT_COVER_IMAGE_URL = "audio_content_cover_image_url"
|
||||||
|
const val EXTRA_AUDIO_CONTENT_PLAYLIST = "extra_audio_content_playlist"
|
||||||
const val EXTRA_IS_SHOW_SECRET = "extra_is_show_secret"
|
const val EXTRA_IS_SHOW_SECRET = "extra_is_show_secret"
|
||||||
|
|
||||||
const val LIVE_SERVICE_NOTIFICATION_ID: Int = 2
|
const val LIVE_SERVICE_NOTIFICATION_ID: Int = 2
|
||||||
|
|
|
@ -150,4 +150,10 @@ object SharedPreferenceManager {
|
||||||
val listJson = gson.toJson(value)
|
val listJson = gson.toJson(value)
|
||||||
sharedPreferences[Constants.PREF_NO_CHAT_ROOM] = listJson
|
sharedPreferences[Constants.PREF_NO_CHAT_ROOM] = listJson
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isPlayerServiceRunning: Boolean
|
||||||
|
get() = sharedPreferences[Constants.PREF_IS_PLAYER_SERVICE_RUNNING, false]
|
||||||
|
set(value) {
|
||||||
|
sharedPreferences[Constants.PREF_IS_PLAYER_SERVICE_RUNNING] = value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,4 +9,17 @@ object Utils {
|
||||||
|
|
||||||
return "%02d:%02d:%02d".format(hours, minutes, seconds)
|
return "%02d:%02d:%02d".format(hours, minutes, seconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun convertStringToDuration(timeString: String): Long {
|
||||||
|
val parts = timeString.split(":")
|
||||||
|
if (parts.size != 3) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
val hours = parts[0].toLong()
|
||||||
|
val minutes = parts[1].toLong()
|
||||||
|
val seconds = parts[2].toLong()
|
||||||
|
|
||||||
|
return 1000 * (hours * 3600 + minutes * 60 + seconds)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue