forked from j62/ctbrec
Add column which opens a preview popup, when hovered over
Add a column to the recorded models table, which can be used to open a small preview popup. The popup opens, when the mouse hovers over the table cell for a certain amount of time or if the cell gets clicked. The preview plays the stream with the lowest quality without audio.
This commit is contained in:
parent
52016c6a86
commit
754271c466
|
@ -0,0 +1,314 @@
|
||||||
|
package ctbrec.ui;
|
||||||
|
|
||||||
|
import java.io.InterruptedIOException;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import ctbrec.Config;
|
||||||
|
import ctbrec.recorder.download.StreamSource;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.event.EventHandler;
|
||||||
|
import javafx.geometry.Insets;
|
||||||
|
import javafx.geometry.Point2D;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.control.ProgressIndicator;
|
||||||
|
import javafx.scene.control.TableColumn;
|
||||||
|
import javafx.scene.control.TableRow;
|
||||||
|
import javafx.scene.control.TableView;
|
||||||
|
import javafx.scene.image.Image;
|
||||||
|
import javafx.scene.image.ImageView;
|
||||||
|
import javafx.scene.input.MouseButton;
|
||||||
|
import javafx.scene.input.MouseEvent;
|
||||||
|
import javafx.scene.layout.Region;
|
||||||
|
import javafx.scene.layout.StackPane;
|
||||||
|
import javafx.scene.media.Media;
|
||||||
|
import javafx.scene.media.MediaPlayer;
|
||||||
|
import javafx.scene.media.MediaView;
|
||||||
|
import javafx.stage.Popup;
|
||||||
|
|
||||||
|
public class PreviewPopupHandler implements EventHandler<MouseEvent> {
|
||||||
|
private static final transient Logger LOG = LoggerFactory.getLogger(PreviewPopupHandler.class);
|
||||||
|
|
||||||
|
private static final int offset = 10;
|
||||||
|
private long timeForPopupOpen = TimeUnit.SECONDS.toMillis(1);
|
||||||
|
private long timeForPopupClose = 400;
|
||||||
|
private Popup popup = new Popup();
|
||||||
|
private Node parent;
|
||||||
|
private ImageView preview = new ImageView();
|
||||||
|
private MediaView videoPreview;
|
||||||
|
private MediaPlayer videoPlayer;
|
||||||
|
private Media video;
|
||||||
|
private JavaFxModel model;
|
||||||
|
private volatile long openCountdown = -1;
|
||||||
|
private volatile long closeCountdown = -1;
|
||||||
|
private volatile long lastModelChange = -1;
|
||||||
|
private volatile boolean changeModel = false;
|
||||||
|
private ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||||
|
private Future<?> future;
|
||||||
|
private ProgressIndicator progressIndicator;
|
||||||
|
private StackPane pane;
|
||||||
|
|
||||||
|
public PreviewPopupHandler(Node parent) {
|
||||||
|
this.parent = parent;
|
||||||
|
|
||||||
|
videoPreview = new MediaView();
|
||||||
|
videoPreview.setFitWidth(Config.getInstance().getSettings().thumbWidth);
|
||||||
|
videoPreview.setFitHeight(videoPreview.getFitWidth() * 9 / 16);
|
||||||
|
videoPreview.setPreserveRatio(true);
|
||||||
|
StackPane.setMargin(videoPreview, new Insets(5));
|
||||||
|
|
||||||
|
preview.setFitWidth(Config.getInstance().getSettings().thumbWidth);
|
||||||
|
preview.setPreserveRatio(true);
|
||||||
|
preview.setSmooth(true);
|
||||||
|
preview.setStyle("-fx-background-radius: 10px, 10px, 10px, 10px;");
|
||||||
|
preview.visibleProperty().bind(videoPreview.visibleProperty().not());
|
||||||
|
StackPane.setMargin(preview, new Insets(5));
|
||||||
|
|
||||||
|
progressIndicator = new ProgressIndicator();
|
||||||
|
progressIndicator.setVisible(false);
|
||||||
|
progressIndicator.prefWidthProperty().bind(videoPreview.fitWidthProperty());
|
||||||
|
|
||||||
|
Region veil = new Region();
|
||||||
|
veil.setStyle("-fx-background-color: rgba(0, 0, 0, 0.8)");
|
||||||
|
veil.visibleProperty().bind(progressIndicator.visibleProperty());
|
||||||
|
StackPane.setMargin(veil, new Insets(5));
|
||||||
|
|
||||||
|
pane = new StackPane();
|
||||||
|
pane.getChildren().addAll(preview, videoPreview, veil, progressIndicator);
|
||||||
|
pane.setStyle("-fx-background-color: -fx-outer-border, -fx-inner-border, -fx-base;"+
|
||||||
|
"-fx-background-insets: 0 0 -1 0, 0, 1, 2;" +
|
||||||
|
"-fx-background-radius: 10px, 10px, 10px, 10px;" +
|
||||||
|
"-fx-padding: 1;" +
|
||||||
|
"-fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.8), 20, 0, 0, 0);");
|
||||||
|
popup.getContent().add(pane);
|
||||||
|
|
||||||
|
createTimerThread();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handle(MouseEvent event) {
|
||||||
|
if(!isInPreviewColumn(event)) {
|
||||||
|
closeCountdown = timeForPopupClose;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(event.getEventType() == MouseEvent.MOUSE_CLICKED && event.getButton() == MouseButton.PRIMARY) {
|
||||||
|
model = getModel(event);
|
||||||
|
popup.setX(event.getScreenX()+ offset);
|
||||||
|
popup.setY(event.getScreenY()+ offset);
|
||||||
|
showPopup();
|
||||||
|
openCountdown = -1;
|
||||||
|
} else if(event.getEventType() == MouseEvent.MOUSE_ENTERED) {
|
||||||
|
popup.setX(event.getScreenX()+ offset);
|
||||||
|
popup.setY(event.getScreenY()+ offset);
|
||||||
|
JavaFxModel model = getModel(event);
|
||||||
|
if(model != null) {
|
||||||
|
closeCountdown = -1;
|
||||||
|
boolean modelChanged = model != this.model;
|
||||||
|
this.model = model;
|
||||||
|
if(popup.isShowing()) {
|
||||||
|
openCountdown = -1;
|
||||||
|
if(modelChanged) {
|
||||||
|
lastModelChange = System.currentTimeMillis();
|
||||||
|
changeModel = true;
|
||||||
|
future.cancel(true);
|
||||||
|
progressIndicator.setVisible(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
openCountdown = timeForPopupOpen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if(event.getEventType() == MouseEvent.MOUSE_EXITED) {
|
||||||
|
openCountdown = -1;
|
||||||
|
closeCountdown = timeForPopupClose;
|
||||||
|
model = null;
|
||||||
|
} else if(event.getEventType() == MouseEvent.MOUSE_MOVED) {
|
||||||
|
popup.setX(event.getScreenX() + offset);
|
||||||
|
popup.setY(event.getScreenY() + offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isInPreviewColumn(MouseEvent event) {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
TableRow<JavaFxModel> row = (TableRow<JavaFxModel>) event.getSource();
|
||||||
|
TableView<JavaFxModel> table = row.getTableView();
|
||||||
|
double offset = 0;
|
||||||
|
double width = 0;
|
||||||
|
for (TableColumn<JavaFxModel, ?> col : table.getColumns()) {
|
||||||
|
offset += width;
|
||||||
|
width = col.getWidth();
|
||||||
|
if(Objects.equals(col.getId(), "preview")) {
|
||||||
|
Point2D screenToLocal = table.screenToLocal(event.getScreenX(), event.getScreenY());
|
||||||
|
double x = screenToLocal.getX();
|
||||||
|
return x >= offset && x <= offset + width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JavaFxModel getModel(MouseEvent event) {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
TableRow<JavaFxModel> row = (TableRow<JavaFxModel>) event.getSource();
|
||||||
|
TableView<JavaFxModel> table = row.getTableView();
|
||||||
|
int rowIndex = row.getIndex();
|
||||||
|
if(rowIndex < table.getItems().size()) {
|
||||||
|
return table.getItems().get(rowIndex);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showPopup() {
|
||||||
|
startStream(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startStream(JavaFxModel model) {
|
||||||
|
if(future != null && !future.isDone()) {
|
||||||
|
future.cancel(true);
|
||||||
|
}
|
||||||
|
future = executor.submit(() -> {
|
||||||
|
try {
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
progressIndicator.setVisible(true);
|
||||||
|
popup.show(parent.getScene().getWindow());
|
||||||
|
});
|
||||||
|
List<StreamSource> sources = model.getStreamSources();
|
||||||
|
Collections.sort(sources);
|
||||||
|
StreamSource best = sources.get(0);
|
||||||
|
checkInterrupt();
|
||||||
|
video = new Media(best.getMediaPlaylistUrl());
|
||||||
|
if(videoPlayer != null) {
|
||||||
|
videoPlayer.dispose();
|
||||||
|
}
|
||||||
|
videoPlayer = new MediaPlayer(video);
|
||||||
|
videoPlayer.setMute(true);
|
||||||
|
checkInterrupt();
|
||||||
|
videoPlayer.setOnReady(() -> {
|
||||||
|
if(!future.isCancelled()) {
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
double aspect = (double)video.getWidth() / video.getHeight();
|
||||||
|
double w = Config.getInstance().getSettings().thumbWidth;
|
||||||
|
double h = w / aspect;
|
||||||
|
videoPreview.setVisible(true);
|
||||||
|
videoPreview.setMediaPlayer(videoPlayer);
|
||||||
|
resize(w, h);
|
||||||
|
videoPlayer.play();
|
||||||
|
progressIndicator.setVisible(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (IllegalStateException e) {
|
||||||
|
if(e.getMessage().equals("Stream url unknown")) {
|
||||||
|
// fine hls url for mfc not known yet
|
||||||
|
} else {
|
||||||
|
LOG.error("Couldn't start preview video", e);
|
||||||
|
}
|
||||||
|
showTestImage();
|
||||||
|
} catch (InterruptedException | InterruptedIOException e) {
|
||||||
|
// future has been canceled, that's fine
|
||||||
|
} catch (ExecutionException e) {
|
||||||
|
if(e.getCause() instanceof InterruptedException || e.getCause() instanceof InterruptedIOException) {
|
||||||
|
// future has been canceled, that's fine
|
||||||
|
} else {
|
||||||
|
LOG.error("Couldn't start preview video", e);
|
||||||
|
showTestImage();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.error("Couldn't start preview video", e);
|
||||||
|
showTestImage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void resize(double w, double h) {
|
||||||
|
preview.setFitWidth(w);
|
||||||
|
preview.setFitHeight(h);
|
||||||
|
videoPreview.setFitWidth(w);
|
||||||
|
videoPreview.setFitHeight(h);
|
||||||
|
pane.setPrefSize(w, h);
|
||||||
|
popup.setWidth(w);
|
||||||
|
popup.setHeight(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkInterrupt() throws InterruptedException {
|
||||||
|
if(Thread.interrupted()) {
|
||||||
|
throw new InterruptedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showTestImage() {
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
videoPreview.setVisible(false);
|
||||||
|
Image img = new Image(getClass().getResource("/image_not_found.png").toString(), true);
|
||||||
|
preview.setImage(img);
|
||||||
|
double aspect = img.getWidth() / img.getHeight();
|
||||||
|
double w = Config.getInstance().getSettings().thumbWidth;
|
||||||
|
double h = w / aspect;
|
||||||
|
resize(w, h);
|
||||||
|
progressIndicator.setVisible(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void hidePopup() {
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
popup.setX(-1000);
|
||||||
|
popup.setY(-1000);
|
||||||
|
popup.hide();
|
||||||
|
if(videoPlayer != null) {
|
||||||
|
videoPlayer.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createTimerThread() {
|
||||||
|
Thread timerThread = new Thread(() -> {
|
||||||
|
while(true) {
|
||||||
|
openCountdown--;
|
||||||
|
if(openCountdown == 0) {
|
||||||
|
openCountdown = -1;
|
||||||
|
if(model != null) {
|
||||||
|
showPopup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeCountdown--;
|
||||||
|
if(closeCountdown == 0) {
|
||||||
|
hidePopup();
|
||||||
|
closeCountdown = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
openCountdown = Math.max(openCountdown, -1);
|
||||||
|
closeCountdown = Math.max(closeCountdown, -1);
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
long diff = (now - lastModelChange);
|
||||||
|
if(changeModel && diff > 400) {
|
||||||
|
changeModel = false;
|
||||||
|
if(model != null) {
|
||||||
|
startStream(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Thread.sleep(1);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
LOG.error("PreviewPopupTimer interrupted");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
timerThread.setDaemon(true);
|
||||||
|
timerThread.setPriority(Thread.MIN_PRIORITY);
|
||||||
|
timerThread.setName("PreviewPopupTimer");
|
||||||
|
timerThread.start();
|
||||||
|
}
|
||||||
|
}
|
|
@ -31,6 +31,9 @@ import ctbrec.sites.Site;
|
||||||
import ctbrec.ui.controls.AutoFillTextField;
|
import ctbrec.ui.controls.AutoFillTextField;
|
||||||
import ctbrec.ui.controls.Toast;
|
import ctbrec.ui.controls.Toast;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
|
import javafx.beans.property.SimpleStringProperty;
|
||||||
|
import javafx.beans.value.ChangeListener;
|
||||||
|
import javafx.beans.value.ObservableValue;
|
||||||
import javafx.collections.FXCollections;
|
import javafx.collections.FXCollections;
|
||||||
import javafx.collections.ObservableList;
|
import javafx.collections.ObservableList;
|
||||||
import javafx.concurrent.ScheduledService;
|
import javafx.concurrent.ScheduledService;
|
||||||
|
@ -48,6 +51,7 @@ import javafx.scene.control.SelectionMode;
|
||||||
import javafx.scene.control.Tab;
|
import javafx.scene.control.Tab;
|
||||||
import javafx.scene.control.TableColumn;
|
import javafx.scene.control.TableColumn;
|
||||||
import javafx.scene.control.TableColumn.SortType;
|
import javafx.scene.control.TableColumn.SortType;
|
||||||
|
import javafx.scene.control.TableRow;
|
||||||
import javafx.scene.control.TableView;
|
import javafx.scene.control.TableView;
|
||||||
import javafx.scene.control.Tooltip;
|
import javafx.scene.control.Tooltip;
|
||||||
import javafx.scene.control.cell.CheckBoxTableCell;
|
import javafx.scene.control.cell.CheckBoxTableCell;
|
||||||
|
@ -105,8 +109,20 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener {
|
||||||
scrollPane.setFitToWidth(true);
|
scrollPane.setFitToWidth(true);
|
||||||
BorderPane.setMargin(scrollPane, new Insets(5));
|
BorderPane.setMargin(scrollPane, new Insets(5));
|
||||||
|
|
||||||
|
|
||||||
table.setEditable(true);
|
table.setEditable(true);
|
||||||
table.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
|
table.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
|
||||||
|
PreviewPopupHandler previewPopupHandler = new PreviewPopupHandler(table);
|
||||||
|
table.setRowFactory((tableview) -> {
|
||||||
|
TableRow<JavaFxModel> row = new TableRow<>();
|
||||||
|
row.addEventHandler(MouseEvent.ANY, previewPopupHandler);
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
TableColumn<JavaFxModel, String> preview = new TableColumn<>("🎥");
|
||||||
|
preview.setPrefWidth(35);
|
||||||
|
preview.setCellValueFactory(cdf -> new SimpleStringProperty(" ▶ "));
|
||||||
|
preview.setEditable(false);
|
||||||
|
preview.setId("preview");
|
||||||
TableColumn<JavaFxModel, String> name = new TableColumn<>("Model");
|
TableColumn<JavaFxModel, String> name = new TableColumn<>("Model");
|
||||||
name.setPrefWidth(200);
|
name.setPrefWidth(200);
|
||||||
name.setCellValueFactory(new PropertyValueFactory<JavaFxModel, String>("name"));
|
name.setCellValueFactory(new PropertyValueFactory<JavaFxModel, String>("name"));
|
||||||
|
@ -116,21 +132,21 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener {
|
||||||
url.setPrefWidth(400);
|
url.setPrefWidth(400);
|
||||||
url.setEditable(false);
|
url.setEditable(false);
|
||||||
TableColumn<JavaFxModel, Boolean> online = new TableColumn<>("Online");
|
TableColumn<JavaFxModel, Boolean> online = new TableColumn<>("Online");
|
||||||
online.setCellValueFactory((cdf) -> cdf.getValue().getOnlineProperty());
|
online.setCellValueFactory(cdf -> cdf.getValue().getOnlineProperty());
|
||||||
online.setCellFactory(CheckBoxTableCell.forTableColumn(online));
|
online.setCellFactory(CheckBoxTableCell.forTableColumn(online));
|
||||||
online.setPrefWidth(100);
|
online.setPrefWidth(100);
|
||||||
online.setEditable(false);
|
online.setEditable(false);
|
||||||
TableColumn<JavaFxModel, Boolean> recording = new TableColumn<>("Recording");
|
TableColumn<JavaFxModel, Boolean> recording = new TableColumn<>("Recording");
|
||||||
recording.setCellValueFactory((cdf) -> cdf.getValue().getRecordingProperty());
|
recording.setCellValueFactory(cdf -> cdf.getValue().getRecordingProperty());
|
||||||
recording.setCellFactory(CheckBoxTableCell.forTableColumn(recording));
|
recording.setCellFactory(CheckBoxTableCell.forTableColumn(recording));
|
||||||
recording.setPrefWidth(100);
|
recording.setPrefWidth(100);
|
||||||
recording.setEditable(false);
|
recording.setEditable(false);
|
||||||
TableColumn<JavaFxModel, Boolean> paused = new TableColumn<>("Paused");
|
TableColumn<JavaFxModel, Boolean> paused = new TableColumn<>("Paused");
|
||||||
paused.setCellValueFactory((cdf) -> cdf.getValue().getPausedProperty());
|
paused.setCellValueFactory(cdf -> cdf.getValue().getPausedProperty());
|
||||||
paused.setCellFactory(CheckBoxTableCell.forTableColumn(paused));
|
paused.setCellFactory(CheckBoxTableCell.forTableColumn(paused));
|
||||||
paused.setPrefWidth(100);
|
paused.setPrefWidth(100);
|
||||||
paused.setEditable(true);
|
paused.setEditable(true);
|
||||||
table.getColumns().addAll(name, url, online, recording, paused);
|
table.getColumns().addAll(preview, name, url, online, recording, paused);
|
||||||
table.setItems(observableModels);
|
table.setItems(observableModels);
|
||||||
table.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, event -> {
|
table.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, event -> {
|
||||||
popup = createContextMenu();
|
popup = createContextMenu();
|
||||||
|
@ -144,7 +160,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener {
|
||||||
popup.hide();
|
popup.hide();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
table.addEventFilter(KeyEvent.KEY_PRESSED, event -> {
|
table.addEventHandler(KeyEvent.KEY_PRESSED, event -> {
|
||||||
List<JavaFxModel> selectedModels = table.getSelectionModel().getSelectedItems();
|
List<JavaFxModel> selectedModels = table.getSelectionModel().getSelectedItems();
|
||||||
if (event.getCode() == KeyCode.DELETE) {
|
if (event.getCode() == KeyCode.DELETE) {
|
||||||
stopAction(selectedModels);
|
stopAction(selectedModels);
|
||||||
|
@ -305,11 +321,20 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener {
|
||||||
int index = observableModels.indexOf(updatedModel);
|
int index = observableModels.indexOf(updatedModel);
|
||||||
if (index == -1) {
|
if (index == -1) {
|
||||||
observableModels.add(updatedModel);
|
observableModels.add(updatedModel);
|
||||||
updatedModel.getPausedProperty().addListener((obs, oldV, newV) -> {
|
updatedModel.getPausedProperty().addListener(new ChangeListener<Boolean>() {
|
||||||
if(newV) {
|
boolean firstChange = true;
|
||||||
pauseRecording(Collections.singletonList(updatedModel));
|
@Override
|
||||||
} else {
|
public void changed(ObservableValue<? extends Boolean> obs, Boolean oldV, Boolean newV) {
|
||||||
resumeRecording(Collections.singletonList(updatedModel));
|
if(firstChange) {
|
||||||
|
// don't react to the first change, because that is made by the recorder and not by the user
|
||||||
|
firstChange = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newV) {
|
||||||
|
pauseRecording(Collections.singletonList(updatedModel));
|
||||||
|
} else {
|
||||||
|
resumeRecording(Collections.singletonList(updatedModel));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
Loading…
Reference in New Issue