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