diff --git a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/DefaultPlaybackController.java b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/DefaultPlaybackController.java index c3586b29e6..e01d6a48db 100644 --- a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/DefaultPlaybackController.java +++ b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/DefaultPlaybackController.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.ext.mediasession; +import android.os.Bundle; +import android.os.ResultReceiver; import android.support.v4.media.session.PlaybackStateCompat; import com.google.android.exoplayer2.C; @@ -125,4 +127,14 @@ public class DefaultPlaybackController implements MediaSessionConnector.Playback player.stop(); } + @Override + public String[] getCommands() { + return null; + } + + @Override + public void onCommand(Player player, String command, Bundle extras, ResultReceiver cb) { + // Do nothing. + } + } diff --git a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java index 3a4a80733d..a64f163733 100644 --- a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java +++ b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.java @@ -79,10 +79,24 @@ public final class MediaSessionConnector { private static final int EDITOR_MEDIA_SESSION_FLAGS = BASE_MEDIA_SESSION_FLAGS | MediaSessionCompat.FLAG_HANDLES_QUEUE_COMMANDS; + /** + * Receiver of media commands sent by a media controller. + */ + public interface CommandReceiver { + /** + * Returns the commands the receiver handles, or {@code null} if no commands need to be handled. + */ + String[] getCommands(); + /** + * See {@link MediaSessionCompat.Callback#onCommand(String, Bundle, ResultReceiver)}. + */ + void onCommand(Player player, String command, Bundle extras, ResultReceiver cb); + } + /** * Interface to which playback preparation actions are delegated. */ - public interface PlaybackPreparer { + public interface PlaybackPreparer extends CommandReceiver { long ACTIONS = PlaybackStateCompat.ACTION_PREPARE | PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID @@ -121,16 +135,12 @@ public final class MediaSessionConnector { * See {@link MediaSessionCompat.Callback#onPrepareFromUri(Uri, Bundle)}. */ void onPrepareFromUri(Uri uri, Bundle extras); - /** - * See {@link MediaSessionCompat.Callback#onCommand(String, Bundle, ResultReceiver)}. - */ - void onCommand(String command, Bundle extras, ResultReceiver cb); } /** * Interface to which playback actions are delegated. */ - public interface PlaybackController { + public interface PlaybackController extends CommandReceiver { long ACTIONS = PlaybackStateCompat.ACTION_PLAY_PAUSE | PlaybackStateCompat.ACTION_PLAY | PlaybackStateCompat.ACTION_PAUSE | PlaybackStateCompat.ACTION_SEEK_TO @@ -178,7 +188,7 @@ public final class MediaSessionConnector { * Handles queue navigation actions, and updates the media session queue by calling * {@code MediaSessionCompat.setQueue()}. */ - public interface QueueNavigator { + public interface QueueNavigator extends CommandReceiver { long ACTIONS = PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM | PlaybackStateCompat.ACTION_SKIP_TO_NEXT | PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS @@ -240,7 +250,7 @@ public final class MediaSessionConnector { /** * Handles media session queue edits. */ - public interface QueueEditor { + public interface QueueEditor extends CommandReceiver { long ACTIONS = PlaybackStateCompat.ACTION_SET_RATING; @@ -309,6 +319,7 @@ public final class MediaSessionConnector { private final ExoPlayerEventListener exoPlayerEventListener; private final MediaSessionCallback mediaSessionCallback; private final PlaybackController playbackController; + private final Map commandMap; private Player player; private CustomActionProvider[] customActionProviders; @@ -328,7 +339,7 @@ public final class MediaSessionConnector { * @param mediaSession The {@link MediaSessionCompat} to connect to. */ public MediaSessionConnector(MediaSessionCompat mediaSession) { - this(mediaSession, new DefaultPlaybackController()); + this(mediaSession, null); } /** @@ -350,7 +361,8 @@ public final class MediaSessionConnector { * instances passed to {@link #setPlayer(Player, PlaybackPreparer, CustomActionProvider...)}. * * @param mediaSession The {@link MediaSessionCompat} to connect to. - * @param playbackController A {@link PlaybackController} for handling playback actions. + * @param playbackController A {@link PlaybackController} for handling playback actions, or + * {@code null} if the connector should handle playback actions directly. * @param doMaintainMetadata Whether the connector should maintain the metadata of the session. If * {@code false}, you need to maintain the metadata of the media session yourself (provide at * least the duration to allow clients to show a progress bar). @@ -358,7 +370,8 @@ public final class MediaSessionConnector { public MediaSessionConnector(MediaSessionCompat mediaSession, PlaybackController playbackController, boolean doMaintainMetadata) { this.mediaSession = mediaSession; - this.playbackController = playbackController; + this.playbackController = playbackController != null ? playbackController + : new DefaultPlaybackController(); this.handler = new Handler(Looper.myLooper() != null ? Looper.myLooper() : Looper.getMainLooper()); this.doMaintainMetadata = doMaintainMetadata; @@ -367,6 +380,8 @@ public final class MediaSessionConnector { mediaSessionCallback = new MediaSessionCallback(); exoPlayerEventListener = new ExoPlayerEventListener(); customActionMap = Collections.emptyMap(); + commandMap = new HashMap<>(); + registerCommandReceiver(playbackController); } /** @@ -386,8 +401,12 @@ public final class MediaSessionConnector { this.player.removeListener(exoPlayerEventListener); mediaSession.setCallback(null); } - this.playbackPreparer = playbackPreparer; + unregisterCommandReceiver(this.playbackPreparer); + this.player = player; + this.playbackPreparer = playbackPreparer; + registerCommandReceiver(playbackPreparer); + this.customActionProviders = (player != null && customActionProviders != null) ? customActionProviders : new CustomActionProvider[0]; if (player != null) { @@ -416,7 +435,9 @@ public final class MediaSessionConnector { * @param queueNavigator The queue navigator. */ public void setQueueNavigator(QueueNavigator queueNavigator) { + unregisterCommandReceiver(this.queueNavigator); this.queueNavigator = queueNavigator; + registerCommandReceiver(queueNavigator); } /** @@ -425,11 +446,29 @@ public final class MediaSessionConnector { * @param queueEditor The queue editor. */ public void setQueueEditor(QueueEditor queueEditor) { + unregisterCommandReceiver(this.queueEditor); this.queueEditor = queueEditor; + registerCommandReceiver(queueEditor); mediaSession.setFlags(queueEditor == null ? BASE_MEDIA_SESSION_FLAGS : EDITOR_MEDIA_SESSION_FLAGS); } + private void registerCommandReceiver(CommandReceiver commandReceiver) { + if (commandReceiver != null && commandReceiver.getCommands() != null) { + for (String command : commandReceiver.getCommands()) { + commandMap.put(command, commandReceiver); + } + } + } + + private void unregisterCommandReceiver(CommandReceiver commandReceiver) { + if (commandReceiver != null && commandReceiver.getCommands() != null) { + for (String command : commandReceiver.getCommands()) { + commandMap.remove(command); + } + } + } + private void updateMediaSessionPlaybackState() { PlaybackStateCompat.Builder builder = new PlaybackStateCompat.Builder(); if (player == null) { @@ -473,11 +512,8 @@ public final class MediaSessionConnector { } private long buildPlaybackActions() { - long actions = 0; - if (playbackController != null) { - actions |= (PlaybackController.ACTIONS & playbackController - .getSupportedPlaybackActions(player)); - } + long actions = (PlaybackController.ACTIONS + & playbackController.getSupportedPlaybackActions(player)); if (playbackPreparer != null) { actions |= (PlaybackPreparer.ACTIONS & playbackPreparer.getSupportedPrepareActions()); } @@ -562,7 +598,7 @@ public final class MediaSessionConnector { } private boolean canDispatchToPlaybackController(long action) { - return playbackController != null && (playbackController.getSupportedPlaybackActions(player) + return (playbackController.getSupportedPlaybackActions(player) & PlaybackController.ACTIONS & action) != 0; } @@ -583,10 +619,15 @@ public final class MediaSessionConnector { @Override public void onTimelineChanged(Timeline timeline, Object manifest) { + int windowCount = player.getCurrentTimeline().getWindowCount(); + int windowIndex = player.getCurrentWindowIndex(); if (queueNavigator != null) { queueNavigator.onTimelineChanged(player); + updateMediaSessionPlaybackState(); + } else if (currentWindowCount != windowCount || currentWindowIndex != windowIndex) { + // active queue item and queue navigation actions may need to be updated + updateMediaSessionPlaybackState(); } - int windowCount = player.getCurrentTimeline().getWindowCount(); if (currentWindowCount != windowCount) { // active queue item and queue navigation actions may need to be updated updateMediaSessionPlaybackState(); @@ -638,8 +679,8 @@ public final class MediaSessionConnector { if (queueNavigator != null) { queueNavigator.onCurrentWindowIndexChanged(player); } - updateMediaSessionMetadata(); currentWindowIndex = player.getCurrentWindowIndex(); + updateMediaSessionMetadata(); } updateMediaSessionPlaybackState(); } @@ -732,8 +773,9 @@ public final class MediaSessionConnector { @Override public void onCommand(String command, Bundle extras, ResultReceiver cb) { - if (playbackPreparer != null) { - playbackPreparer.onCommand(command, extras, cb); + CommandReceiver commandReceiver = commandMap.get(command); + if (commandReceiver != null) { + commandReceiver.onCommand(player, command, extras, cb); } } diff --git a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueEditor.java b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueEditor.java new file mode 100644 index 0000000000..65090a3c1c --- /dev/null +++ b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueEditor.java @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2017 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. + */ +package com.google.android.exoplayer2.ext.mediasession; + +import android.os.Bundle; +import android.os.ResultReceiver; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.media.MediaDescriptionCompat; +import android.support.v4.media.RatingCompat; +import android.support.v4.media.session.MediaControllerCompat; +import android.support.v4.media.session.MediaSessionCompat; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.source.DynamicConcatenatingMediaSource; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.util.Util; +import java.util.List; + +/** + * A {@link MediaSessionConnector.QueueEditor} implementation based on the + * {@link DynamicConcatenatingMediaSource}. + *

+ * This class implements the {@link MediaSessionConnector.CommandReceiver} interface and handles + * the {@link #COMMAND_MOVE_QUEUE_ITEM} to move a queue item instead of removing and inserting it. + * This allows to move the currently playing window without interrupting playback. + */ +public final class TimelineQueueEditor implements MediaSessionConnector.QueueEditor, + MediaSessionConnector.CommandReceiver { + + public static final String COMMAND_MOVE_QUEUE_ITEM = "exo_move_window"; + public static final String EXTRA_FROM_INDEX = "from_index"; + public static final String EXTRA_TO_INDEX = "to_index"; + + /** + * Factory to create {@link MediaSource}s. + */ + public interface MediaSourceFactory { + /** + * Creates a {@link MediaSource} for the given {@link MediaDescriptionCompat}. + * + * @param description The {@link MediaDescriptionCompat} to create a media source for. + * @return A {@link MediaSource} or {@code null} if no source can be created for the given + * description. + */ + @Nullable MediaSource createMediaSource(MediaDescriptionCompat description); + } + + /** + * Adapter to get {@link MediaDescriptionCompat} of items in the queue and to notify the + * application about changes in the queue to sync the data structure backing the + * {@link MediaSessionConnector}. + */ + public interface QueueDataAdapter { + /** + * Gets the {@link MediaDescriptionCompat} for a {@code position}. + * + * @param position The position in the queue for which to provide a description. + * @return A {@link MediaDescriptionCompat}. + */ + MediaDescriptionCompat getMediaDescription(int position); + /** + * Adds a {@link MediaDescriptionCompat} at the given {@code position}. + * + * @param position The position at which to add. + * @param description The {@link MediaDescriptionCompat} to be added. + */ + void add(int position, MediaDescriptionCompat description); + /** + * Removes the item at the given {@code position}. + * + * @param position The position at which to remove the item. + */ + void remove(int position); + /** + * Moves a queue item from position {@code from} to position {@code to}. + * + * @param from The position from which to remove the item. + * @param to The target position to which to move the item. + */ + void move(int from, int to); + } + + /** + * Used to evaluate whether two {@link MediaDescriptionCompat} are considered equal. + */ + interface MediaDescriptionEqualityChecker { + /** + * Returns {@code true} whether the descriptions are considered equal. + * + * @param d1 The first {@link MediaDescriptionCompat}. + * @param d2 The second {@link MediaDescriptionCompat}. + * @return {@code true} if considered equal. + */ + boolean equals(MediaDescriptionCompat d1, MediaDescriptionCompat d2); + } + + /** + * Media description comparator comparing the media IDs. Media IDs are considered equals if both + * are {@code null}. + */ + public static final class MediaIdEqualityChecker implements MediaDescriptionEqualityChecker { + + @Override + public boolean equals(MediaDescriptionCompat d1, MediaDescriptionCompat d2) { + return Util.areEqual(d1.getMediaId(), d2.getMediaId()); + } + + } + + private final MediaControllerCompat mediaController; + private final QueueDataAdapter queueDataAdapter; + private final MediaSourceFactory sourceFactory; + private final MediaDescriptionEqualityChecker equalityChecker; + private final DynamicConcatenatingMediaSource queueMediaSource; + + /** + * Creates a new {@link TimelineQueueEditor} with a given mediaSourceFactory. + * + * @param mediaController A {@link MediaControllerCompat} to read the current queue. + * @param queueMediaSource The {@link DynamicConcatenatingMediaSource} to + * manipulate. + * @param queueDataAdapter A {@link QueueDataAdapter} to change the backing data. + * @param sourceFactory The {@link MediaSourceFactory} to build media sources. + */ + public TimelineQueueEditor(@NonNull MediaControllerCompat mediaController, + @NonNull DynamicConcatenatingMediaSource queueMediaSource, + @NonNull QueueDataAdapter queueDataAdapter, @NonNull MediaSourceFactory sourceFactory) { + this(mediaController, queueMediaSource, queueDataAdapter, sourceFactory, + new MediaIdEqualityChecker()); + } + + /** + * Creates a new {@link TimelineQueueEditor} with a given mediaSourceFactory. + * + * @param mediaController A {@link MediaControllerCompat} to read the current queue. + * @param queueMediaSource The {@link DynamicConcatenatingMediaSource} to + * manipulate. + * @param queueDataAdapter A {@link QueueDataAdapter} to change the backing data. + * @param sourceFactory The {@link MediaSourceFactory} to build media sources. + * @param equalityChecker The {@link MediaDescriptionEqualityChecker} to match queue items. + */ + public TimelineQueueEditor(@NonNull MediaControllerCompat mediaController, + @NonNull DynamicConcatenatingMediaSource queueMediaSource, + @NonNull QueueDataAdapter queueDataAdapter, @NonNull MediaSourceFactory sourceFactory, + @NonNull MediaDescriptionEqualityChecker equalityChecker) { + this.mediaController = mediaController; + this.queueMediaSource = queueMediaSource; + this.queueDataAdapter = queueDataAdapter; + this.sourceFactory = sourceFactory; + this.equalityChecker = equalityChecker; + } + + @Override + public long getSupportedQueueEditorActions(@Nullable Player player) { + return 0; + } + + @Override + public void onAddQueueItem(Player player, MediaDescriptionCompat description) { + onAddQueueItem(player, description, player.getCurrentTimeline().getWindowCount()); + } + + @Override + public void onAddQueueItem(Player player, MediaDescriptionCompat description, int index) { + MediaSource mediaSource = sourceFactory.createMediaSource(description); + if (mediaSource != null) { + queueDataAdapter.add(index, description); + queueMediaSource.addMediaSource(index, mediaSource); + } + } + + @Override + public void onRemoveQueueItem(Player player, MediaDescriptionCompat description) { + List queue = mediaController.getQueue(); + for (int i = 0; i < queue.size(); i++) { + if (equalityChecker.equals(queue.get(i).getDescription(), description)) { + onRemoveQueueItemAt(player, i); + return; + } + } + } + + @Override + public void onRemoveQueueItemAt(Player player, int index) { + queueDataAdapter.remove(index); + queueMediaSource.removeMediaSource(index); + } + + @Override + public void onSetRating(Player player, RatingCompat rating) { + // Do nothing. + } + + // CommandReceiver implementation. + + @NonNull + @Override + public String[] getCommands() { + return new String[] {COMMAND_MOVE_QUEUE_ITEM}; + } + + @Override + public void onCommand(Player player, String command, Bundle extras, ResultReceiver cb) { + int from = extras.getInt(EXTRA_FROM_INDEX, C.INDEX_UNSET); + int to = extras.getInt(EXTRA_TO_INDEX, C.INDEX_UNSET); + if (from != C.INDEX_UNSET && to != C.INDEX_UNSET) { + queueDataAdapter.move(from, to); + queueMediaSource.moveMediaSource(from, to); + } + } + +} diff --git a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueNavigator.java b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueNavigator.java index 8c7d3be114..777949863d 100644 --- a/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueNavigator.java +++ b/extensions/mediasession/src/main/java/com/google/android/exoplayer2/ext/mediasession/TimelineQueueNavigator.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.ext.mediasession; +import android.os.Bundle; +import android.os.ResultReceiver; import android.support.annotation.Nullable; import android.support.v4.media.MediaDescriptionCompat; import android.support.v4.media.session.MediaSessionCompat; @@ -164,6 +166,18 @@ public abstract class TimelineQueueNavigator implements MediaSessionConnector.Qu player.setShuffleModeEnabled(shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL); } + // CommandReceiver implementation. + + @Override + public String[] getCommands() { + return null; + } + + @Override + public void onCommand(Player player, String command, Bundle extras, ResultReceiver cb) { + // Do nothing. + } + private void publishFloatingQueueWindow(Player player) { if (player.getCurrentTimeline().isEmpty()) { mediaSession.setQueue(Collections.emptyList());