From 7c5fe927c503c1d342c3d1ecebab9edbec9a9162 Mon Sep 17 00:00:00 2001 From: Gustav Haglund <gustav.haglund@gmail.com> Date: Thu, 25 Apr 2019 17:18:43 +0200 Subject: [PATCH] feat: Android interuption (#240) * Add AudioFocusListener, refactor VolumeListener * Update js and readme * Cleanup * Set volume levels correct --- README.md | 1 + .../react/MediaSessionCallback.java | 68 +++++++++ .../react/MusicControlAudioFocusListener.java | 59 ++++++++ .../react/MusicControlEventEmitter.java | 73 ++++++++++ .../react/MusicControlListener.java | 135 ------------------ .../react/MusicControlModule.java | 47 +++--- .../react/MusicControlVolumeListener.java | 49 +++++++ src/index.ts | 8 +- 8 files changed, 279 insertions(+), 161 deletions(-) create mode 100644 android/src/main/java/com/tanguyantoine/react/MediaSessionCallback.java create mode 100644 android/src/main/java/com/tanguyantoine/react/MusicControlAudioFocusListener.java create mode 100644 android/src/main/java/com/tanguyantoine/react/MusicControlEventEmitter.java delete mode 100644 android/src/main/java/com/tanguyantoine/react/MusicControlListener.java create mode 100644 android/src/main/java/com/tanguyantoine/react/MusicControlVolumeListener.java diff --git a/README.md b/README.md index 7f4e1868..1f2f4e67 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,7 @@ componentDidMount() { MusicControl.enableBackgroundMode(true); // on iOS, pause playback during audio interruptions (incoming calls) and resume afterwards. + // As of {{ INSERT NEXT VERSION HERE}} works for android aswell. MusicControl.handleAudioInterruptions(true); MusicControl.on('play', ()=> { diff --git a/android/src/main/java/com/tanguyantoine/react/MediaSessionCallback.java b/android/src/main/java/com/tanguyantoine/react/MediaSessionCallback.java new file mode 100644 index 00000000..c86c54f6 --- /dev/null +++ b/android/src/main/java/com/tanguyantoine/react/MediaSessionCallback.java @@ -0,0 +1,68 @@ +package com.tanguyantoine.react; + +import android.support.v4.media.RatingCompat; +import android.support.v4.media.session.MediaSessionCompat; + +public class MediaSessionCallback extends MediaSessionCompat.Callback { + private final MusicControlEventEmitter emitter; + + MediaSessionCallback(MusicControlEventEmitter emitter) { + this.emitter = emitter; + } + + @Override + public void onPlay() { + emitter.onPlay(); + } + + @Override + public void onPause() { + emitter.onPause(); + } + + @Override + public void onStop() { + emitter.onStop(); + } + + @Override + public void onSkipToNext() { + emitter.onSkipToNext(); + } + + @Override + public void onSkipToPrevious() { + emitter.onSkipToPrevious(); + } + + @Override + public void onSeekTo(long pos) { + emitter.onSeekTo(pos); + } + + @Override + public void onFastForward() { + emitter.onFastForward(); + } + + @Override + public void onRewind() { + emitter.onRewind(); + } + + @Override + public void onSetRating(RatingCompat rating) { + if(MusicControlModule.INSTANCE == null) return; + int type = MusicControlModule.INSTANCE.ratingType; + + if(type == RatingCompat.RATING_PERCENTAGE) { + emitter.onSetRating(rating.getPercentRating()); + } else if(type == RatingCompat.RATING_HEART) { + emitter.onSetRating(rating.hasHeart()); + } else if(type == RatingCompat.RATING_THUMB_UP_DOWN) { + emitter.onSetRating(rating.isThumbUp()); + } else { + emitter.onSetRating(rating.getStarRating()); + } + } +} diff --git a/android/src/main/java/com/tanguyantoine/react/MusicControlAudioFocusListener.java b/android/src/main/java/com/tanguyantoine/react/MusicControlAudioFocusListener.java new file mode 100644 index 00000000..949451fb --- /dev/null +++ b/android/src/main/java/com/tanguyantoine/react/MusicControlAudioFocusListener.java @@ -0,0 +1,59 @@ +package com.tanguyantoine.react; + +import android.content.Context; +import android.media.AudioFocusRequest; +import android.media.AudioManager; +import android.os.Build; + +import com.facebook.react.bridge.ReactApplicationContext; + +public class MusicControlAudioFocusListener implements AudioManager.OnAudioFocusChangeListener { + private final MusicControlEventEmitter emitter; + private final MusicControlVolumeListener volume; + + private AudioManager mAudioManager; + private AudioFocusRequest mFocusRequest; + + MusicControlAudioFocusListener(ReactApplicationContext context, MusicControlEventEmitter emitter, + MusicControlVolumeListener volume) { + this.emitter = emitter; + this.volume = volume; + + this.mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + } + + @Override + public void onAudioFocusChange(int focusChange) { + if (focusChange == AudioManager.AUDIOFOCUS_LOSS) { + emitter.onStop(); + } else if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) { + emitter.onPause(); + } else if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) { + volume.setCurrentVolume(40); + } else if (focusChange == AudioManager.AUDIOFOCUS_GAIN) { + if (volume.getCurrentVolume() != 100) { + volume.setCurrentVolume(100); + } + emitter.onPlay(); + } + } + + public void requestAudioFocus() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + mFocusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setOnAudioFocusChangeListener(this).build(); + + mAudioManager.requestAudioFocus(mFocusRequest); + } else { + mAudioManager.requestAudioFocus(this, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); + } + } + + public void abandonAudioFocus() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + mAudioManager.abandonAudioFocusRequest(mFocusRequest); + } else { + mAudioManager.abandonAudioFocus(this); + } + } +} diff --git a/android/src/main/java/com/tanguyantoine/react/MusicControlEventEmitter.java b/android/src/main/java/com/tanguyantoine/react/MusicControlEventEmitter.java new file mode 100644 index 00000000..bf867c9f --- /dev/null +++ b/android/src/main/java/com/tanguyantoine/react/MusicControlEventEmitter.java @@ -0,0 +1,73 @@ +package com.tanguyantoine.react; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.modules.core.DeviceEventManagerModule; + +public class MusicControlEventEmitter { + private static void sendEvent(ReactApplicationContext context, String type, Object value) { + WritableMap data = Arguments.createMap(); + data.putString("name", type); + + if(value != null) { + if(value instanceof Double || value instanceof Float) { + data.putDouble("value", (double)value); + } else if(value instanceof Boolean) { + data.putBoolean("value", (boolean)value); + } else if(value instanceof Integer) { + data.putInt("value", (int)value); + } + } + + context.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit("RNMusicControlEvent", data); + } + + private final ReactApplicationContext context; + + MusicControlEventEmitter(ReactApplicationContext context) { + this.context = context; + } + + public void onPlay() { + sendEvent(context, "play", null); + } + + public void onPause() { + sendEvent(context, "pause", null); + } + + public void onStop() { + sendEvent(context, "stop", null); + } + + public void onSkipToNext() { + sendEvent(context, "nextTrack", null); + } + + public void onSkipToPrevious() { + sendEvent(context, "previousTrack", null); + } + + public void onSeekTo(long pos) { + sendEvent(context, "seek", pos / 1000D); + } + + public void onFastForward() { + sendEvent(context, "skipForward", null); + } + + public void onRewind() { + sendEvent(context, "skipBackward", null); + } + + public void onSetRating(float rating) { + sendEvent(context,"setRating", rating); + } + public void onSetRating(boolean hasHeartOrThumb) { + sendEvent(context,"setRating", hasHeartOrThumb); + } + + public void onVolumeChange(int volume) { sendEvent(context, "volume", volume); } +} diff --git a/android/src/main/java/com/tanguyantoine/react/MusicControlListener.java b/android/src/main/java/com/tanguyantoine/react/MusicControlListener.java deleted file mode 100644 index f12df845..00000000 --- a/android/src/main/java/com/tanguyantoine/react/MusicControlListener.java +++ /dev/null @@ -1,135 +0,0 @@ -package com.tanguyantoine.react; - -import android.support.v4.media.RatingCompat; -import android.support.v4.media.VolumeProviderCompat; -import android.support.v4.media.session.MediaSessionCompat; -import com.facebook.react.bridge.Arguments; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.WritableMap; -import com.facebook.react.modules.core.DeviceEventManagerModule; - -public class MusicControlListener extends MediaSessionCompat.Callback { - - private static void sendEvent(ReactApplicationContext context, String type, Object value) { - WritableMap data = Arguments.createMap(); - data.putString("name", type); - - if(value == null) { - // NOOP - } else if(value instanceof Double || value instanceof Float) { - data.putDouble("value", (double)value); - } else if(value instanceof Boolean) { - data.putBoolean("value", (boolean)value); - } else if(value instanceof Integer) { - data.putInt("value", (int)value); - } - - context.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit("RNMusicControlEvent", data); - } - - private final ReactApplicationContext context; - - MusicControlListener(ReactApplicationContext context) { - this.context = context; - } - - @Override - public void onPlay() { - sendEvent(context, "play", null); - } - - @Override - public void onPause() { - sendEvent(context, "pause", null); - } - - @Override - public void onStop() { - sendEvent(context, "stop", null); - } - - @Override - public void onSkipToNext() { - sendEvent(context, "nextTrack", null); - } - - @Override - public void onSkipToPrevious() { - sendEvent(context, "previousTrack", null); - } - - @Override - public void onSeekTo(long pos) { - sendEvent(context, "seek", pos / 1000D); - } - - @Override - public void onFastForward() { - sendEvent(context, "skipForward", null); - } - - @Override - public void onRewind() { - sendEvent(context, "skipBackward", null); - } - - @Override - public void onSetRating(RatingCompat rating) { - if(MusicControlModule.INSTANCE == null) return; - int type = MusicControlModule.INSTANCE.ratingType; - - if(type == RatingCompat.RATING_PERCENTAGE) { - sendEvent(context, "setRating", rating.getPercentRating()); - } else if(type == RatingCompat.RATING_HEART) { - sendEvent(context, "setRating", rating.hasHeart()); - } else if(type == RatingCompat.RATING_THUMB_UP_DOWN) { - sendEvent(context, "setRating", rating.isThumbUp()); - } else { - sendEvent(context, "setRating", rating.getStarRating()); - } - } - - public static class VolumeListener extends VolumeProviderCompat { - - private final ReactApplicationContext context; - public VolumeListener(ReactApplicationContext context, boolean changeable, int maxVolume, int currentVolume) { - super(changeable ? VOLUME_CONTROL_ABSOLUTE : VOLUME_CONTROL_FIXED, maxVolume, currentVolume); - this.context = context; - } - - public boolean isChangeable() { - return getVolumeControl() != VolumeProviderCompat.VOLUME_CONTROL_FIXED; - } - - @Override - public void onSetVolumeTo(int volume) { - setCurrentVolume(volume); - sendEvent(context, "volume", volume); - } - - @Override - public void onAdjustVolume(int direction) { - int maxVolume = getMaxVolume(); - int tick = direction * (maxVolume / 10); - int volume = Math.max(Math.min(getCurrentVolume() + tick, maxVolume), 0); - - setCurrentVolume(volume); - sendEvent(context, "volume", volume); - } - - public VolumeListener create(Boolean changeable, Integer maxVolume, Integer currentVolume) { - if(currentVolume == null) { - currentVolume = getCurrentVolume(); - } else { - setCurrentVolume(currentVolume); - } - - if(changeable == null) changeable = isChangeable(); - if(maxVolume == null) maxVolume = getMaxVolume(); - - if(changeable == isChangeable() && maxVolume == getMaxVolume()) return this; - return new VolumeListener(context, changeable, maxVolume, currentVolume); - } - } - -} diff --git a/android/src/main/java/com/tanguyantoine/react/MusicControlModule.java b/android/src/main/java/com/tanguyantoine/react/MusicControlModule.java index ac3c78c5..a5fa9faf 100644 --- a/android/src/main/java/com/tanguyantoine/react/MusicControlModule.java +++ b/android/src/main/java/com/tanguyantoine/react/MusicControlModule.java @@ -1,6 +1,5 @@ package com.tanguyantoine.react; -import android.app.Notification; import android.app.NotificationManager; import android.app.NotificationChannel; import android.content.ComponentCallbacks2; @@ -8,7 +7,6 @@ import android.content.Intent; import android.content.IntentFilter; import android.content.Context; -import android.content.res.Resources; import android.content.res.Configuration; import android.graphics.Bitmap; import android.graphics.BitmapFactory; @@ -23,7 +21,6 @@ import android.support.v4.media.session.MediaSessionCompat; import android.support.v4.media.session.PlaybackStateCompat; import android.support.v4.app.NotificationCompat; -import android.support.v4.content.ContextCompat; import android.support.v4.media.app.NotificationCompat.MediaStyle; import android.util.Log; import com.facebook.react.bridge.ReactApplicationContext; @@ -31,7 +28,6 @@ import com.facebook.react.bridge.ReactMethod; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.ReadableType; -import com.facebook.react.packagerconnection.NotificationOnlyHandler; import com.facebook.react.views.imagehelper.ResourceDrawableIdHelper; import java.io.IOException; import java.io.InputStream; @@ -53,11 +49,14 @@ public class MusicControlModule extends ReactContextBaseJavaModule implements Co private PlaybackStateCompat.Builder pb; public NotificationCompat.Builder nb; + private PlaybackStateCompat state; public MusicControlNotification notification; - private MusicControlListener.VolumeListener volume; + private MusicControlVolumeListener volume; private MusicControlReceiver receiver; + private MusicControlEventEmitter emitter; + private MusicControlAudioFocusListener afListener; private Thread artworkThread; @@ -121,12 +120,15 @@ public void init() { ComponentName compName = new ComponentName(context, MusicControlReceiver.class); + emitter = new MusicControlEventEmitter(context); + session = new MediaSessionCompat(context, "MusicControl", compName, null); session.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS | MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS); - session.setCallback(new MusicControlListener(context)); - volume = new MusicControlListener.VolumeListener(context, true, 100, 100); + session.setCallback(new MediaSessionCallback(emitter)); + + volume = new MusicControlVolumeListener(context, emitter, true, 100, 100); if(remoteVolume) { session.setPlaybackToRemote(volume); } else { @@ -161,6 +163,8 @@ public void init() { Intent myIntent = new Intent(context, MusicControlNotification.NotificationService.class); + afListener = new MusicControlAudioFocusListener(context, emitter, volume); + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O){ context.startForegroundService(myIntent); @@ -213,6 +217,15 @@ public void enableBackgroundMode(boolean enable) { // Nothing? } + @ReactMethod + public void observeAudioInterruptions(boolean enable) { + if (enable) { + afListener.requestAudioFocus(); + } else { + afListener.abandonAudioFocus(); + } + } + @ReactMethod synchronized public void setNowPlaying(ReadableMap metadata) { init(); @@ -243,8 +256,6 @@ synchronized public void setNowPlaying(ReadableMap metadata) { rating = RatingCompat.newUnratedRating(ratingType); } - - md.putText(MediaMetadataCompat.METADATA_KEY_TITLE, title); md.putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist); md.putText(MediaMetadataCompat.METADATA_KEY_ALBUM, album); @@ -414,13 +425,13 @@ synchronized public void enableControl(String control, boolean enable, ReadableM case "closeNotification": if(enable) { if (options.hasKey("when")) { - if ("always".equals(options.getString("when"))) { - this.notificationClose = notificationClose.ALWAYS; - }else if ("paused".equals(options.getString("when"))) { - this.notificationClose = notificationClose.PAUSED; - }else { - this.notificationClose = notificationClose.NEVER; - } + if ("always".equals(options.getString("when"))) { + this.notificationClose = notificationClose.ALWAYS; + }else if ("paused".equals(options.getString("when"))) { + this.notificationClose = notificationClose.PAUSED; + }else { + this.notificationClose = notificationClose.NEVER; + } } return; } @@ -448,7 +459,6 @@ private Bitmap loadArtwork(String url, boolean local) { try { // If we are running the app in debug mode, the "local" image will be served from htt://localhost:8080, so we need to check for this case and load those images from URL if(local && !url.startsWith("http")) { - // Gets the drawable from the RN's helper for local resources ResourceDrawableIdHelper helper = ResourceDrawableIdHelper.getInstance(); Drawable image = helper.getResourceDrawable(getReactApplicationContext(), url); @@ -458,16 +468,13 @@ private Bitmap loadArtwork(String url, boolean local) { } else { bitmap = BitmapFactory.decodeFile(url); } - } else { - // Open connection to the URL and decodes the image URLConnection con = new URL(url).openConnection(); con.connect(); InputStream input = con.getInputStream(); bitmap = BitmapFactory.decodeStream(input); input.close(); - } } catch(IOException ex) { Log.w(TAG, "Could not load the artwork", ex); diff --git a/android/src/main/java/com/tanguyantoine/react/MusicControlVolumeListener.java b/android/src/main/java/com/tanguyantoine/react/MusicControlVolumeListener.java new file mode 100644 index 00000000..95281b75 --- /dev/null +++ b/android/src/main/java/com/tanguyantoine/react/MusicControlVolumeListener.java @@ -0,0 +1,49 @@ +package com.tanguyantoine.react; + +import android.support.v4.media.VolumeProviderCompat; +import com.facebook.react.bridge.ReactApplicationContext; + +public class MusicControlVolumeListener extends VolumeProviderCompat { + private final ReactApplicationContext context; + private final MusicControlEventEmitter emitter; + + MusicControlVolumeListener(ReactApplicationContext context, MusicControlEventEmitter emitter, boolean changeable, int maxVolume, int currentVolume) { + super(changeable ? VOLUME_CONTROL_ABSOLUTE : VOLUME_CONTROL_FIXED, maxVolume, currentVolume); + this.context = context; + this.emitter = emitter; + } + + public boolean isChangeable() { + return getVolumeControl() != VolumeProviderCompat.VOLUME_CONTROL_FIXED; + } + + @Override + public void onSetVolumeTo(int volume) { + setCurrentVolume(volume); + emitter.onVolumeChange(volume); + } + + @Override + public void onAdjustVolume(int direction) { + int maxVolume = getMaxVolume(); + int tick = direction * (maxVolume / 10); + int volume = Math.max(Math.min(getCurrentVolume() + tick, maxVolume), 0); + + setCurrentVolume(volume); + emitter.onVolumeChange(volume); + } + + public MusicControlVolumeListener create(Boolean changeable, Integer maxVolume, Integer currentVolume) { + if(currentVolume == null) { + currentVolume = getCurrentVolume(); + } else { + setCurrentVolume(currentVolume); + } + + if(changeable == null) changeable = isChangeable(); + if(maxVolume == null) maxVolume = getMaxVolume(); + + if(changeable == isChangeable() && maxVolume == getMaxVolume()) return this; + return new MusicControlVolumeListener(context, emitter, changeable, maxVolume, currentVolume); + } +} diff --git a/src/index.ts b/src/index.ts index ef05e56c..18fb69d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -88,12 +88,8 @@ const MusicControl = { }) NativeMusicControl.stopControl() }, - handleAudioInterruptions: function(enable: boolean): void { - if (IS_ANDROID) { - console.log('Audio interruption handling not implemented for Android') - } else { - NativeMusicControl.observeAudioInterruptions(enable) - } + handleAudioInterruptions: function(enable: boolean): void { + NativeMusicControl.observeAudioInterruptions(enable) } }