diff --git a/android/src/main/java/com/brentvatne/common/api/MixWithOthers.kt b/android/src/main/java/com/brentvatne/common/api/MixWithOthers.kt new file mode 100644 index 0000000000..208c41038e --- /dev/null +++ b/android/src/main/java/com/brentvatne/common/api/MixWithOthers.kt @@ -0,0 +1,39 @@ +package com.brentvatne.common.api + +import androidx.annotation.IntDef +import kotlin.annotation.Retention + +internal object MixWithOthers { + /** + * Either the width or height is decreased to obtain the desired aspect ratio. + */ + const val MIX_INHERIT = 0 + + /** + * The width is fixed and the height is increased or decreased to obtain the desired aspect ratio. + */ + const val MIX_DUCK = 1 + + /** + * The height is fixed and the width is increased or decreased to obtain the desired aspect ratio. + */ + const val MIX_MIX = 2 + + @JvmStatic + @Mode + fun toMixWithOthers(ordinal: String): Int = + when (ordinal) { + "inherit" -> MIX_INHERIT + "duck" -> MIX_DUCK + "mix" -> MIX_MIX + else -> MIX_INHERIT + } + + @Retention(AnnotationRetention.SOURCE) + @IntDef( + MIX_INHERIT, + MIX_DUCK, + MIX_MIX, + ) + annotation class Mode +} diff --git a/android/src/main/java/com/brentvatne/common/api/RNVPlayerInterface.kt b/android/src/main/java/com/brentvatne/common/api/RNVPlayerInterface.kt new file mode 100644 index 0000000000..f65fae4504 --- /dev/null +++ b/android/src/main/java/com/brentvatne/common/api/RNVPlayerInterface.kt @@ -0,0 +1,26 @@ +package com.brentvatne.common.api + +import com.brentvatne.common.react.VideoEventEmitter + +// RNVPlayerInterface is an abstraction of a player implementation +// It allows to use player in a generic code +interface RNVPlayerInterface { + // return true if playback is muted + + val isMuted: Boolean + + // return true if playback is ongoing and false when paused + val isPlaying: Boolean + + // return the eventEmitter associated to the player + val eventEmitter: VideoEventEmitter + + // pause player + fun pausePlayback() + + // decrease audio volume internally to handle audio ducking request + fun audioDuck() + + // decrease audio volume internally from ducking request + fun audioRestoreFromDuck() +} diff --git a/android/src/main/java/com/brentvatne/common/toolbox/AudioManagerDelegate.kt b/android/src/main/java/com/brentvatne/common/toolbox/AudioManagerDelegate.kt new file mode 100644 index 0000000000..ba03f6af1a --- /dev/null +++ b/android/src/main/java/com/brentvatne/common/toolbox/AudioManagerDelegate.kt @@ -0,0 +1,109 @@ +package com.brentvatne.common.toolbox + +import android.content.Context +import android.media.AudioManager +import android.media.AudioManager.OnAudioFocusChangeListener +import androidx.annotation.IntDef +import androidx.media3.common.FileTypes.Type +import com.brentvatne.common.api.MixWithOthers +import com.brentvatne.common.api.RNVPlayerInterface +import com.facebook.react.uimanager.ThemedReactContext + +/** + * Delegate audio management to this class + * This is an helper to group all generic android code which do not depend on player implementation + */ +class AudioManagerDelegate(player: RNVPlayerInterface, themedReactContext: ThemedReactContext) { + + companion object { + const val TAG = "AudioFocusDelegate" + } + + // indicates if audio focus shall be handled + var mixWithOthers: Int = MixWithOthers.MIX_INHERIT + + // indicates app currently have audio focus + var hasAudioFocus = false + + val audioManager: AudioManager = themedReactContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager + + private val audioFocusChangeListener = OnAudioFocusChangedListener(player, themedReactContext, this) + + /** implementation of OnAudioFocusChangedListener + * It reports audio events to the app and request volume change to the player + **/ + private class OnAudioFocusChangedListener( + private val player: RNVPlayerInterface, + private val themedReactContext: ThemedReactContext, + private val audioFocusDelegate: AudioManagerDelegate + ) : OnAudioFocusChangeListener { + override fun onAudioFocusChange(focusChange: Int) { + when (focusChange) { + AudioManager.AUDIOFOCUS_LOSS -> { + audioFocusDelegate.hasAudioFocus = false + player.eventEmitter.onAudioFocusChanged.invoke(false) + // FIXME this pause can cause issue if content doesn't have pause capability (can happen on live channel) + themedReactContext.currentActivity?.runOnUiThread(player::pausePlayback) + audioFocusDelegate.audioManager.abandonAudioFocus(this) + } + + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> player.eventEmitter.onAudioFocusChanged.invoke(false) + + AudioManager.AUDIOFOCUS_GAIN -> { + audioFocusDelegate.hasAudioFocus = true + player.eventEmitter.onAudioFocusChanged.invoke(true) + } + + else -> { + DebugLog.e(TAG, "unhandled audioFocusChange $focusChange") + } + } + if (player.isPlaying) { + if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) { + // Lower the volume + if (!player.isMuted) { + player.audioDuck() + } + } else if (focusChange == AudioManager.AUDIOFOCUS_GAIN) { + // Raise it back to normal + if (!player.isMuted) { + player.audioRestoreFromDuck() + } + } + } + } + } + + /** + * request audio Focus + */ + fun requestAudioFocus(): Boolean { + if (mixWithOthers == MixWithOthers.MIX_MIX || hasAudioFocus) { + return true + } + val result: Int = audioManager.requestAudioFocus( + audioFocusChangeListener, + AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN + ) + hasAudioFocus = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED + return hasAudioFocus + } + + /** + * Abandon audio Focus + */ + fun abandonAudioFocus() { + audioManager.abandonAudioFocus(audioFocusChangeListener) + } + + /** + * change system audio output + */ + fun changeOutput(isSpeakerOutput: Boolean) { + audioManager.setMode( + if (isSpeakerOutput) AudioManager.MODE_NORMAL else AudioManager.MODE_IN_COMMUNICATION + ) + audioManager.setSpeakerphoneOn(isSpeakerOutput) + } +} diff --git a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java index 8fefa34c7d..90647db86b 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java +++ b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java @@ -15,7 +15,6 @@ import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; -import android.media.AudioManager; import android.net.Uri; import android.os.Build; import android.os.Handler; @@ -108,6 +107,8 @@ import com.brentvatne.common.api.BufferingStrategy; import com.brentvatne.common.api.ControlsConfig; import com.brentvatne.common.api.DRMProps; +import com.brentvatne.common.api.MixWithOthers; +import com.brentvatne.common.api.RNVPlayerInterface; import com.brentvatne.common.api.ResizeMode; import com.brentvatne.common.api.SideLoadedTextTrack; import com.brentvatne.common.api.Source; @@ -116,6 +117,7 @@ import com.brentvatne.common.api.Track; import com.brentvatne.common.api.VideoTrack; import com.brentvatne.common.react.VideoEventEmitter; +import com.brentvatne.common.toolbox.AudioManagerDelegate; import com.brentvatne.common.toolbox.DebugLog; import com.brentvatne.common.toolbox.ReactBridgeUtils; import com.brentvatne.react.BuildConfig; @@ -141,7 +143,6 @@ import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Objects; import java.util.UUID; import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; @@ -157,7 +158,8 @@ public class ReactExoplayerView extends FrameLayout implements BecomingNoisyListener, DrmSessionEventListener, AdEvent.AdEventListener, - AdErrorEvent.AdErrorListener { + AdErrorEvent.AdErrorListener, + RNVPlayerInterface { public static final double DEFAULT_MAX_HEAP_ALLOCATION_PERCENT = 1; public static final double DEFAULT_MIN_BUFFER_MEMORY_RESERVE = 0; @@ -172,7 +174,8 @@ public class ReactExoplayerView extends FrameLayout implements DEFAULT_COOKIE_MANAGER.setCookiePolicy(CookiePolicy.ACCEPT_ORIGINAL_SERVER); } - protected final VideoEventEmitter eventEmitter; + public final VideoEventEmitter eventEmitter = new VideoEventEmitter(); + private final ReactExoplayerConfig config; private final DefaultBandwidthMeter bandwidthMeter; private LegacyPlayerControlView playerControlView; @@ -203,7 +206,6 @@ public class ReactExoplayerView extends FrameLayout implements private boolean isPaused; private boolean isBuffering; private boolean muted = false; - private boolean hasAudioFocus = false; private float rate = 1f; private AudioOutput audioOutput = AudioOutput.SPEAKER; private float audioVolume = 1f; @@ -234,7 +236,7 @@ public class ReactExoplayerView extends FrameLayout implements private String videoTrackValue; private String textTrackType = "disabled"; private String textTrackValue; - private boolean disableFocus; + private int mixWithOthers = MixWithOthers.MIX_INHERIT; private boolean focusable = true; private BufferingStrategy.BufferingStrategyEnum bufferingStrategy; private boolean disableDisconnectError; @@ -249,9 +251,8 @@ public class ReactExoplayerView extends FrameLayout implements // React private final ThemedReactContext themedReactContext; - private final AudioManager audioManager; private final AudioBecomingNoisyReceiver audioBecomingNoisyReceiver; - private final AudioManager.OnAudioFocusChangeListener audioFocusChangeListener; + private AudioManagerDelegate audioFocusDelegate; // store last progress event values to avoid sending unnecessary messages private long lastPos = -1; @@ -269,6 +270,13 @@ public void setCmcdConfigurationFactory(CmcdConfiguration.Factory factory) { this.cmcdConfigurationFactory = factory; } + public boolean isPlaying() { + if (player == null || !player.getPlayWhenReady()) { + return false; + } + return true; + } + private void updateProgress() { if (player != null) { if (playerControlView != null && isPlayingAd() && controls) { @@ -314,16 +322,21 @@ public double getPositionInFirstPeriodMsForCurrentWindow(long currentPosition) { public ReactExoplayerView(ThemedReactContext context, ReactExoplayerConfig config) { super(context); this.themedReactContext = context; - this.eventEmitter = new VideoEventEmitter(); this.config = config; this.bandwidthMeter = config.getBandwidthMeter(); mainHandler = new Handler(); createViews(); - audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); themedReactContext.addLifecycleEventListener(this); audioBecomingNoisyReceiver = new AudioBecomingNoisyReceiver(themedReactContext); - audioFocusChangeListener = new OnAudioFocusChangedListener(this, themedReactContext); + } + + private AudioManagerDelegate getAudioFocusDelegate() { + if (audioFocusDelegate == null) { + audioFocusDelegate = new AudioManagerDelegate(this, themedReactContext); + audioFocusDelegate.setMixWithOthers(mixWithOthers); + } + return audioFocusDelegate; } private boolean isPlayingAd() { @@ -667,6 +680,17 @@ public void setViewType(int viewType) { exoPlayerView.updateSurfaceView(viewType); } + @NonNull + @Override + public VideoEventEmitter getEventEmitter() { + return eventEmitter; + } + + @Override + public boolean isMuted() { + return muted; + } + private class RNVLoadControl extends DefaultLoadControl { private final int availableHeapInBytes; private final Runtime runtime; @@ -1280,78 +1304,14 @@ private void releasePlayer() { } } - private static class OnAudioFocusChangedListener implements AudioManager.OnAudioFocusChangeListener { - private final ReactExoplayerView view; - private final ThemedReactContext themedReactContext; - - private OnAudioFocusChangedListener(ReactExoplayerView view, ThemedReactContext themedReactContext) { - this.view = view; - this.themedReactContext = themedReactContext; - } - - @Override - public void onAudioFocusChange(int focusChange) { - Activity activity = themedReactContext.getCurrentActivity(); - - switch (focusChange) { - case AudioManager.AUDIOFOCUS_LOSS: - view.hasAudioFocus = false; - view.eventEmitter.onAudioFocusChanged.invoke(false); - // FIXME this pause can cause issue if content doesn't have pause capability (can happen on live channel) - if (activity != null) { - activity.runOnUiThread(view::pausePlayback); - } - view.audioManager.abandonAudioFocus(this); - break; - case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: - view.eventEmitter.onAudioFocusChanged.invoke(false); - break; - case AudioManager.AUDIOFOCUS_GAIN: - view.hasAudioFocus = true; - view.eventEmitter.onAudioFocusChanged.invoke(true); - break; - default: - break; - } - - if (view.player != null && activity != null) { - if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) { - // Lower the volume - if (!view.muted) { - activity.runOnUiThread(() -> - view.player.setVolume(view.audioVolume * 0.8f) - ); - } - } else if (focusChange == AudioManager.AUDIOFOCUS_GAIN) { - // Raise it back to normal - if (!view.muted) { - activity.runOnUiThread(() -> - view.player.setVolume(view.audioVolume * 1) - ); - } - } - } - } - } - - private boolean requestAudioFocus() { - if (disableFocus || source.getUri() == null || this.hasAudioFocus) { - return true; - } - int result = audioManager.requestAudioFocus(audioFocusChangeListener, - AudioManager.STREAM_MUSIC, - AudioManager.AUDIOFOCUS_GAIN); - return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED; - } - private void setPlayWhenReady(boolean playWhenReady) { if (player == null) { return; } if (playWhenReady) { - this.hasAudioFocus = requestAudioFocus(); - if (this.hasAudioFocus) { + boolean hasAudioGranted = getAudioFocusDelegate().requestAudioFocus(); + if (hasAudioGranted) { player.setPlayWhenReady(true); } } else { @@ -1368,7 +1328,7 @@ private void resumePlayback() { } } - private void pausePlayback() { + public void pausePlayback() { if (player != null) { if (player.getPlayWhenReady()) { setPlayWhenReady(false); @@ -1383,7 +1343,7 @@ private void stopPlayback() { } private void onStopPlayback() { - audioManager.abandonAudioFocus(audioFocusChangeListener); + getAudioFocusDelegate().abandonAudioFocus(); } private void updateResumePosition() { @@ -2224,12 +2184,8 @@ private void changeAudioOutput(AudioOutput output) { .setContentType(contentType) .build(); player.setAudioAttributes(audioAttributes, false); - AudioManager audioManager = (AudioManager) themedReactContext.getSystemService(Context.AUDIO_SERVICE); boolean isSpeakerOutput = output == AudioOutput.SPEAKER; - audioManager.setMode( - isSpeakerOutput ? AudioManager.MODE_NORMAL - : AudioManager.MODE_IN_COMMUNICATION); - audioManager.setSpeakerphoneOn(isSpeakerOutput); + getAudioFocusDelegate().changeOutput(isSpeakerOutput); } } @@ -2242,7 +2198,7 @@ public void setAudioOutput(AudioOutput output) { public void setVolumeModifier(float volume) { audioVolume = volume; - if (player != null) { + if (player != null && !muted) { player.setVolume(audioVolume); } } @@ -2286,8 +2242,13 @@ public void setPlayInBackground(boolean playInBackground) { this.playInBackground = playInBackground; } - public void setDisableFocus(boolean disableFocus) { - this.disableFocus = disableFocus; + public void setMixWithOthers(int _mixWithOthers) { + mixWithOthers = _mixWithOthers; + // do not use getAudioFocusDelegate() + // should not be created here as we are not sure the playback is really required + if (audioFocusDelegate != null) { + audioFocusDelegate.setMixWithOthers(mixWithOthers); + } } public void setFocusable(boolean focusable) { @@ -2470,4 +2431,20 @@ public void setControlsStyles(ControlsConfig controlsStyles) { controlsConfig = controlsStyles; refreshControlsStyles(); } + + public void audioDuck() { + if (themedReactContext.getCurrentActivity() != null && !muted) { + themedReactContext.getCurrentActivity().runOnUiThread(() -> player.setVolume(audioVolume * 0.8f)); + } + } + + public void audioRestoreFromDuck() { + if (themedReactContext.getCurrentActivity() != null) { + themedReactContext.getCurrentActivity().runOnUiThread(() -> { + if (!muted) { + player.setVolume(audioVolume); + } + }); + } + } } diff --git a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.kt b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.kt index 967da9ff20..feccf0ebed 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.kt +++ b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.kt @@ -5,6 +5,7 @@ import android.util.Log import com.brentvatne.common.api.BufferConfig import com.brentvatne.common.api.BufferingStrategy import com.brentvatne.common.api.ControlsConfig +import com.brentvatne.common.api.MixWithOthers import com.brentvatne.common.api.ResizeMode import com.brentvatne.common.api.Source import com.brentvatne.common.api.SubtitleStyle @@ -45,7 +46,7 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View private const val PROP_MIN_LOAD_RETRY_COUNT = "minLoadRetryCount" private const val PROP_MAXIMUM_BIT_RATE = "maxBitRate" private const val PROP_PLAY_IN_BACKGROUND = "playInBackground" - private const val PROP_DISABLE_FOCUS = "disableFocus" + private const val PROP_DISABLE_MIX_WITH_OTHERS = "mixWithOthers" private const val PROP_BUFFERING_STRATEGY = "bufferingStrategy" private const val PROP_DISABLE_DISCONNECT_ERROR = "disableDisconnectError" private const val PROP_FOCUSABLE = "focusable" @@ -197,9 +198,9 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View videoView.setPlayInBackground(playInBackground) } - @ReactProp(name = PROP_DISABLE_FOCUS, defaultBoolean = false) - fun setDisableFocus(videoView: ReactExoplayerView, disableFocus: Boolean) { - videoView.setDisableFocus(disableFocus) + @ReactProp(name = PROP_DISABLE_MIX_WITH_OTHERS) + fun setMixWithOthers(videoView: ReactExoplayerView, disableFocus: String) { + videoView.setMixWithOthers(MixWithOthers.toMixWithOthers(disableFocus)) } @ReactProp(name = PROP_FOCUSABLE, defaultBoolean = true) diff --git a/docs/pages/component/props.mdx b/docs/pages/component/props.mdx index 3d2549a23e..74c2f782d7 100644 --- a/docs/pages/component/props.mdx +++ b/docs/pages/component/props.mdx @@ -220,12 +220,15 @@ debug={{ ### `disableFocus` +> [!WARNING] +> Deprecated, use mixWithOthers instead + Determines whether video audio should override background music/audio in Android devices. -- **false (default)** - Override background audio/music -- **true** - Let background audio/music from other apps play +- **false (default)** - Override background audio/music. Equivalent to mixWithOthers={'inherit'} +- **true** - Let background audio/music from other apps play. equivalent to mixWithOthers={'duck'} Note: Allows multiple videos to play if set to `true`. If `false`, when one video is playing and another is started, the first video will be paused. @@ -394,11 +397,11 @@ minLoadRetryCount={5} // retry 5 times ### `mixWithOthers` - + Controls how Audio mix with other apps. -- **"inherit" (default)** - Use the default AVPlayer behavior +- **"inherit" (default)** - Use the default player behavior - **"mix"** - Audio from this video mixes with audio from other apps. - **"duck"** - Reduces the volume of other apps while audio from this video plays. diff --git a/src/Video.tsx b/src/Video.tsx index 8bdd92ed17..13a214ce30 100644 --- a/src/Video.tsx +++ b/src/Video.tsx @@ -46,7 +46,7 @@ import { } from './utils'; import NativeVideoManager from './specs/NativeVideoManager'; import type {VideoSaveData} from './specs/NativeVideoManager'; -import {CmcdMode, ViewType} from './types'; +import {CmcdMode, MixWithOthersType, ViewType} from './types'; import type { OnLoadData, OnTextTracksData, @@ -89,6 +89,8 @@ const Video = forwardRef( selectedTextTrack, useTextureView, useSecureView, + disableFocus, + mixWithOthers, viewType, shutterColor, adTagUrl, @@ -784,6 +786,14 @@ const Video = forwardRef( }), [showPoster], ); + if (disableFocus != undefined && mixWithOthers) { + console.warn('disableFocus is deprecated, please use only mixWithOthers'); + } + const _mixWithOthers = mixWithOthers + ? mixWithOthers + : disableFocus + ? MixWithOthersType.DUCK + : MixWithOthersType.INHERIT; return ( @@ -800,6 +810,7 @@ const Video = forwardRef( selectedAudioTrack={_selectedAudioTrack} selectedVideoTrack={_selectedVideoTrack} shutterColor={_shutterColor} + mixWithOthers={_mixWithOthers} onGetLicense={usingExternalGetLicense ? onGetLicense : undefined} onVideoLoad={ onLoad || hasPoster diff --git a/src/specs/VideoNativeComponent.ts b/src/specs/VideoNativeComponent.ts index c87af6835b..ecfc86399b 100644 --- a/src/specs/VideoNativeComponent.ts +++ b/src/specs/VideoNativeComponent.ts @@ -326,7 +326,6 @@ export type OnControlsVisibilityChange = Readonly<{ export interface VideoNativeProps extends ViewProps { src?: VideoSrc; allowsExternalPlayback?: boolean; // ios, true - disableFocus?: boolean; // android maxBitRate?: Float; resizeMode?: WithDefault; repeat?: boolean; diff --git a/src/types/video.ts b/src/types/video.ts index ff965bc53a..f10d407995 100644 --- a/src/types/video.ts +++ b/src/types/video.ts @@ -295,6 +295,7 @@ export interface ReactVideoProps extends ReactVideoEvents, ViewProps { contentStartTime?: number; // Android controls?: boolean; currentPlaybackTime?: number; // Android + /** @deprecated Use mixWithOthers */ disableFocus?: boolean; disableDisconnectError?: boolean; // Android filter?: EnumValues; // iOS @@ -307,7 +308,7 @@ export interface ReactVideoProps extends ReactVideoEvents, ViewProps { ignoreSilentSwitch?: EnumValues; // iOS minLoadRetryCount?: number; // Android maxBitRate?: number; - mixWithOthers?: EnumValues; // iOS + mixWithOthers?: EnumValues; // iOS, android muted?: boolean; paused?: boolean; pictureInPicture?: boolean; // iOS