流式播放器与静态播放器

一、功能

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" }
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容