feat: add notification controls (#3723)
* 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>
This commit is contained in:
parent
c59d00a0f0
commit
8ad4be459b
@ -61,6 +61,6 @@ We have an discord server where you can ask questions and get help. [Join the di
|
|||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="./docs/assets/baners/twg-dark.png" />
|
<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" />
|
<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>
|
</picture>
|
||||||
</a>
|
</a>
|
@ -12,10 +12,15 @@ import static com.brentvatne.exoplayer.DataSourceUtil.buildAssetDataSourceFactor
|
|||||||
import android.annotation.SuppressLint;
|
import android.annotation.SuppressLint;
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
import android.app.ActivityManager;
|
import android.app.ActivityManager;
|
||||||
|
import android.content.ComponentName;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.ServiceConnection;
|
||||||
import android.media.AudioManager;
|
import android.media.AudioManager;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
|
import android.os.Build;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
|
import android.os.IBinder;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
import android.os.Message;
|
import android.os.Message;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
@ -27,6 +32,7 @@ import android.widget.ImageButton;
|
|||||||
|
|
||||||
import androidx.activity.OnBackPressedCallback;
|
import androidx.activity.OnBackPressedCallback;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
import androidx.annotation.WorkerThread;
|
import androidx.annotation.WorkerThread;
|
||||||
import androidx.core.view.WindowCompat;
|
import androidx.core.view.WindowCompat;
|
||||||
import androidx.core.view.WindowInsetsCompat;
|
import androidx.core.view.WindowInsetsCompat;
|
||||||
@ -35,6 +41,7 @@ import androidx.media3.common.AudioAttributes;
|
|||||||
import androidx.media3.common.C;
|
import androidx.media3.common.C;
|
||||||
import androidx.media3.common.Format;
|
import androidx.media3.common.Format;
|
||||||
import androidx.media3.common.MediaItem;
|
import androidx.media3.common.MediaItem;
|
||||||
|
import androidx.media3.common.MediaMetadata;
|
||||||
import androidx.media3.common.Metadata;
|
import androidx.media3.common.Metadata;
|
||||||
import androidx.media3.common.PlaybackException;
|
import androidx.media3.common.PlaybackException;
|
||||||
import androidx.media3.common.PlaybackParameters;
|
import androidx.media3.common.PlaybackParameters;
|
||||||
@ -94,6 +101,7 @@ import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter;
|
|||||||
import androidx.media3.extractor.metadata.emsg.EventMessage;
|
import androidx.media3.extractor.metadata.emsg.EventMessage;
|
||||||
import androidx.media3.extractor.metadata.id3.Id3Frame;
|
import androidx.media3.extractor.metadata.id3.Id3Frame;
|
||||||
import androidx.media3.extractor.metadata.id3.TextInformationFrame;
|
import androidx.media3.extractor.metadata.id3.TextInformationFrame;
|
||||||
|
import androidx.media3.session.MediaSessionService;
|
||||||
import androidx.media3.ui.LegacyPlayerControlView;
|
import androidx.media3.ui.LegacyPlayerControlView;
|
||||||
|
|
||||||
import com.brentvatne.common.api.BufferConfig;
|
import com.brentvatne.common.api.BufferConfig;
|
||||||
@ -172,6 +180,10 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||||||
private ExoPlayer player;
|
private ExoPlayer player;
|
||||||
private DefaultTrackSelector trackSelector;
|
private DefaultTrackSelector trackSelector;
|
||||||
private boolean playerNeedsSource;
|
private boolean playerNeedsSource;
|
||||||
|
private MediaMetadata customMetadata;
|
||||||
|
|
||||||
|
private ServiceConnection playbackServiceConnection;
|
||||||
|
private PlaybackServiceBinder playbackServiceBinder;
|
||||||
|
|
||||||
private int resumeWindow;
|
private int resumeWindow;
|
||||||
private long resumePosition;
|
private long resumePosition;
|
||||||
@ -224,6 +236,8 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||||||
private String[] drmLicenseHeader = null;
|
private String[] drmLicenseHeader = null;
|
||||||
private boolean controls;
|
private boolean controls;
|
||||||
private Uri adTagUrl;
|
private Uri adTagUrl;
|
||||||
|
|
||||||
|
private boolean showNotificationControls = false;
|
||||||
// \ End props
|
// \ End props
|
||||||
|
|
||||||
// React
|
// React
|
||||||
@ -342,6 +356,12 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||||||
cleanUpResources();
|
cleanUpResources();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDetachedFromWindow() {
|
||||||
|
cleanupPlaybackService();
|
||||||
|
super.onDetachedFromWindow();
|
||||||
|
}
|
||||||
|
|
||||||
public void cleanUpResources() {
|
public void cleanUpResources() {
|
||||||
stopPlayback();
|
stopPlayback();
|
||||||
themedReactContext.removeLifecycleEventListener(this);
|
themedReactContext.removeLifecycleEventListener(this);
|
||||||
@ -656,6 +676,10 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||||||
PlaybackParameters params = new PlaybackParameters(rate, 1f);
|
PlaybackParameters params = new PlaybackParameters(rate, 1f);
|
||||||
player.setPlaybackParameters(params);
|
player.setPlaybackParameters(params);
|
||||||
changeAudioOutput(this.audioOutput);
|
changeAudioOutput(this.audioOutput);
|
||||||
|
|
||||||
|
if(showNotificationControls) {
|
||||||
|
setupPlaybackService();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private DrmSessionManager initializePlayerDrm(ReactExoplayerView self) {
|
private DrmSessionManager initializePlayerDrm(ReactExoplayerView self) {
|
||||||
@ -741,6 +765,69 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||||||
applyModifiers();
|
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 {
|
private DrmSessionManager buildDrmSessionManager(UUID uuid, String licenseUrl, String[] keyRequestPropertiesArray) throws UnsupportedDrmException {
|
||||||
return buildDrmSessionManager(uuid, licenseUrl, keyRequestPropertiesArray, 0);
|
return buildDrmSessionManager(uuid, licenseUrl, keyRequestPropertiesArray, 0);
|
||||||
}
|
}
|
||||||
@ -795,7 +882,12 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||||||
}
|
}
|
||||||
config.setDisableDisconnectError(this.disableDisconnectError);
|
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) {
|
if (adTagUrl != null) {
|
||||||
mediaItemBuilder.setAdsConfiguration(
|
mediaItemBuilder.setAdsConfiguration(
|
||||||
@ -935,12 +1027,20 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||||||
if (adsLoader != null) {
|
if (adsLoader != null) {
|
||||||
adsLoader.setPlayer(null);
|
adsLoader.setPlayer(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(playbackServiceBinder != null) {
|
||||||
|
playbackServiceBinder.getService().unregisterPlayer(player);
|
||||||
|
themedReactContext.unbindService(playbackServiceConnection);
|
||||||
|
}
|
||||||
|
|
||||||
updateResumePosition();
|
updateResumePosition();
|
||||||
player.release();
|
player.release();
|
||||||
player.removeListener(this);
|
player.removeListener(this);
|
||||||
trackSelector = null;
|
trackSelector = null;
|
||||||
|
|
||||||
player = null;
|
player = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (adsLoader != null) {
|
if (adsLoader != null) {
|
||||||
adsLoader.release();
|
adsLoader.release();
|
||||||
}
|
}
|
||||||
@ -1542,7 +1642,21 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||||||
|
|
||||||
// ReactExoplayerViewManager public api
|
// 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) {
|
if (uri != null) {
|
||||||
boolean isSourceEqual = uri.equals(srcUri) && cropStartMs == this.cropStartMs && cropEndMs == this.cropEndMs;
|
boolean isSourceEqual = uri.equals(srcUri) && cropStartMs == this.cropStartMs && cropEndMs == this.cropEndMs;
|
||||||
hasDrmFailed = false;
|
hasDrmFailed = false;
|
||||||
@ -1555,6 +1669,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||||||
this.mediaDataSourceFactory =
|
this.mediaDataSourceFactory =
|
||||||
DataSourceUtil.getDefaultDataSourceFactory(this.themedReactContext, bandwidthMeter,
|
DataSourceUtil.getDefaultDataSourceFactory(this.themedReactContext, bandwidthMeter,
|
||||||
this.requestHeaders);
|
this.requestHeaders);
|
||||||
|
this.customMetadata = customMetadata;
|
||||||
|
|
||||||
if (!isSourceEqual) {
|
if (!isSourceEqual) {
|
||||||
reloadSource();
|
reloadSource();
|
||||||
@ -1573,6 +1688,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||||||
this.extension = null;
|
this.extension = null;
|
||||||
this.requestHeaders = null;
|
this.requestHeaders = null;
|
||||||
this.mediaDataSourceFactory = null;
|
this.mediaDataSourceFactory = null;
|
||||||
|
customMetadata = null;
|
||||||
clearResumePosition();
|
clearResumePosition();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1956,6 +2072,16 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||||||
this.contentStartTime = 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) {
|
public void setDisableBuffering(boolean disableBuffering) {
|
||||||
this.disableBuffering = disableBuffering;
|
this.disableBuffering = disableBuffering;
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@ import android.text.TextUtils;
|
|||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.media3.common.MediaMetadata;
|
||||||
import androidx.media3.common.util.Util;
|
import androidx.media3.common.util.Util;
|
||||||
import androidx.media3.datasource.RawResourceDataSource;
|
import androidx.media3.datasource.RawResourceDataSource;
|
||||||
import androidx.media3.exoplayer.DefaultLoadControl;
|
import androidx.media3.exoplayer.DefaultLoadControl;
|
||||||
@ -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_START_POSITION = "startPosition";
|
||||||
private static final String PROP_SRC_CROP_START = "cropStart";
|
private static final String PROP_SRC_CROP_START = "cropStart";
|
||||||
private static final String PROP_SRC_CROP_END = "cropEnd";
|
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_AD_TAG_URL = "adTagUrl";
|
||||||
private static final String PROP_SRC_TYPE = "type";
|
private static final String PROP_SRC_TYPE = "type";
|
||||||
private static final String PROP_DRM = "drm";
|
private static final String PROP_DRM = "drm";
|
||||||
@ -82,6 +84,7 @@ public class ReactExoplayerViewManager extends ViewGroupManager<ReactExoplayerVi
|
|||||||
private static final String PROP_CONTROLS = "controls";
|
private static final String PROP_CONTROLS = "controls";
|
||||||
private static final String PROP_SUBTITLE_STYLE = "subtitleStyle";
|
private static final String PROP_SUBTITLE_STYLE = "subtitleStyle";
|
||||||
private static final String PROP_SHUTTER_COLOR = "shutterColor";
|
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 static final String PROP_DEBUG = "debug";
|
||||||
|
|
||||||
private final ReactExoplayerConfig config;
|
private final ReactExoplayerConfig config;
|
||||||
@ -166,6 +169,32 @@ public class ReactExoplayerViewManager extends ViewGroupManager<ReactExoplayerVi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)) {
|
if (TextUtils.isEmpty(uriString)) {
|
||||||
videoView.clearSrc();
|
videoView.clearSrc();
|
||||||
return;
|
return;
|
||||||
@ -175,7 +204,7 @@ public class ReactExoplayerViewManager extends ViewGroupManager<ReactExoplayerVi
|
|||||||
Uri srcUri = Uri.parse(uriString);
|
Uri srcUri = Uri.parse(uriString);
|
||||||
|
|
||||||
if (srcUri != null) {
|
if (srcUri != null) {
|
||||||
videoView.setSrc(srcUri, startPositionMs, cropStartMs, cropEndMs, extension, headers);
|
videoView.setSrc(srcUri, startPositionMs, cropStartMs, cropEndMs, extension, headers, customMetadata);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
int identifier = context.getResources().getIdentifier(
|
int identifier = context.getResources().getIdentifier(
|
||||||
@ -400,6 +429,11 @@ public class ReactExoplayerViewManager extends ViewGroupManager<ReactExoplayerVi
|
|||||||
videoView.setBufferConfig(config);
|
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)
|
@ReactProp(name = PROP_DEBUG, defaultBoolean = false)
|
||||||
public void setDebug(final ReactExoplayerView videoView,
|
public void setDebug(final ReactExoplayerView videoView,
|
||||||
@Nullable final ReadableMap debugConfig) {
|
@Nullable final ReadableMap debugConfig) {
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,150 @@
|
|||||||
|
package com.brentvatne.exoplayer
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Binder
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.session.CommandButton
|
||||||
|
import androidx.media3.session.MediaSession
|
||||||
|
import androidx.media3.session.MediaSessionService
|
||||||
|
import androidx.media3.session.MediaStyleNotificationHelper
|
||||||
|
import androidx.media3.session.SessionCommand
|
||||||
|
import okhttp3.internal.immutableListOf
|
||||||
|
|
||||||
|
class PlaybackServiceBinder(val service: VideoPlaybackService) : Binder()
|
||||||
|
|
||||||
|
class VideoPlaybackService : MediaSessionService() {
|
||||||
|
private var mediaSessionsList = mutableMapOf<ExoPlayer, MediaSession>()
|
||||||
|
private var binder = PlaybackServiceBinder(this)
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
private val commandSeekForward = SessionCommand(COMMAND_SEEK_FORWARD, Bundle.EMPTY)
|
||||||
|
private val commandSeekBackward = SessionCommand(COMMAND_SEEK_BACKWARD, Bundle.EMPTY)
|
||||||
|
|
||||||
|
@SuppressLint("PrivateResource")
|
||||||
|
private val seekForwardBtn = CommandButton.Builder()
|
||||||
|
.setDisplayName("forward")
|
||||||
|
.setSessionCommand(commandSeekForward)
|
||||||
|
.setIconResId(androidx.media3.ui.R.drawable.exo_notification_fastforward)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@SuppressLint("PrivateResource")
|
||||||
|
private val seekBackwardBtn = CommandButton.Builder()
|
||||||
|
.setDisplayName("backward")
|
||||||
|
.setSessionCommand(commandSeekBackward)
|
||||||
|
.setIconResId(androidx.media3.ui.R.drawable.exo_notification_rewind)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Player Registry
|
||||||
|
|
||||||
|
fun registerPlayer(player: ExoPlayer) {
|
||||||
|
if (mediaSessionsList.containsKey(player)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val mediaSession = MediaSession.Builder(this, player)
|
||||||
|
.setId("RNVideoPlaybackService_" + player.hashCode())
|
||||||
|
.setCallback(VideoPlaybackCallback(SEEK_INTERVAL_MS))
|
||||||
|
.setCustomLayout(immutableListOf(seekBackwardBtn, seekForwardBtn))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
mediaSessionsList[player] = mediaSession
|
||||||
|
addSession(mediaSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unregisterPlayer(player: ExoPlayer) {
|
||||||
|
hidePlayerNotification(player)
|
||||||
|
val session = mediaSessionsList.remove(player)
|
||||||
|
session?.release()
|
||||||
|
|
||||||
|
if (mediaSessionsList.isEmpty()) {
|
||||||
|
cleanup()
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
|
||||||
|
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder {
|
||||||
|
super.onBind(intent)
|
||||||
|
return binder
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
|
||||||
|
createSessionNotification(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
cleanup()
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
cleanup()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createSessionNotification(session: MediaSession) {
|
||||||
|
val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
notificationManager.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
NOTIFICATION_CHANEL_ID,
|
||||||
|
NOTIFICATION_CHANEL_ID,
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.player.currentMediaItem == null) {
|
||||||
|
notificationManager.cancel(session.player.hashCode())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val notificationCompact = NotificationCompat.Builder(this, NOTIFICATION_CHANEL_ID)
|
||||||
|
.setSmallIcon(androidx.media3.session.R.drawable.media3_icon_circular_play)
|
||||||
|
.setStyle(MediaStyleNotificationHelper.MediaStyle(session))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
notificationManager.notify(session.player.hashCode(), notificationCompact)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hidePlayerNotification(player: ExoPlayer) {
|
||||||
|
val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
notificationManager.cancel(player.hashCode())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hideAllNotifications() {
|
||||||
|
val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
notificationManager.cancelAll()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
notificationManager.deleteNotificationChannel(NOTIFICATION_CHANEL_ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cleanup() {
|
||||||
|
hideAllNotifications()
|
||||||
|
mediaSessionsList.forEach { (_, session) ->
|
||||||
|
session.release()
|
||||||
|
}
|
||||||
|
mediaSessionsList.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val COMMAND_SEEK_FORWARD = "SEEK_FORWARD"
|
||||||
|
const val COMMAND_SEEK_BACKWARD = "SEEK_BACKWARD"
|
||||||
|
const val NOTIFICATION_CHANEL_ID = "RNVIDEO_SESSION_NOTIFICATION"
|
||||||
|
const val SEEK_INTERVAL_MS = 10000L
|
||||||
|
}
|
||||||
|
}
|
@ -706,20 +706,23 @@ source={{ uri: 'https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8'
|
|||||||
|
|
||||||
#### Overriding the metadata of a source
|
#### Overriding the metadata of a source
|
||||||
|
|
||||||
<PlatformsList types={['tvOS']} />
|
<PlatformsList types={['Android, iOS, tvOS']} />
|
||||||
|
|
||||||
Provide an optional `title`, `subtitle`, `customImageUri` and/or `description` properties for the video.
|
Provide an optional `title`, `subtitle`, `artist`, `imageUri` and/or `description` properties for the video.
|
||||||
Useful when to adapt the tvOS playback experience.
|
Useful when using notification controls on Android or iOS or to adapt the tvOS playback experience.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
source={{
|
source={{
|
||||||
uri: 'https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8',
|
uri: 'https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8',
|
||||||
title: 'Custom Title',
|
metadata: {
|
||||||
subtitle: 'Custom Subtitle',
|
title: 'Custom Title',
|
||||||
description: 'Custom Description',
|
subtitle: 'Custom Subtitle',
|
||||||
customImageUri: 'https://pbs.twimg.com/profile_images/1498641868397191170/6qW2XkuI_400x400.png'
|
artist: 'Custom Artist',
|
||||||
|
description: 'Custom Description',
|
||||||
|
imageUri: 'https://pbs.twimg.com/profile_images/1498641868397191170/6qW2XkuI_400x400.png'
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -780,6 +783,45 @@ textTracks={[
|
|||||||
]}
|
]}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `showNotificationContorols`
|
||||||
|
|
||||||
|
<PlatformsList types={['Android', 'iOS']}/>
|
||||||
|
|
||||||
|
Controls whether to show media controls in the notification area.
|
||||||
|
For Android each Video component will have its own notification controls and for iOS only one notification control will be shown for the last Active Video component.
|
||||||
|
|
||||||
|
You propably want also set `playInBackground` to `true` to keep the video playing when the app is in the background or `playWhenInactive` to `true` to keep the video playing when notifications or the Control Center are in front of the video.
|
||||||
|
|
||||||
|
To customize the notification controls you can use `metadata` property in the `source` prop.
|
||||||
|
|
||||||
|
- **false (default)** - Don't show media controls in the notification area
|
||||||
|
- **true** - Show media controls in the notification area
|
||||||
|
|
||||||
|
**To test notification controls on iOS you need to run the app on a real device, as the simulator does not support it.**
|
||||||
|
|
||||||
|
**For Android you have to add the following code in your `AndroidManifest.xml` file:**
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
...
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
...
|
||||||
|
|
||||||
|
<application>
|
||||||
|
...
|
||||||
|
<service
|
||||||
|
android:name="com.brentvatne.exoplayer.VideoPlaybackService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaPlayback">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
|
```
|
||||||
|
|
||||||
### `useSecureView`
|
### `useSecureView`
|
||||||
|
|
||||||
<PlatformsList types={['Android']} />
|
<PlatformsList types={['Android']} />
|
||||||
|
@ -10,6 +10,9 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MainApplication"
|
android:name=".MainApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@ -32,5 +35,13 @@
|
|||||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<service
|
||||||
|
android:name="com.brentvatne.exoplayer.VideoPlaybackService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaPlayback">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
@ -71,6 +71,7 @@ interface StateType {
|
|||||||
showRNVControls: boolean;
|
showRNVControls: boolean;
|
||||||
useCache: boolean;
|
useCache: boolean;
|
||||||
poster?: string;
|
poster?: string;
|
||||||
|
showNotificationControls: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
class VideoPlayer extends Component {
|
class VideoPlayer extends Component {
|
||||||
@ -100,6 +101,7 @@ class VideoPlayer extends Component {
|
|||||||
showRNVControls: false,
|
showRNVControls: false,
|
||||||
useCache: false,
|
useCache: false,
|
||||||
poster: undefined,
|
poster: undefined,
|
||||||
|
showNotificationControls: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
seekerWidth = 0;
|
seekerWidth = 0;
|
||||||
@ -115,10 +117,24 @@ class VideoPlayer extends Component {
|
|||||||
{
|
{
|
||||||
description: 'local file portrait',
|
description: 'local file portrait',
|
||||||
uri: require('./portrait.mp4'),
|
uri: require('./portrait.mp4'),
|
||||||
|
metadata: {
|
||||||
|
title: 'Test Title',
|
||||||
|
subtitle: 'Test Subtitle',
|
||||||
|
artist: 'Test Artist',
|
||||||
|
description: 'Test Description',
|
||||||
|
imageUri: 'https://pbs.twimg.com/profile_images/1498641868397191170/6qW2XkuI_400x400.png'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: '(hls|live) red bull tv',
|
description: '(hls|live) red bull tv',
|
||||||
uri: 'https://rbmn-live.akamaized.net/hls/live/590964/BoRB-AT/master_928.m3u8',
|
uri: 'https://rbmn-live.akamaized.net/hls/live/590964/BoRB-AT/master_928.m3u8',
|
||||||
|
metadata: {
|
||||||
|
title: 'Custom Title',
|
||||||
|
subtitle: 'Custom Subtitle',
|
||||||
|
artist: 'Custom Artist',
|
||||||
|
description: 'Custom Description',
|
||||||
|
imageUri: 'https://pbs.twimg.com/profile_images/1498641868397191170/6qW2XkuI_400x400.png'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'invalid URL',
|
description: 'invalid URL',
|
||||||
@ -410,6 +426,12 @@ class VideoPlayer extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleShowNotificationControls() {
|
||||||
|
this.setState({
|
||||||
|
showNotificationControls: !this.state.showNotificationControls,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
goToChannel(channel: number) {
|
goToChannel(channel: number) {
|
||||||
this.setState({
|
this.setState({
|
||||||
srcListId: channel,
|
srcListId: channel,
|
||||||
@ -729,6 +751,14 @@ class VideoPlayer extends Component {
|
|||||||
selectedText="poster"
|
selectedText="poster"
|
||||||
unselectedText="no poster"
|
unselectedText="no poster"
|
||||||
/>
|
/>
|
||||||
|
<ToggleControl
|
||||||
|
isSelected={this.state.showNotificationControls}
|
||||||
|
onPress={() => {
|
||||||
|
this.toggleShowNotificationControls();
|
||||||
|
}}
|
||||||
|
selectedText="hide notification controls"
|
||||||
|
unselectedText="show notification controls"
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.generalControls}>
|
<View style={styles.generalControls}>
|
||||||
{/* shall be replaced by slider */}
|
{/* shall be replaced by slider */}
|
||||||
@ -858,6 +888,7 @@ class VideoPlayer extends Component {
|
|||||||
return (
|
return (
|
||||||
<TouchableOpacity style={viewStyle}>
|
<TouchableOpacity style={viewStyle}>
|
||||||
<Video
|
<Video
|
||||||
|
showNotificationControls={this.state.showNotificationControls}
|
||||||
ref={(ref: VideoRef) => {
|
ref={(ref: VideoRef) => {
|
||||||
this.video = ref;
|
this.video = ref;
|
||||||
}}
|
}}
|
||||||
|
@ -13,11 +13,13 @@ export default function App() {
|
|||||||
uri: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
|
uri: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
|
||||||
// uri: 'https://sample.vodobox.net/skate_phantom_flex_4k/skate_phantom_flex_4k.m3u8',
|
// uri: 'https://sample.vodobox.net/skate_phantom_flex_4k/skate_phantom_flex_4k.m3u8',
|
||||||
// type: 'm3u8',
|
// type: 'm3u8',
|
||||||
title: 'Custom Title',
|
metadata: {
|
||||||
subtitle: 'Custom Subtitle',
|
title: 'Custom Title',
|
||||||
description: 'Custom Description',
|
subtitle: 'Custom Subtitle',
|
||||||
customImageUri:
|
description: 'Custom Description',
|
||||||
'https://pbs.twimg.com/profile_images/1498641868397191170/6qW2XkuI_400x400.png',
|
imageUri:
|
||||||
|
'https://pbs.twimg.com/profile_images/1498641868397191170/6qW2XkuI_400x400.png',
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
style={[styles.fullScreen, StyleSheet.absoluteFillObject]}
|
style={[styles.fullScreen, StyleSheet.absoluteFillObject]}
|
||||||
controls
|
controls
|
||||||
|
28
ios/Video/DataStructures/CustomMetadata.swift
Normal file
28
ios/Video/DataStructures/CustomMetadata.swift
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
struct CustomMetadata {
|
||||||
|
let title: String?
|
||||||
|
let subtitle: String?
|
||||||
|
let artist: String?
|
||||||
|
let description: String?
|
||||||
|
let imageUri: String?
|
||||||
|
|
||||||
|
let json: NSDictionary?
|
||||||
|
|
||||||
|
init(_ json: NSDictionary?) {
|
||||||
|
guard let json else {
|
||||||
|
self.json = nil
|
||||||
|
title = nil
|
||||||
|
subtitle = nil
|
||||||
|
artist = nil
|
||||||
|
description = nil
|
||||||
|
imageUri = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.json = json
|
||||||
|
title = json["title"] as? String ?? ""
|
||||||
|
subtitle = json["subtitle"] as? String ?? ""
|
||||||
|
artist = json["artist"] as? String ?? ""
|
||||||
|
description = json["description"] as? String ?? ""
|
||||||
|
imageUri = json["imageUri"] as? String ?? ""
|
||||||
|
}
|
||||||
|
}
|
@ -8,11 +8,7 @@ struct VideoSource {
|
|||||||
let startPosition: Float64?
|
let startPosition: Float64?
|
||||||
let cropStart: Int64?
|
let cropStart: Int64?
|
||||||
let cropEnd: Int64?
|
let cropEnd: Int64?
|
||||||
// Custom Metadata
|
let customMetadata: CustomMetadata?
|
||||||
let title: String?
|
|
||||||
let subtitle: String?
|
|
||||||
let description: String?
|
|
||||||
let customImageUri: String?
|
|
||||||
|
|
||||||
let json: NSDictionary?
|
let json: NSDictionary?
|
||||||
|
|
||||||
@ -28,10 +24,7 @@ struct VideoSource {
|
|||||||
self.startPosition = nil
|
self.startPosition = nil
|
||||||
self.cropStart = nil
|
self.cropStart = nil
|
||||||
self.cropEnd = nil
|
self.cropEnd = nil
|
||||||
self.title = nil
|
self.customMetadata = nil
|
||||||
self.subtitle = nil
|
|
||||||
self.description = nil
|
|
||||||
self.customImageUri = nil
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.json = json
|
self.json = json
|
||||||
@ -54,9 +47,6 @@ struct VideoSource {
|
|||||||
self.startPosition = json["startPosition"] as? Float64
|
self.startPosition = json["startPosition"] as? Float64
|
||||||
self.cropStart = (json["cropStart"] as? Float64).flatMap { Int64(round($0)) }
|
self.cropStart = (json["cropStart"] as? Float64).flatMap { Int64(round($0)) }
|
||||||
self.cropEnd = (json["cropEnd"] as? Float64).flatMap { Int64(round($0)) }
|
self.cropEnd = (json["cropEnd"] as? Float64).flatMap { Int64(round($0)) }
|
||||||
self.title = json["title"] as? String
|
self.customMetadata = CustomMetadata(json["metadata"] as? NSDictionary)
|
||||||
self.subtitle = json["subtitle"] as? String
|
|
||||||
self.description = json["description"] as? String
|
|
||||||
self.customImageUri = json["customImageUri"] as? String
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -389,15 +389,21 @@ enum RCTVideoUtils {
|
|||||||
return item.copy() as! AVMetadataItem
|
return item.copy() as! AVMetadataItem
|
||||||
}
|
}
|
||||||
|
|
||||||
static func createImageMetadataItem(imageUri: String) -> Data? {
|
static func createImageMetadataItem(imageUri: String) async -> Data? {
|
||||||
if let uri = URL(string: imageUri),
|
guard let url = URL(string: imageUri) else {
|
||||||
let imgData = try? Data(contentsOf: uri),
|
return nil
|
||||||
let image = UIImage(data: imgData),
|
|
||||||
let pngData = image.pngData() {
|
|
||||||
return pngData
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
do {
|
||||||
|
let (data, _) = try await URLSession.shared.data(from: url)
|
||||||
|
guard let image = UIImage(data: data), let pngData = image.pngData() else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return pngData
|
||||||
|
} catch {
|
||||||
|
print("Error fetching image data: \(error.localizedDescription)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func getCurrentWindow() -> UIWindow? {
|
static func getCurrentWindow() -> UIWindow? {
|
||||||
|
281
ios/Video/NowPlayingInfoCenterManager.swift
Normal file
281
ios/Video/NowPlayingInfoCenterManager.swift
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
import Foundation
|
||||||
|
import MediaPlayer
|
||||||
|
|
||||||
|
class NowPlayingInfoCenterManager {
|
||||||
|
static let shared = NowPlayingInfoCenterManager()
|
||||||
|
|
||||||
|
private let SEEK_INTERVAL_SECONDS: Double = 10
|
||||||
|
|
||||||
|
private weak var currentPlayer: AVPlayer?
|
||||||
|
private var players = NSHashTable<AVPlayer>.weakObjects()
|
||||||
|
|
||||||
|
private var observers: [Int: NSKeyValueObservation] = [:]
|
||||||
|
private var playbackObserver: Any?
|
||||||
|
|
||||||
|
private var playTarget: Any?
|
||||||
|
private var pauseTarget: Any?
|
||||||
|
private var skipForwardTarget: Any?
|
||||||
|
private var skipBackwardTarget: Any?
|
||||||
|
private var playbackPositionTarget: Any?
|
||||||
|
private var seekTarget: Any?
|
||||||
|
|
||||||
|
private var receivingRemoveControlEvents = false {
|
||||||
|
didSet {
|
||||||
|
if receivingRemoveControlEvents {
|
||||||
|
try? AVAudioSession.sharedInstance().setCategory(.playback)
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
UIApplication.shared.beginReceivingRemoteControlEvents()
|
||||||
|
} else {
|
||||||
|
UIApplication.shared.endReceivingRemoteControlEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerPlayer(player: AVPlayer) {
|
||||||
|
if players.contains(player) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if receivingRemoveControlEvents == false {
|
||||||
|
receivingRemoveControlEvents = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if let oldObserver = observers[player.hashValue] {
|
||||||
|
oldObserver.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
observers[player.hashValue] = observePlayers(player: player)
|
||||||
|
players.add(player)
|
||||||
|
|
||||||
|
if currentPlayer == nil {
|
||||||
|
setCurrentPlayer(player: player)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removePlayer(player: AVPlayer) {
|
||||||
|
if !players.contains(player) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let observer = observers[players.hashValue] {
|
||||||
|
observer.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
observers.removeValue(forKey: players.hashValue)
|
||||||
|
players.remove(player)
|
||||||
|
|
||||||
|
if currentPlayer == player {
|
||||||
|
currentPlayer = nil
|
||||||
|
updateMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
if players.allObjects.isEmpty {
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func cleanup() {
|
||||||
|
observers.removeAll()
|
||||||
|
players.removeAllObjects()
|
||||||
|
|
||||||
|
if let playbackObserver {
|
||||||
|
currentPlayer?.removeTimeObserver(playbackObserver)
|
||||||
|
}
|
||||||
|
|
||||||
|
let commandCenter = MPRemoteCommandCenter.shared()
|
||||||
|
invalidateTargets(commandCenter)
|
||||||
|
|
||||||
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = [:]
|
||||||
|
receivingRemoveControlEvents = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setCurrentPlayer(player: AVPlayer) {
|
||||||
|
if player == currentPlayer {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let playbackObserver {
|
||||||
|
currentPlayer?.removeTimeObserver(playbackObserver)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPlayer = player
|
||||||
|
registerTargets()
|
||||||
|
|
||||||
|
updateMetadata()
|
||||||
|
|
||||||
|
// one second observer
|
||||||
|
playbackObserver = player.addPeriodicTimeObserver(
|
||||||
|
forInterval: CMTime(value: 1, timescale: 4),
|
||||||
|
queue: .global(),
|
||||||
|
using: { [weak self] _ in
|
||||||
|
self?.updatePlaybackInfo()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func registerTargets() {
|
||||||
|
let commandCenter = MPRemoteCommandCenter.shared()
|
||||||
|
|
||||||
|
invalidateTargets(commandCenter)
|
||||||
|
|
||||||
|
playTarget = commandCenter.playCommand.addTarget { [weak self] _ in
|
||||||
|
guard let self, let player = self.currentPlayer else {
|
||||||
|
return .commandFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
if player.rate == 0 {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
pauseTarget = commandCenter.pauseCommand.addTarget { [weak self] _ in
|
||||||
|
guard let self, let player = self.currentPlayer else {
|
||||||
|
return .commandFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
if player.rate != 0 {
|
||||||
|
player.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
skipBackwardTarget = commandCenter.skipBackwardCommand.addTarget { [weak self] _ in
|
||||||
|
guard let self, let player = self.currentPlayer else {
|
||||||
|
return .commandFailed
|
||||||
|
}
|
||||||
|
let newTime = player.currentTime() - CMTime(seconds: SEEK_INTERVAL_SECONDS, preferredTimescale: .max)
|
||||||
|
player.seek(to: newTime)
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
skipForwardTarget = commandCenter.skipForwardCommand.addTarget { [weak self] _ in
|
||||||
|
guard let self, let player = self.currentPlayer else {
|
||||||
|
return .commandFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
let newTime = player.currentTime() + CMTime(seconds: SEEK_INTERVAL_SECONDS, preferredTimescale: .max)
|
||||||
|
player.seek(to: newTime)
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
playbackPositionTarget = commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
|
||||||
|
guard let self, let player = self.currentPlayer else {
|
||||||
|
return .commandFailed
|
||||||
|
}
|
||||||
|
if let event = event as? MPChangePlaybackPositionCommandEvent {
|
||||||
|
player.seek(to: CMTime(seconds: event.positionTime, preferredTimescale: .max)) { _ in
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
return .commandFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func invalidateTargets(_ commandCenter: MPRemoteCommandCenter) {
|
||||||
|
commandCenter.playCommand.removeTarget(playTarget)
|
||||||
|
commandCenter.pauseCommand.removeTarget(pauseTarget)
|
||||||
|
commandCenter.skipForwardCommand.removeTarget(skipForwardTarget)
|
||||||
|
commandCenter.skipBackwardCommand.removeTarget(skipBackwardTarget)
|
||||||
|
commandCenter.changePlaybackPositionCommand.removeTarget(playbackPositionTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateMetadata() {
|
||||||
|
guard let player = currentPlayer, let currentItem = player.currentItem else {
|
||||||
|
let commandCenter = MPRemoteCommandCenter.shared()
|
||||||
|
invalidateTargets(commandCenter)
|
||||||
|
|
||||||
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = [:]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// commonMetadata is metadata from asset, externalMetadata is custom metadata set by user
|
||||||
|
let metadata = currentItem.asset.commonMetadata + currentItem.externalMetadata
|
||||||
|
var nowPlayingInfo: [String: Any] = [:]
|
||||||
|
|
||||||
|
if let titleItem = AVMetadataItem.metadataItems(from: metadata, filteredByIdentifier: .commonIdentifierTitle).first?.value {
|
||||||
|
nowPlayingInfo[MPMediaItemPropertyTitle] = titleItem
|
||||||
|
} else {
|
||||||
|
nowPlayingInfo[MPMediaItemPropertyTitle] = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if let artistItem = AVMetadataItem.metadataItems(from: metadata, filteredByIdentifier: .commonIdentifierArtist).first?.value {
|
||||||
|
nowPlayingInfo[MPMediaItemPropertyArtist] = artistItem
|
||||||
|
} else {
|
||||||
|
nowPlayingInfo[MPMediaItemPropertyArtist] = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// I have some issue with this - setting artworkItem when it not set dont return nil but also is crashing application
|
||||||
|
// this is very hacky workaround for it
|
||||||
|
if let artworkItem = AVMetadataItem.metadataItems(from: metadata, filteredByIdentifier: .commonIdentifierArtwork).first?.value as? Data {
|
||||||
|
if let image = UIImage(data: artworkItem) {
|
||||||
|
nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size, requestHandler: { _ in
|
||||||
|
return image
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: UIImage().size, requestHandler: { _ in
|
||||||
|
UIImage()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = currentItem.duration.seconds
|
||||||
|
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentItem.currentTime().seconds
|
||||||
|
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.rate
|
||||||
|
nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaType.video.rawValue
|
||||||
|
|
||||||
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updatePlaybackInfo() {
|
||||||
|
guard let player = currentPlayer, let currentItem = player.currentItem else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// We dont want to update playback if we did not set metadata yet
|
||||||
|
if var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo {
|
||||||
|
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = currentItem.duration.seconds
|
||||||
|
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentItem.currentTime().seconds.rounded()
|
||||||
|
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.rate
|
||||||
|
|
||||||
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findNewCurrentPlayer() {
|
||||||
|
if let newPlayer = players.allObjects.first(where: {
|
||||||
|
$0.rate != 0
|
||||||
|
}) {
|
||||||
|
setCurrentPlayer(player: newPlayer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We will observe players rate to find last active player that info will be displayed
|
||||||
|
private func observePlayers(player: AVPlayer) -> NSKeyValueObservation {
|
||||||
|
return player.observe(\.rate) { [weak self] player, change in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
let rate = change.newValue
|
||||||
|
|
||||||
|
// case where there is new player that is not paused
|
||||||
|
// In this case event is triggered by non currentPlayer
|
||||||
|
if rate != 0 && currentPlayer != player {
|
||||||
|
setCurrentPlayer(player: player)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// case where currentPlayer was paused
|
||||||
|
// In this case event is triggeret by currentPlayer
|
||||||
|
if rate == 0 && currentPlayer == player {
|
||||||
|
findNewCurrentPlayer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -66,6 +66,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
|||||||
private var _filterEnabled = false
|
private var _filterEnabled = false
|
||||||
private var _presentingViewController: UIViewController?
|
private var _presentingViewController: UIViewController?
|
||||||
private var _startPosition: Float64 = -1
|
private var _startPosition: Float64 = -1
|
||||||
|
private var _showNotificationControls = false
|
||||||
private var _pictureInPictureEnabled = false {
|
private var _pictureInPictureEnabled = false {
|
||||||
didSet {
|
didSet {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@ -244,6 +245,10 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
|||||||
self.removePlayerLayer()
|
self.removePlayerLayer()
|
||||||
_playerObserver.clearPlayer()
|
_playerObserver.clearPlayer()
|
||||||
|
|
||||||
|
if let player = _player {
|
||||||
|
NowPlayingInfoCenterManager.shared.removePlayer(player: player)
|
||||||
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
_pip = nil
|
_pip = nil
|
||||||
#endif
|
#endif
|
||||||
@ -358,54 +363,54 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
|||||||
// MARK: - Player and source
|
// MARK: - Player and source
|
||||||
|
|
||||||
func preparePlayerItem() async throws -> AVPlayerItem {
|
func preparePlayerItem() async throws -> AVPlayerItem {
|
||||||
guard let source = self._source else {
|
guard let source = _source else {
|
||||||
DebugLog("The source not exist")
|
DebugLog("The source not exist")
|
||||||
self.isSetSourceOngoing = false
|
isSetSourceOngoing = false
|
||||||
self.applyNextSource()
|
applyNextSource()
|
||||||
throw NSError(domain: "", code: 0, userInfo: nil)
|
throw NSError(domain: "", code: 0, userInfo: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let uri = source.uri, uri.starts(with: "ph://") {
|
if let uri = source.uri, uri.starts(with: "ph://") {
|
||||||
let photoAsset = await RCTVideoUtils.preparePHAsset(uri: uri)
|
let photoAsset = await RCTVideoUtils.preparePHAsset(uri: uri)
|
||||||
return await self.playerItemPrepareText(asset: photoAsset, assetOptions: nil, uri: source.uri ?? "")
|
return await playerItemPrepareText(asset: photoAsset, assetOptions: nil, uri: source.uri ?? "")
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let assetResult = RCTVideoUtils.prepareAsset(source: source),
|
guard let assetResult = RCTVideoUtils.prepareAsset(source: source),
|
||||||
let asset = assetResult.asset,
|
let asset = assetResult.asset,
|
||||||
let assetOptions = assetResult.assetOptions else {
|
let assetOptions = assetResult.assetOptions else {
|
||||||
DebugLog("Could not find video URL in source '\(String(describing: self._source))'")
|
DebugLog("Could not find video URL in source '\(String(describing: _source))'")
|
||||||
self.isSetSourceOngoing = false
|
isSetSourceOngoing = false
|
||||||
self.applyNextSource()
|
applyNextSource()
|
||||||
throw NSError(domain: "", code: 0, userInfo: nil)
|
throw NSError(domain: "", code: 0, userInfo: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let assetResult = RCTVideoUtils.prepareAsset(source: source),
|
guard let assetResult = RCTVideoUtils.prepareAsset(source: source),
|
||||||
let asset = assetResult.asset,
|
let asset = assetResult.asset,
|
||||||
let assetOptions = assetResult.assetOptions else {
|
let assetOptions = assetResult.assetOptions else {
|
||||||
DebugLog("Could not find video URL in source '\(String(describing: self._source))'")
|
DebugLog("Could not find video URL in source '\(String(describing: _source))'")
|
||||||
self.isSetSourceOngoing = false
|
isSetSourceOngoing = false
|
||||||
self.applyNextSource()
|
applyNextSource()
|
||||||
throw NSError(domain: "", code: 0, userInfo: nil)
|
throw NSError(domain: "", code: 0, userInfo: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let startPosition = self._source?.startPosition {
|
if let startPosition = _source?.startPosition {
|
||||||
self._startPosition = startPosition / 1000
|
_startPosition = startPosition / 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
#if USE_VIDEO_CACHING
|
#if USE_VIDEO_CACHING
|
||||||
if self._videoCache.shouldCache(source: source, textTracks: self._textTracks) {
|
if _videoCache.shouldCache(source: source, textTracks: _textTracks) {
|
||||||
return try await self._videoCache.playerItemForSourceUsingCache(uri: source.uri, assetOptions: assetOptions)
|
return try await _videoCache.playerItemForSourceUsingCache(uri: source.uri, assetOptions: assetOptions)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
if self._drm != nil || self._localSourceEncryptionKeyScheme != nil {
|
if _drm != nil || _localSourceEncryptionKeyScheme != nil {
|
||||||
self._resouceLoaderDelegate = RCTResourceLoaderDelegate(
|
_resouceLoaderDelegate = RCTResourceLoaderDelegate(
|
||||||
asset: asset,
|
asset: asset,
|
||||||
drm: self._drm,
|
drm: _drm,
|
||||||
localSourceEncryptionKeyScheme: self._localSourceEncryptionKeyScheme,
|
localSourceEncryptionKeyScheme: _localSourceEncryptionKeyScheme,
|
||||||
onVideoError: self.onVideoError,
|
onVideoError: onVideoError,
|
||||||
onGetLicense: self.onGetLicense,
|
onGetLicense: onGetLicense,
|
||||||
reactTag: self.reactTag
|
reactTag: reactTag
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -413,53 +418,62 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setupPlayer(playerItem: AVPlayerItem) async throws {
|
func setupPlayer(playerItem: AVPlayerItem) async throws {
|
||||||
if !self.isSetSourceOngoing {
|
if !isSetSourceOngoing {
|
||||||
DebugLog("setSrc has been canceled last step")
|
DebugLog("setSrc has been canceled last step")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
self._player?.pause()
|
_player?.pause()
|
||||||
self._playerItem = playerItem
|
_playerItem = playerItem
|
||||||
self._playerObserver.playerItem = self._playerItem
|
_playerObserver.playerItem = _playerItem
|
||||||
self.setPreferredForwardBufferDuration(self._preferredForwardBufferDuration)
|
setPreferredForwardBufferDuration(_preferredForwardBufferDuration)
|
||||||
self.setPlaybackRange(playerItem, withCropStart: self._source?.cropStart, withCropEnd: self._source?.cropEnd)
|
setPlaybackRange(playerItem, withCropStart: _source?.cropStart, withCropEnd: _source?.cropEnd)
|
||||||
self.setFilter(self._filterName)
|
setFilter(_filterName)
|
||||||
if let maxBitRate = self._maxBitRate {
|
if let maxBitRate = _maxBitRate {
|
||||||
self._playerItem?.preferredPeakBitRate = Double(maxBitRate)
|
_playerItem?.preferredPeakBitRate = Double(maxBitRate)
|
||||||
}
|
}
|
||||||
|
|
||||||
self._player = self._player ?? AVPlayer()
|
if _player == nil {
|
||||||
|
_player = AVPlayer()
|
||||||
|
_player!.replaceCurrentItem(with: playerItem)
|
||||||
|
|
||||||
self._player?.replaceCurrentItem(with: playerItem)
|
// We need to register player after we set current item and only for init
|
||||||
|
NowPlayingInfoCenterManager.shared.registerPlayer(player: _player!)
|
||||||
|
} else {
|
||||||
|
_player?.replaceCurrentItem(with: playerItem)
|
||||||
|
|
||||||
self._playerObserver.player = self._player
|
// later we can just call "updateMetadata:
|
||||||
self.applyModifiers()
|
NowPlayingInfoCenterManager.shared.updateMetadata()
|
||||||
self._player?.actionAtItemEnd = .none
|
}
|
||||||
|
|
||||||
|
_playerObserver.player = _player
|
||||||
|
applyModifiers()
|
||||||
|
_player?.actionAtItemEnd = .none
|
||||||
|
|
||||||
if #available(iOS 10.0, *) {
|
if #available(iOS 10.0, *) {
|
||||||
self.setAutomaticallyWaitsToMinimizeStalling(self._automaticallyWaitsToMinimizeStalling)
|
setAutomaticallyWaitsToMinimizeStalling(_automaticallyWaitsToMinimizeStalling)
|
||||||
}
|
}
|
||||||
|
|
||||||
#if USE_GOOGLE_IMA
|
#if USE_GOOGLE_IMA
|
||||||
if self._adTagUrl != nil {
|
if _adTagUrl != nil {
|
||||||
// Set up your content playhead and contentComplete callback.
|
// Set up your content playhead and contentComplete callback.
|
||||||
self._contentPlayhead = IMAAVPlayerContentPlayhead(avPlayer: self._player!)
|
_contentPlayhead = IMAAVPlayerContentPlayhead(avPlayer: _player!)
|
||||||
|
|
||||||
self._imaAdsManager.setUpAdsLoader()
|
_imaAdsManager.setUpAdsLoader()
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
// Perform on next run loop, otherwise onVideoLoadStart is nil
|
// Perform on next run loop, otherwise onVideoLoadStart is nil
|
||||||
self.onVideoLoadStart?([
|
onVideoLoadStart?([
|
||||||
"src": [
|
"src": [
|
||||||
"uri": self._source?.uri ?? NSNull(),
|
"uri": _source?.uri ?? NSNull(),
|
||||||
"type": self._source?.type ?? NSNull(),
|
"type": _source?.type ?? NSNull(),
|
||||||
"isNetwork": NSNumber(value: self._source?.isNetwork ?? false),
|
"isNetwork": NSNumber(value: _source?.isNetwork ?? false),
|
||||||
],
|
],
|
||||||
"drm": self._drm?.json ?? NSNull(),
|
"drm": _drm?.json ?? NSNull(),
|
||||||
"target": self.reactTag,
|
"target": reactTag,
|
||||||
])
|
])
|
||||||
self.isSetSourceOngoing = false
|
isSetSourceOngoing = false
|
||||||
self.applyNextSource()
|
applyNextSource()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
@ -478,6 +492,11 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
|||||||
self._player?.replaceCurrentItem(with: nil)
|
self._player?.replaceCurrentItem(with: nil)
|
||||||
self.isSetSourceOngoing = false
|
self.isSetSourceOngoing = false
|
||||||
self.applyNextSource()
|
self.applyNextSource()
|
||||||
|
|
||||||
|
if let player = self._player {
|
||||||
|
NowPlayingInfoCenterManager.shared.removePlayer(player: player)
|
||||||
|
}
|
||||||
|
|
||||||
DebugLog("setSrc Stopping playback")
|
DebugLog("setSrc Stopping playback")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -500,6 +519,10 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
|||||||
self.onVideoError?(["error": error.localizedDescription])
|
self.onVideoError?(["error": error.localizedDescription])
|
||||||
self.isSetSourceOngoing = false
|
self.isSetSourceOngoing = false
|
||||||
self.applyNextSource()
|
self.applyNextSource()
|
||||||
|
|
||||||
|
if let player = self._player {
|
||||||
|
NowPlayingInfoCenterManager.shared.removePlayer(player: player)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -523,7 +546,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
|||||||
|
|
||||||
func playerItemPrepareText(asset: AVAsset!, assetOptions: NSDictionary?, uri: String) async -> AVPlayerItem {
|
func playerItemPrepareText(asset: AVAsset!, assetOptions: NSDictionary?, uri: String) async -> AVPlayerItem {
|
||||||
if (self._textTracks == nil) || self._textTracks?.isEmpty == true || (uri.hasSuffix(".m3u8")) {
|
if (self._textTracks == nil) || self._textTracks?.isEmpty == true || (uri.hasSuffix(".m3u8")) {
|
||||||
return self.playerItemPropegateMetadata(AVPlayerItem(asset: asset))
|
return await self.playerItemPropegateMetadata(AVPlayerItem(asset: asset))
|
||||||
}
|
}
|
||||||
|
|
||||||
// AVPlayer can't airplay AVMutableCompositions
|
// AVPlayer can't airplay AVMutableCompositions
|
||||||
@ -540,26 +563,30 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
|||||||
self.setTextTracks(validTextTracks)
|
self.setTextTracks(validTextTracks)
|
||||||
}
|
}
|
||||||
|
|
||||||
return self.playerItemPropegateMetadata(AVPlayerItem(asset: mixComposition))
|
return await self.playerItemPropegateMetadata(AVPlayerItem(asset: mixComposition))
|
||||||
}
|
}
|
||||||
|
|
||||||
func playerItemPropegateMetadata(_ playerItem: AVPlayerItem!) -> AVPlayerItem {
|
func playerItemPropegateMetadata(_ playerItem: AVPlayerItem!) async -> AVPlayerItem {
|
||||||
var mapping: [AVMetadataIdentifier: Any] = [:]
|
var mapping: [AVMetadataIdentifier: Any] = [:]
|
||||||
|
|
||||||
if let title = _source?.title {
|
if let title = _source?.customMetadata?.title {
|
||||||
mapping[.commonIdentifierTitle] = title
|
mapping[.commonIdentifierTitle] = title
|
||||||
}
|
}
|
||||||
|
|
||||||
if let subtitle = _source?.subtitle {
|
if let artist = _source?.customMetadata?.artist {
|
||||||
|
mapping[.commonIdentifierArtist] = artist
|
||||||
|
}
|
||||||
|
|
||||||
|
if let subtitle = _source?.customMetadata?.subtitle {
|
||||||
mapping[.iTunesMetadataTrackSubTitle] = subtitle
|
mapping[.iTunesMetadataTrackSubTitle] = subtitle
|
||||||
}
|
}
|
||||||
|
|
||||||
if let description = _source?.description {
|
if let description = _source?.customMetadata?.description {
|
||||||
mapping[.commonIdentifierDescription] = description
|
mapping[.commonIdentifierDescription] = description
|
||||||
}
|
}
|
||||||
|
|
||||||
if let customImageUri = _source?.customImageUri,
|
if let imageUri = _source?.customMetadata?.imageUri,
|
||||||
let imageData = RCTVideoUtils.createImageMetadataItem(imageUri: customImageUri) {
|
let imageData = await RCTVideoUtils.createImageMetadataItem(imageUri: imageUri) {
|
||||||
mapping[.commonIdentifierArtwork] = imageData
|
mapping[.commonIdentifierArtwork] = imageData
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1006,6 +1033,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
|||||||
func createPlayerViewController(player: AVPlayer, withPlayerItem _: AVPlayerItem) -> RCTVideoPlayerViewController {
|
func createPlayerViewController(player: AVPlayer, withPlayerItem _: AVPlayerItem) -> RCTVideoPlayerViewController {
|
||||||
let viewController = RCTVideoPlayerViewController()
|
let viewController = RCTVideoPlayerViewController()
|
||||||
viewController.showsPlaybackControls = self._controls
|
viewController.showsPlaybackControls = self._controls
|
||||||
|
viewController.updatesNowPlayingInfoCenter = false
|
||||||
viewController.rctDelegate = self
|
viewController.rctDelegate = self
|
||||||
viewController.preferredOrientation = _fullscreenOrientation
|
viewController.preferredOrientation = _fullscreenOrientation
|
||||||
|
|
||||||
@ -1057,6 +1085,21 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
func setShowNotificationControls(_ showNotificationControls: Bool) {
|
||||||
|
guard let player = _player else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if showNotificationControls {
|
||||||
|
NowPlayingInfoCenterManager.shared.registerPlayer(player: player)
|
||||||
|
} else {
|
||||||
|
NowPlayingInfoCenterManager.shared.removePlayer(player: player)
|
||||||
|
}
|
||||||
|
|
||||||
|
_showNotificationControls = showNotificationControls
|
||||||
|
}
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
func setProgressUpdateInterval(_ progressUpdateInterval: Float) {
|
func setProgressUpdateInterval(_ progressUpdateInterval: Float) {
|
||||||
_playerObserver.replaceTimeObserverIfSet(Float64(progressUpdateInterval))
|
_playerObserver.replaceTimeObserverIfSet(Float64(progressUpdateInterval))
|
||||||
@ -1182,7 +1225,11 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
|||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
override func removeFromSuperview() {
|
override func removeFromSuperview() {
|
||||||
_player?.pause()
|
if let player = _player {
|
||||||
|
player.pause()
|
||||||
|
NowPlayingInfoCenterManager.shared.removePlayer(player: player)
|
||||||
|
}
|
||||||
|
|
||||||
_player = nil
|
_player = nil
|
||||||
_resouceLoaderDelegate = nil
|
_resouceLoaderDelegate = nil
|
||||||
_playerObserver.clearPlayer()
|
_playerObserver.clearPlayer()
|
||||||
@ -1354,6 +1401,10 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handlePlaybackFailed() {
|
func handlePlaybackFailed() {
|
||||||
|
if let player = _player {
|
||||||
|
NowPlayingInfoCenterManager.shared.removePlayer(player: player)
|
||||||
|
}
|
||||||
|
|
||||||
guard let _playerItem else { return }
|
guard let _playerItem else { return }
|
||||||
onVideoError?(
|
onVideoError?(
|
||||||
[
|
[
|
||||||
|
@ -37,6 +37,7 @@ RCT_EXPORT_VIEW_PROPERTY(progressUpdateInterval, float);
|
|||||||
RCT_EXPORT_VIEW_PROPERTY(restoreUserInterfaceForPIPStopCompletionHandler, BOOL);
|
RCT_EXPORT_VIEW_PROPERTY(restoreUserInterfaceForPIPStopCompletionHandler, BOOL);
|
||||||
RCT_EXPORT_VIEW_PROPERTY(localSourceEncryptionKeyScheme, NSString);
|
RCT_EXPORT_VIEW_PROPERTY(localSourceEncryptionKeyScheme, NSString);
|
||||||
RCT_EXPORT_VIEW_PROPERTY(subtitleStyle, NSDictionary);
|
RCT_EXPORT_VIEW_PROPERTY(subtitleStyle, NSDictionary);
|
||||||
|
RCT_EXPORT_VIEW_PROPERTY(showNotificationControls, BOOL)
|
||||||
/* Should support: onLoadStart, onLoad, and onError to stay consistent with Image */
|
/* Should support: onLoadStart, onLoad, and onError to stay consistent with Image */
|
||||||
RCT_EXPORT_VIEW_PROPERTY(onVideoLoadStart, RCTDirectEventBlock);
|
RCT_EXPORT_VIEW_PROPERTY(onVideoLoadStart, RCTDirectEventBlock);
|
||||||
RCT_EXPORT_VIEW_PROPERTY(onVideoLoad, RCTDirectEventBlock);
|
RCT_EXPORT_VIEW_PROPERTY(onVideoLoad, RCTDirectEventBlock);
|
||||||
|
@ -166,10 +166,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
|||||||
startPosition: resolvedSource.startPosition ?? -1,
|
startPosition: resolvedSource.startPosition ?? -1,
|
||||||
cropStart: resolvedSource.cropStart || 0,
|
cropStart: resolvedSource.cropStart || 0,
|
||||||
cropEnd: resolvedSource.cropEnd,
|
cropEnd: resolvedSource.cropEnd,
|
||||||
title: resolvedSource.title,
|
metadata: resolvedSource.metadata,
|
||||||
subtitle: resolvedSource.subtitle,
|
|
||||||
description: resolvedSource.description,
|
|
||||||
customImageUri: resolvedSource.customImageUri,
|
|
||||||
};
|
};
|
||||||
}, [source]);
|
}, [source]);
|
||||||
|
|
||||||
|
@ -19,6 +19,13 @@ type Headers = ReadonlyArray<
|
|||||||
}>
|
}>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
type VideoMetadata = Readonly<{
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
description?: string;
|
||||||
|
imageUri?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
export type VideoSrc = Readonly<{
|
export type VideoSrc = Readonly<{
|
||||||
uri?: string;
|
uri?: string;
|
||||||
isNetwork?: boolean;
|
isNetwork?: boolean;
|
||||||
@ -31,10 +38,7 @@ export type VideoSrc = Readonly<{
|
|||||||
startPosition?: Float;
|
startPosition?: Float;
|
||||||
cropStart?: Float;
|
cropStart?: Float;
|
||||||
cropEnd?: Float;
|
cropEnd?: Float;
|
||||||
title?: string;
|
metadata?: VideoMetadata;
|
||||||
subtitle?: string;
|
|
||||||
description?: string;
|
|
||||||
customImageUri?: string;
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type DRMType = WithDefault<string, 'widevine'>;
|
type DRMType = WithDefault<string, 'widevine'>;
|
||||||
@ -300,7 +304,7 @@ export interface VideoNativeProps extends ViewProps {
|
|||||||
restoreUserInterfaceForPIPStopCompletionHandler?: boolean;
|
restoreUserInterfaceForPIPStopCompletionHandler?: boolean;
|
||||||
localSourceEncryptionKeyScheme?: string;
|
localSourceEncryptionKeyScheme?: string;
|
||||||
debug?: DebugConfig;
|
debug?: DebugConfig;
|
||||||
|
showNotificationControls?: WithDefault<boolean, false>; // Android, iOS
|
||||||
bufferConfig?: BufferConfig; // Android
|
bufferConfig?: BufferConfig; // Android
|
||||||
contentStartTime?: Int32; // Android
|
contentStartTime?: Int32; // Android
|
||||||
currentPlaybackTime?: Double; // Android
|
currentPlaybackTime?: Double; // Android
|
||||||
|
@ -22,10 +22,7 @@ export type ReactVideoSourceProperties = {
|
|||||||
startPosition?: number;
|
startPosition?: number;
|
||||||
cropStart?: number;
|
cropStart?: number;
|
||||||
cropEnd?: number;
|
cropEnd?: number;
|
||||||
title?: string;
|
metadata?: VideoMetadata;
|
||||||
subtitle?: string;
|
|
||||||
description?: string;
|
|
||||||
customImageUri?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ReactVideoSource = Readonly<
|
export type ReactVideoSource = Readonly<
|
||||||
@ -34,6 +31,14 @@ export type ReactVideoSource = Readonly<
|
|||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type VideoMetadata = Readonly<{
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
description?: string;
|
||||||
|
artist?: string;
|
||||||
|
imageUri?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
export type DebugConfig = Readonly<{
|
export type DebugConfig = Readonly<{
|
||||||
enable?: boolean;
|
enable?: boolean;
|
||||||
thread?: boolean;
|
thread?: boolean;
|
||||||
@ -221,6 +226,7 @@ export interface ReactVideoProps extends ReactVideoEvents, ViewProps {
|
|||||||
repeat?: boolean;
|
repeat?: boolean;
|
||||||
reportBandwidth?: boolean; //Android
|
reportBandwidth?: boolean; //Android
|
||||||
resizeMode?: EnumValues<VideoResizeMode>;
|
resizeMode?: EnumValues<VideoResizeMode>;
|
||||||
|
showNotificationControls?: boolean; // Android, iOS
|
||||||
selectedAudioTrack?: SelectedTrack;
|
selectedAudioTrack?: SelectedTrack;
|
||||||
selectedTextTrack?: SelectedTrack;
|
selectedTextTrack?: SelectedTrack;
|
||||||
selectedVideoTrack?: SelectedVideoTrack; // android
|
selectedVideoTrack?: SelectedVideoTrack; // android
|
||||||
|
Loading…
Reference in New Issue
Block a user