Add playback resumption support to session demo app

The main part of adding resumption support is storing the
last media id and position whenever the mediaItem or
isPlaying state changes.

PiperOrigin-RevId: 752783723
This commit is contained in:
tonihei 2025-04-29 10:11:28 -07:00 committed by Copybara-Service
parent 965fc81f08
commit a62af0387b
6 changed files with 146 additions and 5 deletions

View File

@ -62,6 +62,12 @@
</intent-filter> </intent-filter>
</service> </service>
<receiver android:name="androidx.media3.session.MediaButtonReceiver" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
</application> </application>
</manifest> </manifest>

View File

@ -64,6 +64,12 @@
android:authorities="androidx.media3" android:authorities="androidx.media3"
android:exported="true" /> android:exported="true" />
<receiver android:name="androidx.media3.session.MediaButtonReceiver" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
</application> </application>
</manifest> </manifest>

View File

@ -11,6 +11,9 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
plugins {
id "com.google.protobuf" version "0.9.5"
}
apply from: "$gradle.ext.androidxMediaSettingsDir/common_library_config.gradle" apply from: "$gradle.ext.androidxMediaSettingsDir/common_library_config.gradle"
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
@ -50,9 +53,26 @@ android {
} }
} }
protobuf {
protoc {
artifact = 'com.google.protobuf:protoc:3.0.0'
}
generateProtoTasks {
all().forEach { task ->
task.plugins {
create("java")
}
}
}
}
dependencies { dependencies {
implementation 'androidx.core:core-ktx:' + androidxCoreVersion implementation 'androidx.core:core-ktx:' + androidxCoreVersion
implementation 'androidx.appcompat:appcompat:' + androidxAppCompatVersion implementation 'androidx.appcompat:appcompat:' + androidxAppCompatVersion
implementation 'androidx.datastore:datastore:1.1.5'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.9.0'
implementation 'com.google.protobuf:protobuf-java:3.24.4'
implementation project(modulePrefix + 'lib-common-ktx')
implementation project(modulePrefix + 'lib-exoplayer') implementation project(modulePrefix + 'lib-exoplayer')
implementation project(modulePrefix + 'lib-exoplayer-dash') implementation project(modulePrefix + 'lib-exoplayer-dash')
implementation project(modulePrefix + 'lib-exoplayer-hls') implementation project(modulePrefix + 'lib-exoplayer-hls')

View File

@ -15,7 +15,6 @@
*/ */
package androidx.media3.demo.session package androidx.media3.demo.session
import android.content.Context
import android.os.Bundle import android.os.Bundle
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
@ -32,23 +31,26 @@ import androidx.media3.session.SessionResult
import com.google.common.collect.ImmutableList import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListenableFuture
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.guava.future
/** A [MediaLibraryService.MediaLibrarySession.Callback] implementation. */ /** A [MediaLibraryService.MediaLibrarySession.Callback] implementation. */
open class DemoMediaLibrarySessionCallback(context: Context) : open class DemoMediaLibrarySessionCallback(val service: DemoPlaybackService) :
MediaLibraryService.MediaLibrarySession.Callback { MediaLibraryService.MediaLibrarySession.Callback {
init { init {
MediaItemTree.initialize(context.assets) MediaItemTree.initialize(service.assets)
} }
private val commandButtons: List<CommandButton> = private val commandButtons: List<CommandButton> =
listOf( listOf(
CommandButton.Builder(CommandButton.ICON_SHUFFLE_OFF) CommandButton.Builder(CommandButton.ICON_SHUFFLE_OFF)
.setDisplayName(context.getString(R.string.exo_controls_shuffle_on_description)) .setDisplayName(service.getString(R.string.exo_controls_shuffle_on_description))
.setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON, Bundle.EMPTY)) .setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON, Bundle.EMPTY))
.build(), .build(),
CommandButton.Builder(CommandButton.ICON_SHUFFLE_ON) CommandButton.Builder(CommandButton.ICON_SHUFFLE_ON)
.setDisplayName(context.getString(R.string.exo_controls_shuffle_off_description)) .setDisplayName(service.getString(R.string.exo_controls_shuffle_off_description))
.setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF, Bundle.EMPTY)) .setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF, Bundle.EMPTY))
.build(), .build(),
) )
@ -179,6 +181,26 @@ open class DemoMediaLibrarySessionCallback(context: Context) :
) )
} }
@OptIn(UnstableApi::class) // onPlaybackResumption callback + MediaItemsWithStartPosition
override fun onPlaybackResumption(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
): ListenableFuture<MediaItemsWithStartPosition> {
return CoroutineScope(Dispatchers.Unconfined).future {
service.retrieveLastStoredMediaUidAndPosition()?.let {
maybeExpandSingleItemToPlaylist(
mediaItem = MediaItem.Builder().setMediaId(it.mediaId).build(),
startIndex = 0,
startPositionMs = it.positionMs,
)
?.let {
return@future it
}
}
throw IllegalStateException("previous media id not found")
}
}
private fun resolveMediaItems(mediaItems: List<MediaItem>): List<MediaItem> { private fun resolveMediaItems(mediaItems: List<MediaItem>): List<MediaItem> {
val playlist = mutableListOf<MediaItem>() val playlist = mutableListOf<MediaItem>()
mediaItems.forEach { mediaItem -> mediaItems.forEach { mediaItem ->

View File

@ -19,13 +19,19 @@ import android.Manifest
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.datastore.core.DataStore
import androidx.datastore.core.Serializer
import androidx.datastore.dataStore
import androidx.media3.common.AudioAttributes import androidx.media3.common.AudioAttributes
import androidx.media3.common.Player
import androidx.media3.common.listen
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.demo.session.service.R import androidx.media3.demo.session.service.R
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
@ -34,6 +40,12 @@ import androidx.media3.session.MediaConstants
import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSession.ControllerInfo import androidx.media3.session.MediaSession.ControllerInfo
import java.io.InputStream
import java.io.OutputStream
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
open class DemoPlaybackService : MediaLibraryService() { open class DemoPlaybackService : MediaLibraryService() {
@ -44,6 +56,25 @@ open class DemoPlaybackService : MediaLibraryService() {
private const val CHANNEL_ID = "demo_session_notification_channel_id" private const val CHANNEL_ID = "demo_session_notification_channel_id"
} }
object PreferenceDataStore {
private val Context._dataStore: DataStore<Preferences> by
dataStore(
fileName = "preferences.pb",
serializer =
object : Serializer<Preferences> {
override val defaultValue: Preferences = Preferences.getDefaultInstance()
override suspend fun readFrom(input: InputStream): Preferences =
Preferences.parseFrom(input)
override suspend fun writeTo(preferences: Preferences, output: OutputStream) =
preferences.writeTo(output)
},
)
fun get(context: Context) = context.applicationContext._dataStore
}
/** /**
* Returns the single top session activity. It is used by the notification when the app task is * Returns the single top session activity. It is used by the notification when the app task is
* active and an activity is in the fore or background. * active and an activity is in the fore or background.
@ -102,12 +133,22 @@ open class DemoPlaybackService : MediaLibraryService() {
super.onDestroy() super.onDestroy()
} }
@OptIn(UnstableApi::class) // Player.listen
private fun initializeSessionAndPlayer() { private fun initializeSessionAndPlayer() {
val player = val player =
ExoPlayer.Builder(this) ExoPlayer.Builder(this)
.setAudioAttributes(AudioAttributes.DEFAULT, /* handleAudioFocus= */ true) .setAudioAttributes(AudioAttributes.DEFAULT, /* handleAudioFocus= */ true)
.build() .build()
player.addAnalyticsListener(EventLogger()) player.addAnalyticsListener(EventLogger())
CoroutineScope(Dispatchers.Unconfined).launch {
player.listen { events ->
if (
events.containsAny(Player.EVENT_IS_PLAYING_CHANGED, Player.EVENT_MEDIA_ITEM_TRANSITION)
) {
storeCurrentMediaUidAndPosition()
}
}
}
mediaLibrarySession = mediaLibrarySession =
MediaLibrarySession.Builder(this, player, createLibrarySessionCallback()) MediaLibrarySession.Builder(this, player, createLibrarySessionCallback())
@ -126,6 +167,24 @@ open class DemoPlaybackService : MediaLibraryService() {
} }
} }
private fun storeCurrentMediaUidAndPosition() {
val mediaID = mediaLibrarySession.player.currentMediaItem?.mediaId
if (mediaID == null) {
return
}
val positionMs = mediaLibrarySession.player.currentPosition
CoroutineScope(Dispatchers.IO).launch {
PreferenceDataStore.get(this@DemoPlaybackService).updateData { _ ->
Preferences.newBuilder().setMediaId(mediaID).setPositionMs(positionMs).build()
}
}
}
suspend fun retrieveLastStoredMediaUidAndPosition(): Preferences? {
val preferences = PreferenceDataStore.get(this).data.first()
return if (preferences != Preferences.getDefaultInstance()) preferences else null
}
@OptIn(UnstableApi::class) // MediaSessionService.Listener @OptIn(UnstableApi::class) // MediaSessionService.Listener
private inner class MediaSessionServiceListener : Listener { private inner class MediaSessionServiceListener : Listener {

View File

@ -0,0 +1,28 @@
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
syntax = "proto3";
package androidx.media3.demo.session;
option java_package = "androidx.media3.demo.session";
option java_multiple_files = true;
message Preferences {
// The media id of the last played item.
string media_id = 1;
// The position in the last played item in milliseconds.
int64 position_ms = 2;
}