From a62af0387bb8b6ade93ae5f53ca13752b4e95b70 Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 29 Apr 2025 10:11:28 -0700 Subject: [PATCH] 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 --- demos/session/src/main/AndroidManifest.xml | 6 ++ .../src/main/AndroidManifest.xml | 6 ++ demos/session_service/build.gradle | 20 +++++++ .../DemoMediaLibrarySessionCallback.kt | 32 ++++++++-- .../demo/session/DemoPlaybackService.kt | 59 +++++++++++++++++++ .../src/main/proto/preferences.proto | 28 +++++++++ 6 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 demos/session_service/src/main/proto/preferences.proto diff --git a/demos/session/src/main/AndroidManifest.xml b/demos/session/src/main/AndroidManifest.xml index e6b8d37879..5a9f01a8f1 100644 --- a/demos/session/src/main/AndroidManifest.xml +++ b/demos/session/src/main/AndroidManifest.xml @@ -62,6 +62,12 @@ + + + + + + diff --git a/demos/session_automotive/src/main/AndroidManifest.xml b/demos/session_automotive/src/main/AndroidManifest.xml index e5b78628eb..9afdf3ac0e 100644 --- a/demos/session_automotive/src/main/AndroidManifest.xml +++ b/demos/session_automotive/src/main/AndroidManifest.xml @@ -64,6 +64,12 @@ android:authorities="androidx.media3" android:exported="true" /> + + + + + + diff --git a/demos/session_service/build.gradle b/demos/session_service/build.gradle index 6137e86524..fdd99e48f3 100644 --- a/demos/session_service/build.gradle +++ b/demos/session_service/build.gradle @@ -11,6 +11,9 @@ // 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. +plugins { + id "com.google.protobuf" version "0.9.5" +} apply from: "$gradle.ext.androidxMediaSettingsDir/common_library_config.gradle" 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 { implementation 'androidx.core:core-ktx:' + androidxCoreVersion 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-dash') implementation project(modulePrefix + 'lib-exoplayer-hls') diff --git a/demos/session_service/src/main/java/androidx/media3/demo/session/DemoMediaLibrarySessionCallback.kt b/demos/session_service/src/main/java/androidx/media3/demo/session/DemoMediaLibrarySessionCallback.kt index db86eed049..88a3d2069e 100644 --- a/demos/session_service/src/main/java/androidx/media3/demo/session/DemoMediaLibrarySessionCallback.kt +++ b/demos/session_service/src/main/java/androidx/media3/demo/session/DemoMediaLibrarySessionCallback.kt @@ -15,7 +15,6 @@ */ package androidx.media3.demo.session -import android.content.Context import android.os.Bundle import androidx.annotation.OptIn import androidx.media3.common.MediaItem @@ -32,23 +31,26 @@ import androidx.media3.session.SessionResult import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.Futures 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. */ -open class DemoMediaLibrarySessionCallback(context: Context) : +open class DemoMediaLibrarySessionCallback(val service: DemoPlaybackService) : MediaLibraryService.MediaLibrarySession.Callback { init { - MediaItemTree.initialize(context.assets) + MediaItemTree.initialize(service.assets) } private val commandButtons: List = listOf( 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)) .build(), 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)) .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 { + 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): List { val playlist = mutableListOf() mediaItems.forEach { mediaItem -> diff --git a/demos/session_service/src/main/java/androidx/media3/demo/session/DemoPlaybackService.kt b/demos/session_service/src/main/java/androidx/media3/demo/session/DemoPlaybackService.kt index f63b8678ee..8fa8f439c6 100644 --- a/demos/session_service/src/main/java/androidx/media3/demo/session/DemoPlaybackService.kt +++ b/demos/session_service/src/main/java/androidx/media3/demo/session/DemoPlaybackService.kt @@ -19,13 +19,19 @@ import android.Manifest import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent +import android.content.Context import android.content.pm.PackageManager import android.os.Build import androidx.annotation.OptIn import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat 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.Player +import androidx.media3.common.listen import androidx.media3.common.util.UnstableApi import androidx.media3.demo.session.service.R import androidx.media3.exoplayer.ExoPlayer @@ -34,6 +40,12 @@ import androidx.media3.session.MediaConstants import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaSession 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() { @@ -44,6 +56,25 @@ open class DemoPlaybackService : MediaLibraryService() { private const val CHANNEL_ID = "demo_session_notification_channel_id" } + object PreferenceDataStore { + private val Context._dataStore: DataStore by + dataStore( + fileName = "preferences.pb", + serializer = + object : Serializer { + 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 * active and an activity is in the fore or background. @@ -102,12 +133,22 @@ open class DemoPlaybackService : MediaLibraryService() { super.onDestroy() } + @OptIn(UnstableApi::class) // Player.listen private fun initializeSessionAndPlayer() { val player = ExoPlayer.Builder(this) .setAudioAttributes(AudioAttributes.DEFAULT, /* handleAudioFocus= */ true) .build() 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.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 private inner class MediaSessionServiceListener : Listener { diff --git a/demos/session_service/src/main/proto/preferences.proto b/demos/session_service/src/main/proto/preferences.proto new file mode 100644 index 0000000000..d83e95f8fc --- /dev/null +++ b/demos/session_service/src/main/proto/preferences.proto @@ -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; +}