|
| 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 | +} |
0 commit comments