Add OculusVRInput
This commit is contained in:
parent
c26316d81c
commit
2464dcd17a
@ -101,11 +101,21 @@ public class OculusVR implements VRAPI {
|
|||||||
*/
|
*/
|
||||||
private final Vector3f[] hmdRelativeEyePositions = new Vector3f[2];
|
private final Vector3f[] hmdRelativeEyePositions = new Vector3f[2];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current state of the tracked components (HMD, touch)
|
||||||
|
*/
|
||||||
|
private OVRTrackingState trackingState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The position and orientation of the user's head.
|
* The position and orientation of the user's head.
|
||||||
*/
|
*/
|
||||||
private OVRPosef headPose;
|
private OVRPosef headPose;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The state of the Touch controllers.
|
||||||
|
*/
|
||||||
|
private OculusVRInput input;
|
||||||
|
|
||||||
// The size of the texture drawn onto the HMD
|
// The size of the texture drawn onto the HMD
|
||||||
private int textureW;
|
private int textureW;
|
||||||
private int textureH;
|
private int textureH;
|
||||||
@ -129,8 +139,8 @@ public class OculusVR implements VRAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public OpenVRInput getVRinput() {
|
public OculusVRInput getVRinput() {
|
||||||
throw new UnsupportedOperationException();
|
return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -184,7 +194,7 @@ public class OculusVR implements VRAPI {
|
|||||||
if (ovr_Create(pHmd, luid) != ovrSuccess) {
|
if (ovr_Create(pHmd, luid) != ovrSuccess) {
|
||||||
System.out.println("create failed, try debug");
|
System.out.println("create failed, try debug");
|
||||||
//debug headset is now enabled via the Oculus Configuration util . tools -> Service -> Configure
|
//debug headset is now enabled via the Oculus Configuration util . tools -> Service -> Configure
|
||||||
return false;
|
return false; // TODO fix memory leak - destroy() is not called
|
||||||
}
|
}
|
||||||
session = pHmd.get(0);
|
session = pHmd.get(0);
|
||||||
memFree(pHmd);
|
memFree(pHmd);
|
||||||
@ -198,7 +208,7 @@ public class OculusVR implements VRAPI {
|
|||||||
System.out.println("ovr_GetHmdDesc = " + hmdDesc.ManufacturerString() + " " + hmdDesc.ProductNameString() + " " + hmdDesc.SerialNumberString() + " " + hmdDesc.Type());
|
System.out.println("ovr_GetHmdDesc = " + hmdDesc.ManufacturerString() + " " + hmdDesc.ProductNameString() + " " + hmdDesc.SerialNumberString() + " " + hmdDesc.Type());
|
||||||
if (hmdDesc.Type() == ovrHmd_None) {
|
if (hmdDesc.Type() == ovrHmd_None) {
|
||||||
System.out.println("missing init");
|
System.out.println("missing init");
|
||||||
return false;
|
return false; // TODO fix memory leak - destroy() is not called
|
||||||
}
|
}
|
||||||
|
|
||||||
resolutionW = hmdDesc.Resolution().w();
|
resolutionW = hmdDesc.Resolution().w();
|
||||||
@ -206,7 +216,7 @@ public class OculusVR implements VRAPI {
|
|||||||
System.out.println("resolution W=" + resolutionW + ", H=" + resolutionH);
|
System.out.println("resolution W=" + resolutionW + ", H=" + resolutionH);
|
||||||
if (resolutionW == 0) {
|
if (resolutionW == 0) {
|
||||||
System.out.println("Huh - width=0");
|
System.out.println("Huh - width=0");
|
||||||
return false;
|
return false; // TODO fix memory leak - destroy() is not called
|
||||||
}
|
}
|
||||||
|
|
||||||
// FOV
|
// FOV
|
||||||
@ -254,6 +264,12 @@ public class OculusVR implements VRAPI {
|
|||||||
// Do this so others relying on our texture size get it correct.
|
// Do this so others relying on our texture size get it correct.
|
||||||
findHMDTextureSize();
|
findHMDTextureSize();
|
||||||
|
|
||||||
|
// Set up the tracking system
|
||||||
|
trackingState = OVRTrackingState.malloc();
|
||||||
|
|
||||||
|
// Set up the input
|
||||||
|
input = new OculusVRInput(this, session, sessionStatus, trackingState);
|
||||||
|
|
||||||
// throw new UnsupportedOperationException("Not yet implemented!");
|
// throw new UnsupportedOperationException("Not yet implemented!");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -261,12 +277,13 @@ public class OculusVR implements VRAPI {
|
|||||||
@Override
|
@Override
|
||||||
public void updatePose() {
|
public void updatePose() {
|
||||||
double ftiming = ovr_GetPredictedDisplayTime(session, 0);
|
double ftiming = ovr_GetPredictedDisplayTime(session, 0);
|
||||||
OVRTrackingState hmdState = OVRTrackingState.malloc();
|
ovr_GetTrackingState(session, ftiming, true, trackingState);
|
||||||
ovr_GetTrackingState(session, ftiming, true, hmdState);
|
ovr_GetSessionStatus(session, sessionStatus);
|
||||||
|
|
||||||
|
input.updateControllerStates();
|
||||||
|
|
||||||
//get head pose
|
//get head pose
|
||||||
headPose = hmdState.HeadPose().ThePose();
|
headPose = trackingState.HeadPose().ThePose();
|
||||||
hmdState.free();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -278,6 +295,9 @@ public class OculusVR implements VRAPI {
|
|||||||
public void destroy() {
|
public void destroy() {
|
||||||
// fovPorts: contents are managed by LibOVR, no need to do anything.
|
// fovPorts: contents are managed by LibOVR, no need to do anything.
|
||||||
|
|
||||||
|
// Clean up the input
|
||||||
|
input.dispose();
|
||||||
|
|
||||||
// Check if we've set up rendering - if so, clean that up.
|
// Check if we've set up rendering - if so, clean that up.
|
||||||
if (chains != null) {
|
if (chains != null) {
|
||||||
// Destroy our set of huge buffer images.
|
// Destroy our set of huge buffer images.
|
||||||
@ -299,6 +319,7 @@ public class OculusVR implements VRAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hmdDesc.free();
|
hmdDesc.free();
|
||||||
|
trackingState.free();
|
||||||
sessionStatus.free();
|
sessionStatus.free();
|
||||||
|
|
||||||
// Wrap everything up
|
// Wrap everything up
|
||||||
@ -636,6 +657,10 @@ public class OculusVR implements VRAPI {
|
|||||||
public OVRPosef getEyePose(int eye) {
|
public OVRPosef getEyePose(int eye) {
|
||||||
return eyeRenderDesc[eye].HmdToEyePose();
|
return eyeRenderDesc[eye].HmdToEyePose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public VREnvironment getEnvironment() {
|
||||||
|
return environment;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* vim: set ts=4 softtabstop=0 sw=4 expandtab: */
|
/* vim: set ts=4 softtabstop=0 sw=4 expandtab: */
|
||||||
|
367
jme3-vr/src/main/java/com/jme3/input/vr/OculusVRInput.java
Normal file
367
jme3-vr/src/main/java/com/jme3/input/vr/OculusVRInput.java
Normal file
@ -0,0 +1,367 @@
|
|||||||
|
package com.jme3.input.vr;
|
||||||
|
|
||||||
|
import com.jme3.app.VREnvironment;
|
||||||
|
import com.jme3.math.*;
|
||||||
|
import com.jme3.renderer.Camera;
|
||||||
|
import com.jme3.scene.Spatial;
|
||||||
|
import com.jme3.util.VRViewManagerOculus;
|
||||||
|
import org.lwjgl.ovr.*;
|
||||||
|
|
||||||
|
import static org.lwjgl.ovr.OVR.*;
|
||||||
|
|
||||||
|
public class OculusVRInput implements VRInputAPI {
|
||||||
|
// State control
|
||||||
|
private final OVRInputState inputState;
|
||||||
|
private final OVRSessionStatus sessionStatus;
|
||||||
|
private final OVRTrackingState trackingState;
|
||||||
|
private final OculusVR hardware;
|
||||||
|
private long session;
|
||||||
|
|
||||||
|
// Setup values
|
||||||
|
private float axisMultiplier = 1;
|
||||||
|
|
||||||
|
// Cached stuff
|
||||||
|
private int buttons, touch;
|
||||||
|
|
||||||
|
// Used to calculate sinceLastCall stuff
|
||||||
|
private int lastButtons, lastTouch;
|
||||||
|
private final Vector2f[][] lastAxises;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The state data (linear and angular velocity and acceleration) for each hand
|
||||||
|
*/
|
||||||
|
private OVRPoseStatef[] handStates;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The position and orientation of the Touch controllers.
|
||||||
|
*/
|
||||||
|
private OVRPosef[] handPoses;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The object forms of the tracked controllers.
|
||||||
|
*/
|
||||||
|
private final OculusController[] controllers = {
|
||||||
|
new OculusController(0),
|
||||||
|
new OculusController(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
public OculusVRInput(OculusVR hardware, long session,
|
||||||
|
OVRSessionStatus sessionStatus, OVRTrackingState trackingState) {
|
||||||
|
this.hardware = hardware;
|
||||||
|
this.session = session;
|
||||||
|
this.sessionStatus = sessionStatus;
|
||||||
|
this.trackingState = trackingState;
|
||||||
|
|
||||||
|
inputState = OVRInputState.calloc();
|
||||||
|
|
||||||
|
handStates = new OVRPoseStatef[ovrHand_Count];
|
||||||
|
handPoses = new OVRPosef[handStates.length];
|
||||||
|
lastAxises = new Vector2f[handStates.length][3]; // trigger+grab+thumbstick for each hand.
|
||||||
|
}
|
||||||
|
|
||||||
|
public void dispose() {
|
||||||
|
inputState.free();
|
||||||
|
session = 0; // Crashing > undefined behaviour if this object is incorrectly accessed again.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void updateControllerStates() {
|
||||||
|
// Handle buttons, axies
|
||||||
|
ovr_GetInputState(session, ovrControllerType_Touch, inputState);
|
||||||
|
buttons = inputState.Buttons();
|
||||||
|
touch = inputState.Touches();
|
||||||
|
|
||||||
|
// Get the touch controller poses
|
||||||
|
// TODO what if no touch controllers are available?
|
||||||
|
for (int hand = 0; hand < handPoses.length; hand++) {
|
||||||
|
handStates[hand] = trackingState.HandPoses(hand);
|
||||||
|
handPoses[hand] = handStates[hand].ThePose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Vector3f cv(OVRVector3f in) {
|
||||||
|
// TODO do we want to reuse vectors rather than making new ones?
|
||||||
|
// TODO OpenVRInput does this, but it will probably cause some bugs.
|
||||||
|
return OculusVR.vecO2J(in, new Vector3f()); // This also fixes the coordinate space transform issues.
|
||||||
|
}
|
||||||
|
|
||||||
|
private Vector2f cv(OVRVector2f in) {
|
||||||
|
// TODO do we want to reuse vectors rather than making new ones?
|
||||||
|
// TODO OpenVRInput does this, but it will probably cause some bugs.
|
||||||
|
return new Vector2f(in.x(), in.y());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Quaternion cq(OVRQuatf in) {
|
||||||
|
// TODO do we want to reuse quaternions rather than making new ones?
|
||||||
|
// TODO OpenVRInput does this, but it will probably cause some bugs.
|
||||||
|
return OculusVR.quatO2J(in, new Quaternion()); // This also fixes the coordinate space transform issues.
|
||||||
|
}
|
||||||
|
|
||||||
|
private Vector2f axis(float input) {
|
||||||
|
// See above comments about reusing vectors
|
||||||
|
return new Vector2f(input, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tracking (position, rotation, velocity, status)
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Vector3f getPosition(int index) {
|
||||||
|
return cv(handPoses[index].Position());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Vector3f getVelocity(int controllerIndex) {
|
||||||
|
return cv(handStates[controllerIndex].LinearVelocity());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Quaternion getOrientation(int index) {
|
||||||
|
return cq(handPoses[index].Orientation());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Vector3f getAngularVelocity(int controllerIndex) {
|
||||||
|
return cv(handStates[controllerIndex].AngularVelocity());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Quaternion getFinalObserverRotation(int index) {
|
||||||
|
// Copied from OpenVRInput
|
||||||
|
|
||||||
|
VREnvironment env = hardware.getEnvironment();
|
||||||
|
VRViewManagerOculus vrvm = (VRViewManagerOculus) hardware.getEnvironment().getVRViewManager();
|
||||||
|
|
||||||
|
Object obs = env.getObserver();
|
||||||
|
Quaternion tempq = new Quaternion(); // TODO move to class scope?
|
||||||
|
if (obs instanceof Camera) {
|
||||||
|
tempq.set(((Camera) obs).getRotation());
|
||||||
|
} else {
|
||||||
|
tempq.set(((Spatial) obs).getWorldRotation());
|
||||||
|
}
|
||||||
|
|
||||||
|
return tempq.multLocal(getOrientation(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Vector3f getFinalObserverPosition(int index) {
|
||||||
|
// Copied from OpenVRInput
|
||||||
|
|
||||||
|
VREnvironment env = hardware.getEnvironment();
|
||||||
|
VRViewManagerOculus vrvm = (VRViewManagerOculus) hardware.getEnvironment().getVRViewManager();
|
||||||
|
|
||||||
|
Object obs = env.getObserver();
|
||||||
|
Vector3f pos = getPosition(index);
|
||||||
|
if (obs instanceof Camera) {
|
||||||
|
((Camera) obs).getRotation().mult(pos, pos);
|
||||||
|
return pos.addLocal(((Camera) obs).getLocation());
|
||||||
|
} else {
|
||||||
|
((Spatial) obs).getWorldRotation().mult(pos, pos);
|
||||||
|
return pos.addLocal(((Spatial) obs).getWorldTranslation());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isInputDeviceTracking(int index) {
|
||||||
|
int flags = trackingState.HandStatusFlags(index);
|
||||||
|
return (flags & ovrStatus_PositionTracked) != 0; // TODO do we require orientation as well?
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input Getters
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Vector2f getAxis(int controllerIndex, VRInputType forAxis) {
|
||||||
|
Vector2f result = getAxisRaw(controllerIndex, forAxis);
|
||||||
|
return result == null ? null : result.multLocal(axisMultiplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Vector2f getAxisRaw(int controllerIndex, VRInputType forAxis) {
|
||||||
|
switch (forAxis) {
|
||||||
|
case OculusThumbstickAxis:
|
||||||
|
return cv(inputState.Thumbstick(controllerIndex));
|
||||||
|
case OculusTriggerAxis:
|
||||||
|
return axis(inputState.IndexTrigger(controllerIndex));
|
||||||
|
case OculusGripAxis:
|
||||||
|
return axis(inputState.HandTrigger(controllerIndex));
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isButtonDown(int controllerIndex, VRInputType checkButton) {
|
||||||
|
return isButtonDownForStatus(controllerIndex, checkButton, buttons, touch);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isButtonDownForStatus(int controllerIndex, VRInputType checkButton, int buttons, int touch) {
|
||||||
|
int buttonMask = (controllerIndex == ovrHand_Left) ? ovrButton_LMask : ovrButton_RMask;
|
||||||
|
int touchMask = (controllerIndex == ovrHand_Left) ?
|
||||||
|
(ovrTouch_LButtonMask + ovrTouch_LPoseMask) :
|
||||||
|
(ovrTouch_RButtonMask + ovrTouch_RPoseMask);
|
||||||
|
|
||||||
|
switch (checkButton) {
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
|
||||||
|
case OculusTopButton: // Physical buttons
|
||||||
|
case OculusBottomButton:
|
||||||
|
case OculusThumbstickButton:
|
||||||
|
case OculusMenuButton:
|
||||||
|
return (buttons & buttonMask & checkButton.getValue()) != 0;
|
||||||
|
|
||||||
|
case OculusTopTouch: // Standard capacitive buttons
|
||||||
|
case OculusBottomTouch:
|
||||||
|
case OculusThumbstickTouch:
|
||||||
|
case OculusThumbrestTouch:
|
||||||
|
case OculusIndexTouch:
|
||||||
|
case OculusThumbUp: // Calculated/virtual capacitive buttons
|
||||||
|
case OculusIndexPointing:
|
||||||
|
return (touch & touchMask & checkButton.getValue()) != 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since-last-call stuff
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void resetInputSinceLastCall() {
|
||||||
|
lastButtons = 0;
|
||||||
|
lastTouch = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean wasButtonPressedSinceLastCall(int controllerIndex, VRInputType checkButton) {
|
||||||
|
boolean wasPressed = isButtonDownForStatus(controllerIndex, checkButton, lastButtons, lastTouch);
|
||||||
|
lastButtons = buttons;
|
||||||
|
lastTouch = touch;
|
||||||
|
return !wasPressed && isButtonDown(controllerIndex, checkButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Vector2f getAxisDeltaSinceLastCall(int controllerIndex, VRInputType forAxis) {
|
||||||
|
int index;
|
||||||
|
switch (forAxis) {
|
||||||
|
case OculusTriggerAxis:
|
||||||
|
index = 0;
|
||||||
|
break;
|
||||||
|
case OculusGripAxis:
|
||||||
|
index = 1;
|
||||||
|
break;
|
||||||
|
case OculusThumbstickAxis:
|
||||||
|
index = 2;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Vector2f last = lastAxises[controllerIndex][index];
|
||||||
|
if (last == null) {
|
||||||
|
last = lastAxises[controllerIndex][index] = new Vector2f();
|
||||||
|
}
|
||||||
|
|
||||||
|
Vector2f current = getAxis(controllerIndex, forAxis);
|
||||||
|
|
||||||
|
// TODO could this lead to accuracy problems?
|
||||||
|
current.subtractLocal(last);
|
||||||
|
last.addLocal(current);
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Misc
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean init() {
|
||||||
|
throw new UnsupportedOperationException("Input initialized at creation time");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void updateConnectedControllers() {
|
||||||
|
throw new UnsupportedOperationException("Automatically done by LibOVR (I think?)");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public float getAxisMultiplier() {
|
||||||
|
return axisMultiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setAxisMultiplier(float axisMultiplier) {
|
||||||
|
this.axisMultiplier = axisMultiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void triggerHapticPulse(int controllerIndex, float seconds) {
|
||||||
|
// TODO: How do we time so we can turn the feedback off?
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isInputFocused() {
|
||||||
|
return sessionStatus.IsVisible(); // TODO do we need HmdMounted, or is it counted in IsVisible
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Object getRawControllerState(int index) {
|
||||||
|
throw new UnsupportedOperationException("Cannot get raw controller state!");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void swapHands() {
|
||||||
|
// Do nothing.
|
||||||
|
// TODO although OSVR and OpenVR if it has more than two controllers both do nothing, shouldn't we be
|
||||||
|
// TODO throwing an exception or something?
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getTrackedControllerCount() {
|
||||||
|
// TODO: Shouldn't we be seeing if the user has the touch controllers first?
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public VRTrackedController getTrackedController(int index) {
|
||||||
|
return controllers[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The object form representation of a controller.
|
||||||
|
*/
|
||||||
|
public class OculusController implements VRTrackedController {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ID of the hand to track
|
||||||
|
*/
|
||||||
|
private int hand;
|
||||||
|
|
||||||
|
public OculusController(int hand) {
|
||||||
|
this.hand = hand;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getControllerName() {
|
||||||
|
return "Touch"; // TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getControllerManufacturer() {
|
||||||
|
return "Oculus"; // TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Vector3f getPosition() {
|
||||||
|
return OculusVRInput.this.getPosition(hand);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Quaternion getOrientation() {
|
||||||
|
return OculusVRInput.this.getOrientation(hand);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Matrix4f getPose() {
|
||||||
|
Matrix4f mat = new Matrix4f();
|
||||||
|
mat.setRotationQuaternion(getOrientation());
|
||||||
|
mat.setTranslation(getPosition());
|
||||||
|
return mat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user