一、功能
1.使用 AudioTrack 流式播放 PCM 音频
2.使用 MediaPlayer 静态播放 MP3 音频
3.使用 MediaSessionCompat 进行媒体会话管理
4.使用 MediaSessionCompat.Callback 接收播放控制回调
5.使用 MediaControllerCompat 进行播放控制
6.使用 MediaControllerCompat.Callback 接收播放状态回调
7.使用 MediaStyle 及 MediaSession 构造通知栏
8.使用 AudioRecord 录制 PCM 音频
二、实现
1.播放器服务端
PodcastPlayerService
package com.tomorrow.mediaplayerdemo.podcastplayer.service
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import android.support.v4.media.session.MediaSessionCompat
import com.tomorrow.mediaplayerdemo.Log
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.EXTRA_KEY_ACTION
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.EXTRA_KEY_MEDIA_ID
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.MEDIA_ACTION_PAUSE
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.MEDIA_ACTION_PLAY
private const val TAG = "PodcastPlayerServiceTAG"
// 播放器服务端
class PodcastPlayerService : Service() {
private lateinit var playerHelper: PodcastPlayerHelper
private val binder = LocalBinder()
override fun onCreate() {
super.onCreate()
Log.CnFeatureUi.i(TAG, "onCreate")
playerHelper = PodcastPlayerHelper(this)
playerHelper.init()
}
override fun onBind(intent: Intent?): IBinder? {
Log.CnFeatureUi.i(TAG, "onBind")
return binder
}
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int
): Int {
Log.CnFeatureUi.i(TAG, "onStartCommand")
intent?.let {
val action = it.getStringExtra(EXTRA_KEY_ACTION) ?: ""
val mediaId = it.getStringExtra(EXTRA_KEY_MEDIA_ID) ?: ""
if (action == MEDIA_ACTION_PLAY) {
playerHelper.playMedia(mediaId)
} else if (action == MEDIA_ACTION_PAUSE) {
playerHelper.pauseMedia()
}
}
return super.onStartCommand(intent, flags, startId)
}
override fun onDestroy() {
super.onDestroy()
Log.CnFeatureUi.i(TAG, "onDestroy")
playerHelper.destroy()
}
inner class LocalBinder : Binder() {
fun getMediaSession(): MediaSessionCompat {
return playerHelper.getMediaSession()
}
}
}
PodcastPlayerHelper
package com.tomorrow.mediaplayerdemo.podcastplayer.service
import android.content.Context
import android.support.v4.media.session.MediaSessionCompat
import android.widget.Toast
import com.tomorrow.mediaplayerdemo.CommonContextHolder
import com.tomorrow.mediaplayerdemo.Log
import com.tomorrow.mediaplayerdemo.podcastplayer.model.PodcastPlayerDataManager
private const val TAG = "PodcastPlayerHelper"
// 播放辅助类
class PodcastPlayerHelper(context: Context) {
private val playerCommon = PodcastPlayerCommon(context)
private val playerStream = PodcastPlayerStream(playerCommon)
private val playerStatic = PodcastPlayerStatic(playerCommon)
private val callback = object : MediaSessionCompat.Callback() {
override fun onPlay() {
onPlayCallback()
}
override fun onPause() {
onPauseCallback()
}
override fun onSeekTo(pos: Long) {
onSeekToCallback(pos)
}
override fun onSetPlaybackSpeed(speed: Float) {
onSetPlaybackSpeedCallback(speed)
}
}
fun init() {
Log.CnFeatureUi.i(TAG, "init")
playerCommon.init(callback)
playerStream.init()
playerStatic.init()
}
fun destroy() {
Log.CnFeatureUi.i(TAG, "destroy")
playerCommon.destroy()
playerStream.destroy()
playerStatic.destroy()
}
fun getMediaSession(): MediaSessionCompat {
return playerCommon.getMediaSession()
}
// 通过播放列表播放
fun playMedia(mediaId: String) {
Log.CnFeatureUi.i(TAG, "playMedia, mediaId: $mediaId")
if (playerCommon.mediaId.isEmpty()) { // 之前没有播过
// 播放新的内容
playNewMediaId(mediaId)
} else if (mediaId != playerCommon.mediaId) { // 之前有播过,但是 mediaId 不同
// 停止之前的播放
if (playerCommon.isStreamMode) {
playerStream.setPausedCallback {
Log.CnFeatureUi.i(TAG, "playMedia, stream paused callback")
// 播放新的内容
playNewMediaId(mediaId)
playerStream.setPausedCallback(null)
}
playerStream.pauseStream()
} else {
playerStatic.resetStatic()
// 播放新的内容
playNewMediaId(mediaId)
}
} else { // 之前有播过,且 mediaId 相同
if (playerCommon.isPlayComplete) { // 已播放完毕
if (PodcastPlayerDataManager.isLocalFileExist(mediaId)) {
if (playerCommon.isStreamMode) {
playerStatic.playStatic(PodcastPlayerDataManager.getLocalFilePath(mediaId))
playerCommon.isStreamMode = false
playerCommon.isStreamLoading = false
playerCommon.updateMetadata()
} else {
playerStatic.replayStatic()
playerCommon.isPlayComplete = false
}
} else {
Log.CnFeatureUi.e(
TAG,
"playMedia, play complete, but local file not exist, ignore"
)
Toast.makeText(
CommonContextHolder.getApplicationContext(),
"已播放完毕",
Toast.LENGTH_SHORT
).show()
}
} else { // 未播放完毕
if (playerCommon.isStreamMode) {
playerStream.resumeStream()
} else {
playerStatic.resumeStatic()
}
}
}
}
private fun playNewMediaId(mediaId: String) {
Log.CnFeatureUi.i(TAG, "playNewMediaId: $mediaId")
playerCommon.mediaId = mediaId
if (PodcastPlayerDataManager.isLocalFileExist(mediaId)) {
playerStatic.playStatic(PodcastPlayerDataManager.getLocalFilePath(mediaId))
playerCommon.isStreamMode = false
playerCommon.isStreamLoading = false
} else {
val cacheExist = PodcastPlayerDataManager.fetchStream(mediaId)
playerStream.playStream()
playerCommon.isStreamMode = true
playerCommon.isStreamLoading = !cacheExist
}
playerCommon.isPlayComplete = false
playerCommon.updateMetadata()
}
// 通过播放列表暂停
fun pauseMedia() {
Log.CnFeatureUi.i(TAG, "pauseMedia")
if (playerCommon.isStreamMode) {
playerStream.pauseStream()
} else {
playerStatic.pauseStatic()
}
}
// 通过通知栏播放、通过播放页面播放
fun onPlayCallback() {
Log.CnFeatureUi.i(TAG, "onPlayCallback")
if (playerCommon.mediaId.isNotEmpty()) { // 之前有播过
if (playerCommon.isPlayComplete) { // 已播放完毕
if (PodcastPlayerDataManager.isLocalFileExist(playerCommon.mediaId)) {
if (playerCommon.isStreamMode) {
playerStatic.playStatic(
PodcastPlayerDataManager.getLocalFilePath(
playerCommon.mediaId
)
)
} else {
playerStatic.replayStatic()
playerCommon.isPlayComplete = false
}
} else {
Log.CnFeatureUi.e(
TAG,
"onPlayCallback, play complete but local file not exist, ignore"
)
Toast.makeText(
CommonContextHolder.getApplicationContext(),
"已播放完毕",
Toast.LENGTH_SHORT
).show()
}
} else { // 未播放完毕
if (playerCommon.isStreamMode) {
playerStream.resumeStream()
} else {
playerStatic.resumeStatic()
}
}
} else {
Log.CnFeatureUi.e(
TAG,
"onPlayCallback, not play before, ignore"
)
Toast.makeText(
CommonContextHolder.getApplicationContext(),
"未开始播放",
Toast.LENGTH_SHORT
).show()
}
}
// 点击通知栏暂停、通过播放页面暂停
fun onPauseCallback() {
Log.CnFeatureUi.i(TAG, "onPauseCallback")
if (playerCommon.mediaId.isNotEmpty()) { // 之前有播过
if (playerCommon.isStreamMode) {
playerStream.pauseStream()
} else {
playerStatic.pauseStatic()
}
} else {
Log.CnFeatureUi.e(
TAG,
"onPauseCallback, not play before, ignore"
)
Toast.makeText(
CommonContextHolder.getApplicationContext(),
"未开始播放",
Toast.LENGTH_SHORT
).show()
}
}
// 通过通知栏拖动进度、通过播放页面拖动进度
fun onSeekToCallback(position: Long) {
Log.CnFeatureUi.i(TAG, "onSeekToCallback, position: $position")
if (!playerCommon.isStreamMode) {
playerStatic.seekToStatic(position)
}
}
// 通过播放页面设置倍速
fun onSetPlaybackSpeedCallback(speed: Float) {
Log.CnFeatureUi.i(TAG, "onSetPlaybackSpeedCallback, speed: $speed")
if (!playerCommon.isStreamMode) {
playerStatic.setPlaybackSpeedStatic(speed)
}
}
}
PodcastPlayerCommon
package com.tomorrow.mediaplayerdemo.podcastplayer.service
import android.content.Context
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import com.tomorrow.mediaplayerdemo.Log
import com.tomorrow.mediaplayerdemo.podcastplayer.model.PodcastPlayerDataManager
import com.tomorrow.mediaplayerdemo.podcastplayer.notification.PodcastPlayerNotificationHelper
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.METADATA_CUSTOM_KEY_IS_STREAM_LOADING
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.METADATA_CUSTOM_KEY_IS_STREAM_MODE
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.METADATA_CUSTOM_VALUE_FALSE
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.METADATA_CUSTOM_VALUE_TRUE
private const val TAG = "PodcastPlayerCommon"
// 流式播放与静态播放公共接口
class PodcastPlayerCommon(private val context: Context) {
private lateinit var mediaSession: MediaSessionCompat
private lateinit var notificationHelper: PodcastPlayerNotificationHelper
private lateinit var sessionCallback: MediaSessionCompat.Callback
var mediaId = ""
var isStreamMode = false
var isPlayComplete = false
var isStreamLoading = false
fun init(callback: MediaSessionCompat.Callback) {
Log.CnFeatureUi.i(TAG, "init")
sessionCallback = callback
initMediaSession()
updatePlaybackState(PlaybackStateCompat.STATE_NONE)
notificationHelper = PodcastPlayerNotificationHelper(context, mediaSession)
notificationHelper.updateNotification()
}
fun destroy() {
Log.CnFeatureUi.i(TAG, "destroy")
mediaSession.release()
notificationHelper.cancelNotification()
}
fun getMediaSession(): MediaSessionCompat {
return mediaSession
}
private fun initMediaSession() {
mediaSession = MediaSessionCompat(context, TAG).apply {
isActive = true
setFlags(
MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS
or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
)
}
mediaSession.setCallback(object : MediaSessionCompat.Callback() {
override fun onPlay() {
Log.CnFeatureUi.i(TAG, "mediaSession, onPlay")
sessionCallback.onPlay()
}
override fun onPause() {
Log.CnFeatureUi.i(TAG, "mediaSession, onPause")
sessionCallback.onPause()
}
override fun onSeekTo(pos: Long) {
Log.CnFeatureUi.i(TAG, "mediaSession, onSeekTo: $pos")
sessionCallback.onSeekTo(pos)
}
override fun onSetPlaybackSpeed(speed: Float) {
Log.CnFeatureUi.i(TAG, "mediaSession, onSetPlaybackSpeed: $speed")
sessionCallback.onSetPlaybackSpeed(speed)
}
})
}
fun updatePlaybackState(
state: Int,
position: Long = PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN,
speed: Float = 0f
) {
// Log.CnFeatureUi.i(
// TAG,
// "updatePlaybackState, state: $state, position: $position, speed: $speed"
// )
mediaSession.setPlaybackState(
PlaybackStateCompat.Builder()
.setState(state, position, speed)
.setActions(
PlaybackStateCompat.ACTION_PLAY
or PlaybackStateCompat.ACTION_PAUSE
or PlaybackStateCompat.ACTION_SEEK_TO
or PlaybackStateCompat.ACTION_SET_PLAYBACK_SPEED
)
.build()
)
}
fun updateMetadata(duration: Int = 0) {
val title = PodcastPlayerDataManager.getMediaTitle(mediaId)
val isStreamModeStr =
if (isStreamMode) METADATA_CUSTOM_VALUE_TRUE else METADATA_CUSTOM_VALUE_FALSE
val isStreamLoadingStr =
if (isStreamLoading) METADATA_CUSTOM_VALUE_TRUE else METADATA_CUSTOM_VALUE_FALSE
Log.CnFeatureUi.i(
TAG,
"updateMetadata, mediaId: $mediaId, title: $title, duration: $duration, " +
"isStreamMode: $isStreamModeStr, isStreamLoading: $isStreamLoadingStr"
)
val currentMetadata = mediaSession.controller.metadata
val metadataBuilder = if (currentMetadata != null) {
MediaMetadataCompat.Builder(currentMetadata)
} else {
MediaMetadataCompat.Builder()
}
metadataBuilder.putString(
MediaMetadataCompat.METADATA_KEY_MEDIA_ID,
mediaId
)
metadataBuilder.putString(
MediaMetadataCompat.METADATA_KEY_TITLE,
title
)
metadataBuilder.putLong(
MediaMetadataCompat.METADATA_KEY_DURATION,
duration.toLong()
)
metadataBuilder.putString(
METADATA_CUSTOM_KEY_IS_STREAM_MODE,
isStreamModeStr
)
metadataBuilder.putString(
METADATA_CUSTOM_KEY_IS_STREAM_LOADING,
isStreamLoadingStr
)
mediaSession.setMetadata(metadataBuilder.build())
notificationHelper.updateNotification()
}
}
PodcastPlayerStream
package com.tomorrow.mediaplayerdemo.podcastplayer.service
import android.media.AudioManager
import android.media.AudioTrack
import android.os.Handler
import android.os.HandlerThread
import android.os.Message
import android.support.v4.media.session.PlaybackStateCompat
import com.tomorrow.mediaplayerdemo.Log
import com.tomorrow.mediaplayerdemo.podcastplayer.model.PodcastPlayerDataManager
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.AUDIO_FORMAT
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.BUFFER_SIZE_IN_BYTES
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.CHANNEL_CONFIG
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.SAMPLE_RATE
private const val TAG = "PodcastPlayerStream"
private const val MESSAGE_TYPE_PLAY_STEAM = 1
private const val MESSAGE_TYPE_RELEASE_STEAM = 2
// 使用 AudioTrack 流式播放 PCM 音频
class PodcastPlayerStream(private val playerCommon: PodcastPlayerCommon) {
private lateinit var audioTrack: AudioTrack
private lateinit var handlerThread: HandlerThread
private lateinit var threadHandler: Handler
@Volatile
private var isStreamPlaying = false
private var pausedCallback: (() -> Unit)? = null
fun init() {
Log.CnFeatureUi.i(TAG, "init")
initAudioTrack()
initHandlerThread()
}
fun destroy() {
Log.CnFeatureUi.i(TAG, "destroy")
releaseStream()
handlerThread.quitSafely()
}
private fun initAudioTrack() {
audioTrack = AudioTrack(
AudioManager.STREAM_MUSIC,
SAMPLE_RATE,
CHANNEL_CONFIG,
AUDIO_FORMAT,
BUFFER_SIZE_IN_BYTES,
AudioTrack.MODE_STREAM
)
}
private fun initHandlerThread() {
handlerThread = HandlerThread(TAG)
handlerThread.start()
threadHandler = object : Handler(handlerThread.looper) {
override fun handleMessage(msg: Message) {
val type = msg.what
Log.CnFeatureUi.i(TAG, "HandlerThread handleMessage, type: $type")
if (type == MESSAGE_TYPE_PLAY_STEAM) {
onPlayStream()
} else if (type == MESSAGE_TYPE_RELEASE_STEAM) {
onReleaseStream()
}
}
}
}
fun setPausedCallback(callback: (() -> Unit)?) {
pausedCallback = callback
}
fun playStream() {
Log.CnFeatureUi.i(TAG, "playStream")
if (audioTrack.playState == AudioTrack.PLAYSTATE_PLAYING) {
Log.CnFeatureUi.e(TAG, "playStream, current is playing, ignore")
return
}
isStreamPlaying = true
threadHandler.sendEmptyMessage(MESSAGE_TYPE_PLAY_STEAM)
}
fun pauseStream() {
Log.CnFeatureUi.i(TAG, "pauseStream")
if (audioTrack.playState == AudioTrack.PLAYSTATE_PAUSED) {
Log.CnFeatureUi.e(TAG, "pauseStream, current is paused, ignore")
pausedCallback?.invoke()
return
}
isStreamPlaying = false
PodcastPlayerDataManager.putWakeUpStream()
}
fun resumeStream() {
Log.CnFeatureUi.i(TAG, "resumeStream")
playStream()
}
fun releaseStream() {
Log.CnFeatureUi.i(TAG, "releaseStream")
pauseStream()
threadHandler.sendEmptyMessage(MESSAGE_TYPE_RELEASE_STEAM)
}
private fun onPlayStream() {
Log.CnFeatureUi.i(TAG, "onPlayStream")
if (audioTrack.playState == AudioTrack.PLAYSTATE_PLAYING) {
Log.CnFeatureUi.e(TAG, "onPlayStream, current is playing, ignore")
return
}
audioTrack.play()
playerCommon.updatePlaybackState(PlaybackStateCompat.STATE_PLAYING)
while (isStreamPlaying) {
val data = PodcastPlayerDataManager.getMediaStream() // Notice: May block here
if (data === PodcastPlayerDataManager.getWakeUpStream()) {
Log.CnFeatureUi.i(TAG, "onPlayStream, wake up stream")
continue
}
if (data === PodcastPlayerDataManager.getEndOfStream()) {
Log.CnFeatureUi.i(TAG, "onPlayStream, end of stream")
isStreamPlaying = false
playerCommon.isPlayComplete = true
continue
}
// Log.CnFeatureUi.i(TAG, "onPlayStream, size: ${data.size}")
audioTrack.write(data, 0, data.size)
if (playerCommon.isStreamLoading) {
playerCommon.isStreamLoading = false
playerCommon.updateMetadata()
}
}
Log.CnFeatureUi.i(TAG, "onPlayStream, pause")
audioTrack.pause()
playerCommon.updatePlaybackState(PlaybackStateCompat.STATE_PAUSED)
pausedCallback?.invoke()
}
private fun onReleaseStream() {
Log.CnFeatureUi.i(TAG, "onReleaseStream")
if (audioTrack.playState == AudioTrack.PLAYSTATE_PLAYING
|| audioTrack.playState == AudioTrack.PLAYSTATE_PAUSED
) {
audioTrack.stop()
}
audioTrack.release()
}
}
PodcastPlayerStatic
package com.tomorrow.mediaplayerdemo.podcastplayer.service
import android.content.Context.MODE_PRIVATE
import android.media.MediaPlayer
import android.os.Handler
import android.os.Looper
import android.os.Message
import android.support.v4.media.session.PlaybackStateCompat
import com.tomorrow.mediaplayerdemo.CommonContextHolder
import com.tomorrow.mediaplayerdemo.Log
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.SP_KEY_PLAYBACK_SPEED_INDEX
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.SP_NAME_PODCAST_PLAYER
private const val TAG = "PodcastPlayerStatic"
private const val MESSAGE_TYPE_UPDATE_PLAYBACK_STATE = 1
private const val UPDATE_PLAYBACK_STATE_INTERVAL = 100L
// 使用 MediaPlayer 静态播放 MP3 音频
class PodcastPlayerStatic(private val playerCommon: PodcastPlayerCommon) {
private lateinit var mediaPlayer: MediaPlayer
private val handler = object : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
val type = msg.what
// Log.CnFeatureUi.i(TAG, "handleMessage: $type")
if (type == MESSAGE_TYPE_UPDATE_PLAYBACK_STATE) {
if (mediaPlayer.isPlaying) {
playerCommon.updatePlaybackState(
PlaybackStateCompat.STATE_PLAYING,
mediaPlayer.currentPosition.toLong(),
getPlaybackSpeed()
)
sendEmptyMessageDelayed(
MESSAGE_TYPE_UPDATE_PLAYBACK_STATE,
UPDATE_PLAYBACK_STATE_INTERVAL
)
} else {
playerCommon.updatePlaybackState(
PlaybackStateCompat.STATE_PAUSED,
mediaPlayer.currentPosition.toLong(),
0f
)
}
}
}
}
fun init() {
Log.CnFeatureUi.i(TAG, "init")
initMediaPlayer()
}
private fun initMediaPlayer() {
mediaPlayer = MediaPlayer()
mediaPlayer.setOnCompletionListener {
Log.CnFeatureUi.i(TAG, "onCompletion")
it.pause()
handler.sendEmptyMessage(MESSAGE_TYPE_UPDATE_PLAYBACK_STATE)
playerCommon.isPlayComplete = true
}
}
fun destroy() {
Log.CnFeatureUi.i(TAG, "destroy")
releaseStatic()
}
fun playStatic(filePath: String) {
Log.CnFeatureUi.i(TAG, "playStatic, filePath: $filePath")
prepareAsync(filePath)
}
private fun prepareAsync(filePath: String) {
mediaPlayer.setDataSource(filePath)
mediaPlayer.prepareAsync()
mediaPlayer.setOnPreparedListener {
Log.CnFeatureUi.i(TAG, "prepareAsync, start play")
val params = mediaPlayer.playbackParams
params.speed = getPlaybackSpeed()
mediaPlayer.playbackParams = params
mediaPlayer.start()
handler.sendEmptyMessage(MESSAGE_TYPE_UPDATE_PLAYBACK_STATE)
playerCommon.updateMetadata(mediaPlayer.duration)
}
}
fun pauseStatic() {
Log.CnFeatureUi.i(TAG, "pauseStatic")
if (!mediaPlayer.isPlaying) {
Log.CnFeatureUi.e(TAG, "pauseStatic, current is not playing, ignore")
return
}
mediaPlayer.pause()
handler.sendEmptyMessage(MESSAGE_TYPE_UPDATE_PLAYBACK_STATE)
}
fun resumeStatic() {
Log.CnFeatureUi.i(TAG, "resumeStatic")
if (mediaPlayer.isPlaying) {
Log.CnFeatureUi.e(TAG, "resumeStatic, current is playing, ignore")
return
}
mediaPlayer.start()
handler.sendEmptyMessage(MESSAGE_TYPE_UPDATE_PLAYBACK_STATE)
}
fun replayStatic() {
Log.CnFeatureUi.i(TAG, "replayStatic")
mediaPlayer.seekTo(0)
mediaPlayer.start()
handler.sendEmptyMessage(MESSAGE_TYPE_UPDATE_PLAYBACK_STATE)
}
fun seekToStatic(position: Long) {
Log.CnFeatureUi.i(TAG, "seekToStatic, position: $position")
mediaPlayer.seekTo(position.toInt())
mediaPlayer.start()
handler.sendEmptyMessage(MESSAGE_TYPE_UPDATE_PLAYBACK_STATE)
}
fun setPlaybackSpeedStatic(speed: Float) {
Log.CnFeatureUi.i(TAG, "setPlaybackSpeedStatic, speed: $speed")
val params = mediaPlayer.playbackParams
params.speed = speed
mediaPlayer.playbackParams = params
handler.sendEmptyMessage(MESSAGE_TYPE_UPDATE_PLAYBACK_STATE)
}
fun resetStatic() {
Log.CnFeatureUi.i(TAG, "resetStatic")
handler.removeCallbacksAndMessages(null)
if (mediaPlayer.isPlaying) {
mediaPlayer.stop()
}
mediaPlayer.reset()
}
private fun getPlaybackSpeed(): Float {
return when (getPlaybackSpeedIndex()) {
0 -> 0.75f
1 -> 1f
2 -> 1.25f
3 -> 1.5f
4 -> 2f
else -> 1f
}
}
private fun getPlaybackSpeedIndex(): Int {
return CommonContextHolder.getApplicationContext()!!
.getSharedPreferences(SP_NAME_PODCAST_PLAYER, MODE_PRIVATE).getInt(
SP_KEY_PLAYBACK_SPEED_INDEX,
1
)
}
private fun releaseStatic() {
Log.CnFeatureUi.i(TAG, "releaseStatic")
resetStatic()
mediaPlayer.release()
}
}
2.播放器客户端
PodcastPlayerClient
package com.tomorrow.mediaplayerdemo.podcastplayer.client
import android.app.Activity
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.PlaybackStateCompat
import androidx.lifecycle.MutableLiveData
import com.tomorrow.mediaplayerdemo.CommonContextHolder
import com.tomorrow.mediaplayerdemo.Log
import com.tomorrow.mediaplayerdemo.podcastplayer.service.PodcastPlayerService
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.EXTRA_KEY_ACTION
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.EXTRA_KEY_MEDIA_ID
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.MEDIA_ACTION_PAUSE
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.MEDIA_ACTION_PLAY
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.METADATA_CUSTOM_KEY_IS_STREAM_LOADING
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.METADATA_CUSTOM_KEY_IS_STREAM_MODE
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.METADATA_CUSTOM_VALUE_TRUE
private const val TAG = "PodcastPlayerClient"
// 播放器客户端
class PodcastPlayerClient(private val context: Context) {
val playData = MutableLiveData<PlayData>()
val playDataForHistory = MutableLiveData<PlayDataForHistory>()
private var isBound = false
private var mediaController: MediaControllerCompat? = null
private val mediaCallback: MediaControllerCompat.Callback =
object : MediaControllerCompat.Callback() {
override fun onPlaybackStateChanged(state: PlaybackStateCompat?) {
state?.let {
updatePlaybackState(it)
}
}
override fun onMetadataChanged(metadata: MediaMetadataCompat?) {
metadata?.let {
updateMetadata(it)
}
}
override fun onSessionReady() {
Log.CnFeatureUi.i(TAG, "onSessionReady")
}
override fun onSessionDestroyed() {
Log.CnFeatureUi.e(TAG, "onSessionDestroyed")
mediaController?.unregisterCallback(this)
mediaController = null
}
}
fun init() {
Log.CnFeatureUi.i(TAG, "init")
playData.value = PlayData()
playDataForHistory.value = PlayDataForHistory()
connectService()
}
private fun updatePlaybackState(playbackState: PlaybackStateCompat) {
val isPlaying = playbackState.state == PlaybackStateCompat.STATE_PLAYING
val position = playbackState.position.toInt()
// Log.CnFeatureUi.i(TAG, "updatePlaybackState, isPlaying: $isPlaying, position: $position")
playData.value?.let {
if (isPlaying != it.isPlaying
|| position != it.position
) {
it.isPlaying = isPlaying
it.position = position
playData.value = it
}
}
playDataForHistory.value?.let {
if (isPlaying != it.isPlaying) {
it.isPlaying = isPlaying
playDataForHistory.value = it
}
}
}
private fun updateMetadata(metadata: MediaMetadataCompat) {
val mediaId = metadata.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID)
val title = metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE)
val duration = metadata.getLong(MediaMetadataCompat.METADATA_KEY_DURATION).toInt()
val isStreamMode =
metadata.getString(METADATA_CUSTOM_KEY_IS_STREAM_MODE) == METADATA_CUSTOM_VALUE_TRUE
val isStreamLoading =
metadata.getString(METADATA_CUSTOM_KEY_IS_STREAM_LOADING) == METADATA_CUSTOM_VALUE_TRUE
Log.CnFeatureUi.i(
TAG,
"updateMetadata, mediaId: $mediaId, title: $title, duration: $duration, " +
"isStreamMode: $isStreamMode, isStreamLoading: $isStreamLoading"
)
playData.value?.let {
if (mediaId != it.mediaId
|| title != it.title
|| duration != it.duration
|| isStreamMode != it.isStreamMode
) {
it.mediaId = mediaId
it.title = title
it.duration = duration
it.isStreamMode = isStreamMode
playData.value = it
}
}
playDataForHistory.value?.let {
if (mediaId != it.mediaId
|| title != it.title
|| isStreamMode != it.isStreamMode
|| isStreamLoading != it.isStreamLoading
) {
it.mediaId = mediaId
it.title = title
it.isStreamMode = isStreamMode
it.isStreamLoading = isStreamLoading
playDataForHistory.value = it
}
}
}
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
Log.CnFeatureUi.i(TAG, "onServiceConnected")
isBound = true
val mediaSession =
(service as PodcastPlayerService.LocalBinder).getMediaSession()
mediaController = MediaControllerCompat(context, mediaSession)
MediaControllerCompat.setMediaController(context as Activity, mediaController)
mediaController?.registerCallback(mediaCallback)
mediaController?.playbackState?.let {
updatePlaybackState(it)
}
mediaController?.metadata?.let {
updateMetadata(it)
}
}
override fun onServiceDisconnected(className: ComponentName) {
Log.CnFeatureUi.e(TAG, "onServiceDisconnected")
isBound = false
}
}
private fun connectService() {
Log.CnFeatureUi.i(TAG, "connectService")
val intent = Intent(context, PodcastPlayerService::class.java)
context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
private fun disconnectService() {
Log.CnFeatureUi.i(TAG, "disconnectService")
if (isBound) {
context.unbindService(serviceConnection)
isBound = false
}
}
fun playMedia(mediaId: String) {
Log.CnFeatureUi.i(TAG, "playMedia, mediaId: $mediaId")
startService(MEDIA_ACTION_PLAY, mediaId)
}
fun pauseMedia() {
Log.CnFeatureUi.i(TAG, "pauseMedia")
startService(MEDIA_ACTION_PAUSE)
}
private fun startService(action: String, mediaId: String = "") {
val intent = Intent(
CommonContextHolder.getApplicationContext()!!,
PodcastPlayerService::class.java
).apply {
putExtra(EXTRA_KEY_ACTION, action)
putExtra(EXTRA_KEY_MEDIA_ID, mediaId)
}
CommonContextHolder.getApplicationContext()!!.startForegroundService(intent)
}
fun togglePlay() {
Log.CnFeatureUi.i(TAG, "togglePlay")
if (playData.value?.isPlaying == true) {
mediaController?.transportControls?.pause()
} else {
mediaController?.transportControls?.play()
}
}
fun seekTo(position: Long) {
Log.CnFeatureUi.i(TAG, "seekTo: $position")
mediaController?.transportControls?.seekTo(position)
}
fun setPlaybackSpeed(speed: Float) {
Log.CnFeatureUi.i(TAG, "setPlaybackSpeed: $speed")
mediaController?.transportControls?.setPlaybackSpeed(speed)
}
fun destroy() {
Log.CnFeatureUi.i(TAG, "destroy")
mediaController?.unregisterCallback(mediaCallback)
mediaController = null
disconnectService()
}
}
class PlayData {
var mediaId = ""
var title = ""
var isPlaying = false
var duration = 0
var position = 0
var isStreamMode = false
override fun toString(): String {
return "[mediaId: $mediaId, title: $title, isPlaying: $isPlaying, " +
"duration: $duration, position: $position, isStreamMode: $isStreamMode]"
}
}
class PlayDataForHistory {
var mediaId = ""
var title = ""
var isPlaying = false
var isStreamMode = false
var isStreamLoading = false
override fun toString(): String {
return "[mediaId: $mediaId, title: $title, isPlaying: $isPlaying, " +
"isStreamMode: $isStreamMode, isStreamLoading: $isStreamLoading]"
}
}
3.播放器通知栏
PodcastPlayerNotificationHelper
package com.tomorrow.mediaplayerdemo.podcastplayer.notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.support.v4.media.session.MediaSessionCompat
import androidx.core.app.NotificationManagerCompat
import androidx.media.app.NotificationCompat
import com.tomorrow.mediaplayerdemo.Log
import com.tomorrow.mediaplayerdemo.R
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.CHANNEL_ID
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.CHANNEL_NAME
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.NOTIFICATION_ID
import com.tomorrow.mediaplayerdemo.podcastplayer.view.PodcastPlayerActivity
private const val TAG = "PodcastPlayerNotificationHelper"
// 播放器通知栏
class PodcastPlayerNotificationHelper(
val context: Context,
val mediaSession: MediaSessionCompat
) {
private val notificationManager = NotificationManagerCompat.from(context)
fun updateNotification() {
Log.CnFeatureUi.i(TAG, "updateNotification")
createNotificationChannel()
val pendingIntent = PendingIntent.getActivity(
context,
0,
Intent(context, PodcastPlayerActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = androidx.core.app.NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher_round)
.setStyle(
NotificationCompat.MediaStyle()
.setMediaSession(mediaSession.sessionToken)
.setShowActionsInCompactView(0)
)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
(context as Service).startForeground(NOTIFICATION_ID, notification)
}
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW
).apply {
enableLights(false)
enableVibration(false)
}
notificationManager.createNotificationChannel(channel)
}
fun cancelNotification() {
Log.CnFeatureUi.i(TAG, "cancelNotification")
notificationManager.cancel(NOTIFICATION_ID)
}
}
4.播放器工具类
PodcastPlayerConstants
package com.tomorrow.mediaplayerdemo.podcastplayer.utils
import android.media.AudioFormat
import android.media.AudioTrack
const val SAMPLE_RATE = 24000
const val CHANNEL_CONFIG = AudioFormat.CHANNEL_OUT_MONO
const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT
val BUFFER_SIZE_IN_BYTES = AudioTrack.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT)
const val NOTIFICATION_ID = 1000
const val CHANNEL_ID = "podcast_player_channel"
const val CHANNEL_NAME = "Podcast Player Channel"
const val EXTRA_KEY_MEDIA_ID = "extra_key_media_id"
const val EXTRA_KEY_ACTION = "extra_key_action"
const val MEDIA_ACTION_PLAY = "media_action_play"
const val MEDIA_ACTION_PAUSE = "media_action_pause"
const val METADATA_CUSTOM_KEY_IS_STREAM_MODE = "is_stream_mode"
const val METADATA_CUSTOM_KEY_IS_STREAM_LOADING = "is_stream_loading"
const val METADATA_CUSTOM_VALUE_TRUE = "true"
const val METADATA_CUSTOM_VALUE_FALSE = "false"
const val SP_NAME_PODCAST_PLAYER = "podcast_player"
const val SP_KEY_PLAYBACK_SPEED_INDEX = "playback_speed_index"
5.播放器数据入口
PodcastPlayerDataManager
package com.tomorrow.mediaplayerdemo.podcastplayer.model
import com.tomorrow.mediaplayerdemo.Log
import com.tomorrow.mediaplayerdemo.TestManager
import java.util.concurrent.LinkedBlockingQueue
// 播放器数据入口
object PodcastPlayerDataManager {
private const val TAG = "PodcastPlayerDataManager"
private val streamItem = PodcastPlayerItem()
private val streamQueue = LinkedBlockingQueue<ByteArray>()
private val endOfStream = ByteArray(0)
private val wakeUpStream = ByteArray(0)
fun audioDownloadStart(mediaId: String, title: String) {
Log.CnFeatureUi.i(TAG, "audioDownloadStart, mediaId: $mediaId, title: $title")
streamItem.reset()
streamItem.mediaId = mediaId
streamItem.title = title
streamQueue.clear()
}
fun audioDownloadContinue(mediaId: String, content: ByteArray) {
// Log.CnFeatureUi.i(TAG, "audioDownloadContinue, mediaId: $mediaId, size: ${content.size}")
if (mediaId == streamItem.mediaId) {
streamQueue.put(content)
}
}
fun audioDownloadFinish(mediaId: String) {
Log.CnFeatureUi.i(TAG, "audioDownloadFinish, mediaId: $mediaId")
if (mediaId == streamItem.mediaId) {
streamQueue.put(endOfStream)
}
}
fun getMediaStream(): ByteArray {
return streamQueue.take()
}
fun getEndOfStream(): ByteArray {
return endOfStream
}
fun getWakeUpStream(): ByteArray {
return wakeUpStream
}
fun putWakeUpStream() {
streamQueue.put(wakeUpStream)
}
fun isLocalFileExist(mediaId: String): Boolean {
return TestManager.isLocalFileExist(mediaId) // zwm TODO
}
fun getLocalFilePath(mediaId: String): String {
return TestManager.getLocalFilePath(mediaId) // zwm TODO
}
fun getMediaTitle(mediaId: String): String {
return TestManager.getMediaTitle(mediaId) // zwm TODO
}
fun fetchStream(mediaId: String): Boolean {
Log.CnFeatureUi.i(
TAG,
"fetchStream, fetch mediaId: $mediaId, current mediaId: ${streamItem.mediaId}"
)
if (mediaId == streamItem.mediaId) {
return true
}
streamItem.reset()
streamQueue.clear()
TestManager.fetchStream(mediaId) // zwm TODO
return false
}
}
PodcastPlayerItem
package com.tomorrow.mediaplayerdemo.podcastplayer.model
class PodcastPlayerItem {
var mediaId = ""
var title = ""
fun reset() {
mediaId = ""
title = ""
}
}
TestManager
package com.tomorrow.mediaplayerdemo
import com.tomorrow.mediaplayerdemo.podcastplayer.model.PodcastPlayerDataManager
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.BUFFER_SIZE_IN_BYTES
import java.io.File
import java.io.FileInputStream
// 测试数据
object TestManager {
private const val TAG = "TestManager"
const val AUDIO_ID_1 = "audio_id_1"
const val AUDIO_ID_2 = "audio_id_2"
private val audioId1PcmPath: String =
CommonContextHolder.getApplicationContext()!!
.getExternalFilesDir(null)!!.absolutePath + "/$AUDIO_ID_1.pcm" // zwm TODO: 音频可从 res/raw 目录拷贝到指定目录
private val audioId2PcmPath: String =
CommonContextHolder.getApplicationContext()!!
.getExternalFilesDir(null)!!.absolutePath + "/$AUDIO_ID_2.pcm" // zwm TODO: 音频可从 res/raw 目录拷贝到指定目录
private val audioId1Mp3Path: String =
CommonContextHolder.getApplicationContext()!!
.getExternalFilesDir(null)!!.absolutePath + "/$AUDIO_ID_1.mp3" // zwm TODO: 音频可从 res/raw 目录拷贝到指定目录
private val audioId2Mp3Path: String =
CommonContextHolder.getApplicationContext()!!
.getExternalFilesDir(null)!!.absolutePath + "/$AUDIO_ID_2.mp3" // zwm TODO: 音频可从 res/raw 目录拷贝到指定目录
private val data = HashMap<String, TestData>()
var audioId1Mp3Visible = false
var audioId2Mp3Visible = false
init {
Log.i(TAG, "audioId1Pcm exist: ${File(audioId1PcmPath).exists()}")
Log.i(TAG, "audioId2Pcm exist: ${File(audioId2PcmPath).exists()}")
Log.i(TAG, "audioId1Mp3 exist: ${File(audioId1Mp3Path).exists()}")
Log.i(TAG, "audioId2Mp3 exist: ${File(audioId2Mp3Path).exists()}")
var item = TestData()
item.mediaId = AUDIO_ID_1
item.title = "测试音频 1"
item.filePath = ""
data.put(AUDIO_ID_1, item)
item = TestData()
item.mediaId = AUDIO_ID_2
item.title = "测试音频 2"
item.filePath = ""
data.put(AUDIO_ID_2, item)
audioId1Mp3Visible = false
audioId2Mp3Visible = false
}
fun toggleAudioId1Mp3Visible() {
audioId1Mp3Visible = !audioId1Mp3Visible
if (audioId1Mp3Visible) {
data[AUDIO_ID_1]!!.filePath = audioId1Mp3Path
} else {
data[AUDIO_ID_1]!!.filePath = ""
}
}
fun toggleAudioId2Mp3Visible() {
audioId2Mp3Visible = !audioId2Mp3Visible
if (audioId2Mp3Visible) {
data[AUDIO_ID_2]!!.filePath = audioId2Mp3Path
} else {
data[AUDIO_ID_2]!!.filePath = ""
}
}
fun isLocalFileExist(mediaId: String): Boolean {
if (mediaId == AUDIO_ID_1) {
return audioId1Mp3Visible
} else if (mediaId == AUDIO_ID_2) {
return audioId2Mp3Visible
}
return false
}
fun getLocalFilePath(mediaId: String): String {
if (mediaId == AUDIO_ID_1) {
return audioId1Mp3Path
} else if (mediaId == AUDIO_ID_2) {
return audioId2Mp3Path
}
return ""
}
fun getMediaTitle(mediaId: String): String {
if (mediaId == AUDIO_ID_1) {
return data[AUDIO_ID_1]!!.title
} else if (mediaId == AUDIO_ID_2) {
return data[AUDIO_ID_2]!!.title
}
return ""
}
fun fetchStream(mediaId: String) {
Thread {
Thread.sleep(2000)
val file = File(getLocalFilePath(mediaId))
FileInputStream(file).use { fis ->
PodcastPlayerDataManager.audioDownloadStart(mediaId, getMediaTitle(mediaId))
val buffer = ByteArray(BUFFER_SIZE_IN_BYTES)
while (true) {
val bytesRead = fis.read(buffer)
// Log.i(TAG, "fetchStream, bytesRead: $bytesRead")
if (bytesRead == -1) {
PodcastPlayerDataManager.audioDownloadFinish(mediaId)
break
}
PodcastPlayerDataManager.audioDownloadContinue(
mediaId,
buffer.copyOf(bytesRead)
)
}
}
}.start()
}
}
class TestData {
var mediaId = ""
var title = ""
var filePath = ""
}
6.播放页面
PodcastPlayerActivity
package com.tomorrow.mediaplayerdemo.podcastplayer.view
import android.annotation.SuppressLint
import android.content.Context
import android.os.Bundle
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
import android.widget.PopupWindow
import android.widget.SeekBar
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.core.content.edit
import androidx.databinding.DataBindingUtil
import com.tomorrow.mediaplayerdemo.Log
import com.tomorrow.mediaplayerdemo.R
import com.tomorrow.mediaplayerdemo.databinding.PopcastPlayerActivityBinding
import com.tomorrow.mediaplayerdemo.podcastplayer.client.PlayData
import com.tomorrow.mediaplayerdemo.podcastplayer.client.PodcastPlayerClient
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.SP_KEY_PLAYBACK_SPEED_INDEX
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.SP_NAME_PODCAST_PLAYER
import java.util.Locale
private const val TAG = "PodcastPlayerActivityTAG"
// 播放页面
class PodcastPlayerActivity : ComponentActivity() {
private val binding: PopcastPlayerActivityBinding by lazy {
DataBindingUtil.setContentView(
this,
R.layout.popcast_player_activity
)
}
private val playerClient: PodcastPlayerClient by lazy {
PodcastPlayerClient(this)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.CnFeatureUi.i(TAG, "onCreate")
binding.play.setOnClickListener {
playerClient.togglePlay()
}
binding.progress.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
if (fromUser) {
playerClient.seekTo(progress.toLong())
binding.progress.progress = progress
}
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
binding.speedBtn.setOnClickListener {
showPopupWindow()
}
binding.speedTv.setOnClickListener {
showPopupWindow()
}
playerClient.init()
playerClient.playData.observe(this) {
// Log.CnFeatureUi.i(TAG, "playData change: $it")
if (it.isPlaying) {
binding.play.setImageResource(R.drawable.media_pause_icon)
} else {
binding.play.setImageResource(R.drawable.media_play_icon)
}
binding.title.text = it.title
updateView(it)
}
}
@SuppressLint("MissingInflatedId")
private fun showPopupWindow() {
val popupView = layoutInflater.inflate(R.layout.podcast_player_popup, null)
val popupWindow = PopupWindow(
popupView,
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
popupView.findViewById<TextView>(R.id.speed_075x).setOnClickListener {
playerClient.setPlaybackSpeed(0.75f)
setPlaybackSpeedIndex(0)
binding.speedTv.text = getPlaybackSpeedText(getPlaybackSpeedIndex())
popupWindow.dismiss()
}
popupView.findViewById<TextView>(R.id.speed_100x).setOnClickListener {
playerClient.setPlaybackSpeed(1f)
setPlaybackSpeedIndex(1)
binding.speedTv.text = getPlaybackSpeedText(getPlaybackSpeedIndex())
popupWindow.dismiss()
}
popupView.findViewById<TextView>(R.id.speed_125x).setOnClickListener {
playerClient.setPlaybackSpeed(1.25f)
setPlaybackSpeedIndex(2)
binding.speedTv.text = getPlaybackSpeedText(getPlaybackSpeedIndex())
popupWindow.dismiss()
}
popupView.findViewById<TextView>(R.id.speed_150x).setOnClickListener {
playerClient.setPlaybackSpeed(1.5f)
setPlaybackSpeedIndex(3)
binding.speedTv.text = getPlaybackSpeedText(getPlaybackSpeedIndex())
popupWindow.dismiss()
}
popupView.findViewById<TextView>(R.id.speed_200x).setOnClickListener {
playerClient.setPlaybackSpeed(2f)
setPlaybackSpeedIndex(4)
binding.speedTv.text = getPlaybackSpeedText(getPlaybackSpeedIndex())
popupWindow.dismiss()
}
popupWindow.isFocusable = true
popupWindow.isOutsideTouchable = true
popupWindow.showAsDropDown(binding.speedBtn, 0, dpToPx(this, 30f))
}
private fun dpToPx(context: Context, dp: Float): Int {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
dp,
context.resources.displayMetrics
).toInt()
}
private fun updateView(playData: PlayData) {
// Log.CnFeatureUi.i(TAG, "updateView, playData: $playData")
if (playData.isStreamMode) {
binding.totalDuration.text = "--:--"
binding.currentDuration.text = "--:--"
binding.progress.isEnabled = false
binding.speedBtn.visibility = View.INVISIBLE
binding.speedTv.visibility = View.INVISIBLE
} else {
binding.totalDuration.text = formatDuration(playData.duration)
binding.currentDuration.text = formatDuration(playData.position)
binding.progress.isEnabled = true
binding.progress.max = playData.duration
binding.progress.progress = playData.position
binding.speedBtn.visibility = View.VISIBLE
binding.speedTv.visibility = View.VISIBLE
binding.speedTv.text = getPlaybackSpeedText(getPlaybackSpeedIndex())
}
}
private fun formatDuration(durationMs: Int): String {
val totalSeconds = durationMs / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds)
}
private fun setPlaybackSpeedIndex(speedIndex: Int) {
getSharedPreferences(SP_NAME_PODCAST_PLAYER, MODE_PRIVATE).edit {
putInt(SP_KEY_PLAYBACK_SPEED_INDEX, speedIndex)
}
}
private fun getPlaybackSpeedIndex(): Int {
return getSharedPreferences(SP_NAME_PODCAST_PLAYER, MODE_PRIVATE).getInt(
SP_KEY_PLAYBACK_SPEED_INDEX,
1
)
}
private fun getPlaybackSpeedText(speedIndex: Int): String {
return when (speedIndex) {
0 -> "0.75x"
1 -> "1x"
2 -> "1.25x"
3 -> "1.5x"
4 -> "2x"
else -> "1x"
}
}
override fun onDestroy() {
super.onDestroy()
Log.CnFeatureUi.i(TAG, "onDestroy")
playerClient.destroy()
}
}
popcast_player_activity.xml
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingStart="20dp"
android:paddingEnd="20dp"
android:background="@drawable/player_background">
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="400dp"
android:textSize="20dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<SeekBar
android:id="@+id/progress"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:max="100"
android:progress="0"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/title" />
<TextView
android:id="@+id/current_duration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="--:--"
android:textSize="15dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/progress" />
<TextView
android:id="@+id/total_duration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="--:--"
android:textSize="15dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/progress" />
<ImageView
android:id="@+id/play"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_marginTop="30dp"
android:src="@drawable/media_play_icon"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/total_duration" />
<ImageView
android:id="@+id/speed_btn"
android:layout_width="20dp"
android:layout_height="20dp"
android:src="@drawable/media_speed_icon"
app:layout_constraintBottom_toBottomOf="@id/play"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/play" />
<TextView
android:id="@+id/speed_tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:text="1x"
android:textSize="15dp"
app:layout_constraintBottom_toBottomOf="@id/play"
app:layout_constraintStart_toEndOf="@id/speed_btn"
app:layout_constraintTop_toTopOf="@id/play" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
podcast_player_popup.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/white"
android:padding="10dp">
<TextView
android:id="@+id/speed_075x"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0.75x"
android:textColor="@color/grey"
android:textSize="18sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/speed_100x"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:text="1x"
android:textColor="@color/grey"
android:textSize="18sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/speed_075x"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/speed_125x"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:text="1.25x"
android:textColor="@color/grey"
android:textSize="18sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/speed_100x"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/speed_150x"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:text="1.5x"
android:textColor="@color/grey"
android:textSize="18sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/speed_125x"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/speed_200x"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:text="2x"
android:textColor="@color/grey"
android:textSize="18sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/speed_150x"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
MainActivity
package com.tomorrow.mediaplayerdemo
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.activity.ComponentActivity
import com.tomorrow.mediaplayerdemo.TestManager.AUDIO_ID_1
import com.tomorrow.mediaplayerdemo.TestManager.AUDIO_ID_2
import com.tomorrow.mediaplayerdemo.databinding.ActivityMainBinding
import com.tomorrow.mediaplayerdemo.podcastplayer.client.PodcastPlayerClient
import com.tomorrow.mediaplayerdemo.podcastplayer.view.PodcastPlayerActivity
private const val TAG = "MainActivityTAG"
// 测试页面
class MainActivity : ComponentActivity() {
private lateinit var binding: ActivityMainBinding
private val playerClient by lazy {
PodcastPlayerClient(this)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.i(TAG, "onCreate")
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.btn1.setOnClickListener {
Log.i(TAG, "btn1 click")
TestManager.toggleAudioId1Mp3Visible()
updateView()
}
binding.btn2.setOnClickListener {
Log.i(TAG, "btn2 click")
TestManager.toggleAudioId2Mp3Visible()
updateView()
}
binding.btn3.setOnClickListener {
Log.i(TAG, "btn3 click")
if (playerClient.playData.value?.mediaId != AUDIO_ID_1) {
playerClient.playMedia(AUDIO_ID_1)
} else if (playerClient.playData.value?.isPlaying == true) {
playerClient.pauseMedia()
} else {
playerClient.playMedia(AUDIO_ID_1)
}
}
binding.btn4.setOnClickListener {
Log.i(TAG, "btn4 click")
if (playerClient.playData.value?.mediaId != AUDIO_ID_2) {
playerClient.playMedia(AUDIO_ID_2)
} else if (playerClient.playData.value?.isPlaying == true) {
playerClient.pauseMedia()
} else {
playerClient.playMedia(AUDIO_ID_2)
}
}
binding.item3.setOnClickListener {
Log.i(TAG, "item3 click")
playerClient.playMedia(AUDIO_ID_1)
val intent = Intent(this, PodcastPlayerActivity::class.java)
startActivity(intent)
}
binding.item4.setOnClickListener {
Log.i(TAG, "item4 click")
playerClient.playMedia(AUDIO_ID_2)
val intent = Intent(this, PodcastPlayerActivity::class.java)
startActivity(intent)
}
updateView()
playerClient.init()
playerClient.playDataForHistory.observe(this) {
Log.i(TAG, "playDataForHistory change: $it")
updateView()
}
// 启动 PCM 音频录制页面
// startAudioRecordActivity()
}
private fun startAudioRecordActivity() {
Log.i(TAG, "startAudioRecordActivity")
val intent = Intent(this, AudioRecordActivity::class.java)
startActivity(intent)
}
private fun updateView() {
binding.tv1.text = if (TestManager.audioId1Mp3Visible) {
"$AUDIO_ID_1.mp3 visible: true"
} else {
"$AUDIO_ID_1.mp3 visible: false"
}
binding.tv2.text = if (TestManager.audioId2Mp3Visible) {
"$AUDIO_ID_2.mp3 visible: true"
} else {
"$AUDIO_ID_2.mp3 visible: false"
}
binding.tv3.text = TestManager.getMediaTitle(AUDIO_ID_1)
binding.tv4.text = TestManager.getMediaTitle(AUDIO_ID_2)
if (playerClient.playDataForHistory.value?.mediaId == AUDIO_ID_1
&& playerClient.playDataForHistory.value?.isPlaying == true
) {
if (playerClient.playDataForHistory.value?.isStreamMode == true
&& playerClient.playDataForHistory.value?.isStreamLoading == true
) {
binding.btn3.visibility = View.GONE
binding.progress3.visibility = View.VISIBLE
} else {
binding.btn3.visibility = View.VISIBLE
binding.progress3.visibility = View.GONE
}
binding.btn3.setImageResource(R.drawable.media_pause_icon)
} else {
binding.btn3.setImageResource(R.drawable.media_play_icon)
binding.btn3.visibility = View.VISIBLE
binding.progress3.visibility = View.GONE
}
if (playerClient.playDataForHistory.value?.mediaId == AUDIO_ID_2
&& playerClient.playDataForHistory.value?.isPlaying == true
) {
if (playerClient.playDataForHistory.value?.isStreamMode == true
&& playerClient.playDataForHistory.value?.isStreamLoading == true
) {
binding.btn4.visibility = View.GONE
binding.progress4.visibility = View.VISIBLE
} else {
binding.btn4.visibility = View.VISIBLE
binding.progress4.visibility = View.GONE
}
binding.btn4.setImageResource(R.drawable.media_pause_icon)
} else {
binding.btn4.setImageResource(R.drawable.media_play_icon)
binding.btn4.visibility = View.VISIBLE
binding.progress4.visibility = View.GONE
}
}
override fun onDestroy() {
super.onDestroy()
Log.i(TAG, "onDestroy")
playerClient.destroy()
}
}
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/constraintLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/item1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginTop="100dp"
android:layout_marginEnd="20dp"
android:background="@color/purple_200"
android:padding="10dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/tv1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="20sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/btn1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="切换"
android:textSize="20sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv1" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/item2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginTop="50dp"
android:layout_marginEnd="20dp"
android:background="@color/purple_200"
android:padding="10dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/item1">
<TextView
android:id="@+id/tv2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="20sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/btn2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="切换"
android:textSize="20sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv2" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/item3"
android:layout_width="0dp"
android:layout_height="60dp"
android:layout_marginStart="20dp"
android:layout_marginTop="50dp"
android:layout_marginEnd="20dp"
android:background="@color/purple_200"
android:padding="10dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/item2">
<TextView
android:id="@+id/tv3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:textSize="20sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/btn3"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="20dp"
android:src="@drawable/media_play_icon"
android:textSize="20sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ProgressBar
android:id="@+id/progress3"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="20dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/item4"
android:layout_width="0dp"
android:layout_height="60dp"
android:layout_marginStart="20dp"
android:layout_marginTop="50dp"
android:layout_marginEnd="20dp"
android:background="@color/purple_200"
android:padding="10dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/item3">
<TextView
android:id="@+id/tv4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:textSize="20sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/btn4"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="20dp"
android:src="@drawable/media_play_icon"
android:textSize="20sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ProgressBar
android:id="@+id/progress4"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="20dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
7.PCM 音频录制页面
AudioRecordActivity
package com.tomorrow.mediaplayerdemo
import android.Manifest
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.AudioTrack
import android.media.MediaRecorder
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.annotation.RequiresPermission
import androidx.core.app.ActivityCompat
import com.tomorrow.mediaplayerdemo.databinding.ActivityAudioRecordBinding
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.AUDIO_FORMAT
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.BUFFER_SIZE_IN_BYTES
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.CHANNEL_CONFIG
import com.tomorrow.mediaplayerdemo.podcastplayer.utils.SAMPLE_RATE
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
private const val TAG = "AudioRecordActivityTAG"
// 录制 PCM 音频,播放 PCM 音频
class AudioRecordActivity : ComponentActivity() {
private lateinit var binding: ActivityAudioRecordBinding
private lateinit var audioRecord: AudioRecord
private lateinit var audioTrack: AudioTrack
private var isRecording = false
private var filePath = ""
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.i(TAG, "onCreate")
binding = ActivityAudioRecordBinding.inflate(layoutInflater)
setContentView(binding.root)
if (ActivityCompat.checkSelfPermission(
this,
Manifest.permission.RECORD_AUDIO
) != PackageManager.PERMISSION_GRANTED
) {
Toast.makeText(this, "请开启麦克风权限", Toast.LENGTH_SHORT).show()
}
filePath = getExternalFilesDir(null)!!.absolutePath + "/audio_record.pcm"
binding.startRecord.setOnClickListener { startRecord() }
binding.stopRecord.setOnClickListener { stopRecord() }
binding.playAudio.setOnClickListener { playAudio() }
}
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
private fun startRecord() {
Log.i(TAG, "startRecord")
if (isRecording) return
audioRecord = AudioRecord(
MediaRecorder.AudioSource.MIC,
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO, // Notice: Record should use CHANNEL_IN_MONO
AUDIO_FORMAT,
BUFFER_SIZE_IN_BYTES
)
audioRecord.startRecording()
isRecording = true
Thread { writeAudioDataToFile() }.start()
}
private fun writeAudioDataToFile() {
Log.i(TAG, "writeAudioDataToFile")
val data = ByteArray(BUFFER_SIZE_IN_BYTES)
val file = File(filePath)
FileOutputStream(file).use { os ->
while (isRecording) {
val bytesRead = audioRecord.read(data, 0, BUFFER_SIZE_IN_BYTES)
Log.i(TAG, "writeAudioDataToFile, bytesRead: $bytesRead")
if (bytesRead > 0) {
os.write(data, 0, bytesRead)
}
}
}
}
private fun stopRecord() {
Log.i(TAG, "stopRecord")
if (!isRecording) return
isRecording = false
audioRecord.stop()
audioRecord.release()
}
private fun playAudio() {
Log.i(TAG, "playAudio")
Thread {
val file = File(filePath)
audioTrack = AudioTrack(
AudioManager.STREAM_MUSIC,
SAMPLE_RATE,
CHANNEL_CONFIG,
AUDIO_FORMAT,
BUFFER_SIZE_IN_BYTES,
AudioTrack.MODE_STREAM
)
audioTrack.play()
FileInputStream(file).use { fis ->
val buffer = ByteArray(BUFFER_SIZE_IN_BYTES)
while (true) {
val bytesRead = fis.read(buffer)
Log.i(TAG, "playAudio, bytesRead: $bytesRead")
if (bytesRead == -1) break // End of file
audioTrack.write(buffer, 0, bytesRead)
}
}
audioTrack.stop()
audioTrack.release()
}.start()
}
}
activity_audio_record.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/constraintLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<Button
android:id="@+id/start_record"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="100dp"
android:padding="20dp"
android:text="开始录制"
android:textSize="20sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/stop_record"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="50dp"
android:padding="20dp"
android:text="停止录制"
android:textSize="20sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/start_record" />
<Button
android:id="@+id/play_audio"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="50dp"
android:padding="20dp"
android:text="播放音频"
android:textSize="20sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/stop_record" />
</androidx.constraintlayout.widget.ConstraintLayout>
8.配置文件
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:name=".MainApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.MediaPlayerDemo">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.MediaPlayerDemo">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".podcastplayer.view.PodcastPlayerActivity"
android:exported="false"
android:launchMode="singleTask"
android:theme="@style/Theme.MediaPlayerDemo" />
<service
android:name=".podcastplayer.service.PodcastPlayerService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<activity
android:name=".AudioRecordActivity"
android:exported="false"
android:launchMode="singleTask"
android:theme="@style/Theme.MediaPlayerDemo" />
</application>
</manifest>
build.gradle.kts
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.tomorrow.mediaplayerdemo"
compileSdk = 36
defaultConfig {
applicationId = "com.tomorrow.mediaplayerdemo"
minSdk = 29
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
buildFeatures {
compose = true
}
buildFeatures {
viewBinding = true
}
buildFeatures {
dataBinding = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.media)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}
libs.versions.toml
[versions]
agp = "8.7.2"
kotlin = "2.0.21"
coreKtx = "1.10.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
composeBom = "2024.09.00"
androidx-constraintLayout = "2.1.4"
androidx-media = "1.7.0"
gson = "2.10.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidx-constraintLayout" }
androidx-media = { module = "androidx.media:media", version.ref = "androidx-media" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }