재생목록 플레이어

- 재생, 이전, 다음 기능 추가
This commit is contained in:
klaus 2024-12-13 13:27:42 +09:00
parent 40e82a3796
commit 316c4399ce
13 changed files with 579 additions and 48 deletions

View File

@ -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"
}

View File

@ -182,6 +182,15 @@
android:foregroundServiceType="mediaPlayback"
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] -->
<service
android:name=".fcm.SodaFirebaseMessagingService"

View File

@ -1,22 +1,44 @@
package kr.co.vividnext.sodalive.audio_content.player
import android.app.Dialog
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.SeekBar
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.BottomSheetDialog
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.common.Constants
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 org.koin.androidx.viewmodel.ext.android.viewModel
@UnstableApi
class AudioContentPlayerFragment(
private val screenWidth: Int,
private val playlist: List<AudioContentPlaylistContent>
private val playlist: ArrayList<AudioContentPlaylistContent>
) : 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)
}
})
}
}

View File

@ -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()
}
)
)
}
}

View File

@ -19,43 +19,7 @@ class AudioContentPlayerViewModel(
val isLoading: LiveData<Boolean>
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
}
}

View File

@ -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
}
}

View File

@ -40,7 +40,8 @@ class AudioContentPlaylistCreateActivity : BaseActivity<ActivityAudioContentPlay
category = item.themeStr,
coverUrl = item.coverImageUrl,
duration = item.duration ?: "00:00:00",
creatorNickname = item.creatorNickname
creatorNickname = item.creatorNickname,
creatorProfileUrl = ""
)
)
return@PlaylistAddContentDialogFragment true
@ -54,7 +55,8 @@ class AudioContentPlaylistCreateActivity : BaseActivity<ActivityAudioContentPlay
category = item.themeStr,
coverUrl = item.coverImageUrl,
duration = item.duration ?: "00:00:00",
creatorNickname = item.creatorNickname
creatorNickname = item.creatorNickname,
creatorProfileUrl = ""
)
)
return@PlaylistAddContentDialogFragment true

View File

@ -8,6 +8,7 @@ import android.view.View
import android.widget.ImageView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.load
@ -21,6 +22,7 @@ import kr.co.vividnext.sodalive.databinding.ActivityAudioContentPlaylistDetailBi
import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.android.ext.android.inject
@UnstableApi
class AudioContentPlaylistDetailActivity : BaseActivity<ActivityAudioContentPlaylistDetailBinding>(
ActivityAudioContentPlaylistDetailBinding::inflate
) {
@ -33,7 +35,7 @@ class AudioContentPlaylistDetailActivity : BaseActivity<ActivityAudioContentPlay
private var playlistId: Long = 0
private val playerFragment: AudioContentPlayerFragment by lazy {
AudioContentPlayerFragment(screenWidth, contentList)
AudioContentPlayerFragment(screenWidth, ArrayList(contentList))
}
private val modifyPlaylistResult = registerForActivityResult(

View File

@ -1,7 +1,9 @@
package kr.co.vividnext.sodalive.audio_content.playlist.detail
import android.os.Parcelable
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize
@Keep
data class GetPlaylistDetailResponse(
@ -15,11 +17,13 @@ data class GetPlaylistDetailResponse(
)
@Keep
@Parcelize
data class AudioContentPlaylistContent(
@SerializedName("id") val id: Long,
@SerializedName("title") val title: String,
@SerializedName("category") val category: String,
@SerializedName("coverUrl") val coverUrl: String,
@SerializedName("duration") val duration: String,
@SerializedName("creatorNickname") val creatorNickname: String
)
@SerializedName("creatorNickname") val creatorNickname: String,
@SerializedName("creatorProfileUrl") val creatorProfileUrl: String
) : Parcelable

View File

@ -46,7 +46,8 @@ class AudioContentPlaylistModifyActivity : BaseActivity<ActivityAudioContentPlay
category = item.themeStr,
coverUrl = item.coverImageUrl,
duration = item.duration ?: "00:00:00",
creatorNickname = item.creatorNickname
creatorNickname = item.creatorNickname,
creatorProfileUrl = ""
)
)
return@PlaylistAddContentDialogFragment true
@ -60,7 +61,8 @@ class AudioContentPlaylistModifyActivity : BaseActivity<ActivityAudioContentPlay
category = item.themeStr,
coverUrl = item.coverImageUrl,
duration = item.duration ?: "00:00:00",
creatorNickname = item.creatorNickname
creatorNickname = item.creatorNickname,
creatorProfileUrl = ""
)
)
return@PlaylistAddContentDialogFragment true

View File

@ -15,6 +15,7 @@ object Constants {
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_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_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_CREATOR_ID = "audio_content_creator_id"
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_TITLE = "extra_audio_content_curation_title"
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_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 LIVE_SERVICE_NOTIFICATION_ID: Int = 2

View File

@ -150,4 +150,10 @@ object SharedPreferenceManager {
val listJson = gson.toJson(value)
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
}
}

View File

@ -9,4 +9,17 @@ object Utils {
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)
}
}