mirror of
https://github.com/androidx/media.git
synced 2025-04-30 06:46:50 +08:00
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:
parent
965fc81f08
commit
a62af0387b
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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')
|
||||||
|
@ -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 ->
|
||||||
|
@ -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 {
|
||||||
|
|
||||||
|
28
demos/session_service/src/main/proto/preferences.proto
Normal file
28
demos/session_service/src/main/proto/preferences.proto
Normal 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;
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user