| // Copyright 2017 The Cobalt Authors. All Rights Reserved. |
| // |
| // 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 dev.cobalt.media; |
| |
| import static dev.cobalt.media.Log.TAG; |
| |
| import android.app.Activity; |
| import android.content.Context; |
| import android.graphics.Bitmap; |
| import android.media.AudioAttributes; |
| import android.media.AudioAttributes.Builder; |
| import android.media.AudioFocusRequest; |
| import android.media.AudioManager; |
| import android.os.Build; |
| import android.os.Handler; |
| import android.os.Looper; |
| import android.support.v4.media.MediaMetadataCompat; |
| import android.support.v4.media.session.MediaSessionCompat; |
| import android.support.v4.media.session.PlaybackStateCompat; |
| import android.view.WindowManager; |
| import androidx.annotation.RequiresApi; |
| import dev.cobalt.util.Holder; |
| import dev.cobalt.util.Log; |
| |
| /** |
| * Cobalt MediaSession glue, as well as collection of state and logic to switch on/off Android OS |
| * features used in media playback, such as audio focus, "KEEP_SCREEN_ON" mode, and "visible |
| * behind". |
| */ |
| public class CobaltMediaSession |
| implements AudioManager.OnAudioFocusChangeListener, ArtworkLoader.Callback { |
| |
| // We do handle transport controls and set this flag on all API levels, even though it's |
| // deprecated and unnecessary on API 26+. |
| @SuppressWarnings("deprecation") |
| private static final int MEDIA_SESSION_FLAG_HANDLES_TRANSPORT_CONTROLS = |
| MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS; |
| |
| private AudioFocusRequest audioFocusRequest; |
| |
| interface UpdateVolumeListener { |
| /** Called when there is a change in audio focus. */ |
| void onUpdateVolume(float gain); |
| } |
| |
| /** |
| * When losing audio focus with the option of ducking, we reduce the volume to 10%. This arbitrary |
| * number is what YouTube Android Player infrastructure uses. |
| */ |
| private static final float AUDIO_FOCUS_DUCK_LEVEL = 0.1f; |
| |
| private final Handler mainHandler = new Handler(Looper.getMainLooper()); |
| |
| private final Context context; |
| private final Holder<Activity> activityHolder; |
| |
| private final UpdateVolumeListener volumeListener; |
| private final ArtworkLoader artworkLoader; |
| private MediaSessionCompat mediaSession; |
| |
| // We re-use the builder to hold onto the most recent playback state. |
| private PlaybackStateCompat.Builder playbackStateBuilder = new PlaybackStateCompat.Builder(); |
| |
| // Duplicated in starboard/android/shared/android_media_session_client.h |
| // PlaybackStateCompat |
| private static final int PLAYBACK_STATE_PLAYING = 0; |
| private static final int PLAYBACK_STATE_PAUSED = 1; |
| private static final int PLAYBACK_STATE_NONE = 2; |
| private static final String[] PLAYBACK_STATE_NAME = {"playing", "paused", "none"}; |
| |
| // Accessed on the main looper thread only. |
| private int currentPlaybackState = PLAYBACK_STATE_NONE; |
| private boolean transientPause = false; |
| private boolean suspended = true; |
| private boolean explicitUserActionRequired = false; |
| |
| /** LifecycleCallback to notify listeners when |mediaSession| becomes active or inactive. */ |
| public interface LifecycleCallback { |
| void onMediaSessionLifecycle(boolean isActive, MediaSessionCompat.Token token); |
| } |
| |
| private LifecycleCallback lifecycleCallback = null; |
| |
| /** We use the MediaMetadata to hold onto the most recent metadata and add artwork later. */ |
| public class MediaMetadata { |
| public String title = ""; |
| public String artist = ""; |
| public String album = ""; |
| public Bitmap artwork = null; |
| public long duration = (long) 0.0; |
| |
| public void SetMetadata( |
| String title, String artist, String album, Bitmap artwork, long duration) { |
| this.title = title; |
| this.artist = artist; |
| this.album = album; |
| this.artwork = artwork; |
| this.duration = duration; |
| } |
| } |
| |
| private MediaMetadata metadata = new MediaMetadata(); |
| |
| public CobaltMediaSession( |
| Context context, Holder<Activity> activityHolder, UpdateVolumeListener volumeListener) { |
| this.context = context; |
| this.activityHolder = activityHolder; |
| |
| this.volumeListener = volumeListener; |
| artworkLoader = new ArtworkLoader(this); |
| setMediaSession(); |
| } |
| |
| public void setLifecycleCallback(LifecycleCallback lifecycleCallback) { |
| this.lifecycleCallback = lifecycleCallback; |
| if (lifecycleCallback != null) { |
| lifecycleCallback.onMediaSessionLifecycle( |
| this.mediaSession.isActive(), this.mediaSession.getSessionToken()); |
| } |
| } |
| |
| private void setMediaSession() { |
| Log.i(TAG, "MediaSession new"); |
| if (mediaSession == null) { |
| mediaSession = new MediaSessionCompat(context, TAG); |
| mediaSession.setFlags(MEDIA_SESSION_FLAG_HANDLES_TRANSPORT_CONTROLS); |
| mediaSession.setCallback( |
| new MediaSessionCompat.Callback() { |
| @Override |
| public void onFastForward() { |
| Log.i(TAG, "MediaSession action: FAST FORWARD"); |
| explicitUserActionRequired = false; |
| nativeInvokeAction(PlaybackStateCompat.ACTION_FAST_FORWARD); |
| } |
| |
| @Override |
| public void onPause() { |
| Log.i(TAG, "MediaSession action: PAUSE"); |
| nativeInvokeAction(PlaybackStateCompat.ACTION_PAUSE); |
| } |
| |
| @Override |
| public void onPlay() { |
| Log.i(TAG, "MediaSession action: PLAY"); |
| explicitUserActionRequired = false; |
| nativeInvokeAction(PlaybackStateCompat.ACTION_PLAY); |
| } |
| |
| @Override |
| public void onRewind() { |
| Log.i(TAG, "MediaSession action: REWIND"); |
| explicitUserActionRequired = false; |
| nativeInvokeAction(PlaybackStateCompat.ACTION_REWIND); |
| } |
| |
| @Override |
| public void onSkipToNext() { |
| Log.i(TAG, "MediaSession action: SKIP NEXT"); |
| explicitUserActionRequired = false; |
| nativeInvokeAction(PlaybackStateCompat.ACTION_SKIP_TO_NEXT); |
| } |
| |
| @Override |
| public void onSkipToPrevious() { |
| Log.i(TAG, "MediaSession action: SKIP PREVIOUS"); |
| explicitUserActionRequired = false; |
| nativeInvokeAction(PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS); |
| } |
| |
| @Override |
| public void onSeekTo(long pos) { |
| Log.i(TAG, "MediaSession action: SEEK " + pos); |
| explicitUserActionRequired = false; |
| nativeInvokeAction(PlaybackStateCompat.ACTION_SEEK_TO, pos); |
| } |
| |
| @Override |
| public void onStop() { |
| Log.i(TAG, "MediaSession action: STOP"); |
| nativeInvokeAction(PlaybackStateCompat.ACTION_STOP); |
| } |
| }); |
| } |
| // |metadataBuilder| may still have no fields at this point, yielding empty metadata. |
| MediaMetadataCompat.Builder metadataBuilder = new MediaMetadataCompat.Builder(); |
| mediaSession.setMetadata(metadataBuilder.build()); |
| // |playbackStateBuilder| may still have no fields at this point. |
| mediaSession.setPlaybackState(playbackStateBuilder.build()); |
| } |
| |
| private static void checkMainLooperThread() { |
| if (Looper.getMainLooper() != Looper.myLooper()) { |
| throw new RuntimeException("Must be on main thread"); |
| } |
| } |
| |
| /** |
| * Sets system media resources active or not according to whether media is playing. The concept of |
| * "media focus" encapsulates wake lock, audio focus and active media session so that all three |
| * are set together to stay coherent as playback state changes. This is idempotent as it may be |
| * called multiple times during the course of a media session. |
| */ |
| private void configureMediaFocus(int playbackState) { |
| checkMainLooperThread(); |
| if (transientPause && playbackState == PLAYBACK_STATE_PAUSED) { |
| Log.i(TAG, "Media focus: paused (transient)"); |
| // Don't release media focus while transiently paused, otherwise we won't get audiofocus back |
| // when the transient condition ends and we would leave playback paused. |
| return; |
| } |
| Log.i(TAG, "Media focus: " + PLAYBACK_STATE_NAME[playbackState]); |
| wakeLock(playbackState == PLAYBACK_STATE_PLAYING); |
| audioFocus(playbackState == PLAYBACK_STATE_PLAYING); |
| |
| boolean activating = true; |
| boolean deactivating = false; |
| if (mediaSession != null) { |
| activating = playbackState != PLAYBACK_STATE_NONE && !mediaSession.isActive(); |
| deactivating = playbackState == PLAYBACK_STATE_NONE && mediaSession.isActive(); |
| } |
| if (activating) { |
| // Resuming or new playbacks land here. |
| setMediaSession(); |
| } |
| mediaSession.setActive(playbackState != PLAYBACK_STATE_NONE); |
| if (lifecycleCallback != null) { |
| lifecycleCallback.onMediaSessionLifecycle( |
| this.mediaSession.isActive(), this.mediaSession.getSessionToken()); |
| } |
| if (deactivating) { |
| // Suspending lands here. |
| Log.i(TAG, "MediaSession release"); |
| mediaSession.release(); |
| mediaSession = null; |
| } |
| } |
| |
| private void wakeLock(boolean lock) { |
| Activity activity = activityHolder.get(); |
| if (activity == null) { |
| return; |
| } |
| if (lock) { |
| activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); |
| } else { |
| activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); |
| } |
| } |
| |
| private void audioFocus(boolean focus) { |
| if (focus) { |
| int res; |
| if (Build.VERSION.SDK_INT < 26) { |
| res = requestAudioFocus(); |
| } else { |
| res = requestAudioFocusV26(); |
| } |
| // This shouldn't happen, but pause playback to be nice if it does. |
| if (res != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { |
| Log.w(TAG, "Audiofocus action: PAUSE (not granted)"); |
| nativeInvokeAction(PlaybackStateCompat.ACTION_PAUSE); |
| } |
| } else { |
| if (Build.VERSION.SDK_INT < 26) { |
| abandonAudioFocus(); |
| } else { |
| abandonAudioFocusV26(); |
| } |
| } |
| } |
| |
| @SuppressWarnings("deprecation") |
| private int requestAudioFocus() { |
| return getAudioManager() |
| .requestAudioFocus(this, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); |
| } |
| |
| @RequiresApi(26) |
| private int requestAudioFocusV26() { |
| if (audioFocusRequest == null) { |
| AudioAttributes audioAttributes = |
| new Builder().setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build(); |
| audioFocusRequest = |
| new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) |
| .setOnAudioFocusChangeListener(this) |
| .setAudioAttributes(audioAttributes) |
| .build(); |
| } |
| return getAudioManager().requestAudioFocus(audioFocusRequest); |
| } |
| |
| @SuppressWarnings("deprecation") |
| private void abandonAudioFocus() { |
| getAudioManager().abandonAudioFocus(this); |
| } |
| |
| @RequiresApi(26) |
| private void abandonAudioFocusV26() { |
| if (audioFocusRequest != null) { |
| getAudioManager().abandonAudioFocusRequest(audioFocusRequest); |
| } |
| } |
| |
| /** AudioManager.OnAudioFocusChangeListener implementation. */ |
| @Override |
| public void onAudioFocusChange(int focusChange) { |
| String logExtra = ""; |
| switch (focusChange) { |
| case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: |
| logExtra = " (transient)"; |
| // fall through |
| case AudioManager.AUDIOFOCUS_LOSS: |
| Log.i(TAG, "Audiofocus loss" + logExtra); |
| if (currentPlaybackState == PLAYBACK_STATE_PLAYING) { |
| Log.i(TAG, "Audiofocus action: PAUSE"); |
| nativeInvokeAction(PlaybackStateCompat.ACTION_PAUSE); |
| } |
| break; |
| case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: |
| Log.i(TAG, "Audiofocus duck"); |
| // Lower the volume, keep current play state. |
| // Starting with API 26 the system does automatic ducking without calling our listener, |
| // but we still need this for API < 26. |
| volumeListener.onUpdateVolume(AUDIO_FOCUS_DUCK_LEVEL); |
| break; |
| case AudioManager.AUDIOFOCUS_GAIN: |
| Log.i(TAG, "Audiofocus gain"); |
| // The app has been granted audio focus (again). Raise volume to normal, |
| // restart playback if necessary. |
| volumeListener.onUpdateVolume(1.0f); |
| if (transientPause && currentPlaybackState == PLAYBACK_STATE_PAUSED) { |
| Log.i(TAG, "Audiofocus action: PLAY"); |
| nativeInvokeAction(PlaybackStateCompat.ACTION_PLAY); |
| } |
| break; |
| default: // fall out |
| } |
| |
| // Keep track of whether we're currently paused because of a transient loss of audiofocus. |
| transientPause = (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT); |
| // To restart playback after permanent loss, the user must take an explicit action. |
| // See: https://developer.android.com/guide/topics/media-apps/audio-focus |
| explicitUserActionRequired = (focusChange == AudioManager.AUDIOFOCUS_LOSS); |
| } |
| |
| private AudioManager getAudioManager() { |
| return (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); |
| } |
| |
| public void resume() { |
| mainHandler.post( |
| new Runnable() { |
| @Override |
| public void run() { |
| resumeInternal(); |
| } |
| }); |
| } |
| |
| private void resumeInternal() { |
| checkMainLooperThread(); |
| suspended = false; |
| // Undoing what may have been done in suspendInternal(). |
| configureMediaFocus(currentPlaybackState); |
| } |
| |
| public void suspend() { |
| if (Looper.getMainLooper() == Looper.myLooper()) { |
| suspendInternal(); |
| } else { |
| mainHandler.post( |
| new Runnable() { |
| @Override |
| public void run() { |
| suspendInternal(); |
| } |
| }); |
| } |
| } |
| |
| private void suspendInternal() { |
| checkMainLooperThread(); |
| suspended = true; |
| |
| // We generally believe the HTML5 app playback state as the source of truth for configuring |
| // media focus since only it can know about a momentary pause between videos in a playlist, or |
| // other autoplay scenario when we should keep media focus. However, when suspending, any |
| // active SbPlayer is destroyed and we release media focus, even if the HTML5 app still thinks |
| // it's in a playing state. We'll configure it again in resumeInternal() and the HTML5 app will |
| // be none the wiser. |
| playbackStateBuilder.setState( |
| currentPlaybackState, |
| PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, |
| currentPlaybackState == PLAYBACK_STATE_PLAYING ? 1.0f : 0.0f); |
| configureMediaFocus(PLAYBACK_STATE_NONE); |
| } |
| |
| private static void nativeInvokeAction(long action) { |
| nativeInvokeAction(action, 0); |
| } |
| |
| private static native void nativeInvokeAction(long action, long seekMs); |
| |
| public void updateMediaSession( |
| final int playbackState, |
| final long actions, |
| final long positionMs, |
| final float speed, |
| final String title, |
| final String artist, |
| final String album, |
| final MediaImage[] artwork, |
| final long duration) { |
| mainHandler.post( |
| new Runnable() { |
| @Override |
| public void run() { |
| updateMediaSessionInternal( |
| playbackState, actions, positionMs, speed, title, artist, album, artwork, duration); |
| } |
| }); |
| } |
| |
| /** Called on main looper thread when media session changes. */ |
| private void updateMediaSessionInternal( |
| int playbackState, |
| long actions, |
| long positionMs, |
| float speed, |
| String title, |
| String artist, |
| String album, |
| MediaImage[] artwork, |
| final long duration) { |
| checkMainLooperThread(); |
| |
| boolean hasStateChange = this.currentPlaybackState != playbackState; |
| // Always keep track of what the HTML5 app thinks the playback state is so we can configure the |
| // media focus correctly, either immediately or when resuming from being suspended. |
| this.currentPlaybackState = playbackState; |
| |
| // Don't update anything while suspended. |
| if (suspended) { |
| Log.i(TAG, "Playback state change while suspended: " + PLAYBACK_STATE_NAME[playbackState]); |
| return; |
| } |
| |
| if (hasStateChange) { |
| if (playbackState == PLAYBACK_STATE_PLAYING) { |
| // We don't want to request media focus if |explicitUserActionRequired| is true when we |
| // don't have window focus. Ideally, we should recognize user action to re-request audio |
| // focus if |explicitUserActionRequired| is true. Currently we're not able to recognize |
| // it. But if we don't have window focus, we know the user is not interacting with our app |
| // and we should not request media focus. |
| if (!explicitUserActionRequired || activityHolder.get().hasWindowFocus()) { |
| explicitUserActionRequired = false; |
| configureMediaFocus(playbackState); |
| } else { |
| Log.w(TAG, "Audiofocus action: PAUSE (explicit user action required)"); |
| nativeInvokeAction(PlaybackStateCompat.ACTION_PAUSE); |
| } |
| } else { |
| // It's fine to abandon media focus anytime. |
| configureMediaFocus(playbackState); |
| } |
| } |
| |
| // Ignore updates to the MediaSession metadata if playback is stopped. |
| if (playbackState == PLAYBACK_STATE_NONE) { |
| return; |
| } |
| |
| int androidPlaybackState; |
| String stateName; |
| switch (playbackState) { |
| case PLAYBACK_STATE_PLAYING: |
| androidPlaybackState = PlaybackStateCompat.STATE_PLAYING; |
| stateName = "PLAYING"; |
| break; |
| case PLAYBACK_STATE_PAUSED: |
| androidPlaybackState = PlaybackStateCompat.STATE_PAUSED; |
| stateName = "PAUSED"; |
| break; |
| case PLAYBACK_STATE_NONE: |
| default: |
| androidPlaybackState = PlaybackStateCompat.STATE_NONE; |
| stateName = "NONE"; |
| break; |
| } |
| |
| Log.i( |
| TAG, |
| String.format( |
| "MediaSession state: %s, position: %d ms, speed: %f x, duration: %d ms", |
| stateName, positionMs, speed, duration)); |
| |
| playbackStateBuilder = |
| new PlaybackStateCompat.Builder() |
| .setActions(actions) |
| .setState(androidPlaybackState, positionMs, speed); |
| mediaSession.setPlaybackState(playbackStateBuilder.build()); |
| |
| // Let metadata hold onto the most recent metadata and add artwork later. |
| metadata.SetMetadata(title, artist, album, artworkLoader.getOrLoadArtwork(artwork), duration); |
| |
| // Update the metadata as soon as we can - even before artwork is loaded. |
| updateMetadata(false); |
| } |
| |
| private void updateMetadata(boolean resetMetadataWithEmptyBuilder) { |
| MediaMetadataCompat.Builder metadataBuilder = new MediaMetadataCompat.Builder(); |
| // Reset the metadata to make sure the artwork update correctly. |
| if (resetMetadataWithEmptyBuilder) mediaSession.setMetadata(metadataBuilder.build()); |
| |
| metadataBuilder |
| .putString(MediaMetadataCompat.METADATA_KEY_TITLE, metadata.title) |
| .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, metadata.artist) |
| .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, metadata.album) |
| .putBitmap(MediaMetadataCompat.METADATA_KEY_ART, metadata.artwork) |
| .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, metadata.duration); |
| |
| // Set mediaSession's metadata to update the "Now Playing Card". |
| mediaSession.setMetadata(metadataBuilder.build()); |
| } |
| |
| @Override |
| public void onArtworkLoaded(Bitmap bitmap) { |
| metadata.artwork = bitmap; |
| // Update artwork when it is ready to use. |
| updateMetadata(true); |
| } |
| } |