Skip to content

Commit

Permalink
feat: add notification controls (#3723)
Browse files Browse the repository at this point in the history
* feat(ios): add `showNotificationControls` prop

* feat(android): add `showNotificationControls` prop

* add docs

* feat!: add `metadata` property to srouce

This is breaking change for iOS/tvOS as we are moving some properties, but I believe that this will more readable and more user friendly

* chore(ios): remove UI blocking function

* code review changes for android

* update example

* fix readme

* fix typos

* update docs

* fix typo

* chore: improve sample metadata notification

* update codegen types

* rename properties

* update tvOS example

* reset metadata on source change

* update docs

---------

Co-authored-by: Olivier Bouillet <freeboub@gmail.com>
  • Loading branch information
KrzysztofMoch and freeboub authored May 7, 2024
1 parent c59d00a commit 8ad4be4
Show file tree
Hide file tree
Showing 18 changed files with 908 additions and 105 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,6 @@ We have an discord server where you can ask questions and get help. [Join the di
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./docs/assets/baners/twg-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./docs/assets/baners/twg-light.png" />
<img alt="TheWidlarzGroup" src="./docs/assets/baners/twg-light-1.png" />
<img alt="TheWidlarzGroup" src="./docs/assets/baners/twg-light.png" />
</picture>
</a>
130 changes: 128 additions & 2 deletions android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.ActivityManager;
import android.content.ComponentName;
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;
import android.os.IBinder;
import android.os.Looper;
import android.os.Message;
import android.text.TextUtils;
Expand All @@ -27,6 +32,7 @@

import androidx.activity.OnBackPressedCallback;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
Expand All @@ -35,6 +41,7 @@
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.Metadata;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.PlaybackParameters;
Expand Down Expand Up @@ -94,6 +101,7 @@
import androidx.media3.extractor.metadata.emsg.EventMessage;
import androidx.media3.extractor.metadata.id3.Id3Frame;
import androidx.media3.extractor.metadata.id3.TextInformationFrame;
import androidx.media3.session.MediaSessionService;
import androidx.media3.ui.LegacyPlayerControlView;

import com.brentvatne.common.api.BufferConfig;
Expand Down Expand Up @@ -172,6 +180,10 @@ public class ReactExoplayerView extends FrameLayout implements
private ExoPlayer player;
private DefaultTrackSelector trackSelector;
private boolean playerNeedsSource;
private MediaMetadata customMetadata;

private ServiceConnection playbackServiceConnection;
private PlaybackServiceBinder playbackServiceBinder;

private int resumeWindow;
private long resumePosition;
Expand Down Expand Up @@ -224,6 +236,8 @@ public class ReactExoplayerView extends FrameLayout implements
private String[] drmLicenseHeader = null;
private boolean controls;
private Uri adTagUrl;

private boolean showNotificationControls = false;
// \ End props

// React
Expand Down Expand Up @@ -342,6 +356,12 @@ public void onHostDestroy() {
cleanUpResources();
}

@Override
protected void onDetachedFromWindow() {
cleanupPlaybackService();
super.onDetachedFromWindow();
}

public void cleanUpResources() {
stopPlayback();
themedReactContext.removeLifecycleEventListener(this);
Expand Down Expand Up @@ -656,6 +676,10 @@ private void initializePlayerCore(ReactExoplayerView self) {
PlaybackParameters params = new PlaybackParameters(rate, 1f);
player.setPlaybackParameters(params);
changeAudioOutput(this.audioOutput);

if(showNotificationControls) {
setupPlaybackService();
}
}

private DrmSessionManager initializePlayerDrm(ReactExoplayerView self) {
Expand Down Expand Up @@ -741,6 +765,69 @@ private void finishPlayerInitialization() {
applyModifiers();
}

private void setupPlaybackService() {
if (!showNotificationControls || player == null) {
return;
}

playbackServiceConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
playbackServiceBinder = (PlaybackServiceBinder) service;

try {
playbackServiceBinder.getService().registerPlayer(player);
} catch (Exception e) {
DebugLog.e(TAG, "Cloud not register ExoPlayer");
}
}

@Override
public void onServiceDisconnected(ComponentName name) {
try {
playbackServiceBinder.getService().unregisterPlayer(player);
} catch (Exception ignored) {}

playbackServiceBinder = null;
}

@Override
public void onNullBinding(ComponentName name) {
DebugLog.e(TAG, "Cloud not register ExoPlayer");
}
};

Intent intent = new Intent(themedReactContext, VideoPlaybackService.class);
intent.setAction(MediaSessionService.SERVICE_INTERFACE);

themedReactContext.startService(intent);

int flags;
if (Build.VERSION.SDK_INT >= 29) {
flags = Context.BIND_AUTO_CREATE | Context.BIND_INCLUDE_CAPABILITIES;
} else {
flags = Context.BIND_AUTO_CREATE;
}

themedReactContext.bindService(intent, playbackServiceConnection, flags);
}

private void cleanupPlaybackService() {
try {
if(player != null && playbackServiceBinder != null) {
playbackServiceBinder.getService().unregisterPlayer(player);
}

playbackServiceBinder = null;

if(playbackServiceConnection != null) {
themedReactContext.unbindService(playbackServiceConnection);
}
} catch(Exception e) {
DebugLog.w(TAG, "Cloud not cleanup playback service");
}
}

private DrmSessionManager buildDrmSessionManager(UUID uuid, String licenseUrl, String[] keyRequestPropertiesArray) throws UnsupportedDrmException {
return buildDrmSessionManager(uuid, licenseUrl, keyRequestPropertiesArray, 0);
}
Expand Down Expand Up @@ -795,7 +882,12 @@ private MediaSource buildMediaSource(Uri uri, String overrideExtension, DrmSessi
}
config.setDisableDisconnectError(this.disableDisconnectError);

MediaItem.Builder mediaItemBuilder = new MediaItem.Builder().setUri(uri);
MediaItem.Builder mediaItemBuilder = new MediaItem.Builder()
.setUri(uri);

if (customMetadata != null) {
mediaItemBuilder.setMediaMetadata(customMetadata);
}

if (adTagUrl != null) {
mediaItemBuilder.setAdsConfiguration(
Expand Down Expand Up @@ -935,12 +1027,20 @@ private void releasePlayer() {
if (adsLoader != null) {
adsLoader.setPlayer(null);
}

if(playbackServiceBinder != null) {
playbackServiceBinder.getService().unregisterPlayer(player);
themedReactContext.unbindService(playbackServiceConnection);
}

updateResumePosition();
player.release();
player.removeListener(this);
trackSelector = null;

player = null;
}

if (adsLoader != null) {
adsLoader.release();
}
Expand Down Expand Up @@ -1542,7 +1642,21 @@ public void onCues(CueGroup cueGroup) {

// ReactExoplayerViewManager public api

public void setSrc(final Uri uri, final long startPositionMs, final long cropStartMs, final long cropEndMs, final String extension, Map<String, String> headers) {
public void setSrc(final Uri uri, final long startPositionMs, final long cropStartMs, final long cropEndMs, final String extension, Map<String, String> headers, MediaMetadata customMetadata) {

if (this.customMetadata != customMetadata && player != null) {
MediaItem currentMediaItem = player.getCurrentMediaItem();

if (currentMediaItem == null) {
return;
}

MediaItem newMediaItem = currentMediaItem.buildUpon().setMediaMetadata(customMetadata).build();

// This will cause video blink/reload but won't louse progress
player.setMediaItem(newMediaItem, false);
}

if (uri != null) {
boolean isSourceEqual = uri.equals(srcUri) && cropStartMs == this.cropStartMs && cropEndMs == this.cropEndMs;
hasDrmFailed = false;
Expand All @@ -1555,6 +1669,7 @@ public void setSrc(final Uri uri, final long startPositionMs, final long cropSta
this.mediaDataSourceFactory =
DataSourceUtil.getDefaultDataSourceFactory(this.themedReactContext, bandwidthMeter,
this.requestHeaders);
this.customMetadata = customMetadata;

if (!isSourceEqual) {
reloadSource();
Expand All @@ -1573,6 +1688,7 @@ public void clearSrc() {
this.extension = null;
this.requestHeaders = null;
this.mediaDataSourceFactory = null;
customMetadata = null;
clearResumePosition();
}
}
Expand Down Expand Up @@ -1956,6 +2072,16 @@ public void setContentStartTime(int contentStartTime) {
this.contentStartTime = contentStartTime;
}

public void setShowNotificationControls(boolean showNotificationControls) {
this.showNotificationControls = showNotificationControls;

if (playbackServiceConnection == null && showNotificationControls) {
setupPlaybackService();
} else if(!showNotificationControls && playbackServiceConnection != null) {
cleanupPlaybackService();
}
}

public void setDisableBuffering(boolean disableBuffering) {
this.disableBuffering = disableBuffering;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import android.util.Log;

import androidx.annotation.NonNull;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.util.Util;
import androidx.media3.datasource.RawResourceDataSource;
import androidx.media3.exoplayer.DefaultLoadControl;
Expand Down Expand Up @@ -39,6 +40,7 @@ public class ReactExoplayerViewManager extends ViewGroupManager<ReactExoplayerVi
private static final String PROP_SRC_START_POSITION = "startPosition";
private static final String PROP_SRC_CROP_START = "cropStart";
private static final String PROP_SRC_CROP_END = "cropEnd";
private static final String PROP_SRC_METADATA = "metadata";
private static final String PROP_AD_TAG_URL = "adTagUrl";
private static final String PROP_SRC_TYPE = "type";
private static final String PROP_DRM = "drm";
Expand Down Expand Up @@ -82,6 +84,7 @@ public class ReactExoplayerViewManager extends ViewGroupManager<ReactExoplayerVi
private static final String PROP_CONTROLS = "controls";
private static final String PROP_SUBTITLE_STYLE = "subtitleStyle";
private static final String PROP_SHUTTER_COLOR = "shutterColor";
private static final String PROP_SHOW_NOTIFICATION_CONTROLS = "showNotificationControls";
private static final String PROP_DEBUG = "debug";

private final ReactExoplayerConfig config;
Expand Down Expand Up @@ -166,6 +169,32 @@ public void setSrc(final ReactExoplayerView videoView, @Nullable ReadableMap src
}
}

ReadableMap propMetadata = ReactBridgeUtils.safeGetMap(src, PROP_SRC_METADATA);
MediaMetadata customMetadata = null;
if (propMetadata != null) {
String title = ReactBridgeUtils.safeGetString(propMetadata, "title");
String subtitle = ReactBridgeUtils.safeGetString(propMetadata, "subtitle");
String description = ReactBridgeUtils.safeGetString(propMetadata, "description");
String artist = ReactBridgeUtils.safeGetString(propMetadata, "artist");
String imageUriString = ReactBridgeUtils.safeGetString(propMetadata, "imageUri");

Uri imageUri = null;

try {
imageUri = Uri.parse(imageUriString);
} catch (Exception e) {
DebugLog.e("ExoPlayer Warning", "Could not parse imageUri in metadata");
}

customMetadata = new MediaMetadata.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setDescription(description)
.setArtist(artist)
.setArtworkUri(imageUri)
.build();
}

if (TextUtils.isEmpty(uriString)) {
videoView.clearSrc();
return;
Expand All @@ -175,7 +204,7 @@ public void setSrc(final ReactExoplayerView videoView, @Nullable ReadableMap src
Uri srcUri = Uri.parse(uriString);

if (srcUri != null) {
videoView.setSrc(srcUri, startPositionMs, cropStartMs, cropEndMs, extension, headers);
videoView.setSrc(srcUri, startPositionMs, cropStartMs, cropEndMs, extension, headers, customMetadata);
}
} else {
int identifier = context.getResources().getIdentifier(
Expand Down Expand Up @@ -400,6 +429,11 @@ public void setBufferConfig(final ReactExoplayerView videoView, @Nullable Readab
videoView.setBufferConfig(config);
}

@ReactProp(name = PROP_SHOW_NOTIFICATION_CONTROLS)
public void setShowNotificationControls(final ReactExoplayerView videoView, final boolean showNotificationControls) {
videoView.setShowNotificationControls(showNotificationControls);
}

@ReactProp(name = PROP_DEBUG, defaultBoolean = false)
public void setDebug(final ReactExoplayerView videoView,
@Nullable final ReadableMap debugConfig) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.brentvatne.exoplayer

import android.os.Bundle
import androidx.media3.common.Player
import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult
import com.google.common.util.concurrent.ListenableFuture

class VideoPlaybackCallback(private val seekIntervalMS: Long) : MediaSession.Callback {
override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult {
try {
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailablePlayerCommands(
MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
.add(Player.COMMAND_SEEK_FORWARD)
.add(Player.COMMAND_SEEK_BACK)
.build()
).setAvailableSessionCommands(
MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
.add(SessionCommand(VideoPlaybackService.COMMAND_SEEK_FORWARD, Bundle.EMPTY))
.add(SessionCommand(VideoPlaybackService.COMMAND_SEEK_BACKWARD, Bundle.EMPTY))
.build()
)
.build()
} catch (e: Exception) {
return MediaSession.ConnectionResult.reject()
}
}

override fun onCustomCommand(
session: MediaSession,
controller: MediaSession.ControllerInfo,
customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> {
when (customCommand.customAction) {
VideoPlaybackService.COMMAND_SEEK_FORWARD -> session.player.seekTo(session.player.contentPosition + seekIntervalMS)
VideoPlaybackService.COMMAND_SEEK_BACKWARD -> session.player.seekTo(session.player.contentPosition + seekIntervalMS)
}
return super.onCustomCommand(session, controller, customCommand, args)
}
}
Loading

0 comments on commit 8ad4be4

Please sign in to comment.