Separate rendering into its own module
This commit is contained in:
5
src/main/java/com/replaymod/render/rendering/Frame.java
Normal file
5
src/main/java/com/replaymod/render/rendering/Frame.java
Normal file
@@ -0,0 +1,5 @@
|
||||
package com.replaymod.render.rendering;
|
||||
|
||||
public interface Frame {
|
||||
int getFrameId();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.replaymod.render.rendering;
|
||||
|
||||
import java.io.Closeable;
|
||||
|
||||
public interface FrameCapturer<R extends Frame> extends Closeable {
|
||||
|
||||
boolean isDone();
|
||||
|
||||
R process();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.replaymod.render.rendering;
|
||||
|
||||
import java.io.Closeable;
|
||||
|
||||
public interface FrameConsumer<P extends Frame> extends Closeable {
|
||||
|
||||
void consume(P frame);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.replaymod.render.rendering;
|
||||
|
||||
import java.io.Closeable;
|
||||
|
||||
public interface FrameProcessor<R extends Frame, P extends Frame> extends Closeable {
|
||||
|
||||
P process(R rawFrame);
|
||||
|
||||
}
|
||||
112
src/main/java/com/replaymod/render/rendering/Pipeline.java
Normal file
112
src/main/java/com/replaymod/render/rendering/Pipeline.java
Normal file
@@ -0,0 +1,112 @@
|
||||
package com.replaymod.render.rendering;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.crash.CrashReport;
|
||||
import net.minecraft.util.ReportedException;
|
||||
import org.lwjgl.opengl.Display;
|
||||
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class Pipeline<R extends Frame, P extends Frame> implements Runnable {
|
||||
|
||||
private final FrameCapturer<R> capturer;
|
||||
private final FrameProcessor<R, P> processor;
|
||||
private int consumerNextFrame;
|
||||
private final Object consumerLock = new Object();
|
||||
private final FrameConsumer<P> consumer;
|
||||
|
||||
private Thread runningThread;
|
||||
|
||||
public Pipeline(FrameCapturer<R> capturer, FrameProcessor<R, P> processor, FrameConsumer<P> consumer) {
|
||||
this.capturer = capturer;
|
||||
this.processor = processor;
|
||||
this.consumer = consumer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void run() {
|
||||
runningThread = Thread.currentThread();
|
||||
consumerNextFrame = 0;
|
||||
int processors = Runtime.getRuntime().availableProcessors();
|
||||
int processThreads = Math.max(1, processors - 2); // One processor for the main thread and one for ffmpeg, sorry OS :(
|
||||
ExecutorService processService = new ThreadPoolExecutor(processThreads, processThreads,
|
||||
0L, TimeUnit.MILLISECONDS,
|
||||
new ArrayBlockingQueue<Runnable>(2) {
|
||||
@Override
|
||||
public boolean offer(Runnable runnable) {
|
||||
try {
|
||||
put(runnable);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}, new ThreadPoolExecutor.DiscardPolicy());
|
||||
|
||||
Minecraft mc = Minecraft.getMinecraft();
|
||||
while (!capturer.isDone() && !Thread.currentThread().isInterrupted()) {
|
||||
if (Display.isCloseRequested() || mc.hasCrashed) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
R rawFrame = capturer.process();
|
||||
if (rawFrame != null) {
|
||||
processService.submit(new ProcessTask(rawFrame));
|
||||
}
|
||||
}
|
||||
|
||||
processService.shutdown();
|
||||
try {
|
||||
processService.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
|
||||
try {
|
||||
capturer.close();
|
||||
processor.close();
|
||||
consumer.close();
|
||||
} catch (Throwable t) {
|
||||
CrashReport crashReport = CrashReport.makeCrashReport(t, "Cleaning up rendering pipeline");
|
||||
throw new ReportedException(crashReport);
|
||||
}
|
||||
}
|
||||
|
||||
public void cancel() {
|
||||
if (runningThread != null) {
|
||||
runningThread.interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
@RequiredArgsConstructor
|
||||
private class ProcessTask implements Runnable {
|
||||
private final R rawFrame;
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
try {
|
||||
P processedFrame = processor.process(rawFrame);
|
||||
synchronized (consumerLock) {
|
||||
while (consumerNextFrame != processedFrame.getFrameId()) {
|
||||
try {
|
||||
consumerLock.wait();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
consumer.consume(processedFrame);
|
||||
consumerNextFrame++;
|
||||
consumerLock.notifyAll();
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
CrashReport crashReport = CrashReport.makeCrashReport(t, "Processing frame");
|
||||
Minecraft.getMinecraft().crashed(crashReport);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
76
src/main/java/com/replaymod/render/rendering/Pipelines.java
Normal file
76
src/main/java/com/replaymod/render/rendering/Pipelines.java
Normal file
@@ -0,0 +1,76 @@
|
||||
package com.replaymod.render.rendering;
|
||||
|
||||
import com.replaymod.render.RenderSettings;
|
||||
import com.replaymod.render.capturer.*;
|
||||
import com.replaymod.render.frame.CubicOpenGlFrame;
|
||||
import com.replaymod.render.frame.OpenGlFrame;
|
||||
import com.replaymod.render.frame.RGBFrame;
|
||||
import com.replaymod.render.frame.StereoscopicOpenGlFrame;
|
||||
import com.replaymod.render.hooks.EntityRendererHandler;
|
||||
import com.replaymod.render.processor.CubicToRGBProcessor;
|
||||
import com.replaymod.render.processor.EquirectangularToRGBProcessor;
|
||||
import com.replaymod.render.processor.OpenGlToRGBProcessor;
|
||||
import com.replaymod.render.processor.StereoscopicToRGBProcessor;
|
||||
import com.replaymod.render.utils.PixelBufferObject;
|
||||
import lombok.experimental.UtilityClass;
|
||||
|
||||
@UtilityClass
|
||||
public class Pipelines {
|
||||
public static Pipeline newPipeline(RenderSettings.RenderMethod method, RenderInfo renderInfo, FrameConsumer<RGBFrame> consumer) {
|
||||
switch (method) {
|
||||
case DEFAULT:
|
||||
return newDefaultPipeline(renderInfo, consumer);
|
||||
case STEREOSCOPIC:
|
||||
return newStereoscopicPipeline(renderInfo, consumer);
|
||||
case CUBIC:
|
||||
return newCubicPipeline(renderInfo, consumer);
|
||||
case EQUIRECTANGULAR:
|
||||
return newEquirectangularPipeline(renderInfo, consumer);
|
||||
}
|
||||
throw new UnsupportedOperationException("Unknown method: " + method);
|
||||
}
|
||||
|
||||
public static Pipeline<OpenGlFrame, RGBFrame> newDefaultPipeline(RenderInfo renderInfo, FrameConsumer<RGBFrame> consumer) {
|
||||
RenderSettings settings = renderInfo.getRenderSettings();
|
||||
FrameCapturer<OpenGlFrame> capturer;
|
||||
if (PixelBufferObject.SUPPORTED) {
|
||||
capturer = new SimplePboOpenGlFrameCapturer(new EntityRendererHandler(settings), renderInfo);
|
||||
} else {
|
||||
capturer = new SimpleOpenGlFrameCapturer(new EntityRendererHandler(settings), renderInfo);
|
||||
}
|
||||
return new Pipeline<>(capturer, new OpenGlToRGBProcessor(), consumer);
|
||||
}
|
||||
|
||||
public static Pipeline<StereoscopicOpenGlFrame, RGBFrame> newStereoscopicPipeline(RenderInfo renderInfo, FrameConsumer<RGBFrame> consumer) {
|
||||
RenderSettings settings = renderInfo.getRenderSettings();
|
||||
FrameCapturer<StereoscopicOpenGlFrame> capturer;
|
||||
if (PixelBufferObject.SUPPORTED) {
|
||||
capturer = new StereoscopicPboOpenGlFrameCapturer(new EntityRendererHandler(settings), renderInfo);
|
||||
} else {
|
||||
capturer = new StereoscopicOpenGlFrameCapturer(new EntityRendererHandler(settings), renderInfo);
|
||||
}
|
||||
return new Pipeline<>(capturer, new StereoscopicToRGBProcessor(), consumer);
|
||||
}
|
||||
|
||||
public static Pipeline<CubicOpenGlFrame, RGBFrame> newCubicPipeline(RenderInfo renderInfo, FrameConsumer<RGBFrame> consumer) {
|
||||
RenderSettings settings = renderInfo.getRenderSettings();
|
||||
FrameCapturer<CubicOpenGlFrame> capturer;
|
||||
if (PixelBufferObject.SUPPORTED) {
|
||||
capturer = new CubicPboOpenGlFrameCapturer(new EntityRendererHandler(settings), renderInfo, settings.getVideoWidth() / 4);
|
||||
} else {
|
||||
capturer = new CubicOpenGlFrameCapturer(new EntityRendererHandler(settings), renderInfo, settings.getVideoWidth() / 4);
|
||||
}
|
||||
return new Pipeline<>(capturer, new CubicToRGBProcessor(), consumer);
|
||||
}
|
||||
|
||||
public static Pipeline<CubicOpenGlFrame, RGBFrame> newEquirectangularPipeline(RenderInfo renderInfo, FrameConsumer<RGBFrame> consumer) {
|
||||
RenderSettings settings = renderInfo.getRenderSettings();
|
||||
FrameCapturer<CubicOpenGlFrame> capturer;
|
||||
if (PixelBufferObject.SUPPORTED) {
|
||||
capturer = new CubicPboOpenGlFrameCapturer(new EntityRendererHandler(settings), renderInfo, settings.getVideoWidth() / 4);
|
||||
} else {
|
||||
capturer = new CubicOpenGlFrameCapturer(new EntityRendererHandler(settings), renderInfo, settings.getVideoWidth() / 4);
|
||||
}
|
||||
return new Pipeline<>(capturer, new EquirectangularToRGBProcessor(settings.getVideoWidth() / 4), consumer);
|
||||
}
|
||||
}
|
||||
308
src/main/java/com/replaymod/render/rendering/VideoRenderer.java
Normal file
308
src/main/java/com/replaymod/render/rendering/VideoRenderer.java
Normal file
@@ -0,0 +1,308 @@
|
||||
package com.replaymod.render.rendering;
|
||||
|
||||
import com.google.common.base.Optional;
|
||||
import com.replaymod.core.ReplayMod;
|
||||
import com.replaymod.pathing.path.Keyframe;
|
||||
import com.replaymod.pathing.path.Path;
|
||||
import com.replaymod.pathing.path.Timeline;
|
||||
import com.replaymod.pathing.properties.TimestampProperty;
|
||||
import com.replaymod.render.RenderSettings;
|
||||
import com.replaymod.render.VideoWriter;
|
||||
import com.replaymod.render.capturer.RenderInfo;
|
||||
import com.replaymod.render.frame.RGBFrame;
|
||||
import com.replaymod.render.gui.GuiVideoRenderer;
|
||||
import com.replaymod.render.hooks.ChunkLoadingRenderGlobal;
|
||||
import com.replaymod.render.hooks.RenderReplayTimer;
|
||||
import com.replaymod.render.metadata.MetadataInjector;
|
||||
import com.replaymod.replay.ReplayHandler;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.audio.SoundCategory;
|
||||
import net.minecraft.client.gui.ScaledResolution;
|
||||
import net.minecraft.client.renderer.OpenGlHelper;
|
||||
import net.minecraft.util.Timer;
|
||||
import org.lwjgl.input.Mouse;
|
||||
import org.lwjgl.opengl.Display;
|
||||
import org.lwjgl.util.Dimension;
|
||||
import org.lwjgl.util.ReadableDimension;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.FutureTask;
|
||||
|
||||
import static com.google.common.collect.Iterables.getLast;
|
||||
import static net.minecraft.client.renderer.GlStateManager.*;
|
||||
import static org.lwjgl.opengl.GL11.GL_COLOR_BUFFER_BIT;
|
||||
import static org.lwjgl.opengl.GL11.GL_DEPTH_BUFFER_BIT;
|
||||
|
||||
public class VideoRenderer implements RenderInfo {
|
||||
private final Minecraft mc = Minecraft.getMinecraft();
|
||||
private final RenderSettings settings;
|
||||
private final ReplayHandler replayHandler;
|
||||
private final Timeline timeline;
|
||||
private final Pipeline renderingPipeline;
|
||||
private final VideoWriter videoWriter;
|
||||
|
||||
private int fps;
|
||||
private boolean mouseWasGrabbed;
|
||||
private boolean debugInfoWasShown;
|
||||
private Map originalSoundLevels;
|
||||
|
||||
private ChunkLoadingRenderGlobal chunkLoadingRenderGlobal;
|
||||
|
||||
private int framesDone;
|
||||
private int totalFrames;
|
||||
|
||||
private final GuiVideoRenderer gui;
|
||||
private boolean paused;
|
||||
private boolean cancelled;
|
||||
|
||||
public VideoRenderer(RenderSettings settings, ReplayHandler replayHandler, Timeline timeline) throws IOException {
|
||||
this.settings = settings;
|
||||
this.replayHandler = replayHandler;
|
||||
this.timeline = timeline;
|
||||
this.gui = new GuiVideoRenderer(this);
|
||||
this.renderingPipeline = Pipelines.newPipeline(settings.getRenderMethod(), this,
|
||||
videoWriter = new VideoWriter(settings) {
|
||||
@Override
|
||||
public void consume(RGBFrame frame) {
|
||||
gui.updatePreview(frame);
|
||||
super.consume(frame);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render this video.
|
||||
* @return {@code true} if rendering was successful, {@code false} if the user aborted rendering (or the window was closed)
|
||||
*/
|
||||
public boolean renderVideo() {
|
||||
setup();
|
||||
|
||||
// Because this might take some time to prepare we'll render the GUI at least once to not confuse the user
|
||||
drawGui();
|
||||
|
||||
Timer timer = mc.timer;
|
||||
|
||||
// Play up to one second before starting to render
|
||||
// This is necessary in order to ensure that all entities have at least two position packets
|
||||
// and their first position in the recording is correct.
|
||||
// Note that it is impossible to also get the interpolation between their latest position
|
||||
// and the one in the recording correct as there's no reliable way to tell when the server ticks
|
||||
// or when we should be done with the interpolation of the entity
|
||||
Optional<Integer> optionalVideoStartTime = timeline.getValue(TimestampProperty.PROPERTY, 0);
|
||||
if (optionalVideoStartTime.isPresent()) {
|
||||
int videoStart = optionalVideoStartTime.get();
|
||||
|
||||
if (videoStart > 1000) {
|
||||
int replayTime = videoStart - 1000;
|
||||
timer.elapsedPartialTicks = timer.renderPartialTicks = 0;
|
||||
timer.timerSpeed = 1;
|
||||
while (replayTime < videoStart) {
|
||||
timer.elapsedTicks = 1;
|
||||
replayTime += 50;
|
||||
replayHandler.getReplaySender().sendPacketsTill(replayTime);
|
||||
tick();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mc.renderGlobal.renderEntitiesStartupCounter = 0;
|
||||
|
||||
renderingPipeline.run();
|
||||
|
||||
if (settings.isInject360Metadata()) {
|
||||
MetadataInjector.inject360Metadata(settings.getOutputFile());
|
||||
}
|
||||
|
||||
finish();
|
||||
return !cancelled;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float updateForNextFrame() {
|
||||
if (!settings.isHighPerformance() || framesDone % fps == 0) {
|
||||
drawGui();
|
||||
}
|
||||
|
||||
timeline.applyToGame(getVideoTime(), replayHandler);
|
||||
|
||||
int elapsedTicks = mc.timer.elapsedTicks;
|
||||
while (elapsedTicks-- > 0) {
|
||||
tick();
|
||||
}
|
||||
|
||||
framesDone++;
|
||||
return mc.timer.renderPartialTicks;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RenderSettings getRenderSettings() {
|
||||
return settings;
|
||||
}
|
||||
|
||||
private void setup() {
|
||||
replayHandler.getReplaySender().setSyncModeAndWait();
|
||||
|
||||
if (!OpenGlHelper.isFramebufferEnabled()) {
|
||||
Display.setResizable(false);
|
||||
}
|
||||
if (mc.gameSettings.showDebugInfo) {
|
||||
debugInfoWasShown = true;
|
||||
mc.gameSettings.showDebugInfo = false;
|
||||
}
|
||||
if (Mouse.isGrabbed()) {
|
||||
mouseWasGrabbed = true;
|
||||
}
|
||||
Mouse.setGrabbed(false);
|
||||
mc.timer = new RenderReplayTimer(mc.timer);
|
||||
mc.timer.timerSpeed = 1;
|
||||
|
||||
// Mute all sounds except GUI sounds (buttons, etc.)
|
||||
Map<SoundCategory, Float> mutedSounds = new EnumMap<>(SoundCategory.class);
|
||||
for (SoundCategory category : SoundCategory.values()) {
|
||||
mutedSounds.put(category, 0f);
|
||||
}
|
||||
originalSoundLevels = mc.gameSettings.mapSoundLevels;
|
||||
mutedSounds.put(SoundCategory.MASTER, (Float) originalSoundLevels.get(SoundCategory.MASTER));
|
||||
mc.gameSettings.mapSoundLevels = mutedSounds;
|
||||
|
||||
fps = settings.getFramesPerSecond();
|
||||
|
||||
long duration = 0;
|
||||
for (Path path : timeline.getPaths()) {
|
||||
if (!path.isActive()) continue;
|
||||
|
||||
// Prepare path interpolations
|
||||
path.updateAll();
|
||||
// Find end time
|
||||
Collection<Keyframe> keyframes = path.getKeyframes();
|
||||
if (keyframes.size() > 0) {
|
||||
duration = Math.max(duration, getLast(keyframes).getTime());
|
||||
}
|
||||
}
|
||||
|
||||
totalFrames = (int) (duration*fps/1000);
|
||||
|
||||
ScaledResolution scaled = new ScaledResolution(mc, mc.displayWidth, mc.displayHeight);
|
||||
gui.toMinecraft().setWorldAndResolution(mc, scaled.getScaledWidth(), scaled.getScaledHeight());
|
||||
|
||||
chunkLoadingRenderGlobal = new ChunkLoadingRenderGlobal(mc.renderGlobal);
|
||||
}
|
||||
|
||||
private void finish() {
|
||||
replayHandler.getReplaySender().setAsyncMode(true);
|
||||
|
||||
if (!OpenGlHelper.isFramebufferEnabled()) {
|
||||
Display.setResizable(true);
|
||||
}
|
||||
mc.gameSettings.showDebugInfo = debugInfoWasShown;
|
||||
if (mouseWasGrabbed) {
|
||||
mc.mouseHelper.grabMouseCursor();
|
||||
}
|
||||
mc.timer = ((RenderReplayTimer) mc.timer).getWrapped();
|
||||
mc.gameSettings.mapSoundLevels = originalSoundLevels;
|
||||
mc.displayGuiScreen(null);
|
||||
if (chunkLoadingRenderGlobal != null) {
|
||||
chunkLoadingRenderGlobal.uninstall();
|
||||
}
|
||||
|
||||
ReplayMod.soundHandler.playRenderSuccessSound();
|
||||
|
||||
mc.displayGuiScreen(null);
|
||||
}
|
||||
|
||||
private void tick() {
|
||||
synchronized (mc.scheduledTasks) {
|
||||
while (!mc.scheduledTasks.isEmpty()) {
|
||||
((FutureTask) mc.scheduledTasks.poll()).run();
|
||||
}
|
||||
}
|
||||
|
||||
mc.currentScreen = gui.toMinecraft();
|
||||
try {
|
||||
mc.runTick();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public void drawGui() {
|
||||
do {
|
||||
pushMatrix();
|
||||
clear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
|
||||
enableTexture2D();
|
||||
mc.getFramebuffer().bindFramebuffer(true);
|
||||
mc.entityRenderer.setupOverlayRendering();
|
||||
|
||||
try {
|
||||
gui.toMinecraft().handleInput();
|
||||
} catch (IOException e) {
|
||||
// That's a strange exception from this kind of method O_o
|
||||
// It isn't actually thrown here, so we'll deal with it the easy way
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
ScaledResolution scaled = new ScaledResolution(mc, mc.displayWidth, mc.displayHeight);
|
||||
int mouseX = Mouse.getX() * scaled.getScaledWidth() / mc.displayWidth;
|
||||
int mouseY = scaled.getScaledHeight() - Mouse.getY() * scaled.getScaledHeight() / mc.displayHeight - 1;
|
||||
gui.toMinecraft().drawScreen(mouseX, mouseY, 0);
|
||||
|
||||
mc.getFramebuffer().unbindFramebuffer();
|
||||
popMatrix();
|
||||
pushMatrix();
|
||||
mc.getFramebuffer().framebufferRender(mc.displayWidth, mc.displayHeight);
|
||||
popMatrix();
|
||||
|
||||
|
||||
// if not in high performance mode, update the gui size if screen size changed
|
||||
// otherwise just swap the progress gui to screen
|
||||
if (settings.isHighPerformance()) {
|
||||
Display.update();
|
||||
} else {
|
||||
mc.updateDisplay();
|
||||
}
|
||||
if (Mouse.isGrabbed()) {
|
||||
Mouse.setGrabbed(false);
|
||||
}
|
||||
if (paused) {
|
||||
try {
|
||||
Thread.sleep(50);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} while (paused);
|
||||
}
|
||||
|
||||
public int getFramesDone() {
|
||||
return framesDone;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReadableDimension getFrameSize() {
|
||||
return new Dimension(settings.getVideoWidth(), settings.getVideoHeight());
|
||||
}
|
||||
|
||||
public int getTotalFrames() {
|
||||
return totalFrames;
|
||||
}
|
||||
|
||||
public int getVideoTime() { return framesDone * 1000 / fps; }
|
||||
|
||||
public void setPaused(boolean paused) {
|
||||
this.paused = paused;
|
||||
}
|
||||
|
||||
public boolean isPaused() {
|
||||
return paused;
|
||||
}
|
||||
|
||||
public void cancel() {
|
||||
videoWriter.abort();
|
||||
this.cancelled = true;
|
||||
renderingPipeline.cancel();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user