refactor(android): migrate VideoEventEmitter to Kotlin (#3962)
* refactor(android): migrate VideoEventEmitter to Kotlin * feat(android): apply rewritten EventEmitter's functions * refactor(android): remove duplicated code * fix(android): fix lint error * fix(android): fix event name value * refactor(android): rename of event constants for Fabric - https://github.com/facebook/react-native/blob/v0.74.3/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstantsHelper.java#L136-L138
This commit is contained in:
@@ -142,6 +142,7 @@ import java.lang.Math;
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Callable;
|
||||
@@ -173,7 +174,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
DEFAULT_COOKIE_MANAGER.setCookiePolicy(CookiePolicy.ACCEPT_ORIGINAL_SERVER);
|
||||
}
|
||||
|
||||
private final VideoEventEmitter eventEmitter;
|
||||
protected final VideoEventEmitter eventEmitter;
|
||||
private final ReactExoplayerConfig config;
|
||||
private final DefaultBandwidthMeter bandwidthMeter;
|
||||
private LegacyPlayerControlView playerControlView;
|
||||
@@ -280,7 +281,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
lastPos = pos;
|
||||
lastBufferDuration = bufferedDuration;
|
||||
lastDuration = duration;
|
||||
eventEmitter.progressChanged(pos, bufferedDuration, player.getDuration(), getPositionInFirstPeriodMsForCurrentWindow(pos));
|
||||
eventEmitter.onVideoProgress.invoke(pos, bufferedDuration, player.getDuration(), getPositionInFirstPeriodMsForCurrentWindow(pos));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,7 +308,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
public ReactExoplayerView(ThemedReactContext context, ReactExoplayerConfig config) {
|
||||
super(context);
|
||||
this.themedReactContext = context;
|
||||
this.eventEmitter = new VideoEventEmitter(context);
|
||||
this.eventEmitter = new VideoEventEmitter();
|
||||
this.config = config;
|
||||
this.bandwidthMeter = config.getBandwidthMeter();
|
||||
|
||||
@@ -323,12 +324,6 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
return player != null && player.isPlayingAd();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setId(int id) {
|
||||
super.setId(id);
|
||||
eventEmitter.setViewId(id);
|
||||
}
|
||||
|
||||
private void createViews() {
|
||||
if (CookieHandler.getDefault() != DEFAULT_COOKIE_MANAGER) {
|
||||
CookieHandler.setDefault(DEFAULT_COOKIE_MANAGER);
|
||||
@@ -388,13 +383,13 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
public void onBandwidthSample(int elapsedMs, long bytes, long bitrate) {
|
||||
if (mReportBandwidth) {
|
||||
if (player == null) {
|
||||
eventEmitter.bandwidthReport(bitrate, 0, 0, "-1");
|
||||
eventEmitter.onVideoBandwidthUpdate.invoke(bitrate, 0, 0, "-1");
|
||||
} else {
|
||||
Format videoFormat = player.getVideoFormat();
|
||||
int width = videoFormat != null ? videoFormat.width : 0;
|
||||
int height = videoFormat != null ? videoFormat.height : 0;
|
||||
String trackId = videoFormat != null ? videoFormat.id : "-1";
|
||||
eventEmitter.bandwidthReport(bitrate, height, width, trackId);
|
||||
eventEmitter.onVideoBandwidthUpdate.invoke(bitrate, height, width, trackId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -423,7 +418,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
playerControlView.addVisibilityListener(new LegacyPlayerControlView.VisibilityListener() {
|
||||
@Override
|
||||
public void onVisibilityChange(int visibility) {
|
||||
eventEmitter.controlsVisibilityChanged(visibility == View.VISIBLE);
|
||||
eventEmitter.onControlsVisibilityChange.invoke(visibility == View.VISIBLE);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -471,7 +466,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
//Handling the pauseButton click event
|
||||
ImageButton pauseButton = playerControlView.findViewById(R.id.exo_pause);
|
||||
pauseButton.setOnClickListener((View v) ->
|
||||
setPausedModifier(true)
|
||||
setPausedModifier(true)
|
||||
);
|
||||
|
||||
//Handling the fullScreenButton click event
|
||||
@@ -682,7 +677,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
}
|
||||
if (activity == null) {
|
||||
DebugLog.e(TAG, "Failed to initialize Player!, null activity");
|
||||
eventEmitter.error("Failed to initialize Player!", new Exception("Current Activity is null!"), "1001");
|
||||
eventEmitter.onVideoError.invoke("Failed to initialize Player!", new Exception("Current Activity is null!"), "1001");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -699,7 +694,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
DebugLog.e(TAG, "Failed to initialize Player! 1");
|
||||
DebugLog.e(TAG, ex.toString());
|
||||
ex.printStackTrace();
|
||||
self.eventEmitter.error(ex.toString(), ex, "1001");
|
||||
eventEmitter.onVideoError.invoke(ex.toString(), ex, "1001");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -711,7 +706,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
DebugLog.e(TAG, "Failed to initialize Player! 2");
|
||||
DebugLog.e(TAG, ex.toString());
|
||||
ex.printStackTrace();
|
||||
eventEmitter.error(ex.toString(), ex, "1001");
|
||||
eventEmitter.onVideoError.invoke(ex.toString(), ex, "1001");
|
||||
}
|
||||
};
|
||||
mainHandler.postDelayed(mainRunnable, 1);
|
||||
@@ -797,7 +792,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
int errorStringId = Util.SDK_INT < 18 ? R.string.error_drm_not_supported
|
||||
: (e.reason == UnsupportedDrmException.REASON_UNSUPPORTED_SCHEME
|
||||
? R.string.error_drm_unsupported_scheme : R.string.error_drm_unknown);
|
||||
eventEmitter.error(getResources().getString(errorStringId), e, "3003");
|
||||
eventEmitter.onVideoError.invoke(getResources().getString(errorStringId), e, "3003");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -812,7 +807,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
if (drmSessionManager == null && drmProps != null && drmProps.getDrmUUID() != null) {
|
||||
// Failed to intialize DRM session manager - cannot continue
|
||||
DebugLog.e(TAG, "Failed to initialize DRM Session Manager Framework!");
|
||||
eventEmitter.error("Failed to initialize DRM Session Manager Framework!", new Exception("DRM Session Manager Framework failure!"), "3003");
|
||||
eventEmitter.onVideoError.invoke("Failed to initialize DRM Session Manager Framework!", new Exception("DRM Session Manager Framework failure!"), "3003");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -873,7 +868,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
|
||||
reLayoutControls();
|
||||
|
||||
eventEmitter.loadStart();
|
||||
eventEmitter.onVideoLoadStart.invoke();
|
||||
loadVideoStarted = true;
|
||||
|
||||
finishPlayerInitialization();
|
||||
@@ -985,7 +980,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
return buildDrmSessionManager(uuid, licenseUrl, keyRequestPropertiesArray, ++retryCount);
|
||||
}
|
||||
// Handle the unknow exception and emit to JS
|
||||
eventEmitter.error(ex.toString(), ex, "3006");
|
||||
eventEmitter.onVideoError.invoke(ex.toString(), ex, "3006");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1193,7 +1188,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
switch (focusChange) {
|
||||
case AudioManager.AUDIOFOCUS_LOSS:
|
||||
view.hasAudioFocus = false;
|
||||
view.eventEmitter.audioFocusChanged(false);
|
||||
view.eventEmitter.onAudioFocusChanged.invoke(false);
|
||||
// FIXME this pause can cause issue if content doesn't have pause capability (can happen on live channel)
|
||||
if (activity != null) {
|
||||
activity.runOnUiThread(view::pausePlayback);
|
||||
@@ -1201,11 +1196,11 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
view.audioManager.abandonAudioFocus(this);
|
||||
break;
|
||||
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
|
||||
view.eventEmitter.audioFocusChanged(false);
|
||||
view.eventEmitter.onAudioFocusChanged.invoke(false);
|
||||
break;
|
||||
case AudioManager.AUDIOFOCUS_GAIN:
|
||||
view.hasAudioFocus = true;
|
||||
view.eventEmitter.audioFocusChanged(true);
|
||||
view.eventEmitter.onAudioFocusChanged.invoke(true);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -1216,14 +1211,14 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
// Lower the volume
|
||||
if (!view.muted) {
|
||||
activity.runOnUiThread(() ->
|
||||
view.player.setVolume(view.audioVolume * 0.8f)
|
||||
view.player.setVolume(view.audioVolume * 0.8f)
|
||||
);
|
||||
}
|
||||
} else if (focusChange == AudioManager.AUDIOFOCUS_GAIN) {
|
||||
// Raise it back to normal
|
||||
if (!view.muted) {
|
||||
activity.runOnUiThread(() ->
|
||||
view.player.setVolume(view.audioVolume * 1)
|
||||
view.player.setVolume(view.audioVolume * 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1326,7 +1321,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
// AudioBecomingNoisyListener implementation
|
||||
@Override
|
||||
public void onAudioBecomingNoisy() {
|
||||
eventEmitter.audioBecomingNoisy();
|
||||
eventEmitter.onVideoAudioBecomingNoisy.invoke();
|
||||
}
|
||||
|
||||
// Player.Listener implementation
|
||||
@@ -1341,11 +1336,11 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
int playbackState = player.getPlaybackState();
|
||||
boolean playWhenReady = player.getPlayWhenReady();
|
||||
String text = "onStateChanged: playWhenReady=" + playWhenReady + ", playbackState=";
|
||||
eventEmitter.playbackRateChange(playWhenReady && playbackState == ExoPlayer.STATE_READY ? 1.0f : 0.0f);
|
||||
eventEmitter.onPlaybackRateChange.invoke(playWhenReady && playbackState == ExoPlayer.STATE_READY ? 1.0f : 0.0f);
|
||||
switch (playbackState) {
|
||||
case Player.STATE_IDLE:
|
||||
text += "idle";
|
||||
eventEmitter.idle();
|
||||
eventEmitter.onVideoIdle.invoke();
|
||||
clearProgressMessageHandler();
|
||||
if (!player.getPlayWhenReady()) {
|
||||
setKeepScreenOn(false);
|
||||
@@ -1359,7 +1354,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
break;
|
||||
case Player.STATE_READY:
|
||||
text += "ready";
|
||||
eventEmitter.ready();
|
||||
eventEmitter.onReadyForDisplay.invoke();
|
||||
onBuffering(false);
|
||||
clearProgressMessageHandler(); // ensure there is no other message
|
||||
startProgressHandler();
|
||||
@@ -1377,7 +1372,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
case Player.STATE_ENDED:
|
||||
text += "ended";
|
||||
updateProgress();
|
||||
eventEmitter.end();
|
||||
eventEmitter.onVideoEnd.invoke();
|
||||
onStopPlayback();
|
||||
setKeepScreenOn(false);
|
||||
break;
|
||||
@@ -1434,7 +1429,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
if (videoTracks != null) {
|
||||
isUsingContentResolution = true;
|
||||
}
|
||||
eventEmitter.load(duration, currentPosition, width, height,
|
||||
eventEmitter.onVideoLoad.invoke(duration, currentPosition, width, height,
|
||||
audioTracks, textTracks, videoTracks, trackId );
|
||||
|
||||
});
|
||||
@@ -1443,7 +1438,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
|
||||
ArrayList<VideoTrack> videoTracks = getVideoTrackInfo();
|
||||
|
||||
eventEmitter.load(duration, currentPosition, width, height,
|
||||
eventEmitter.onVideoLoad.invoke(duration, currentPosition, width, height,
|
||||
audioTracks, textTracks, videoTracks, trackId);
|
||||
}
|
||||
}
|
||||
@@ -1626,13 +1621,13 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
}
|
||||
|
||||
isBuffering = buffering;
|
||||
eventEmitter.buffering(buffering);
|
||||
eventEmitter.onVideoBuffer.invoke(buffering);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPositionDiscontinuity(@NonNull Player.PositionInfo oldPosition, @NonNull Player.PositionInfo newPosition, @Player.DiscontinuityReason int reason) {
|
||||
if (reason == Player.DISCONTINUITY_REASON_SEEK) {
|
||||
eventEmitter.seek(player.getCurrentPosition(), newPosition.positionMs % 1000); // time are in seconds /°\
|
||||
eventEmitter.onVideoSeek.invoke(player.getCurrentPosition(), newPosition.positionMs % 1000); // time are in seconds /°\
|
||||
if (isUsingContentResolution) {
|
||||
// We need to update the selected track to make sure that it still matches user selection if track list has changed in this period
|
||||
setSelectedTrack(C.TRACK_TYPE_VIDEO, videoTrackType, videoTrackValue);
|
||||
@@ -1655,7 +1650,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION
|
||||
&& player.getRepeatMode() == Player.REPEAT_MODE_ONE) {
|
||||
updateProgress();
|
||||
eventEmitter.end();
|
||||
eventEmitter.onVideoEnd.invoke();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1666,24 +1661,24 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
|
||||
@Override
|
||||
public void onTracksChanged(@NonNull Tracks tracks) {
|
||||
eventEmitter.textTracks(getTextTrackInfo());
|
||||
eventEmitter.audioTracks(getAudioTrackInfo());
|
||||
eventEmitter.videoTracks(getVideoTrackInfo());
|
||||
eventEmitter.onTextTracks.invoke(getTextTrackInfo());
|
||||
eventEmitter.onAudioTracks.invoke(getAudioTrackInfo());
|
||||
eventEmitter.onVideoTracks.invoke(getVideoTrackInfo());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlaybackParametersChanged(PlaybackParameters params) {
|
||||
eventEmitter.playbackRateChange(params.speed);
|
||||
eventEmitter.onPlaybackRateChange.invoke(params.speed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVolumeChanged(float volume) {
|
||||
eventEmitter.volumeChange(volume);
|
||||
eventEmitter.onVolumeChange.invoke(volume);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onIsPlayingChanged(boolean isPlaying) {
|
||||
eventEmitter.playbackStateChanged(isPlaying);
|
||||
eventEmitter.onVideoPlaybackStateChanged.invoke(isPlaying);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1709,7 +1704,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
default:
|
||||
break;
|
||||
}
|
||||
eventEmitter.error(errorString, e, errorCode);
|
||||
eventEmitter.onVideoError.invoke(errorString, e, errorCode);
|
||||
playerNeedsSource = true;
|
||||
if (isBehindLiveWindow(e)) {
|
||||
clearResumePosition();
|
||||
@@ -1760,13 +1755,13 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
DebugLog.d(TAG, "unhandled metadata " + entry);
|
||||
}
|
||||
}
|
||||
eventEmitter.timedMetadata(metadataArray);
|
||||
eventEmitter.onTimedMetadata.invoke(metadataArray);
|
||||
}
|
||||
|
||||
public void onCues(CueGroup cueGroup) {
|
||||
if (!cueGroup.cues.isEmpty() && cueGroup.cues.get(0).text != null) {
|
||||
String subtitleText = cueGroup.cues.get(0).text.toString();
|
||||
eventEmitter.textTrackDataChanged(subtitleText);
|
||||
eventEmitter.onTextTrackDataChanged.invoke(subtitleText);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2220,7 +2215,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
Window window = activity.getWindow();
|
||||
WindowInsetsControllerCompat controller = new WindowInsetsControllerCompat(window, window.getDecorView());
|
||||
if (isFullscreen) {
|
||||
eventEmitter.fullscreenWillPresent();
|
||||
eventEmitter.onVideoFullscreenPlayerWillPresent.invoke();
|
||||
if (fullScreenPlayerView != null) {
|
||||
fullScreenPlayerView.show();
|
||||
}
|
||||
@@ -2228,10 +2223,10 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false);
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars());
|
||||
controller.setSystemBarsBehavior(WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
|
||||
eventEmitter.fullscreenDidPresent();
|
||||
eventEmitter.onVideoFullscreenPlayerDidPresent.invoke();
|
||||
});
|
||||
} else {
|
||||
eventEmitter.fullscreenWillDismiss();
|
||||
eventEmitter.onVideoFullscreenPlayerWillDismiss.invoke();
|
||||
if (fullScreenPlayerView != null) {
|
||||
fullScreenPlayerView.dismiss();
|
||||
reLayoutControls();
|
||||
@@ -2240,7 +2235,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
UiThreadUtil.runOnUiThread(() -> {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, true);
|
||||
controller.show(WindowInsetsCompat.Type.systemBars());
|
||||
eventEmitter.fullscreenDidDismiss();
|
||||
eventEmitter.onVideoFullscreenPlayerDidDismiss.invoke();
|
||||
});
|
||||
}
|
||||
// need to be done at the end to avoid hiding fullscreen control button when fullScreenPlayerView is shown
|
||||
@@ -2291,7 +2286,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
@Override
|
||||
public void onDrmSessionManagerError(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId, @NonNull Exception e) {
|
||||
DebugLog.d("DRM Info", "onDrmSessionManagerError");
|
||||
eventEmitter.error("onDrmSessionManagerError", e, "3002");
|
||||
eventEmitter.onVideoError.invoke("onDrmSessionManagerError", e, "3002");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -2333,16 +2328,21 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
@Override
|
||||
public void onAdEvent(AdEvent adEvent) {
|
||||
if (adEvent.getAdData() != null) {
|
||||
eventEmitter.receiveAdEvent(adEvent.getType().name(), adEvent.getAdData());
|
||||
eventEmitter.onReceiveAdEvent.invoke(adEvent.getType().name(), adEvent.getAdData());
|
||||
} else {
|
||||
eventEmitter.receiveAdEvent(adEvent.getType().name());
|
||||
eventEmitter.onReceiveAdEvent.invoke(adEvent.getType().name(), null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAdError(AdErrorEvent adErrorEvent) {
|
||||
AdError error = adErrorEvent.getError();
|
||||
eventEmitter.receiveAdErrorEvent(error.getMessage(), String.valueOf(error.getErrorCode()), String.valueOf(error.getErrorType()));
|
||||
Map<String, String> errMap = Map.of(
|
||||
"message", error.getMessage(),
|
||||
"code", String.valueOf(error.getErrorCode()),
|
||||
"type", String.valueOf(error.getErrorType())
|
||||
);
|
||||
eventEmitter.onReceiveAdEvent.invoke("ERROR", errMap);
|
||||
}
|
||||
|
||||
public void setControlsStyles(ControlsConfig controlsStyles) {
|
||||
|
||||
@@ -18,13 +18,12 @@ import com.brentvatne.common.api.SideLoadedTextTrackList;
|
||||
import com.brentvatne.common.api.Source;
|
||||
import com.brentvatne.common.api.SubtitleStyle;
|
||||
import com.brentvatne.common.api.ViewType;
|
||||
import com.brentvatne.common.react.VideoEventEmitter;
|
||||
import com.brentvatne.common.react.EventTypes;
|
||||
import com.brentvatne.common.toolbox.DebugLog;
|
||||
import com.brentvatne.common.toolbox.ReactBridgeUtils;
|
||||
import com.brentvatne.react.ReactNativeVideoManager;
|
||||
import com.facebook.react.bridge.ReadableArray;
|
||||
import com.facebook.react.bridge.ReadableMap;
|
||||
import com.facebook.react.common.MapBuilder;
|
||||
import com.facebook.react.uimanager.ThemedReactContext;
|
||||
import com.facebook.react.uimanager.ViewGroupManager;
|
||||
import com.facebook.react.uimanager.annotations.ReactProp;
|
||||
@@ -110,11 +109,13 @@ public class ReactExoplayerViewManager extends ViewGroupManager<ReactExoplayerVi
|
||||
|
||||
@Override
|
||||
public @Nullable Map<String, Object> getExportedCustomDirectEventTypeConstants() {
|
||||
MapBuilder.Builder<String, Object> builder = MapBuilder.builder();
|
||||
for (String event : VideoEventEmitter.Events) {
|
||||
builder.put(event, MapBuilder.of("registrationName", event));
|
||||
}
|
||||
return builder.build();
|
||||
return EventTypes.Companion.toMap();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addEventEmitters(@NonNull ThemedReactContext reactContext, @NonNull ReactExoplayerView view) {
|
||||
super.addEventEmitters(reactContext, view);
|
||||
view.eventEmitter.addEventEmitters(reactContext, view);
|
||||
}
|
||||
|
||||
@ReactProp(name = PROP_DRM)
|
||||
|
||||
Reference in New Issue
Block a user