Introduce MediaSessionConnector.CommandReceiver interface and add TimelineQueueEditor.
------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=166475351
This commit is contained in:
parent
1b9c904dba
commit
01f4819844
@ -15,6 +15,8 @@
|
||||
*/
|
||||
package com.google.android.exoplayer2.ext.mediasession;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.os.ResultReceiver;
|
||||
import android.support.v4.media.session.PlaybackStateCompat;
|
||||
|
||||
import com.google.android.exoplayer2.C;
|
||||
@ -125,4 +127,14 @@ public class DefaultPlaybackController implements MediaSessionConnector.Playback
|
||||
player.stop();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] getCommands() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCommand(Player player, String command, Bundle extras, ResultReceiver cb) {
|
||||
// Do nothing.
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -79,10 +79,24 @@ public final class MediaSessionConnector {
|
||||
private static final int EDITOR_MEDIA_SESSION_FLAGS = BASE_MEDIA_SESSION_FLAGS
|
||||
| MediaSessionCompat.FLAG_HANDLES_QUEUE_COMMANDS;
|
||||
|
||||
/**
|
||||
* Receiver of media commands sent by a media controller.
|
||||
*/
|
||||
public interface CommandReceiver {
|
||||
/**
|
||||
* Returns the commands the receiver handles, or {@code null} if no commands need to be handled.
|
||||
*/
|
||||
String[] getCommands();
|
||||
/**
|
||||
* See {@link MediaSessionCompat.Callback#onCommand(String, Bundle, ResultReceiver)}.
|
||||
*/
|
||||
void onCommand(Player player, String command, Bundle extras, ResultReceiver cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface to which playback preparation actions are delegated.
|
||||
*/
|
||||
public interface PlaybackPreparer {
|
||||
public interface PlaybackPreparer extends CommandReceiver {
|
||||
|
||||
long ACTIONS = PlaybackStateCompat.ACTION_PREPARE
|
||||
| PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID
|
||||
@ -121,16 +135,12 @@ public final class MediaSessionConnector {
|
||||
* See {@link MediaSessionCompat.Callback#onPrepareFromUri(Uri, Bundle)}.
|
||||
*/
|
||||
void onPrepareFromUri(Uri uri, Bundle extras);
|
||||
/**
|
||||
* See {@link MediaSessionCompat.Callback#onCommand(String, Bundle, ResultReceiver)}.
|
||||
*/
|
||||
void onCommand(String command, Bundle extras, ResultReceiver cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface to which playback actions are delegated.
|
||||
*/
|
||||
public interface PlaybackController {
|
||||
public interface PlaybackController extends CommandReceiver {
|
||||
|
||||
long ACTIONS = PlaybackStateCompat.ACTION_PLAY_PAUSE | PlaybackStateCompat.ACTION_PLAY
|
||||
| PlaybackStateCompat.ACTION_PAUSE | PlaybackStateCompat.ACTION_SEEK_TO
|
||||
@ -178,7 +188,7 @@ public final class MediaSessionConnector {
|
||||
* Handles queue navigation actions, and updates the media session queue by calling
|
||||
* {@code MediaSessionCompat.setQueue()}.
|
||||
*/
|
||||
public interface QueueNavigator {
|
||||
public interface QueueNavigator extends CommandReceiver {
|
||||
|
||||
long ACTIONS = PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM
|
||||
| PlaybackStateCompat.ACTION_SKIP_TO_NEXT | PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
|
||||
@ -240,7 +250,7 @@ public final class MediaSessionConnector {
|
||||
/**
|
||||
* Handles media session queue edits.
|
||||
*/
|
||||
public interface QueueEditor {
|
||||
public interface QueueEditor extends CommandReceiver {
|
||||
|
||||
long ACTIONS = PlaybackStateCompat.ACTION_SET_RATING;
|
||||
|
||||
@ -309,6 +319,7 @@ public final class MediaSessionConnector {
|
||||
private final ExoPlayerEventListener exoPlayerEventListener;
|
||||
private final MediaSessionCallback mediaSessionCallback;
|
||||
private final PlaybackController playbackController;
|
||||
private final Map<String, CommandReceiver> commandMap;
|
||||
|
||||
private Player player;
|
||||
private CustomActionProvider[] customActionProviders;
|
||||
@ -328,7 +339,7 @@ public final class MediaSessionConnector {
|
||||
* @param mediaSession The {@link MediaSessionCompat} to connect to.
|
||||
*/
|
||||
public MediaSessionConnector(MediaSessionCompat mediaSession) {
|
||||
this(mediaSession, new DefaultPlaybackController());
|
||||
this(mediaSession, null);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -350,7 +361,8 @@ public final class MediaSessionConnector {
|
||||
* instances passed to {@link #setPlayer(Player, PlaybackPreparer, CustomActionProvider...)}.
|
||||
*
|
||||
* @param mediaSession The {@link MediaSessionCompat} to connect to.
|
||||
* @param playbackController A {@link PlaybackController} for handling playback actions.
|
||||
* @param playbackController A {@link PlaybackController} for handling playback actions, or
|
||||
* {@code null} if the connector should handle playback actions directly.
|
||||
* @param doMaintainMetadata Whether the connector should maintain the metadata of the session. If
|
||||
* {@code false}, you need to maintain the metadata of the media session yourself (provide at
|
||||
* least the duration to allow clients to show a progress bar).
|
||||
@ -358,7 +370,8 @@ public final class MediaSessionConnector {
|
||||
public MediaSessionConnector(MediaSessionCompat mediaSession,
|
||||
PlaybackController playbackController, boolean doMaintainMetadata) {
|
||||
this.mediaSession = mediaSession;
|
||||
this.playbackController = playbackController;
|
||||
this.playbackController = playbackController != null ? playbackController
|
||||
: new DefaultPlaybackController();
|
||||
this.handler = new Handler(Looper.myLooper() != null ? Looper.myLooper()
|
||||
: Looper.getMainLooper());
|
||||
this.doMaintainMetadata = doMaintainMetadata;
|
||||
@ -367,6 +380,8 @@ public final class MediaSessionConnector {
|
||||
mediaSessionCallback = new MediaSessionCallback();
|
||||
exoPlayerEventListener = new ExoPlayerEventListener();
|
||||
customActionMap = Collections.emptyMap();
|
||||
commandMap = new HashMap<>();
|
||||
registerCommandReceiver(playbackController);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -386,8 +401,12 @@ public final class MediaSessionConnector {
|
||||
this.player.removeListener(exoPlayerEventListener);
|
||||
mediaSession.setCallback(null);
|
||||
}
|
||||
this.playbackPreparer = playbackPreparer;
|
||||
unregisterCommandReceiver(this.playbackPreparer);
|
||||
|
||||
this.player = player;
|
||||
this.playbackPreparer = playbackPreparer;
|
||||
registerCommandReceiver(playbackPreparer);
|
||||
|
||||
this.customActionProviders = (player != null && customActionProviders != null)
|
||||
? customActionProviders : new CustomActionProvider[0];
|
||||
if (player != null) {
|
||||
@ -416,7 +435,9 @@ public final class MediaSessionConnector {
|
||||
* @param queueNavigator The queue navigator.
|
||||
*/
|
||||
public void setQueueNavigator(QueueNavigator queueNavigator) {
|
||||
unregisterCommandReceiver(this.queueNavigator);
|
||||
this.queueNavigator = queueNavigator;
|
||||
registerCommandReceiver(queueNavigator);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -425,11 +446,29 @@ public final class MediaSessionConnector {
|
||||
* @param queueEditor The queue editor.
|
||||
*/
|
||||
public void setQueueEditor(QueueEditor queueEditor) {
|
||||
unregisterCommandReceiver(this.queueEditor);
|
||||
this.queueEditor = queueEditor;
|
||||
registerCommandReceiver(queueEditor);
|
||||
mediaSession.setFlags(queueEditor == null ? BASE_MEDIA_SESSION_FLAGS
|
||||
: EDITOR_MEDIA_SESSION_FLAGS);
|
||||
}
|
||||
|
||||
private void registerCommandReceiver(CommandReceiver commandReceiver) {
|
||||
if (commandReceiver != null && commandReceiver.getCommands() != null) {
|
||||
for (String command : commandReceiver.getCommands()) {
|
||||
commandMap.put(command, commandReceiver);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void unregisterCommandReceiver(CommandReceiver commandReceiver) {
|
||||
if (commandReceiver != null && commandReceiver.getCommands() != null) {
|
||||
for (String command : commandReceiver.getCommands()) {
|
||||
commandMap.remove(command);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updateMediaSessionPlaybackState() {
|
||||
PlaybackStateCompat.Builder builder = new PlaybackStateCompat.Builder();
|
||||
if (player == null) {
|
||||
@ -473,11 +512,8 @@ public final class MediaSessionConnector {
|
||||
}
|
||||
|
||||
private long buildPlaybackActions() {
|
||||
long actions = 0;
|
||||
if (playbackController != null) {
|
||||
actions |= (PlaybackController.ACTIONS & playbackController
|
||||
.getSupportedPlaybackActions(player));
|
||||
}
|
||||
long actions = (PlaybackController.ACTIONS
|
||||
& playbackController.getSupportedPlaybackActions(player));
|
||||
if (playbackPreparer != null) {
|
||||
actions |= (PlaybackPreparer.ACTIONS & playbackPreparer.getSupportedPrepareActions());
|
||||
}
|
||||
@ -562,7 +598,7 @@ public final class MediaSessionConnector {
|
||||
}
|
||||
|
||||
private boolean canDispatchToPlaybackController(long action) {
|
||||
return playbackController != null && (playbackController.getSupportedPlaybackActions(player)
|
||||
return (playbackController.getSupportedPlaybackActions(player)
|
||||
& PlaybackController.ACTIONS & action) != 0;
|
||||
}
|
||||
|
||||
@ -583,10 +619,15 @@ public final class MediaSessionConnector {
|
||||
|
||||
@Override
|
||||
public void onTimelineChanged(Timeline timeline, Object manifest) {
|
||||
int windowCount = player.getCurrentTimeline().getWindowCount();
|
||||
int windowIndex = player.getCurrentWindowIndex();
|
||||
if (queueNavigator != null) {
|
||||
queueNavigator.onTimelineChanged(player);
|
||||
updateMediaSessionPlaybackState();
|
||||
} else if (currentWindowCount != windowCount || currentWindowIndex != windowIndex) {
|
||||
// active queue item and queue navigation actions may need to be updated
|
||||
updateMediaSessionPlaybackState();
|
||||
}
|
||||
int windowCount = player.getCurrentTimeline().getWindowCount();
|
||||
if (currentWindowCount != windowCount) {
|
||||
// active queue item and queue navigation actions may need to be updated
|
||||
updateMediaSessionPlaybackState();
|
||||
@ -638,8 +679,8 @@ public final class MediaSessionConnector {
|
||||
if (queueNavigator != null) {
|
||||
queueNavigator.onCurrentWindowIndexChanged(player);
|
||||
}
|
||||
updateMediaSessionMetadata();
|
||||
currentWindowIndex = player.getCurrentWindowIndex();
|
||||
updateMediaSessionMetadata();
|
||||
}
|
||||
updateMediaSessionPlaybackState();
|
||||
}
|
||||
@ -732,8 +773,9 @@ public final class MediaSessionConnector {
|
||||
|
||||
@Override
|
||||
public void onCommand(String command, Bundle extras, ResultReceiver cb) {
|
||||
if (playbackPreparer != null) {
|
||||
playbackPreparer.onCommand(command, extras, cb);
|
||||
CommandReceiver commandReceiver = commandMap.get(command);
|
||||
if (commandReceiver != null) {
|
||||
commandReceiver.onCommand(player, command, extras, cb);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,226 @@
|
||||
/*
|
||||
* Copyright (c) 2017 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.google.android.exoplayer2.ext.mediasession;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.os.ResultReceiver;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v4.media.MediaDescriptionCompat;
|
||||
import android.support.v4.media.RatingCompat;
|
||||
import android.support.v4.media.session.MediaControllerCompat;
|
||||
import android.support.v4.media.session.MediaSessionCompat;
|
||||
import com.google.android.exoplayer2.C;
|
||||
import com.google.android.exoplayer2.Player;
|
||||
import com.google.android.exoplayer2.source.DynamicConcatenatingMediaSource;
|
||||
import com.google.android.exoplayer2.source.MediaSource;
|
||||
import com.google.android.exoplayer2.util.Util;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A {@link MediaSessionConnector.QueueEditor} implementation based on the
|
||||
* {@link DynamicConcatenatingMediaSource}.
|
||||
* <p>
|
||||
* This class implements the {@link MediaSessionConnector.CommandReceiver} interface and handles
|
||||
* the {@link #COMMAND_MOVE_QUEUE_ITEM} to move a queue item instead of removing and inserting it.
|
||||
* This allows to move the currently playing window without interrupting playback.
|
||||
*/
|
||||
public final class TimelineQueueEditor implements MediaSessionConnector.QueueEditor,
|
||||
MediaSessionConnector.CommandReceiver {
|
||||
|
||||
public static final String COMMAND_MOVE_QUEUE_ITEM = "exo_move_window";
|
||||
public static final String EXTRA_FROM_INDEX = "from_index";
|
||||
public static final String EXTRA_TO_INDEX = "to_index";
|
||||
|
||||
/**
|
||||
* Factory to create {@link MediaSource}s.
|
||||
*/
|
||||
public interface MediaSourceFactory {
|
||||
/**
|
||||
* Creates a {@link MediaSource} for the given {@link MediaDescriptionCompat}.
|
||||
*
|
||||
* @param description The {@link MediaDescriptionCompat} to create a media source for.
|
||||
* @return A {@link MediaSource} or {@code null} if no source can be created for the given
|
||||
* description.
|
||||
*/
|
||||
@Nullable MediaSource createMediaSource(MediaDescriptionCompat description);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter to get {@link MediaDescriptionCompat} of items in the queue and to notify the
|
||||
* application about changes in the queue to sync the data structure backing the
|
||||
* {@link MediaSessionConnector}.
|
||||
*/
|
||||
public interface QueueDataAdapter {
|
||||
/**
|
||||
* Gets the {@link MediaDescriptionCompat} for a {@code position}.
|
||||
*
|
||||
* @param position The position in the queue for which to provide a description.
|
||||
* @return A {@link MediaDescriptionCompat}.
|
||||
*/
|
||||
MediaDescriptionCompat getMediaDescription(int position);
|
||||
/**
|
||||
* Adds a {@link MediaDescriptionCompat} at the given {@code position}.
|
||||
*
|
||||
* @param position The position at which to add.
|
||||
* @param description The {@link MediaDescriptionCompat} to be added.
|
||||
*/
|
||||
void add(int position, MediaDescriptionCompat description);
|
||||
/**
|
||||
* Removes the item at the given {@code position}.
|
||||
*
|
||||
* @param position The position at which to remove the item.
|
||||
*/
|
||||
void remove(int position);
|
||||
/**
|
||||
* Moves a queue item from position {@code from} to position {@code to}.
|
||||
*
|
||||
* @param from The position from which to remove the item.
|
||||
* @param to The target position to which to move the item.
|
||||
*/
|
||||
void move(int from, int to);
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to evaluate whether two {@link MediaDescriptionCompat} are considered equal.
|
||||
*/
|
||||
interface MediaDescriptionEqualityChecker {
|
||||
/**
|
||||
* Returns {@code true} whether the descriptions are considered equal.
|
||||
*
|
||||
* @param d1 The first {@link MediaDescriptionCompat}.
|
||||
* @param d2 The second {@link MediaDescriptionCompat}.
|
||||
* @return {@code true} if considered equal.
|
||||
*/
|
||||
boolean equals(MediaDescriptionCompat d1, MediaDescriptionCompat d2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Media description comparator comparing the media IDs. Media IDs are considered equals if both
|
||||
* are {@code null}.
|
||||
*/
|
||||
public static final class MediaIdEqualityChecker implements MediaDescriptionEqualityChecker {
|
||||
|
||||
@Override
|
||||
public boolean equals(MediaDescriptionCompat d1, MediaDescriptionCompat d2) {
|
||||
return Util.areEqual(d1.getMediaId(), d2.getMediaId());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private final MediaControllerCompat mediaController;
|
||||
private final QueueDataAdapter queueDataAdapter;
|
||||
private final MediaSourceFactory sourceFactory;
|
||||
private final MediaDescriptionEqualityChecker equalityChecker;
|
||||
private final DynamicConcatenatingMediaSource queueMediaSource;
|
||||
|
||||
/**
|
||||
* Creates a new {@link TimelineQueueEditor} with a given mediaSourceFactory.
|
||||
*
|
||||
* @param mediaController A {@link MediaControllerCompat} to read the current queue.
|
||||
* @param queueMediaSource The {@link DynamicConcatenatingMediaSource} to
|
||||
* manipulate.
|
||||
* @param queueDataAdapter A {@link QueueDataAdapter} to change the backing data.
|
||||
* @param sourceFactory The {@link MediaSourceFactory} to build media sources.
|
||||
*/
|
||||
public TimelineQueueEditor(@NonNull MediaControllerCompat mediaController,
|
||||
@NonNull DynamicConcatenatingMediaSource queueMediaSource,
|
||||
@NonNull QueueDataAdapter queueDataAdapter, @NonNull MediaSourceFactory sourceFactory) {
|
||||
this(mediaController, queueMediaSource, queueDataAdapter, sourceFactory,
|
||||
new MediaIdEqualityChecker());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new {@link TimelineQueueEditor} with a given mediaSourceFactory.
|
||||
*
|
||||
* @param mediaController A {@link MediaControllerCompat} to read the current queue.
|
||||
* @param queueMediaSource The {@link DynamicConcatenatingMediaSource} to
|
||||
* manipulate.
|
||||
* @param queueDataAdapter A {@link QueueDataAdapter} to change the backing data.
|
||||
* @param sourceFactory The {@link MediaSourceFactory} to build media sources.
|
||||
* @param equalityChecker The {@link MediaDescriptionEqualityChecker} to match queue items.
|
||||
*/
|
||||
public TimelineQueueEditor(@NonNull MediaControllerCompat mediaController,
|
||||
@NonNull DynamicConcatenatingMediaSource queueMediaSource,
|
||||
@NonNull QueueDataAdapter queueDataAdapter, @NonNull MediaSourceFactory sourceFactory,
|
||||
@NonNull MediaDescriptionEqualityChecker equalityChecker) {
|
||||
this.mediaController = mediaController;
|
||||
this.queueMediaSource = queueMediaSource;
|
||||
this.queueDataAdapter = queueDataAdapter;
|
||||
this.sourceFactory = sourceFactory;
|
||||
this.equalityChecker = equalityChecker;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getSupportedQueueEditorActions(@Nullable Player player) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAddQueueItem(Player player, MediaDescriptionCompat description) {
|
||||
onAddQueueItem(player, description, player.getCurrentTimeline().getWindowCount());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAddQueueItem(Player player, MediaDescriptionCompat description, int index) {
|
||||
MediaSource mediaSource = sourceFactory.createMediaSource(description);
|
||||
if (mediaSource != null) {
|
||||
queueDataAdapter.add(index, description);
|
||||
queueMediaSource.addMediaSource(index, mediaSource);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRemoveQueueItem(Player player, MediaDescriptionCompat description) {
|
||||
List<MediaSessionCompat.QueueItem> queue = mediaController.getQueue();
|
||||
for (int i = 0; i < queue.size(); i++) {
|
||||
if (equalityChecker.equals(queue.get(i).getDescription(), description)) {
|
||||
onRemoveQueueItemAt(player, i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRemoveQueueItemAt(Player player, int index) {
|
||||
queueDataAdapter.remove(index);
|
||||
queueMediaSource.removeMediaSource(index);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSetRating(Player player, RatingCompat rating) {
|
||||
// Do nothing.
|
||||
}
|
||||
|
||||
// CommandReceiver implementation.
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String[] getCommands() {
|
||||
return new String[] {COMMAND_MOVE_QUEUE_ITEM};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCommand(Player player, String command, Bundle extras, ResultReceiver cb) {
|
||||
int from = extras.getInt(EXTRA_FROM_INDEX, C.INDEX_UNSET);
|
||||
int to = extras.getInt(EXTRA_TO_INDEX, C.INDEX_UNSET);
|
||||
if (from != C.INDEX_UNSET && to != C.INDEX_UNSET) {
|
||||
queueDataAdapter.move(from, to);
|
||||
queueMediaSource.moveMediaSource(from, to);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -15,6 +15,8 @@
|
||||
*/
|
||||
package com.google.android.exoplayer2.ext.mediasession;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.os.ResultReceiver;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v4.media.MediaDescriptionCompat;
|
||||
import android.support.v4.media.session.MediaSessionCompat;
|
||||
@ -164,6 +166,18 @@ public abstract class TimelineQueueNavigator implements MediaSessionConnector.Qu
|
||||
player.setShuffleModeEnabled(shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL);
|
||||
}
|
||||
|
||||
// CommandReceiver implementation.
|
||||
|
||||
@Override
|
||||
public String[] getCommands() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCommand(Player player, String command, Bundle extras, ResultReceiver cb) {
|
||||
// Do nothing.
|
||||
}
|
||||
|
||||
private void publishFloatingQueueWindow(Player player) {
|
||||
if (player.getCurrentTimeline().isEmpty()) {
|
||||
mediaSession.setQueue(Collections.<MediaSessionCompat.QueueItem>emptyList());
|
||||
|
Loading…
x
Reference in New Issue
Block a user