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;
+}