Skip to content

Commit eb35308

Browse files
authored
Receiver notify empty (#1200)
1 parent d8c1a14 commit eb35308

12 files changed

Lines changed: 690 additions & 823 deletions

File tree

pillarbox-cast-receiver/build.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ plugins {
99
alias(libs.plugins.pillarbox.android.library.tested.module)
1010
}
1111

12+
android {
13+
buildFeatures {
14+
buildConfig = true
15+
}
16+
}
17+
1218
dependencies {
1319
implementation(project(":pillarbox-cast"))
1420
api(project(":pillarbox-player"))
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
* Copyright (c) SRG SSR. All rights reserved.
3+
* License information is available from the LICENSE file.
4+
*/
5+
package ch.srgssr.pillarbox.cast.receiver
6+
7+
import android.util.Log
8+
import com.google.android.gms.cast.MediaInfo
9+
import com.google.android.gms.cast.MediaLiveSeekableRange
10+
import com.google.android.gms.cast.MediaMetadata
11+
import com.google.android.gms.cast.MediaQueueData
12+
import com.google.android.gms.cast.MediaQueueItem
13+
import com.google.android.gms.cast.MediaStatus
14+
import com.google.android.gms.cast.tv.media.MediaManager
15+
import com.google.android.gms.cast.tv.media.MediaStatusWriter
16+
17+
/**
18+
* An interceptor that log only the final [MediaStatus].
19+
*/
20+
object LogMediaStatusInterceptor : MediaManager.MediaStatusInterceptor {
21+
private const val TAG = "MediaStatusInterceptor"
22+
23+
override fun intercept(mediaStatusWriter: MediaStatusWriter) {
24+
val mediaStatus = mediaStatusWriter.mediaStatus
25+
with(mediaStatus) {
26+
Log.d(TAG, "MediaStatus:")
27+
Log.d(TAG, " currentItemId = ${this.currentItemId}")
28+
Log.d(TAG, " queueItems = #$queueItemCount - ${queueItems.map { it.toPrettyString() }}")
29+
Log.d(TAG, " queueData = ${queueData?.prettyString()}")
30+
Log.d(TAG, " state = ${playbackStateString(playerState)}")
31+
Log.d(TAG, " streamPosition = $streamPosition")
32+
Log.d(TAG, " seekableLiveRange = ${liveSeekableRange?.prettyString()}")
33+
Log.d(TAG, " playbackRate = $playbackRate")
34+
Log.d(TAG, " mediaInfo = ${mediaInfo?.prettyString()}")
35+
Log.d(TAG, "--------------------------------------------------------------------------")
36+
}
37+
}
38+
39+
private fun playbackStateString(playbackState: Int): String {
40+
return when (playbackState) {
41+
MediaStatus.PLAYER_STATE_IDLE -> "PLAYER_STATE_IDLE"
42+
MediaStatus.PLAYER_STATE_BUFFERING -> "PLAYER_STATE_BUFFERING"
43+
MediaStatus.PLAYER_STATE_LOADING -> "PLAYER_STATE_LOADING"
44+
MediaStatus.PLAYER_STATE_PAUSED -> "PLAYER_STATE_PAUSED"
45+
MediaStatus.PLAYER_STATE_PLAYING -> "PLAYER_STATE_PLAYING"
46+
else -> "PLAYER_STATE_UNKNOWN"
47+
}
48+
}
49+
50+
private fun streamTypeString(streamType: Int): String {
51+
return when (streamType) {
52+
MediaInfo.STREAM_TYPE_BUFFERED -> "STREAM_TYPE_BUFFERED"
53+
MediaInfo.STREAM_TYPE_LIVE -> "STREAM_TYPE_LIVE"
54+
MediaInfo.STREAM_TYPE_NONE -> "STREAM_TYPE_NONE"
55+
MediaInfo.STREAM_TYPE_INVALID -> "STREAM_TYPE_INVALID"
56+
else -> "Invalid streamType value"
57+
}
58+
}
59+
60+
private fun MediaQueueData.prettyString(): String {
61+
return "MediaQueue name = $name items = ${items?.map { it.toPrettyString() }}"
62+
}
63+
64+
private fun MediaQueueItem.toPrettyString(): String {
65+
return "QueueItem[$itemId] Media = contentId = ${media?.contentId} contentUrl = ${media?.contentUrl} metadata = ${
66+
media?.metadata?.getString(
67+
MediaMetadata.KEY_TITLE
68+
)
69+
}"
70+
}
71+
72+
private fun MediaInfo.prettyString(): String {
73+
return "contentId = $contentId contentUrl = $contentUrl metadata = ${metadata?.prettyString()} streamDuration = $streamDuration ${
74+
streamTypeString(
75+
streamType
76+
)
77+
}"
78+
}
79+
80+
private fun MediaMetadata.prettyString(): String {
81+
return "title = ${getString(MediaMetadata.KEY_TITLE)}"
82+
}
83+
84+
private fun MediaLiveSeekableRange.prettyString(): String {
85+
return "isMovingWindow = $isMovingWindow [$startTime - $endTime] isLiveDone = $isLiveDone"
86+
}
87+
}
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
/*
2+
* Copyright (c) SRG SSR. All rights reserved.
3+
* License information is available from the LICENSE file.
4+
*/
5+
package ch.srgssr.pillarbox.cast.receiver
6+
7+
import android.util.Log
8+
import androidx.media3.cast.MediaItemConverter
9+
import androidx.media3.common.C
10+
import androidx.media3.common.Player
11+
import androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM
12+
import androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM
13+
import ch.srgssr.pillarbox.cast.PillarboxCastUtil
14+
import ch.srgssr.pillarbox.cast.TracksConverter
15+
import ch.srgssr.pillarbox.cast.receiver.extensions.contains
16+
import ch.srgssr.pillarbox.cast.receiver.extensions.getItemIndex
17+
import ch.srgssr.pillarbox.cast.receiver.extensions.getItemIndexOrNull
18+
import ch.srgssr.pillarbox.cast.receiver.extensions.insert
19+
import ch.srgssr.pillarbox.cast.receiver.extensions.move
20+
import ch.srgssr.pillarbox.cast.receiver.extensions.queueSize
21+
import ch.srgssr.pillarbox.cast.receiver.extensions.remove
22+
import ch.srgssr.pillarbox.player.extension.setTrackOverride
23+
import ch.srgssr.pillarbox.player.tracks.disableTextTrack
24+
import ch.srgssr.pillarbox.player.tracks.setAutoAudioTrack
25+
import ch.srgssr.pillarbox.player.tracks.setAutoVideoTrack
26+
import com.google.android.gms.cast.MediaQueueItem
27+
import com.google.android.gms.cast.MediaTrack
28+
import com.google.android.gms.cast.tv.CastReceiverContext
29+
import com.google.android.gms.cast.tv.media.FetchItemsRequestData
30+
import com.google.android.gms.cast.tv.media.MediaCommandCallback
31+
import com.google.android.gms.cast.tv.media.MediaManager
32+
import com.google.android.gms.cast.tv.media.MediaQueueManager
33+
import com.google.android.gms.cast.tv.media.QueueInsertRequestData
34+
import com.google.android.gms.cast.tv.media.QueueRemoveRequestData
35+
import com.google.android.gms.cast.tv.media.QueueReorderRequestData
36+
import com.google.android.gms.cast.tv.media.QueueUpdateRequestData
37+
import com.google.android.gms.cast.tv.media.SetPlaybackRateRequestData
38+
import com.google.android.gms.tasks.Task
39+
import com.google.android.gms.tasks.Tasks
40+
import java.util.Collections
41+
import kotlin.collections.forEach
42+
import kotlin.math.absoluteValue
43+
44+
/**
45+
* Callback for Cast media commands.
46+
* This class has the default implementation to call methods of the MediaSession which MediaManager currently attaches to.
47+
* https://developers.google.com/android/reference/com/google/android/gms/cast/tv/media/MediaCommandCallback
48+
*/
49+
internal class MediaCommandCallbackImpl(
50+
private val player: Player,
51+
private val mediaItemConverter: MediaItemConverter,
52+
private val tracksConverter: TracksConverter,
53+
private val mediaManager: MediaManager = CastReceiverContext.getInstance().mediaManager,
54+
private val mediaQueueManager: MediaQueueManager = mediaManager.mediaQueueManager,
55+
) : MediaCommandCallback() {
56+
57+
override fun onQueueInsert(senderId: String?, requestData: QueueInsertRequestData): Task<Void?> {
58+
with(requestData) {
59+
Log.d(
60+
TAG,
61+
"onQueueInsert insertBefore = $insertBefore items = $items currentItemId = $currentItemId currentItemIndex = $currentItemIndex"
62+
)
63+
}
64+
val insertIndex = requestData.insertBefore?.let {
65+
mediaQueueManager.getItemIndexOrNull(it)
66+
} ?: Int.MAX_VALUE
67+
mediaQueueManager.insert(requestData.items, insertIndex)
68+
player.addMediaItems(insertIndex, requestData.items.map { mediaItemConverter.toMediaItem(it) })
69+
mediaQueueManager.notifyItemsInserted(requestData.items.map { item -> item.itemId }, requestData.insertBefore)
70+
mediaManager.broadcastMediaStatus()
71+
return voidTask()
72+
}
73+
74+
override fun onQueueRemove(senderId: String?, requestData: QueueRemoveRequestData): Task<Void?> {
75+
with(requestData) {
76+
Log.d(TAG, "onQueueRemove itemIds = $itemIds currentItemId = $currentItemId currentTime = $currentTime")
77+
}
78+
val removedItemIds = mutableListOf<Int>()
79+
check(mediaQueueManager.queueSize == player.mediaItemCount)
80+
requestData.itemIds.forEach { itemId ->
81+
mediaQueueManager.getItemIndexOrNull(itemId)?.let { index ->
82+
mediaQueueManager.remove(index)
83+
player.removeMediaItem(index)
84+
removedItemIds.add(itemId)
85+
}
86+
}
87+
mediaQueueManager.notifyItemsRemoved(removedItemIds)
88+
mediaManager.broadcastMediaStatus()
89+
return voidTask()
90+
}
91+
92+
/**
93+
* Call when sender queueReorder
94+
* https://developers.google.com/android/reference/com/google/android/gms/cast/framework/media/RemoteMediaClient#queueReorderItems(int[],%20int,%20org.json.JSONObject)
95+
*/
96+
override fun onQueueReorder(senderId: String?, requestData: QueueReorderRequestData): Task<Void?> {
97+
with(requestData) {
98+
Log.d(TAG, "onQueueReorder itemIds = $itemIds insertBefore = $insertBefore")
99+
}
100+
check(mediaQueueManager.queueSize == player.mediaItemCount)
101+
val insertBeforeId = requestData.insertBefore ?: MediaQueueItem.INVALID_ITEM_ID
102+
if (insertBeforeId != MediaQueueItem.INVALID_ITEM_ID && mediaQueueManager.contains(insertBeforeId)) {
103+
reorderQueueItemsBeforeItemId(insertBeforeId, requestData.itemIds)
104+
} else {
105+
moveAtTheEndOfTheQueue(requestData.itemIds)
106+
}
107+
mediaQueueManager.notifyQueueFullUpdate()
108+
mediaManager.broadcastMediaStatus()
109+
return super.onQueueReorder(senderId, requestData)
110+
}
111+
112+
/*
113+
* [A,D,G,H,B,E] reorder at the end [D,H,B] => [A,G,E,D,H,B]
114+
*/
115+
private fun moveAtTheEndOfTheQueue(itemIds: List<Int>) {
116+
itemIds.forEach { itemId ->
117+
mediaQueueManager.getItemIndexOrNull(itemId)?.let { index ->
118+
mediaQueueManager.move(index, index + 1, player.mediaItemCount)
119+
player.moveMediaItem(index, player.mediaItemCount)
120+
}
121+
}
122+
}
123+
124+
private fun reorderQueueItemsBeforeItemId(insertBeforeId: Int, itemIds: List<Int>) {
125+
itemIds.forEach { itemId ->
126+
val index = mediaQueueManager.getItemIndex(itemId)
127+
val insertBeforeIndex = mediaQueueManager.getItemIndex(insertBeforeId)
128+
if (index >= 0 && insertBeforeIndex >= 0) {
129+
val indexToMove = if (index > insertBeforeIndex) insertBeforeIndex else (insertBeforeIndex - 1).coerceAtLeast(0)
130+
mediaQueueManager.move(index, index + 1, indexToMove)
131+
player.moveMediaItem(index, indexToMove)
132+
}
133+
}
134+
}
135+
136+
override fun onQueueUpdate(senderId: String?, requestData: QueueUpdateRequestData): Task<Void?> {
137+
with(requestData) {
138+
Log.d(
139+
TAG,
140+
"onQueueUpdate items = $items jump from $currentItemId to $jump " +
141+
"shuffle = $shuffle repeatMode = $repeatMode"
142+
)
143+
}
144+
requestData.shuffle?.let {
145+
shuffle()
146+
}
147+
requestData.repeatMode?.let {
148+
player.repeatMode = PillarboxCastUtil.getRepeatModeFromQueueRepeatMode(it)
149+
}
150+
requestData.jump.takeIf { it != 0 }?.let { jump ->
151+
repeat(jump.absoluteValue) {
152+
if (jump < 0 && player.isCommandAvailable(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)) {
153+
player.seekToPreviousMediaItem()
154+
}
155+
if (jump > 0 && player.isCommandAvailable(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)) {
156+
player.seekToNextMediaItem()
157+
}
158+
}
159+
}
160+
161+
requestData.currentItemId?.let { currentItemId ->
162+
mediaQueueManager.getItemIndexOrNull(currentItemId)?.let { i ->
163+
player.seekTo(i, C.TIME_UNSET)
164+
}
165+
}
166+
167+
// Do not call super method, jump is handled in it but not other features.
168+
return voidTask()
169+
}
170+
171+
private fun shuffle() {
172+
if (mediaQueueManager.queueItems.isNullOrEmpty()) return
173+
val queueItems = checkNotNull(mediaQueueManager.queueItems)
174+
val queueItemIds = queueItems.map { item -> item.itemId }
175+
Collections.shuffle(queueItemIds)
176+
moveAtTheEndOfTheQueue(queueItemIds)
177+
mediaQueueManager.notifyQueueFullUpdate()
178+
mediaManager.broadcastMediaStatus()
179+
}
180+
181+
override fun onFetchItems(p0: String?, requestData: FetchItemsRequestData): Task<Void?> {
182+
with(requestData) {
183+
Log.d(TAG, "onFetchItems fetch $itemId nextCount = $nextCount previousCount = $prevCount")
184+
}
185+
return super.onFetchItems(p0, requestData)
186+
}
187+
188+
override fun onSelectTracksByType(
189+
senderId: String?,
190+
type: Int,
191+
mediaTracks: List<MediaTrack>
192+
): Task<Void?> {
193+
Log.d(TAG, "onSelectTracksByType: type = $type tracks = ${mediaTracks.map { it.id }}")
194+
val tracksInfo = tracksConverter.toCastTracksInfo(player.currentTracks)
195+
mediaTracks.forEach { mediaTrack ->
196+
val trackIndex = tracksInfo.mediaTracks.indexOfFirst { mediaTrack.id == it.id }
197+
if (trackIndex >= 0) {
198+
val trackOverride = tracksInfo.trackSelectionOverrides[trackIndex]
199+
player.setTrackOverride(trackOverride)
200+
}
201+
}
202+
// Empty means automatic tracks or disable the track? When using ExoPlayer clicking in audio "auto" it will disable track
203+
if (mediaTracks.isEmpty()) {
204+
when (type) {
205+
MediaTrack.TYPE_AUDIO -> player.setAutoAudioTrack() // Auto when not specific track is selected
206+
MediaTrack.TYPE_TEXT -> player.disableTextTrack() // Disabled when no track selected.
207+
MediaTrack.TYPE_VIDEO -> player.setAutoVideoTrack() // Auto when not specific track is selected
208+
}
209+
}
210+
return voidTask()
211+
}
212+
213+
override fun onSetPlaybackRate(senderId: String?, requestData: SetPlaybackRateRequestData): Task<Void?> {
214+
with(requestData) {
215+
Log.d(TAG, "onSetPlaybackRate: rate = $playbackRate relative = $relativePlaybackRate")
216+
}
217+
val relativePlaybackRate = requestData.relativePlaybackRate
218+
val newSpeed = if (relativePlaybackRate != null) {
219+
(player.playbackParameters.speed * relativePlaybackRate).toFloat()
220+
} else {
221+
requestData.playbackRate?.toFloat()?.takeIf { it > 0f }
222+
}
223+
224+
newSpeed?.let {
225+
player.setPlaybackSpeed(it)
226+
}
227+
return voidTask()
228+
}
229+
230+
companion object {
231+
private const val TAG = "MediaCommandCallback"
232+
private fun voidTask() = Tasks.forResult<Void?>(null)
233+
}
234+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
* Copyright (c) SRG SSR. All rights reserved.
3+
* License information is available from the LICENSE file.
4+
*/
5+
package ch.srgssr.pillarbox.cast.receiver
6+
7+
import android.util.Log
8+
import androidx.media3.cast.MediaItemConverter
9+
import androidx.media3.common.C
10+
import androidx.media3.common.Player
11+
import com.google.android.gms.cast.MediaLoadRequestData
12+
import com.google.android.gms.cast.MediaQueueItem
13+
import com.google.android.gms.cast.tv.media.MediaLoadCommandCallback
14+
import com.google.android.gms.cast.tv.media.MediaManager
15+
import com.google.android.gms.tasks.Task
16+
import com.google.android.gms.tasks.Tasks
17+
18+
/**
19+
* Handle load media request from sender.
20+
*
21+
* @link https://developers.google.com/android/reference/com/google/android/gms/cast/tv/media/MediaLoadCommandCallback
22+
*/
23+
internal class MediaLoadCommandCallbackImpl(
24+
private val mediaManager: MediaManager,
25+
private val player: Player,
26+
private val mediaItemConverter: MediaItemConverter,
27+
) : MediaLoadCommandCallback() {
28+
29+
override fun onLoad(senderId: String?, loadRequest: MediaLoadRequestData): Task<MediaLoadRequestData?> {
30+
Log.d(TAG, "onLoad from $senderId #items = ${loadRequest.queueData?.items?.size} startIndex = ${loadRequest.queueData?.startIndex}")
31+
/*
32+
* setDataFromLoad configures
33+
* - MediaQueueManager and setup MediaQueueItem ids
34+
* - MediaStatus is also modified data from the loadRequest.
35+
*/
36+
mediaManager.setDataFromLoad(loadRequest)
37+
loadRequest.queueData?.let { queueData ->
38+
val positionMs = if (loadRequest.currentTime < 0) C.TIME_UNSET else loadRequest.currentTime
39+
val startIndex = if (queueData.startIndex < 0) C.INDEX_UNSET else queueData.startIndex
40+
player.setMediaItems(queueData.items.orEmpty().map(mediaItemConverter::toMediaItem), startIndex, positionMs)
41+
} ?: loadRequest.mediaInfo?.let { mediaInfo ->
42+
val mediaQueueItem = MediaQueueItem.Builder(mediaInfo)
43+
.build()
44+
val positionMs = if (loadRequest.currentTime < 0) C.TIME_UNSET else loadRequest.currentTime
45+
player.setMediaItem(mediaItemConverter.toMediaItem(mediaQueueItem), positionMs)
46+
}
47+
val playbackRate = loadRequest.playbackRate.toFloat()
48+
if (playbackRate > 0f) {
49+
player.setPlaybackSpeed(playbackRate)
50+
}
51+
player.playWhenReady = loadRequest.autoplay == true
52+
player.prepare()
53+
return Tasks.forResult(loadRequest)
54+
}
55+
56+
companion object {
57+
private const val TAG = "MediaLoadCallback"
58+
}
59+
}

0 commit comments

Comments
 (0)