安卓实现播放器app

安卓给app提供了:

MediaPlayer:播放视频或音频功能,详见谷歌MediaPlayer文档

借助MediaPlayer,我们可以轻松的实现一个简单的播放器app。一般来说app显示内容放在Activity中。但是试想一般的播放器要求app进入后台后可以继续播放声音,app回到前台后可以继续播放视频。因此其实MediaPlayer更适合放在Service中来播放视频,而我们的Activity仅显示视频即可。接下来我们依次实现MediaService和MediaActivity。

首先实现我们的核心类MediaService:

class MediaService : Service() {
    val mMediaPlayer = MediaPlayer()  // 播放媒体文件的对象
    val mMediaBinder = MediaBinder()  // 传给Activity,使得Activity可以与我们这个Service通信
    var mIsForeground = false  // 为true时变成前台服务,为false时停止前台服务成为一般的后台服务
    var mMediaListener: MediaListener? = null  // mMediaPlayer的相关回调
    var mTimer: Timer? = null  // 用来定时调用mMediaListener.onProgress回调方法的定时器
    var mTimerTask = object : TimerTask() {  // 定时调用mMediaListener.onProgress回调方法的任务
        override fun run() {
            mMediaListener?.onProgress(mMediaPlayer.currentPosition, mMediaPlayer.duration)
        }
    }

    // 此类的方法都是从Activity调用的
    inner class MediaBinder : Binder() {
        // 设置显示视频的surfaceHolder
        fun setDisplay(surfaceHolder: SurfaceHolder?) {
            mMediaPlayer.setDisplay(surfaceHolder)
        }

        // 设置一个额外的媒体播放完毕时回调
        fun setMediaListener(mediaListener: MediaListener?) {
            mMediaListener = mediaListener
            mMediaPlayer.setOnVideoSizeChangedListener(mMediaListener)
            if (mMediaListener == null) {
                mTimer?.cancel()
                mTimer = null
            } else if (mTimer == null) {
                mTimer = Timer().apply { schedule(mTimerTask, 0, 100) }
            }
        }

        // 打开媒体文件
        fun open(uri: Uri) {
            mMediaPlayer.reset()
            mMediaPlayer.setDataSource(this@MediaService, uri)
            mMediaPlayer.prepareAsync()
        }

        // 继续或暂停播放媒体,返回true代表继续播放了,返回false代表暂停播放了
        fun playOrPause(): Boolean {
            if (mMediaPlayer.isPlaying) mMediaPlayer.pause()
            else mMediaPlayer.start()
            updateServiceState()
            return mMediaPlayer.isPlaying
        }

        // 停止播放媒体
        fun stop() {
            mMediaPlayer.stop()
            updateServiceState()
        }

        // 调整播放进度
        fun seekTo(millisecond: Int) {
            mMediaPlayer.seekTo(millisecond)
        }
    }

    override fun onBind(intent: Intent): IBinder {
        return mMediaBinder
    }

    override fun onCreate() {
        super.onCreate()

        mMediaPlayer.setOnPreparedListener {
            mMediaPlayer.start()
            updateServiceState()
        }
        mMediaPlayer.setOnCompletionListener {
            mMediaListener?.onCompletion(it)
            updateServiceState()
        }
        mMediaPlayer.setOnErrorListener { mp, what, extra ->
            Log.e(TAG, "MediaPlayer error! mediaPlayer=$mp, what=$what, extra=$extra")
            updateServiceState()
            true
        }
    }

    // 如果正在播放媒体文件,则变成前台服务,否则变成一般的后台服务
    private fun updateServiceState() {
        if (mMediaPlayer.isPlaying != mIsForeground) {
            mIsForeground = !mIsForeground
            if (mIsForeground) {
                startForegroundNotification()
            } else {
                stopForeground(true)
            }
        }
    }

    // 创建notification,变成前台服务
    private fun startForegroundNotification() {
        val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
        val channel = NotificationChannel("Media", "Media", NotificationManager.IMPORTANCE_HIGH)
        notificationManager.createNotificationChannel(channel)

        val intent = Intent(this, MediaActivity::class.java)
        val pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
        val notification = Notification.Builder(this, "Media")
                .setSmallIcon(R.drawable.ic_media)
                .setContentTitle("Media")
                .setContentText("Playing...")
                .setContentIntent(pendingIntent)
                .build()
        startForeground(123, notification)
    }

    override fun onDestroy() {
        super.onDestroy()
        mMediaPlayer.release()
    }

    companion object {
        private val TAG = MediaService::class.simpleName!!
    }
}

MediaService可以在播放视频时成为前台Service,这样保证我们的MediaService播放视频时不会被突然杀掉,并且也创建了相应的通知,用户只要点击通知即可进入MediaActivity观看视频。
在MediaService中还有MediaBinder,其提供了一些方法供MediaActivity那边调用。

接下来实现我们的布局文件activity_media.xml:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/surface_container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.sc.media.MediaActivity">

    <SurfaceView
        android:id="@+id/surface_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="center" />

    <RelativeLayout
        android:id="@+id/media_control"
        android:layout_width="match_parent"
        android:layout_height="84dp"
        android:layout_gravity="bottom">

        <RelativeLayout
            android:layout_width="match_parent"
            android:layout_height="60dp"
            android:layout_alignParentBottom="true"
            android:paddingVertical="10dp"
            android:background="#80000000">

            <ImageButton
                android:id="@+id/open_media_file_button"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_toStartOf="@id/play_pause_media_button"
                android:src="@drawable/ic_file"
                android:scaleType="fitCenter"
                android:background="@android:color/transparent"
                android:contentDescription="@string/open_media_file" />

            <ImageButton
                android:id="@+id/play_pause_media_button"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_centerInParent="true"
                android:src="@drawable/ic_play_white"
                android:scaleType="fitCenter"
                android:background="@android:color/transparent"
                android:contentDescription="@string/play_or_pause_media" />

            <ImageButton
                android:id="@+id/stop_media_button"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_toEndOf="@id/play_pause_media_button"
                android:src="@drawable/ic_stop"
                android:scaleType="fitCenter"
                android:background="@android:color/transparent"
                android:contentDescription="@string/stop_play_media" />

        </RelativeLayout>

        <com.google.android.material.slider.Slider
            android:id="@+id/slider"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_alignParentTop="true"
            android:background="@android:color/transparent"
            android:stepSize="1.0"
            android:valueFrom="0.0"
            android:valueTo="1.0"
            android:value="0.0" />

    </RelativeLayout>

</FrameLayout>

我们主要是使用了一个SurfaceView来显示视频,并且使用ImageButton实现我们的打开媒体文件、暂停/播放和停止按钮,还使用Slider实现播放进度条的显示与控制。

最后来实现我们的MediaActivity:

class MediaActivity : AppCompatActivity() {
    lateinit var mSurfaceContainer: FrameLayout  // SurfaceView的全屏父View
    lateinit var mSurfaceView: SurfaceView  // 用来播放视频的SurfaceView
    lateinit var mMediaControlView: View  // 媒体播放工具栏
    lateinit var mPlayPauseButton: ImageButton  // 控制媒体播放暂停的按钮
    lateinit var mSlider: Slider  // 媒体播放进度条

    lateinit var mMediaBinder: MediaService.MediaBinder  // 用来与MediaService通信的Binder对象

    var mVideoRatio = 0f  // 视频的宽高比
    var mSliderTrackingTouch = false  // mSlider是否正在被拖

    val mMediaListener = object : MediaListener {
        // 视频尺寸改变时调用
        override fun onVideoSizeChanged(mp: MediaPlayer?, width: Int, height: Int) {
            mVideoRatio = if (height == 0) 0f else width.toFloat() / height.toFloat()
            updateSurfaceSize()
        }

        // 媒体播放完毕时调用
        override fun onCompletion(mp: MediaPlayer?) {
            mPlayPauseButton.setImageResource(R.drawable.ic_play_white)
        }

        // 播放进度改变时调用
        override fun onProgress(position: Int, duration: Int) {
            if (mSliderTrackingTouch) return
            mSlider.valueTo = duration.toFloat().coerceAtLeast(1f)
            mSlider.value = position.toFloat().coerceAtLeast(mSlider.valueFrom).coerceAtMost(mSlider.valueTo)
        }
    }

    // 连接MediaService的对象
    val mServiceConnection = object : ServiceConnection {
        override fun onServiceConnected(name: ComponentName, service: IBinder) {
            mMediaBinder = service as MediaService.MediaBinder
            mMediaBinder.setMediaListener(mMediaListener)
            mSurfaceView.holder.addCallback(object : SurfaceHolder.Callback {
                override fun surfaceCreated(holder: SurfaceHolder) {
                    mMediaBinder.setDisplay(holder)
                }
                override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
                    Log.d(TAG, "Surface changed! width=$width, height=$height")
                }
                override fun surfaceDestroyed(holder: SurfaceHolder) {
                    mMediaBinder.setDisplay(null)
                }
            })
        }

        override fun onServiceDisconnected(name: ComponentName?) { }
    }

    ......

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_media)

        // 隐藏SystemUI,包括导航栏、状态栏等
        WindowCompat.setDecorFitsSystemWindows(window, false)
        WindowInsetsControllerCompat(window, window.decorView).apply {
            hide(WindowInsetsCompat.Type.systemBars())
            systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
        }

        mSurfaceContainer = findViewById(R.id.surface_container)
        mSurfaceView = findViewById(R.id.surface_view)
        mMediaControlView = findViewById(R.id.media_control)
        mPlayPauseButton = findViewById(R.id.play_pause_media_button)
        mSlider = findViewById(R.id.slider)

        // 保证转屏时可以正确更新SurfaceView的尺寸
        mSurfaceView.viewTreeObserver.addOnGlobalLayoutListener { updateSurfaceSize() }

        // 连接绑定MediaService
        val bindIntent = Intent(this, MediaService::class.java)
        bindService(bindIntent, mServiceConnection, BIND_AUTO_CREATE)

        // 打开媒体文件按钮
        findViewById<ImageButton>(R.id.open_media_file_button).setOnClickListener {
            Intent(Intent.ACTION_GET_CONTENT).apply {
                type = "*/*"
                addCategory(Intent.CATEGORY_OPENABLE)
                startActivityForResult(this, 0)
            }
        }

        // 播放/暂停按钮
        mPlayPauseButton.setOnClickListener {
            if (mMediaBinder.playOrPause()) mPlayPauseButton.setImageResource(R.drawable.ic_pause_white)
            else mPlayPauseButton.setImageResource(R.drawable.ic_play_white)
        }

        // 停止按钮
        findViewById<ImageButton>(R.id.stop_media_button).setOnClickListener {
            mMediaBinder.stop()
            mPlayPauseButton.setImageResource(R.drawable.ic_play_white)
        }

        // 按住进度条时,让进度条显示格式为(分:秒.毫秒)的时间
        mSlider.setLabelFormatter { value: Float ->
            val millis = value.toInt()
            val seconds = millis / 1000
            val minutes = seconds / 60
            "$minutes:${seconds % 60}.${millis % 1000}"
        }

        // 更新mSliderTrackingTouch,使得我们通过这个变量可以得知当前进度条是否正在被拖
        mSlider.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
            override fun onStartTrackingTouch(slider: Slider) { mSliderTrackingTouch = true }
            override fun onStopTrackingTouch(slider: Slider) { mSliderTrackingTouch = false }
        })

        // 通过进度条调整播放进度
        mSlider.addOnChangeListener { slider, value, fromUser ->
            if (mSliderTrackingTouch and fromUser) {
                mMediaBinder.seekTo(value.toInt())
            }
        }
    }

    // 得到打开媒体文件结果
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        val uri: Uri = data?.data ?: return
        mMediaBinder.open(uri)  // 要MediaService开始播放媒体文件
        mPlayPauseButton.setImageResource(R.drawable.ic_pause_white)
    }

    // 根据视频宽高比mVideoRatio和mSurfaceContainer的尺寸更新mSurfaceView的尺寸
    private fun updateSurfaceSize() {
        if (mVideoRatio <= 0) return
        val playerRatio = mSurfaceContainer.width.toFloat() / mSurfaceContainer.height.toFloat()
        val params = mSurfaceView.layoutParams
        if (playerRatio > mVideoRatio) {
            params.width = (mSurfaceContainer.height.toFloat() * mVideoRatio).toInt()
            params.height = mSurfaceContainer.height
        } else if (playerRatio < mVideoRatio) {
            params.width = mSurfaceContainer.width
            params.height = (mSurfaceContainer.width.toFloat() / mVideoRatio).toInt()
        } else {
            params.width = mSurfaceContainer.width
            params.height = mSurfaceContainer.height
        }
        mSurfaceView.layoutParams = params
    }

    override fun onDestroy() {
        super.onDestroy()
        mMediaBinder.setMediaListener(null)
        unbindService(mServiceConnection)
    }

    companion object {
        private val TAG = MediaActivity::class.simpleName!!

        fun actionStart(context: Context) {
            val intent = Intent(context, MediaActivity::class.java)
            context.startActivity(intent)
        }
    }
}

在MediaActivity中,我们在onCreate中首先使用WindowInsetsControllerCompat类隐藏导航栏和状态栏,防止这些系统窗口影响我们看视频的体验。然后调用bindService方法与我们的MediaService建立连接。最后设置打开媒体文件、播放/暂停等按钮设置按键监听器,实现这些按键的功能。

源代码地址:https://github.com/SSSxCCC/SCApp

总结

最好在前台服务中播放视频,这样不管我们的Activity是什么状态都可以继续播放视频。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

SSSxCCC

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值