27.
Episode Player
Written by Fuad Kamal
In the last chapter, you succeeded in adding audio playback to the app, but you stopped short of adding any built-in playback features. In this final chapter of this section, you’ll finish up PodPlay by adding a full playback interface and support for videos.
If you’re following along with your own project, the starter project for this chapter includes an additional icon that you’ll need to complete the section. Open your project then copy the following resources from the provided starter project into yours. Be sure to copy the .png files from the various dpi folders (shown below once as “?dpi” but on the file system, they’ll be “hdpi”, “mdpi”, etc). This includes the following resources:
- res/drawable-?dpi/ic_forward_30_white.png
- res/drawable-?dpi/ic_replay_10_white.png
- res/drawable/ic_play_pause_toggle.xml
If you don’t have your own project, don’t worry. Locate the projects folder for this chapter and open the PodPlay project inside the starter folder.
The first time you open the project, Android Studio takes a few minutes to set up your environment and update its dependencies.
Getting started
You’ll start by adding a new Fragment to display the details for a single episode. This Fragment gets loaded when the user taps on an episode.
The episode detail screen provides an overview of the episode and playback controls. The design looks like this:
The album art is in the upper-left corner. The episode title is to the right. The description takes up the entire center of the layout and because episode descriptions can be long, the TextView is scrollable so that the user can see the full description.
At the bottom is the player controls area. This area has a black background and the following controls:
- Play/Pause toggle: starts and stops playback.
- Skip back: skips back 10 seconds.
- Skip forward: skips forward 30 seconds.
- Speed control: allows the playback speed to be increased.
- Scrubber: displays playback progress and allows scrubbing to any part of the episode.
First up, creating the basic layout.
Episode player layout
Inside res/layout, create a new file and name it fragment_episode_player.xml. Replace its contents with the following:
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black">
<SurfaceView
android:id="@+id/videoSurfaceView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:visibility="invisible"/>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/headerView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="#eeeeee"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/episodeDescTextView"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@android:color/white"
android:padding="8dp"
android:scrollbars="vertical"
app:layout_constraintBottom_toTopOf="@+id/playerControls"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/headerView"
tools:text="Episode description"/>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/playerControls"
android:layout_width="0dp"
android:layout_height="76dp"
android:background="@android:color/background_dark"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
This uses ConstraintLayout for the main Layout, along with an embedded ConstraintLayout to contain the headerView. There’s also an embedded ConstraintLayout to contain the playerControls area. Finally, there’s a SurfaceView, which takes up the entire view and is hidden by default; it’s only visible when a video is playing. The player controls will overlay the video.
You will also need to add the following strings to strings.xml:
<string name="episode_thumbnail">episode thumbnail</string>
<string name="replay_button">replay button</string>
<string name="skip_forward">skip forward</string>
<string name="_1x">1x</string>
<string name="_0_00">0:00</string>
It’s time to add the album art and episode title.
Find the headerView ConstraintLayout section by looking for android:id="@+id/headerView". Add the following before the </androidx.constraintlayout.widget.ConstraintLayout> line, after the headerView section:
<ImageView
android:id="@+id/episodeImageView"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:src="@android:drawable/ic_menu_report_image"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:contentDescription="@string/episode_thumbnail" />
<TextView
android:id="@+id/episodeTitleTextView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginEnd="8dp"
android:layout_marginStart="8dp"
android:text=""
app:layout_constraintBottom_toBottomOf="@+id/episodeImageView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/episodeImageView"
app:layout_constraintTop_toTopOf="@+id/episodeImageView"/>
This places the image view in the upper-left corner and the episode title on the right.
It’s time to take care of the primary player transport controls. Find the playerControls ConstraintLayout section by looking for android:id="@+id/playerControls". Add the following before the </androidx.constraintlayout.widget.ConstraintLayout> line, after the playerControls section:
<ImageButton
android:id="@+id/replayButton"
android:layout_width="34dp"
android:layout_height="34dp"
android:layout_marginEnd="24dp"
android:layout_marginTop="8dp"
android:background="@android:color/transparent"
android:scaleType="fitCenter"
android:src="@drawable/ic_replay_10_white"
app:layout_constraintEnd_toStartOf="@+id/playToggleButton"
app:layout_constraintTop_toTopOf="parent"
android:contentDescription="@string/replay_button" />
<Button
android:id="@+id/playToggleButton"
android:layout_width="34dp"
android:layout_height="34dp"
android:layout_marginTop="8dp"
android:background="@drawable/ic_play_pause_toggle"
android:scaleType="fitCenter"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<ImageButton
android:id="@+id/forwardButton"
android:layout_width="34dp"
android:layout_height="34dp"
android:layout_marginStart="24dp"
android:layout_marginTop="8dp"
android:background="@android:color/transparent"
android:scaleType="fitCenter"
android:src="@drawable/ic_forward_30_white"
app:layout_constraintStart_toEndOf="@+id/playToggleButton"
app:layout_constraintTop_toTopOf="parent"
android:contentDescription="@string/skip_forward" />
<Button
android:id="@+id/speedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginTop="5dp"
android:background="@android:color/transparent"
android:text="@string/_1x"
android:textAllCaps="false"
android:textColor="@android:color/white"
android:textSize="14sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
This adds the skip back, play/pause, skip forward and speed buttons at the top of the play controls section.
Still inside the playerControls ConstraintLayout section, add the following text directly after the code you just added:
<TextView
android:id="@+id/currentTimeTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:layout_marginStart="8dp"
android:text="@string/_0_00"
android:textColor="@android:color/white"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/seekBar"/>
<SeekBar
android:id="@+id/seekBar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:layout_marginEnd="8dp"
android:layout_marginStart="8dp"
android:progressBackgroundTint="@android:color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/endTimeTextView"
app:layout_constraintStart_toEndOf="@+id/currentTimeTextView"/>
<TextView
android:id="@+id/endTimeTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:layout_marginEnd="8dp"
android:text="@string/_0_00"
android:textColor="@android:color/white"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/seekBar" />
This adds the seek bar (scrubber) with current and end times to the bottom of the player controls section.
Episode player fragment
You’re ready to build out the episode player Fragment. This Fragment will display the episode layout and handle all of the playback logic. You’ll move the media-related code from the PodcastDetailsFragment class into this new episode player fragment.
Inside ui, create a class named EpisodePlayerFragment.kt and replace its contents with:
class EpisodePlayerFragment : Fragment() {
private lateinit var databinding: FragmentEpisodePlayerBinding
companion object {
fun newInstance(): EpisodePlayerFragment {
return EpisodePlayerFragment()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
override fun onCreateView(inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?): View {
databinding = FragmentEpisodePlayerBinding.inflate(inflater, container, false)
return databinding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
}
override fun onStart() {
super.onStart()
}
override fun onStop() {
super.onStop()
}
}
This is the minimum code required to display the Fragment. It provides a companion object to create an instance of the Fragment and loads the fragment_episode_player layout in onCreateView().
Note: Choose
androidx.fragment.app.Fragmentfor theFragmentimport.
Episode player navigation
Before finishing the Fragment code, hook up the navigation.
PodcastActivity will control the navigation, but it needs to know when the user selects an episode in the detail View. For that, you can add a new method to the OnPodcastDetails listener which gets triggered when the selection is made.
Open PodcastDetailsFragment.kt and add the following code to the OnPodcastDetailsListener interface.
fun onShowEpisodePlayer(episodeViewData: EpisodeViewData)
Replace all of the the code in onSelectedEpisode() with the following:
listener?.onShowEpisodePlayer(episodeViewData)
When the user selects an episode, this calls onShowEpisodePlayer() on the listener — in this case, PodcastActivity.
Now you can implement onShowEpisodePlayer() in the podcast Activity.
Open PodcastActivity.kt and add the following new method to satisfy the OnPodcastDetailsListener interface:
override fun onShowEpisodePlayer(episodeViewData: EpisodeViewData) {
}
Before you can add the code for this method, you need some supporting code. Start with a method that creates the episode player Fragment.
In PodcastActivity.kt, add the following code to the companion object:
private const val TAG_PLAYER_FRAGMENT = "PlayerFragment"
This tag keeps track of the episode player Fragment in the support Fragment Manager.
Now, add the following method:
private fun createEpisodePlayerFragment(): EpisodePlayerFragment {
var episodePlayerFragment =
supportFragmentManager.findFragmentByTag(TAG_PLAYER_FRAGMENT) as
EpisodePlayerFragment?
if (episodePlayerFragment == null) {
episodePlayerFragment = EpisodePlayerFragment.newInstance()
}
return episodePlayerFragment
}
This method uses the supportFragmentManager.findFragmentByTag() method to first check if the player Fragment was created before. If not, then a new instance is created using EpisodePlayerFragment.newInstance(). The episode player Fragment is then returned to the caller.
You can use the existing PodcastViewModel to keep track of the currently active episode. This makes it simple to retrieve the active episode from the new episode player Fragment.
Open PodcastViewModel.kt and add the following property to the class:
var activeEpisodeViewData: EpisodeViewData? = null
In the podcast Activity, you need a method to create and show the player Fragment. This will look similar to the existing showDetailsFragment() method.
Open PodcastActivity.kt and add the following new method:
private fun showPlayerFragment() {
val episodePlayerFragment = createEpisodePlayerFragment()
supportFragmentManager.beginTransaction().replace(R.id.podcastDetailsContainer,
episodePlayerFragment, TAG_PLAYER_FRAGMENT).addToBackStack("PlayerFragment").commit()
databinding.podcastRecyclerView.visibility = View.INVISIBLE
searchMenuItem.isVisible = false
}
This method creates the episode player Fragment, displays the Fragment, and hides the podcast list RecyclerView. It then hides the search menu item.
Now that all of the supporting methods are in place, you’re ready to implement onShowEpisodePlayer().
Add the following to onShowEpisodePlayer():
podcastViewModel.activeEpisodeViewData = episodeViewData
showPlayerFragment()
This sets the active episode on the podcast view model and calls showPlayerFragment() to display the player Fragment.
Build and run the app. Display the details for a podcast and tap on an episode.
Although the episode player Fragment is displayed, it’s blank since you haven’t populated any of the views yet. Press the back button, and it navigates back to the podcast details screen.
Episode player details
It’s time to get some episode data on the player screen. You’ll use the active episode view data from the podcast view model to populate the Views.
Open EpisodePlayerFragment.kt and add the following property to the class:
private val podcastViewModel: PodcastViewModel by activityViewModels()
This assigns the podcastViewModel property to the active podcast view model.
Next, you need to create a method to set up the view controls using the view model data. Add the following new method:
private fun updateControls() {
// 1
databinding.episodeTitleTextView.text = podcastViewModel.activeEpisodeViewData?.title
// 2
val htmlDesc = podcastViewModel.activeEpisodeViewData?.description ?: ""
val descSpan = HtmlUtils.htmlToSpannable(htmlDesc)
databinding.episodeDescTextView.text = descSpan
databinding.episodeDescTextView.movementMethod = ScrollingMovementMethod()
// 3
val fragmentActivity = activity as FragmentActivity
Glide.with(fragmentActivity)
.load(podcastViewModel.podcastLiveData.value?.imageUrl)
.into(databinding.episodeImageView)
}
Let’s take this one item at a time:
- Set the episode title text view to the episode title.
- Just like the podcast description that’s shown on the podcast details View, the episode description can have HTML formatting that causes display issues if set directly on a text view widget. This code uses the previously created
htmlToSpannable()method to clean up the episode description and make it display correctly. It also setsmovementMethodtoScrollingMovementMethodto allow the description to scroll. - Use Glide to load in the podcast album art and assign it to the episode image view widget.
Add the call to updateControls() to the bottom of onViewCreated():
updateControls()
Build and run the app. Load a podcast episode to view the details. If the episode description is long enough, you can scroll to read the full content.
Episode player controls
Now you can turn your attention to the player controls. You’ll get the basic play, pause, and skip controls working first. Then you’ll focus on the seek bar and speed control.
During the previous chapter, you added some media playback code to PodcastDetailsFragment.
This was sufficient to test that podcast playback was working, but now EpisodePlayerFragment will handle all playback. You’ll start by moving some media playback code from PodcastDetailsFragment to the new EpisodePlayerFragment.
Note: Make sure to delete the code from
PodcastDetailsFragmentwhen moving it toEpisodePlayerFragment. For functions which are left with nothing other than a call tosuperyou should delete them afterward so you aren’t left with redundant overriding methods.
Let’s break this process out step-by-step:
-
Move the following properties from PodcastDetailsFragment.kt to EpisodePlayerFragment.kt:
private lateinit var mediaBrowser: MediaBrowserCompat private var mediaControllerCallback: MediaControllerCallback? = null
Note: If Android Studio changes
MediaControllerCallbacktoPodcastDetailsFragment.MediaControllerCallback, change it back toMediaControllerCallback; it will show a compile error until you get to step 5.
-
Move
startPlaying()from PodcastDetailsFragment.kt to EpisodePlayerFragment.kt. -
Move the code below
super.onStart()fromonStart()in PodcastDetailsFragment.kt to the bottom ofonStart()in EpisodePlayerFragment.kt. -
Move the code below
super.onStop()fromonStop()in PodcastDetailsFragment.kt to the bottom ofonStop()in EpisodePlayerFragment.kt. -
Move the
MediaBrowserCallBacksandMediaControllerCallbackinner classes from PodcastDetailsFragment.kt to EpisodePlayerFragment.kt. -
Move
initMediaBrowser()from PodcastDetailsFragment.kt to EpisodePlayerFragment.kt. -
Move
registerMediaController()from PodcastDetailsFragment.kt to EpisodePlayerFragment.kt. -
Move the call to
initMediaBrowser()fromonCreate()in PodcastDetailsFragment.kt to the bottom ofonCreate()in EpisodePlayerFragment.kt.
Note: If Android Studio has again changed
MediaControllerCallbacktoPodcastDetailsFragment.MediaControllerCallbackanywhere in EpisodePlayerFragment.kt, change them back toMediaControllerCallback
Play/Pause button
Now it’s time to hook up the play/pause button to start and stop playback.
Add the following method to EpisodePlayerFragment:
private fun togglePlayPause() {
val fragmentActivity = activity as FragmentActivity
val controller = MediaControllerCompat.getMediaController(fragmentActivity)
if (controller.playbackState != null) {
if (controller.playbackState.state ==
PlaybackStateCompat.STATE_PLAYING) {
controller.transportControls.pause()
} else {
podcastViewModel.activeEpisodeViewData?.let { startPlaying(it) }
}
} else {
podcastViewModel.activeEpisodeViewData?.let { startPlaying(it) }
}
}
This is similar to the playback code you created in the previous chapter. It gets the current media controller, then it either pauses or starts playback, based on its current state.
Add the following method to listen for the tap on the play/pause button:
private fun setupControls() {
databinding.playToggleButton.setOnClickListener {
togglePlayPause()
}
}
This sets a listener on playToggleButton and calls togglePlayPause() when it’s tapped.
That’s enough to get the media playing, but you also need to update the play/pause button to show the pause icon when playing and the play icon when paused.
You can update the button icon directly in togglePlayPause(), but that won’t keep it in sync if playback is changed from outside the app. To keep the play/pause button in sync — regardless of how the state is changed — use the onPlaybackStateChanged() event from the media controller.
First, create a method to handle the playback state changed.
Add the following method:
private fun handleStateChange(state: Int) {
val isPlaying = state == PlaybackStateCompat.STATE_PLAYING
databinding.playToggleButton.isActivated = isPlaying
}
This sets the play/pause button state to activated if the media is playing or not activated if the media is paused. This results in the button icon changing because the button background in the layout XML is set to the ic_play_pause_toggle.xml selector. If you open this selector, you’ll see that it specifies the play button for the inactive state and the pause button for the active state.
Call this method when the playback state changes. Add the following to onPlaybackStateChanged() in the MediaControllerCallback inner class:
val state = state ?: return
handleStateChange(state.getState())
Finally, add the call to setupControls() before the call to updateControls() in onViewCreated():
setupControls()
Build and run the app.
Load a podcast episode and test out the new play button functionality.
Speed control button
Next, you’ll hook up the speed control button. This button will increase the speed by 0.25x times each time it’s tapped up to a maximum of 2.0x. It will go to 0.75x after reaching the max of 2.0x.
Unlike the play and pause commands, the media session doesn’t have a built-in command to change the playback speed. So how do you inform the media browser service that you want to change the speed? The answer is by using a custom command.
You need to add a new method to intercept custom commands when they come into the media session callback class. The custom command will have a name and a Bundle object with the command parameters.
First, define some constants for the custom command name and the key used in the Bundle object.
Open PodplayMediaCallback.kt and add the following companion object:
companion object {
const val CMD_CHANGESPEED = "change_speed"
const val CMD_EXTRA_SPEED = "speed"
}
This defines a speed change command string and key for the speed.
Next, update setState() to handle a speed option.
Change the setState() declaration to the following:
private fun setState(state: Int, newSpeed: Float? = null) {
This allows an optional newSpeed parameter to be passed to setState().
Before making the changes to setState(), look at the setState call that’s executed on the PlaybackStateCompat.Builder() object. Notice there’s a speed parameter as part of the state.
This speed parameter does not change the playback speed; it only sets the state on the Media Session. You need to change the speed setting directly on the MediaPlayer to affect the playback speed.
In setState(), add the following before the call to PlaybackStateCompat.Builder():
// 1
var speed = 1.0f
// 2
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (newSpeed == null) {
// 3
speed = mediaPlayer?.getPlaybackParams()?.speed ?: 1.0f
} else {
// 4
speed = newSpeed
}
mediaPlayer?.let { mediaPlayer ->
// 5
try {
mediaPlayer.playbackParams = mediaPlayer.playbackParams.setSpeed(speed)
}
catch (e: Exception) {
// 6
mediaPlayer.reset()
mediaUri?.let { mediaUri ->
mediaPlayer.setDataSource(context, mediaUri)
}
mediaPlayer.prepare()
// 7
mediaPlayer.playbackParams = mediaPlayer.playbackParams.setSpeed(speed)
// 8
mediaPlayer.seekTo(position.toInt())
// 9
if (state == PlaybackStateCompat.STATE_PLAYING) {
mediaPlayer.start()
}
}
}
}
Let’s go over this one step at a time:
- Start by setting the default
speedto1.0. - The
MediaPlayergained the ability to change the playback speed beginning with Android 6.0 (Marshmallow). If the version supports speed control, then the code block is executed. - If no new speed has been specified, then
speedis set to the media player’s current speed. - If a new speed is present, then
speedis set to the new speed. - The media player speed is updated to the new speed by setting a new
mediaPlayer.playbackParamsproperty. You can’t change the speed directly on theplaybackParams. A newplaybackParamsobject must be assigned to the media player. This call can throw an exception on some versions of Android, so it is surrounded by a try block. - If the update to
playbackParamsthrows an exception, then the player needs to be reset to clear the state. After a reset, the data source must be set again on the player. - Now that the player has been reset, it’s safe to update the
playbackParams. - Resetting the player sets the playback position back to
0.seekTo()is called to set it back to the previous position. - If the state is set to playing, then the player is started after the reset.
In setState(), update the call to setState() on PlaybackStateCompat.Builder() to pass in the speed for the third parameter:
.setState(state, position, speed)
Next, add a method to extract the speed from a bundle object and call setState() with the speed:
private fun changeSpeed(extras: Bundle) {
var playbackState = PlaybackStateCompat.STATE_PAUSED
if (mediaSession.controller.playbackState != null) {
playbackState = mediaSession.controller.playbackState.state
}
setState(playbackState, extras.getFloat(CMD_EXTRA_SPEED))
}
When the speed is changed, you want to make sure the playback state (playing or paused) doesn’t change. This is accomplished by taking the current playback state and passing it into setState(). playbackState is set to the current playback state if it is valid. If not, playbackState is set to the default state of STATE_PAUSED. You call setState() with playbackState and the new playback speed.
Now you can add the method to process the custom command.
Add the following method to the PodplayMediaCallback class:
override fun onCommand(command: String?, extras: Bundle?,
cb: ResultReceiver?) {
super.onCommand(command, extras, cb)
when (command) {
CMD_CHANGESPEED -> extras?.let { changeSpeed(it) }
}
}
Note: Select
import android.os.ResultReceiverfor theResultReceiverimport.
onCommand() is called by the media session when a custom command is received. You check for the CMD_CHANGESPEED command and then call changeSpeed() with the extras Bundle object.
Now, the episode player Fragment needs to send the custom command when the user changes the speed.
First, you need a property to keep track of the current playback speed.
Open EpisodePlayerFragment.kt and add the following property to the EpisodePlayerFragment class:
private var playerSpeed: Float = 1.0f
This property keeps track of the current speed.
Next, add a method to change the speed by sending the custom command to the media controller.
Add the following method:
private fun changeSpeed() {
// 1
playerSpeed += 0.25f
if (playerSpeed > 2.0f) {
playerSpeed = 0.75f
}
// 2
val bundle = Bundle()
bundle.putFloat(CMD_EXTRA_SPEED, playerSpeed)
// 3
val fragmentActivity = activity as FragmentActivity
val controller = MediaControllerCompat.getMediaController(fragmentActivity)
controller.sendCommand(CMD_CHANGESPEED, bundle, null)
// 4
val speedButtonText = "${playerSpeed}x"
databinding.speedButton.text = speedButtonText
}
Let’s break this down.
- Increase
playerSpeedby 0.25. If the speed goes past 2.0, it’s set back to 0.75. - Create a bundle and set the
CMD_EXTRA_SPEEDkey to the value ofplayerSpeed. - The
CMD_CHANGESPEEDcommand is sent to the media controller along with the bundle object. - Update the speed button text label to show the current playback speed.
You also need to make sure the speed control label is correct after a screen rotation. Add the following line to the end of updateControls():
val speedButtonText = "${playerSpeed}x"
databinding.speedButton.text = speedButtonText
Now, the speed control button needs to call changeSpeed().
Add the following to the end of setupControls():
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
databinding.speedButton.setOnClickListener {
changeSpeed()
}
} else {
databinding.speedButton.visibility = View.INVISIBLE
}
This first checks to see if the device supports the speed setting. If it does, the onClickListener is set on the speed button. The listener calls changeSpeed() when the user taps the speed button. If the device does not support speed control, then the speed button is hidden.
Build and run the app on a device running Android M or newer. Bring up a podcast episode and begin playback. Use the speed control and be amazed at how fast you can fly through a podcast at 2x speed!
Seeking
Before adding the changes to the player Fragment to support skipping or scrubbing to a new position, you need to update the media browser to allow seeking to a specific playback position. This is done by overriding an additional method in PodplayMediaCallback.
Open PodplayMediaCallback.kt and add the following method:
override fun onSeekTo(pos: Long) {
super.onSeekTo(pos)
// 1
mediaPlayer?.seekTo(pos.toInt())
// 2
val playbackState: PlaybackStateCompat? =
mediaSession.controller.playbackState
// 3
if (playbackState != null) {
setState(playbackState.state)
} else {
setState(PlaybackStateCompat.STATE_PAUSED)
}
}
onSeekTo() is called by the media session when the seekTo command is received.
Here’s what’s going on.
- Call
seekTo()on themediaPlayerto change the playback position. - Retrieve the playback state from the media controller.
- Call
setState()so any media browser clients will know about the change in position. This is an important step, as it keeps all media browser client UIs in sync.
If playbackState is not null, then setState() is called with the current state. This ensures that the player keeps playing or stays paused depending on the current playback state.
If playbackState is null, then the playback state is set to paused.
Next, you need a method in the episode player fragment that performs the seek using the media controller.
Open EpisodePlayerFragment.kt and add the following method:
private fun seekBy(seconds: Int) {
val fragmentActivity = activity as FragmentActivity
val controller = MediaControllerCompat.getMediaController(fragmentActivity)
val newPosition = controller.playbackState.position + seconds*1000
controller.transportControls.seekTo(newPosition)
}
This starts by grabbing the media controller and then computes a new playback position by adding to the current playback position. The seconds are multiplied by 1000 to convert to milliseconds as used by the media controller.
Call seekTo() on the media controller transport controls.
This invokes onSeekTo() you defined in the media browser service.
Skip buttons
OK, it’s time to implement the skip forward and back functionality. The media controller allows you to change the playback position directly. To perform a skip, you need to take the current playback position, add a plus or minus offset to get a new position, and then set the new position.
Start by adding listeners on the skip buttons and call the new seekBy method.
Add the following to the bottom of setupControls():
databinding.forwardButton.setOnClickListener {
seekBy(30)
}
databinding.replayButton.setOnClickListener {
seekBy(-10)
}
This sets a listener on the forwardButton that calls seekBy() with a forward skip of 30 seconds. It sets a listener on the replayButton that calls seekBy() with a backward skip of 10 seconds.
The skip buttons are now fully operational.
Build and run the app. Bring up a podcast episode and test out the playback controls. You can play and pause the episode, skip forward and backward and change the speed.
Pull down the notification drawer and pause the playback from there. Notice that the play/pause button icon in the app stays in sync. You may have noticed that the scrubber at the bottom does not move to reflect the current playback position. You’ll fix that now and implement the associated time labels.
Scrubber control
There are a few steps required to make the scrubber functional:
- Update the end time label to reflect the episode duration.
- Keep the scrubber position and current time label updated to match the current playback position.
- Update the playback position when the user drags the scrubber.
Setting the end time label is reasonably straightforward, but not as straightforward as it seems. You may be tempted to take the duration stored in the Episode model and use it to set the label. Unfortunately, the duration provided in the RSS feed is not always accurate, and not always formatted consistently.
The safest way to set the end time is to get the episode duration from the media controller metadata. There’s only one problem: your media browser service doesn’t set the duration! You need to fix that first.
Open PodplayMediaCallback.kt and add the following line to MediaMetadataCompat.Builder() calls in prepareMedia():
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION,
mediaPlayer.duration.toLong())
This takes the duration reported by the media player and sets the proper metadata key on the media session.
Now the episode player can use this metadata when the playback state changes.
Open EpisodePlayerFragment.kt and add the following property to the EpisodePlayerFragment class:
private var episodeDuration: Long = 0
This stores the current episode duration.
Add the following method:
private fun updateControlsFromMetadata(metadata: MediaMetadataCompat) {
episodeDuration = metadata.getLong(MediaMetadataCompat.METADATA_KEY_DURATION)
databinding.endTimeTextView.text = DateUtils.formatElapsedTime((episodeDuration / 1000))
}
Note: Select
android.text.format.DateUtilsfor theDateUtilsimport.
This sets the episodeDuration from the METADATA_KEY_DURATION metadata value. If the value doesn’t exist, then the duration is set to 0. It then uses the duration to set the end time label.
DateUtils.formatElapsedTime() takes the time in seconds and returns a formatted time string as hours:minutes:seconds.
You need to call this new method when the metadata is changed.
Add the following to the bottom of onMetadataChanged() in the inner MediaControllerCallback class:
metadata?.let { updateControlsFromMetadata(it) }
This calls updateControlsFromMetadata() if the metadata is not null.
Next, you’ll add code to keep the scrubber and the current time label in sync with the current playback position.
Add the following line to the end of updateControlsFromMetadata():
databinding.seekBar.max = episodeDuration.toInt()
This sets the range of the scrubber seekBar to match the episode duration. This lets you set the progress value on the seekBar directly to the playback position in milliseconds, and it places the progress indicator at the correct position.
Next, you’ll update the current time label as the scrubber indicator position changes, and update the playback position after the user drags the scrubber indicator to a new position. You can handle both of these tasks by implementing a change listener on the scrubber bar.
First, add a property to keep track of when the user is dragging the scrubber indicator. The reason for this will be explained shortly.
Add the following property to EpisodePlayerFragment:
private var draggingScrubber: Boolean = false
Then add the following to the end of setupControls():
// 1
databinding.seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
// 2
databinding.currentTimeTextView.text = DateUtils.formatElapsedTime((progress / 1000).toLong())
}
override fun onStartTrackingTouch(seekBar: SeekBar) {
// 3
draggingScrubber = true
}
override fun onStopTrackingTouch(seekBar: SeekBar) {
// 4
draggingScrubber = false
// 5
val fragmentActivity = activity as FragmentActivity
val controller = MediaControllerCompat.getMediaController(fragmentActivity)
if (controller.playbackState != null) {
// 6
controller.transportControls.seekTo(seekBar.progress.toLong())
} else {
// 7
seekBar.progress = 0
}
}
})
Let’s step through the code.
-
Set a change listener object on the
seekBar. -
seekBarcallsonProgressChanged()each time the scrubber position changes. You use this as an opportunity to update the current time label and format it to hours:minutes:seconds. -
seekBarcallsonStartTrackingTouch()when the user starts to drag the scrubber indicator.draggingScrubberis set to true. -
seekBarcallsonStopTrackingTouch()when the user stops dragging the scrubber indicator.draggingScrubberis set to false, and the playback position is updated. -
Retrieve the controller object from the Activity.
-
If the controller playback state is valid, then seek directly to the new playback position where the user stopped dragging the scrubber indicator.
-
If the controller playback state is invalid, set the scrubber position back to the beginning.
That’s all you need to allow the user to drag the scrubber to any playback position.
Now you need to update the scrubber position as the play continues. There are several ways you can do this.
One option is to use a ScheduledExecutorService that runs a method every second.
In this method, you query for the current playback state position from the media controller and update the scrubber position accordingly.
For PodPlay, you’ll treat the scrubber movement as an animation. You know how much time is left in the episode and the playback speed, so you can use this to smoothly animate the scrubber indicator until it reaches the end of the scrubber bar.
You’ll implement the animation using a ValueAnimator. You can think of the ValueAnimator as an engine that pumps out values at a steady rate. You’ll use these values to update the scrubber as long as the playback continues.
First, you need a property to hold the ValueAnimator object so it can be canceled if needed.
Add the following property to EpisodePlayerFragment:
private var progressAnimator: ValueAnimator? = null
Now you can create a method to build the animation and kick it off.
Add the following method to the EpisodePlayerFragment class:
// 1
private fun animateScrubber(progress: Int, speed: Float) {
// 2
val timeRemaining = ((episodeDuration - progress) / speed).toInt()
// 3
if (timeRemaining < 0) {
return;
}
// 4
progressAnimator = ValueAnimator.ofInt(
progress, episodeDuration.toInt())
progressAnimator?.let { animator ->
// 5
animator.duration = timeRemaining.toLong()
// 6
animator.interpolator = LinearInterpolator()
// 7
animator.addUpdateListener {
if (draggingScrubber) {
// 8
animator.cancel()
} else {
// 9
databinding.seekBar.progress = animator.animatedValue as Int
}
}
// 10
animator.start()
}
}
Here’s what’s happening:
-
animateScrubber()takes in the current progress and playback speed. - You compute the time remaining until the end of the episode.
- If
timeRemainingis negative then the function is abandoned. This will prevent any unintended side effects when switching between podcasts. - Create a new
ValueAnimatorwith the starting and ending value of the animation and assign it to theprogressAnimatorproperty. - The animation duration is set to the time remaining. This stops the animation when it reaches the end of the episode.
- By default, the
ValueAnimatoruses a non-linear time interpolation where it accelerates at the beginning and decelerates at the end of the animation. The interpolation is set to linear to ensure an even animation. - Set an update listener on the animator. This listener is called by the animator on each step of the animation.
- This is where the
draggingScrubberproperty you set earlier comes into play. If the user is dragging the scrubber then you need to cancel the animation, or it will get into a tug-of-war with the user, and it will not end well. - If the user is not dragging the scrubber, then update the scrubber indicator to the current value from the animator.
- Start the animation.
Now use this new method when the playback state changes to playing.
You can also make sure the scrubber position is updated when the playback state changes. First, update handleStateChange() to use the current playback position and speed.
Update handleStateChange() declaration to the following:
private fun handleStateChange(state: Int, position: Long, speed: Float) {
Then add the following to the end of handleStateChange():
val progress = position.toInt()
databinding.seekBar.progress = progress
val speedButtonText = "${playerSpeed}x"
databinding.speedButton.text = speedButtonText
if (isPlaying) {
animateScrubber(progress, speed)
}
This starts by getting the current progress from the playback state, and then it sets the scrubber to the current progress position and updates the speed control label. If the media is playing, then start the scrubber animation.
You also need to stop the animation when the playback stops. Add the following to the beginning of handleStateChange():
progressAnimator?.let {
it.cancel()
progressAnimator = null
}
If the animator is not null, then cancel it and set it back to null.
Now update the call to handleStateChange() in the onPlaybackStateChanged() method of the MediaControllerCallback class to the following:
handleStateChange(state.state, state.position, state.playbackSpeed)
This passes in the additional parameters added to handleStateChange().
Finally, cancel the animation when the Fragment is stopped.
Add the following after the call to super.onStop() in onStop():
progressAnimator?.cancel()
One minor addition is needed to update the controls after the screen is rotated.
Create the following method to update the controls based on the media controller state:
private fun updateControlsFromController() {
val fragmentActivity = activity as FragmentActivity
val controller = MediaControllerCompat.getMediaController(fragmentActivity)
if (controller != null) {
val metadata = controller.metadata
if (metadata != null) {
handleStateChange(controller.playbackState.state,
controller.playbackState.position, playerSpeed)
updateControlsFromMetadata(controller.metadata)
}
}
}
This method calls handleStateChange and updateControlsFromMetadata to make sure the controls match the playback state after a screen rotation.
Now you’ll call this new method from a couple of key places.
Add the call to the end of onConnected() in MediaBrowserCallBacks:
updateControlsFromController()
Add the call to onStart() before the else statement.
updateControlsFromController()
Build and run the app. Start playback for an episode.
Notice that the current time on the left of the scrubber stays in sync with the playback position and the end time displays the episode duration.
The scrubber indicator moves along with the playback, and you’re able to drag the scrubber to jump to any playback position.
Video playback
The last feature you’ll implement is video playback. If you try to play a video podcast with PodPlay now, only the audio part will play.
Note: Due to errors with handling video playback on older devices, the video playback feature is only available on Android M and newer.
Unlike audio, video playback is a captive experience and is intended to run in the foreground with a UI. For this reason, you’ll abandon the client/server architecture used for audio playback when playing back videos.
You’ll still use MediaSession and MediaPlayer along with the PodplayMediaCallback class, but you’ll control it from EpisodePlayerFragment instead of MediaBrowserService.
Identifying videos
The first thing you need is a means to identify if the episode media is a video. Open PodcastViewModel.kt and add the following to EpisodeViewData:
var isVideo: Boolean = false
The updated EpisodeViewData class should match the following:
data class EpisodeViewData (
var guid: String? = "",
var title: String? = "",
var description: String? = "",
var mediaUrl: String? = "",
var releaseDate: Date? = null,
var duration: String? = "",
var isVideo: Boolean = false
)
Replace the contents of episodesToEpisodesView() with the following:
return episodes.map {
val isVideo = it.mimeType.startsWith("video")
EpisodeViewData(it.guid, it.title, it.description,
it.mediaUrl, it.releaseDate, it.duration, isVideo)
}
This checks the mime type on each episode to see if it starts with the string “video”. If so, isVideo on the EpisodeViewData is set to true.
Now you need to update EpisodePlayerFragment to handle video playback.
To start video playback, you need to perform a few tasks:
- Create a media session and a media player. This is handled in
MediaBrowserServicefor audio files, but for video, it needs to be done inEpisodePlayerFragment. - Update the UI to make the video visible and hide the other UI elements.
- Prepare the
SurfaceViewto playback the video.
Media session
You need a MediaSession object to manage the video playback.
Open EpisodePlayerFragment.kt and add the following property to the class:
private var mediaSession: MediaSessionCompat? = null
Add the following method to initialize the media session:
private fun initMediaSession() {
if (mediaSession == null) {
// 1
mediaSession = MediaSessionCompat(activity as Context,
"EpisodePlayerFragment")
// 2
mediaSession?.setMediaButtonReceiver(null)
}
mediaSession?.let {
registerMediaController(it.sessionToken)
}
}
This is similar to the code created in the last chapter for MediaBrowserService.
- Create a media session if it does not already exist.
- Set the media button receiver to
nullso that media buttons are ignored if the app is not in the foreground.
Media player
You also need a MediaPlayer object just like you did with the MediaBrowserService. Add the following property to EpisodePlayerFragment:
private var mediaPlayer: MediaPlayer? = null
You need to know if the user taps the play button before the media is ready to play.
Add the following property to EpisodePlayerFragment:
private var playOnPrepare: Boolean = false
The media player needs a view on which to display the video. This is where the videoSurfaceView comes into the picture.
Once the media player loads the video, the videoSurfaceView needs to be resized to match the video aspect ratio.
Add the following method to resize the video surface view.
private fun setSurfaceSize() {
// 1
val mediaPlayer = mediaPlayer ?: return
// 2
val videoWidth = mediaPlayer.videoWidth
val videoHeight = mediaPlayer.videoHeight
// 3
val parent = databinding.videoSurfaceView.parent as View
val containerWidth = parent.width
val containerHeight = parent.height
// 4
val layoutAspectRatio = containerWidth.toFloat() /
containerHeight
val videoAspectRatio = videoWidth.toFloat() / videoHeight
// 5
val layoutParams = databinding.videoSurfaceView.layoutParams
// 6
if (videoAspectRatio > layoutAspectRatio) {
layoutParams.height =
(containerWidth / videoAspectRatio).toInt()
} else {
layoutParams.width =
(containerHeight * videoAspectRatio).toInt()
}
// 7
databinding.videoSurfaceView.layoutParams = layoutParams
}
This method’s job is to make the video view match the size of the podcast video and keep the video aspect ratio intact. It does this by taking the longest side of the video and making it fit the view, and then adjusting the other side to keep the original ratio intact.
- If the media player is
null, the method returns early. - Retrieve the current width and height of the video.
- Retrieve the current width and height of the video surface container view.
- Compute the surface view layout aspect ratio.
- Compute the video aspect ratio.
- If the video ratio is larger than the surface view layout ratio, then the surface view layout width is retained, and the height is shrunk to keep the video aspect ratio.
- If the video ratio is smaller than the surface view layout ratio, then the surface view layout height is retained, and the width is shrunk to keep the video aspect ratio.
Now you can call this from the media player initialization code.
Add the following method:
private fun initMediaPlayer() {
if (mediaPlayer == null) {
// 1
mediaPlayer = MediaPlayer()
mediaPlayer?.let { mediaPlayer ->
// 2
mediaPlayer.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
// 3
mediaPlayer.setDataSource(podcastViewModel.activeEpisodeViewData?.mediaUrl)
// 4
mediaPlayer.setOnPreparedListener {
// 5
val fragmentActivity = activity as FragmentActivity
mediaSession?.let { mediaSession ->
val episodeMediaCallback = PodplayMediaCallback(fragmentActivity, mediaSession, it)
mediaSession.setCallback(episodeMediaCallback)
}
// 6
setSurfaceSize()
// 7
if (playOnPrepare) {
togglePlayPause()
}
}
// 8
mediaPlayer.prepareAsync()
}
} else {
// 9
setSurfaceSize()
}
}
Here’s break this down.
- If the media player is
null, create a new one. -
AudioAttributesdefine the behavior of audio playback.setUsagedefines what is the sound you are playing is used for, i.e the reason why you are playing it. For example, another usage type isUSAGE_ALARM.setContentTypedefines what you are playing. The content-type expresses the general category of the content. This information is optional. But in case it is known (for instanceCONTENT_TYPE_MOVIEfor a movie streaming service orCONTENT_TYPE_MUSICfor a music playback application) this information might be used by the audio framework to selectively configure some audio post-processing blocks. There is a third optional attribute type, flags, which can affect how the playback is affected by the system. - Set the media player data source to the episode media URL.
- Set the
onPreparedListenermethod on the media player. - Once the media is ready, the
PodplayMediaCallbackobject is created and assigned as the callback on the current media session. - Set the video surface size to match the video.
- If
playOnPrepareis true, indicating that the user has already tapped the play button, then the video is started. - Call
prepareAsync()on the media player to have it prepare the video in the background. - If the media player is not
null, then you only need to set the video surface size. This happens if there’s a configuration change, such as a screen rotation.
The playOnPrepare flag should be set to true when the play button is tapped. It doesn’t matter that it gets set each time, as long as you know that it was tapped at least once.
Add the following to the beginning of togglePlayPause():
playOnPrepare = true
SurfaceView overview
Finally, add the following method to initialize the video surface and call the new initMediaPlayer method:
private fun initVideoPlayer() {
// 1
databinding.videoSurfaceView.visibility = View.VISIBLE
// 2
val surfaceHolder = databinding.videoSurfaceView.holder
// 3
surfaceHolder.addCallback(object: SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
// 4
initMediaPlayer()
mediaPlayer?.setDisplay(holder)
}
override fun surfaceChanged(var1: SurfaceHolder, var2: Int,
var3: Int, var4: Int) {
}
override fun surfaceDestroyed(var1: SurfaceHolder) {
}
})
}
This method warrants some explanation on how surface views interact with the media player. To display videos, the MediaPlayer object requires access to a SurfaceView. Surface views provide a dedicated drawing surface within your view hierarchy.
When a surface view is made visible, Android must prepare it for use. Surface views provide a SurfaceHolder object that can be used to determine the surface availability.
Surface holders provide a SurfaceHolder.Callback interface to provide notifications about the surface state. The surface view is only available when the surfaceCreated() method is called on the surface holder callback object.
With that in mind, let’s go over the method one step at a time.
- The video surface view is made visible.
- You get a reference to the underlying surface
holder. - You call
addCallback()and provide aSurfaceHolder.Callbackobject to detect when the surface is created. - Once the surface is created, the media player is initialized, and the surface is assigned as the display object for the media player.
Next, you’ll add some conditional code that skips the MediaBrowser creation and usage if it’s a video.
First, create a property to store the video state.
Add the following property to EpisodePlayerFragment:
private var isVideo: Boolean = false
Now, add the following in onCreate() before the call to initMediaBrowser.
isVideo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
podcastViewModel.activeEpisodeViewData?.isVideo ?: false
} else {
false
}
If the device is running Android M or newer, the isVideo flag is set based on the podcast episode media type. Older devices will set isVideo to false and treat all podcast episodes as audio only.
Now, you’ll find all code that references the media browser set a condition around it to only run if the podcast is not a video.
Surround the call to initMediaBrowser() in onCreate() as follows:
if (!isVideo) {
initMediaBrowser()
}
initMediaBrowser() is only called if the media is not a video.
Surround the code in onStart(), except for super.onStart(), with a check for isVideo:
if (!isVideo) {
if (mediaBrowser.isConnected) {
val fragmentActivity = activity as FragmentActivity
if (MediaControllerCompat.getMediaController(fragmentActivity) == null) {
registerMediaController(mediaBrowser.sessionToken)
}
updateControlsFromController()
} else {
mediaBrowser.connect()
}
}
The media browser connection logic is only implemented if the media is not a video.
Add the following code to the end of onStop():
if (isVideo) {
mediaPlayer?.setDisplay(null)
}
Clearing the display surface is required on some versions of Android to prevent issues when the screen is rotated.
Next, add conditional code that initializes the player if the episode is a video.
Add the following before the call to updateControls() in onViewCreated():
if (isVideo) {
initMediaSession()
initVideoPlayer()
}
This initializes the media session and video player when the Activity is created.
There’s one last bit of conditional code for videos. When a video is playing, you want to hide the episode header, episode description, and action bar, while making the media controls container partly transparent.
This allows the video to take up the maximum amount of screen space.
Add the following method to set up the video UI changes.
private fun setupVideoUI() {
databinding.episodeDescTextView.visibility = View.INVISIBLE
databinding.headerView.visibility = View.INVISIBLE
val activity = activity as AppCompatActivity
activity.supportActionBar?.hide()
databinding.playerControls.setBackgroundColor(Color.argb(255/2, 0, 0, 0))
}
This hides everything on the screen except the video controls. It sets the player controls background color to a 50% transparency level.
Call setupVideoUI() when the video is playing by adding the following as the first line inside the “if (isPlaying) {” section of handleStateChange():
if (isVideo) {
setupVideoUI()
}
You need to manually stop the playback when the fragment is exited, so add the following to the end of onStop():
if (!fragmentActivity.isChangingConfigurations) {
mediaPlayer?.release()
mediaPlayer = null
}
If the Fragment is not stopping due to a configuration change, then stop the playback and release the media player. If the Fragment is stopped during a configuration change, such as a screen rotation, then the media player is not recreated.
There’s one more change required to handle the playback controls properly when the screen is rotated.
Add the following to the end of updateControls():
mediaPlayer?.let {
updateControlsFromController()
}
If mediaPlayer is not null, then the controls are updated from the media controller state.
There’s one minor change required in PodplayMediaCallback.kt to make sure the media player is not prepared a second time. You need this because prepareAsync() is already called in the episode player fragment when the media is a video.
In PodplayMediaCallback.kt, add the follow property to the PodplayMediaCallback class:
private var mediaNeedsPrepare: Boolean = false
This property is used to indicate if the media player needs to be prepared.
In initializeMediaPlayer(), add the following line to the end of the if (mediaPlayer == null) { conditional code block:
mediaNeedsPrepare = true
This sets mediaNeedsPrepare to true only if the mediaPlayer is created by PodplayMediaCallback. When playing back videos, the mediaPlayer is created by the EpisodePlayerFragment and passed into PodplayMediaCallback, so mediaNeedsPrepare will not be set to true.
In prepareMedia(), replace the following code,
mediaPlayer.reset()
mediaPlayer.setDataSource(context, mediaUri)
mediaPlayer.prepare()
with this block:
if (mediaNeedsPrepare) {
mediaPlayer.reset()
mediaPlayer.setDataSource(context, mediaUri)
mediaPlayer.prepare()
}
The mediaPlayer is only prepared if mediaNeedsPrepare is true.
That’s all the changes required in the shared PodplayMediaCallback object to support video playback. All of the existing controls, including skip and speed, will work without any changes.
Build and run the app.
Find a video podcast and bring up an episode. When the episode player is first displayed, it won’t look any different than a standard audio podcast. Once you tap the play button, it shows the video.
Note: Depending on your connection, there can be a 1-5 second delay after you press the play button before the video starts playing.
If the video fills the screen, the playback controls will overlay the video. If you rotate the screen, the video will keep playing and adapt to the new screen orientation.
Key Points
- You used a number of built-in overrides for media session to control playback features such play / pause and seeking to various points in the media timeline.
- You created custom commands to add other features such as changing playback speed.
- Custom commands have a name and a Bundle object with the command parameters.
onCommand()is called by the media session when a custom command is received. - You learned how to identify and playback video podcasts, making for a truly dynamic podcast experience!
Where to go from here?
Congratulations, you now have a fully functional podcast player worthy of praise and bragging rights! Pat yourself on the back because you’ve accomplished a lot.
There are plenty of opportunities to improve and take the Podcast player to the next level. Here are just a few ideas:
- Start from the last playback position when a user resumes a podcast. Hint: add a new
lastPositionproperty to the Episode model, and update it when playback stops. - Notify your users periodically with a curated list of the top podcasts. Hint: use Firebase Cloud Messaging. Learn more at https://firebase.google.com/docs/cloud-messaging/.
- Add the ability to create playlists.
- Add an option to download episodes for offline listening. Hint: check out DownloadManager at https://developer.android.com/reference/android/app/DownloadManager.html.
- Add an option to manually add a podcast from an RSS URL.
In the next few chapters, you’ll discover some important topics like how to keep your app up to date, preparing to release it, even testing and publishing. So, sit back, relax and let’s put a bow on these new skills of yours!