Support selecting audio tracks
Implements audio track selection on iOS & Android ExoPlayer. The prop mirrors the API for selectedTextTrack.
This commit is contained in:
parent
933d3dd817
commit
06eb1c57d8
38
README.md
38
README.md
@ -232,6 +232,7 @@ var styles = StyleSheet.create({
|
||||
* [rate](#rate)
|
||||
* [repeat](#repeat)
|
||||
* [resizeMode](#resizemode)
|
||||
* [selectedAudioTrack](#selectedaudiotrack)
|
||||
* [selectedTextTrack](#selectedtexttrack)
|
||||
* [stereoPan](#stereopan)
|
||||
* [textTracks](#texttracks)
|
||||
@ -356,6 +357,36 @@ Determines how to resize the video when the frame doesn't match the raw video di
|
||||
|
||||
Platforms: Android ExoPlayer, Android MediaPlayer, iOS, Windows UWP
|
||||
|
||||
#### selectedAudioTrack
|
||||
Configure which audio track, if any, is played.
|
||||
|
||||
```
|
||||
selectedAudioTrack={{
|
||||
type: Type,
|
||||
value: Value
|
||||
}}
|
||||
```
|
||||
|
||||
Example:
|
||||
```
|
||||
selectedAudioTrack={{
|
||||
type: "title",
|
||||
value: "Dubbing"
|
||||
}}
|
||||
```
|
||||
|
||||
Type | Value | Description
|
||||
--- | --- | ---
|
||||
"system" (default) | N/A | Play the audio track that matches the system language. If none match, play the first track.
|
||||
"disabled" | N/A | Turn off audio
|
||||
"title" | string | Play the audio track with the title specified as the Value, e.g. "French"
|
||||
"language" | string | Play the audio track with the language specified as the Value, e.g. "fr"
|
||||
"index" | number | Play the audio track with the index specified as the value, e.g. 0
|
||||
|
||||
If a track matching the specified Type (and Value if appropriate) is unavailable, the first audio track will be played. If multiple tracks match the criteria, the first match will be used.
|
||||
|
||||
Platforms: Android ExoPlayer, iOS
|
||||
|
||||
#### selectedTextTrack
|
||||
Configure which text track (caption or subtitle), if any, is shown.
|
||||
|
||||
@ -470,7 +501,8 @@ Property | Type | Description
|
||||
currentPosition | number | Time in seconds where the media will start
|
||||
duration | number | Length of the media in seconds
|
||||
naturalSize | object | Properties:<br> * width - Width in pixels that the video was encoded at<br> * height - Height in pixels that the video was encoded at<br> * orientation - "portrait" or "landscape"
|
||||
textTracks | array | An array of text track info objects with the following properties:<br> * index - Index number<br> * title - Description of the track<br> * language - 2 letter [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) language code<br> * type - Mime type of track
|
||||
audioTracks | array | An array of audio track info objects with the following properties:<br> * index - Index number<br> * title - Description of the track<br> * language - 2 letter [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) or 3 letter [ISO639-2](https://en.wikipedia.org/wiki/List_of_ISO_639-2_codes) language code<br> * type - Mime type of track
|
||||
textTracks | array | An array of text track info objects with the following properties:<br> * index - Index number<br> * title - Description of the track<br> * language - 2 letter [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) or 3 letter [ISO 639-2](https://en.wikipedia.org/wiki/List_of_ISO_639-2_codes) language code<br> * type - Mime type of track
|
||||
|
||||
Example:
|
||||
```
|
||||
@ -488,6 +520,10 @@ Example:
|
||||
orientation: 'landscape'
|
||||
width: '1920'
|
||||
},
|
||||
audioTracks: [
|
||||
{ language: 'es', title: 'Spanish', type: 'audio/mpeg', index: 0 },
|
||||
{ language: 'en', title: 'English', type: 'audio/mpeg', index: 1 } ],
|
||||
],
|
||||
textTracks: [
|
||||
{ title: '#1 French', language: 'fr', index: 0, type: 'text/vtt' },
|
||||
{ title: '#2 English CC', language: 'en', index: 1, type: 'text/vtt' },
|
||||
|
7
Video.js
7
Video.js
@ -316,6 +316,13 @@ Video.propTypes = {
|
||||
posterResizeMode: Image.propTypes.resizeMode,
|
||||
repeat: PropTypes.bool,
|
||||
allowsExternalPlayback: PropTypes.bool,
|
||||
selectedAudioTrack: PropTypes.shape({
|
||||
type: PropTypes.string.isRequired,
|
||||
value: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.number
|
||||
])
|
||||
}),
|
||||
selectedTextTrack: PropTypes.shape({
|
||||
type: PropTypes.string.isRequired,
|
||||
value: PropTypes.oneOfType([
|
||||
|
@ -113,6 +113,9 @@ class ReactExoplayerView extends FrameLayout implements
|
||||
private Uri srcUri;
|
||||
private String extension;
|
||||
private boolean repeat;
|
||||
private String audioTrackType;
|
||||
private Dynamic audioTrackValue;
|
||||
private ReadableArray audioTracks;
|
||||
private String textTrackType;
|
||||
private Dynamic textTrackValue;
|
||||
private ReadableArray textTracks;
|
||||
@ -501,20 +504,43 @@ class ReactExoplayerView extends FrameLayout implements
|
||||
private void videoLoaded() {
|
||||
if (loadVideoStarted) {
|
||||
loadVideoStarted = false;
|
||||
setSelectedAudioTrack(audioTrackType, audioTrackValue);
|
||||
setSelectedTextTrack(textTrackType, textTrackValue);
|
||||
Format videoFormat = player.getVideoFormat();
|
||||
int width = videoFormat != null ? videoFormat.width : 0;
|
||||
int height = videoFormat != null ? videoFormat.height : 0;
|
||||
eventEmitter.load(player.getDuration(), player.getCurrentPosition(), width, height,
|
||||
getTextTrackInfo());
|
||||
getAudioTrackInfo(), getTextTrackInfo());
|
||||
}
|
||||
}
|
||||
|
||||
private WritableArray getAudioTrackInfo() {
|
||||
WritableArray audioTracks = Arguments.createArray();
|
||||
|
||||
MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo();
|
||||
int index = getTrackRendererIndex(C.TRACK_TYPE_AUDIO);
|
||||
if (info == null || index == C.INDEX_UNSET) {
|
||||
return audioTracks;
|
||||
}
|
||||
|
||||
TrackGroupArray groups = info.getTrackGroups(index);
|
||||
for (int i = 0; i < groups.length; ++i) {
|
||||
Format format = groups.get(i).getFormat(0);
|
||||
WritableMap textTrack = Arguments.createMap();
|
||||
textTrack.putInt("index", i);
|
||||
textTrack.putString("title", format.id != null ? format.id : "");
|
||||
textTrack.putString("type", format.sampleMimeType);
|
||||
textTrack.putString("language", format.language != null ? format.language : "");
|
||||
audioTracks.pushMap(textTrack);
|
||||
}
|
||||
return audioTracks;
|
||||
}
|
||||
|
||||
private WritableArray getTextTrackInfo() {
|
||||
WritableArray textTracks = Arguments.createArray();
|
||||
|
||||
MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo();
|
||||
int index = getTextTrackRendererIndex();
|
||||
int index = getTrackRendererIndex(C.TRACK_TYPE_TEXT);
|
||||
if (info == null || index == C.INDEX_UNSET) {
|
||||
return textTracks;
|
||||
}
|
||||
@ -647,10 +673,10 @@ class ReactExoplayerView extends FrameLayout implements
|
||||
return false;
|
||||
}
|
||||
|
||||
public int getTextTrackRendererIndex() {
|
||||
public int getTrackRendererIndex(int trackType) {
|
||||
int rendererCount = player.getRendererCount();
|
||||
for (int rendererIndex = 0; rendererIndex < rendererCount; rendererIndex++) {
|
||||
if (player.getRendererType(rendererIndex) == C.TRACK_TYPE_TEXT) {
|
||||
if (player.getRendererType(rendererIndex) == trackType) {
|
||||
return rendererIndex;
|
||||
}
|
||||
}
|
||||
@ -724,12 +750,9 @@ class ReactExoplayerView extends FrameLayout implements
|
||||
this.repeat = repeat;
|
||||
}
|
||||
|
||||
public void setSelectedTextTrack(String type, Dynamic value) {
|
||||
textTrackType = type;
|
||||
textTrackValue = value;
|
||||
|
||||
int index = getTextTrackRendererIndex();
|
||||
if (index == C.INDEX_UNSET) {
|
||||
public void setSelectedTrack(int trackType, String type, Dynamic value) {
|
||||
int rendererIndex = getTrackRendererIndex(trackType);
|
||||
if (rendererIndex == C.INDEX_UNSET) {
|
||||
return;
|
||||
}
|
||||
MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo();
|
||||
@ -737,13 +760,15 @@ class ReactExoplayerView extends FrameLayout implements
|
||||
return;
|
||||
}
|
||||
|
||||
TrackGroupArray groups = info.getTrackGroups(index);
|
||||
TrackGroupArray groups = info.getTrackGroups(rendererIndex);
|
||||
int trackIndex = C.INDEX_UNSET;
|
||||
trackSelector.setSelectionOverride(index, groups, null);
|
||||
|
||||
if (TextUtils.isEmpty(type)) {
|
||||
// Do nothing
|
||||
} else if (type.equals("disabled")) {
|
||||
type = "default";
|
||||
}
|
||||
|
||||
if (type.equals("disabled")) {
|
||||
trackSelector.setSelectionOverride(rendererIndex, groups, null);
|
||||
return;
|
||||
} else if (type.equals("language")) {
|
||||
for (int i = 0; i < groups.length; ++i) {
|
||||
@ -762,26 +787,25 @@ class ReactExoplayerView extends FrameLayout implements
|
||||
}
|
||||
}
|
||||
} else if (type.equals("index")) {
|
||||
if (value.asInt() < groups.length) {
|
||||
trackIndex = value.asInt();
|
||||
} else { // default. Use system settings if possible
|
||||
}
|
||||
} else { // default
|
||||
if (rendererIndex == C.TRACK_TYPE_TEXT) { // Use system settings if possible
|
||||
int sdk = android.os.Build.VERSION.SDK_INT;
|
||||
if (sdk > 18 && groups.length > 0) {
|
||||
CaptioningManager captioningManager = (CaptioningManager) themedReactContext.getSystemService(Context.CAPTIONING_SERVICE);
|
||||
if (captioningManager.isEnabled()) {
|
||||
// default is to take the first object
|
||||
trackIndex = 0;
|
||||
|
||||
String locale = Locale.getDefault().getDisplayLanguage();
|
||||
for (int i = 0; i < groups.length; ++i) {
|
||||
Format format = groups.get(i).getFormat(0);
|
||||
if (format.language != null && format.language.equals(locale)) {
|
||||
trackIndex = i;
|
||||
break;
|
||||
CaptioningManager captioningManager
|
||||
= (CaptioningManager)themedReactContext.getSystemService(Context.CAPTIONING_SERVICE);
|
||||
if (captioningManager != null && captioningManager.isEnabled()) {
|
||||
trackIndex = getTrackIndexForDefaultLocale(groups);
|
||||
}
|
||||
} else {
|
||||
trackSelector.setSelectionOverride(rendererIndex, groups, null);
|
||||
return;
|
||||
}
|
||||
} else if (rendererIndex == C.TRACK_TYPE_AUDIO) {
|
||||
trackIndex = getTrackIndexForDefaultLocale(groups);
|
||||
}
|
||||
} else return;
|
||||
|
||||
}
|
||||
|
||||
if (trackIndex == C.INDEX_UNSET) {
|
||||
@ -792,7 +816,34 @@ class ReactExoplayerView extends FrameLayout implements
|
||||
MappingTrackSelector.SelectionOverride override
|
||||
= new MappingTrackSelector.SelectionOverride(
|
||||
new FixedTrackSelection.Factory(), trackIndex, 0);
|
||||
trackSelector.setSelectionOverride(index, groups, override);
|
||||
trackSelector.setSelectionOverride(rendererIndex, groups, override);
|
||||
}
|
||||
|
||||
private int getTrackIndexForDefaultLocale(TrackGroupArray groups) {
|
||||
int trackIndex = 0; // default if no match
|
||||
String locale2 = Locale.getDefault().getLanguage(); // 2 letter code
|
||||
String locale3 = Locale.getDefault().getISO3Language(); // 3 letter code
|
||||
for (int i = 0; i < groups.length; ++i) {
|
||||
Format format = groups.get(i).getFormat(0);
|
||||
String language = format.language;
|
||||
if (language != null && (language.equals(locale2) || language.equals(locale3))) {
|
||||
trackIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return trackIndex;
|
||||
}
|
||||
|
||||
public void setSelectedAudioTrack(String type, Dynamic value) {
|
||||
audioTrackType = type;
|
||||
audioTrackValue = value;
|
||||
setSelectedTrack(C.TRACK_TYPE_AUDIO, audioTrackType, audioTrackValue);
|
||||
}
|
||||
|
||||
public void setSelectedTextTrack(String type, Dynamic value) {
|
||||
textTrackType = type;
|
||||
textTrackValue = value;
|
||||
setSelectedTrack(C.TRACK_TYPE_TEXT, textTrackType, textTrackValue);
|
||||
}
|
||||
|
||||
public void setPausedModifier(boolean paused) {
|
||||
|
@ -28,6 +28,9 @@ public class ReactExoplayerViewManager extends ViewGroupManager<ReactExoplayerVi
|
||||
private static final String PROP_SRC_HEADERS = "requestHeaders";
|
||||
private static final String PROP_RESIZE_MODE = "resizeMode";
|
||||
private static final String PROP_REPEAT = "repeat";
|
||||
private static final String PROP_SELECTED_AUDIO_TRACK = "selectedAudioTrack";
|
||||
private static final String PROP_SELECTED_AUDIO_TRACK_TYPE = "type";
|
||||
private static final String PROP_SELECTED_AUDIO_TRACK_VALUE = "value";
|
||||
private static final String PROP_SELECTED_TEXT_TRACK = "selectedTextTrack";
|
||||
private static final String PROP_SELECTED_TEXT_TRACK_TYPE = "type";
|
||||
private static final String PROP_SELECTED_TEXT_TRACK_VALUE = "value";
|
||||
@ -127,6 +130,20 @@ public class ReactExoplayerViewManager extends ViewGroupManager<ReactExoplayerVi
|
||||
videoView.setRepeatModifier(repeat);
|
||||
}
|
||||
|
||||
@ReactProp(name = PROP_SELECTED_AUDIO_TRACK)
|
||||
public void setSelectedAudioTrack(final ReactExoplayerView videoView,
|
||||
@Nullable ReadableMap selectedAudioTrack) {
|
||||
String typeString = null;
|
||||
Dynamic value = null;
|
||||
if (selectedAudioTrack != null) {
|
||||
typeString = selectedAudioTrack.hasKey(PROP_SELECTED_AUDIO_TRACK_TYPE)
|
||||
? selectedAudioTrack.getString(PROP_SELECTED_AUDIO_TRACK_TYPE) : null;
|
||||
value = selectedAudioTrack.hasKey(PROP_SELECTED_AUDIO_TRACK_VALUE)
|
||||
? selectedAudioTrack.getDynamic(PROP_SELECTED_AUDIO_TRACK_VALUE) : null;
|
||||
}
|
||||
videoView.setSelectedAudioTrack(typeString, value);
|
||||
}
|
||||
|
||||
@ReactProp(name = PROP_SELECTED_TEXT_TRACK)
|
||||
public void setSelectedTextTrack(final ReactExoplayerView videoView,
|
||||
@Nullable ReadableMap selectedTextTrack) {
|
||||
|
@ -109,6 +109,7 @@ class VideoEventEmitter {
|
||||
private static final String EVENT_PROP_WIDTH = "width";
|
||||
private static final String EVENT_PROP_HEIGHT = "height";
|
||||
private static final String EVENT_PROP_ORIENTATION = "orientation";
|
||||
private static final String EVENT_PROP_AUDIO_TRACKS = "audioTracks";
|
||||
private static final String EVENT_PROP_TEXT_TRACKS = "textTracks";
|
||||
private static final String EVENT_PROP_HAS_AUDIO_FOCUS = "hasAudioFocus";
|
||||
private static final String EVENT_PROP_IS_BUFFERING = "isBuffering";
|
||||
@ -130,7 +131,7 @@ class VideoEventEmitter {
|
||||
}
|
||||
|
||||
void load(double duration, double currentPosition, int videoWidth, int videoHeight,
|
||||
WritableArray textTracks) {
|
||||
WritableArray audioTracks, WritableArray textTracks) {
|
||||
WritableMap event = Arguments.createMap();
|
||||
event.putDouble(EVENT_PROP_DURATION, duration / 1000D);
|
||||
event.putDouble(EVENT_PROP_CURRENT_TIME, currentPosition / 1000D);
|
||||
@ -145,6 +146,7 @@ class VideoEventEmitter {
|
||||
}
|
||||
event.putMap(EVENT_PROP_NATURAL_SIZE, naturalSize);
|
||||
|
||||
event.putArray(EVENT_PROP_AUDIO_TRACKS, audioTracks);
|
||||
event.putArray(EVENT_PROP_TEXT_TRACKS, textTracks);
|
||||
|
||||
// TODO: Actually check if you can.
|
||||
|
@ -47,6 +47,7 @@ static NSString *const timedMetadata = @"timedMetadata";
|
||||
BOOL _allowsExternalPlayback;
|
||||
NSArray * _textTracks;
|
||||
NSDictionary * _selectedTextTrack;
|
||||
NSDictionary * _selectedAudioTrack;
|
||||
BOOL _playbackStalled;
|
||||
BOOL _playInBackground;
|
||||
BOOL _playWhenInactive;
|
||||
@ -498,6 +499,7 @@ static NSString *const timedMetadata = @"timedMetadata";
|
||||
@"height": height,
|
||||
@"orientation": orientation
|
||||
},
|
||||
@"audioTracks": [self getAudioTrackInfo],
|
||||
@"textTracks": [self getTextTrackInfo],
|
||||
@"target": self.reactTag});
|
||||
}
|
||||
@ -729,6 +731,7 @@ static NSString *const timedMetadata = @"timedMetadata";
|
||||
[_player setMuted:NO];
|
||||
}
|
||||
|
||||
[self setSelectedAudioTrack:_selectedAudioTrack];
|
||||
[self setSelectedTextTrack:_selectedTextTrack];
|
||||
[self setResizeMode:_resizeMode];
|
||||
[self setRepeat:_repeat];
|
||||
@ -741,12 +744,64 @@ static NSString *const timedMetadata = @"timedMetadata";
|
||||
_repeat = repeat;
|
||||
}
|
||||
|
||||
- (void)setMediaSelectionTrackForCharacteristic:(AVMediaCharacteristic)characteristic
|
||||
withCriteria:(NSDictionary *)criteria
|
||||
{
|
||||
NSString *type = criteria[@"type"];
|
||||
AVMediaSelectionGroup *group = [_player.currentItem.asset
|
||||
mediaSelectionGroupForMediaCharacteristic:characteristic];
|
||||
AVMediaSelectionOption *mediaOption;
|
||||
|
||||
if ([type isEqualToString:@"disabled"]) {
|
||||
// Do nothing. We want to ensure option is nil
|
||||
} else if ([type isEqualToString:@"language"] || [type isEqualToString:@"title"]) {
|
||||
NSString *value = criteria[@"value"];
|
||||
for (int i = 0; i < group.options.count; ++i) {
|
||||
AVMediaSelectionOption *currentOption = [group.options objectAtIndex:i];
|
||||
NSString *optionValue;
|
||||
if ([type isEqualToString:@"language"]) {
|
||||
optionValue = [currentOption extendedLanguageTag];
|
||||
} else {
|
||||
optionValue = [[[currentOption commonMetadata]
|
||||
valueForKey:@"value"]
|
||||
objectAtIndex:0];
|
||||
}
|
||||
if ([value isEqualToString:optionValue]) {
|
||||
mediaOption = currentOption;
|
||||
break;
|
||||
}
|
||||
}
|
||||
//} else if ([type isEqualToString:@"default"]) {
|
||||
// option = group.defaultOption; */
|
||||
} else if ([type isEqualToString:@"index"]) {
|
||||
if ([criteria[@"value"] isKindOfClass:[NSNumber class]]) {
|
||||
int index = [criteria[@"value"] intValue];
|
||||
if (group.options.count > index) {
|
||||
mediaOption = [group.options objectAtIndex:index];
|
||||
}
|
||||
}
|
||||
} else { // default. invalid type or "system"
|
||||
[_player.currentItem selectMediaOptionAutomaticallyInMediaSelectionGroup:group];
|
||||
return;
|
||||
}
|
||||
|
||||
// If a match isn't found, option will be nil and text tracks will be disabled
|
||||
[_player.currentItem selectMediaOption:mediaOption inMediaSelectionGroup:group];
|
||||
}
|
||||
|
||||
- (void)setSelectedAudioTrack:(NSDictionary *)selectedAudioTrack {
|
||||
_selectedAudioTrack = selectedAudioTrack;
|
||||
[self setMediaSelectionTrackForCharacteristic:AVMediaCharacteristicAudible
|
||||
withCriteria:_selectedAudioTrack];
|
||||
}
|
||||
|
||||
- (void)setSelectedTextTrack:(NSDictionary *)selectedTextTrack {
|
||||
_selectedTextTrack = selectedTextTrack;
|
||||
if (_textTracks) {
|
||||
if (_textTracks) { // sideloaded text tracks
|
||||
[self setSideloadedText];
|
||||
} else {
|
||||
[self setStreamingText];
|
||||
} else { // text tracks included in the HLS playlist
|
||||
[self setMediaSelectionTrackForCharacteristic:AVMediaCharacteristicLegible
|
||||
withCriteria:_selectedTextTrack];
|
||||
}
|
||||
}
|
||||
|
||||
@ -869,9 +924,31 @@ static NSString *const timedMetadata = @"timedMetadata";
|
||||
if (_selectedTextTrack) [self setSelectedTextTrack:_selectedTextTrack];
|
||||
}
|
||||
|
||||
- (NSArray *)getAudioTrackInfo
|
||||
{
|
||||
NSMutableArray *audioTracks = [[NSMutableArray alloc] init];
|
||||
AVMediaSelectionGroup *group = [_player.currentItem.asset
|
||||
mediaSelectionGroupForMediaCharacteristic:AVMediaCharacteristicAudible];
|
||||
for (int i = 0; i < group.options.count; ++i) {
|
||||
AVMediaSelectionOption *currentOption = [group.options objectAtIndex:i];
|
||||
NSString *title = @"";
|
||||
NSArray *values = [[currentOption commonMetadata] valueForKey:@"value"];
|
||||
if (values.count > 0) {
|
||||
title = [values objectAtIndex:0];
|
||||
}
|
||||
NSString *language = [currentOption extendedLanguageTag] ? [currentOption extendedLanguageTag] : @"";
|
||||
NSDictionary *audioTrack = @{
|
||||
@"index": [NSNumber numberWithInt:i],
|
||||
@"title": title,
|
||||
@"language": language
|
||||
};
|
||||
[audioTracks addObject:audioTrack];
|
||||
}
|
||||
return audioTracks;
|
||||
}
|
||||
|
||||
- (NSArray *)getTextTrackInfo
|
||||
{
|
||||
|
||||
// if sideloaded, textTracks will already be set
|
||||
if (_textTracks) return _textTracks;
|
||||
|
||||
|
@ -25,6 +25,7 @@ RCT_EXPORT_VIEW_PROPERTY(repeat, BOOL);
|
||||
RCT_EXPORT_VIEW_PROPERTY(allowsExternalPlayback, BOOL);
|
||||
RCT_EXPORT_VIEW_PROPERTY(textTracks, NSArray);
|
||||
RCT_EXPORT_VIEW_PROPERTY(selectedTextTrack, NSDictionary);
|
||||
RCT_EXPORT_VIEW_PROPERTY(selectedAudioTrack, NSDictionary);
|
||||
RCT_EXPORT_VIEW_PROPERTY(paused, BOOL);
|
||||
RCT_EXPORT_VIEW_PROPERTY(muted, BOOL);
|
||||
RCT_EXPORT_VIEW_PROPERTY(controls, BOOL);
|
||||
|
Loading…
Reference in New Issue
Block a user