323 lines
12 KiB
Java
323 lines
12 KiB
Java
package ctbrec.ui.tabs;
|
|
|
|
import static java.nio.charset.StandardCharsets.*;
|
|
import static java.nio.file.StandardOpenOption.*;
|
|
|
|
import java.io.File;
|
|
import java.io.IOException;
|
|
import java.lang.reflect.Type;
|
|
import java.nio.file.Files;
|
|
import java.time.Instant;
|
|
import java.util.List;
|
|
import java.util.Objects;
|
|
import java.util.concurrent.locks.ReentrantLock;
|
|
import java.util.stream.Collectors;
|
|
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
import com.google.common.eventbus.Subscribe;
|
|
import com.squareup.moshi.JsonAdapter;
|
|
import com.squareup.moshi.Moshi;
|
|
import com.squareup.moshi.Types;
|
|
|
|
import ctbrec.Config;
|
|
import ctbrec.Model;
|
|
import ctbrec.StringUtil;
|
|
import ctbrec.event.EventBusHolder;
|
|
import ctbrec.io.InstantJsonAdapter;
|
|
import ctbrec.io.ModelJsonAdapter;
|
|
import ctbrec.recorder.Recorder;
|
|
import ctbrec.sites.Site;
|
|
import ctbrec.ui.ShutdownListener;
|
|
import ctbrec.ui.action.PlayAction;
|
|
import ctbrec.ui.controls.CustomMouseBehaviorContextMenu;
|
|
import ctbrec.ui.controls.DateTimeCellFactory;
|
|
import ctbrec.ui.controls.SearchBox;
|
|
import ctbrec.ui.event.PlayerStartedEvent;
|
|
import ctbrec.ui.menu.ModelMenuContributor;
|
|
import javafx.beans.property.SimpleObjectProperty;
|
|
import javafx.beans.property.SimpleStringProperty;
|
|
import javafx.collections.FXCollections;
|
|
import javafx.collections.ObservableList;
|
|
import javafx.geometry.Insets;
|
|
import javafx.scene.Cursor;
|
|
import javafx.scene.control.ContextMenu;
|
|
import javafx.scene.control.MenuItem;
|
|
import javafx.scene.control.ScrollPane;
|
|
import javafx.scene.control.SelectionMode;
|
|
import javafx.scene.control.SeparatorMenuItem;
|
|
import javafx.scene.control.Tab;
|
|
import javafx.scene.control.TableCell;
|
|
import javafx.scene.control.TableColumn;
|
|
import javafx.scene.control.TableColumn.SortType;
|
|
import javafx.scene.control.TableView;
|
|
import javafx.scene.input.ContextMenuEvent;
|
|
import javafx.scene.input.KeyCode;
|
|
import javafx.scene.input.KeyEvent;
|
|
import javafx.scene.input.MouseButton;
|
|
import javafx.scene.input.MouseEvent;
|
|
import javafx.scene.layout.BorderPane;
|
|
import javafx.scene.layout.HBox;
|
|
import javafx.scene.layout.Priority;
|
|
import javafx.util.Callback;
|
|
|
|
public class RecentlyWatchedTab extends Tab implements ShutdownListener {
|
|
|
|
private static final Logger LOG = LoggerFactory.getLogger(RecentlyWatchedTab.class);
|
|
|
|
private ObservableList<PlayerStartedEvent> filteredModels = FXCollections.observableArrayList();
|
|
private ObservableList<PlayerStartedEvent> observableModels = FXCollections.observableArrayList();
|
|
private TableView<PlayerStartedEvent> table = new TableView<>();
|
|
private ContextMenu popup;
|
|
private ReentrantLock lock = new ReentrantLock();
|
|
private Recorder recorder;
|
|
private List<Site> sites;
|
|
|
|
public RecentlyWatchedTab(Recorder recorder, List<Site> sites) {
|
|
this.recorder = recorder;
|
|
this.sites = sites;
|
|
setText("Recently Watched");
|
|
createGui();
|
|
loadHistory();
|
|
subscribeToPlayerEvents();
|
|
setOnClosed(evt -> onShutdown());
|
|
}
|
|
|
|
private void createGui() {
|
|
Config config = Config.getInstance();
|
|
var layout = new BorderPane();
|
|
layout.setPadding(new Insets(5, 10, 10, 10));
|
|
|
|
var filterInput = new SearchBox(false);
|
|
filterInput.setPromptText("Filter");
|
|
filterInput.textProperty().addListener( (observableValue, oldValue, newValue) -> {
|
|
String filter = filterInput.getText();
|
|
lock.lock();
|
|
try {
|
|
filter(filter);
|
|
} finally {
|
|
lock.unlock();
|
|
}
|
|
});
|
|
filterInput.getStyleClass().remove("search-box-icon");
|
|
HBox.setHgrow(filterInput, Priority.ALWAYS);
|
|
var topBar = new HBox(5);
|
|
topBar.getChildren().addAll(filterInput);
|
|
layout.setTop(topBar);
|
|
BorderPane.setMargin(topBar, new Insets(0, 0, 5, 0));
|
|
|
|
table.setItems(observableModels);
|
|
table.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
|
|
if (!config.getSettings().showGridLinesInTables) {
|
|
table.setStyle("-fx-table-cell-border-color: transparent;");
|
|
}
|
|
table.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, event -> {
|
|
popup = createContextMenu();
|
|
if (popup != null) {
|
|
popup.show(table, event.getScreenX(), event.getScreenY());
|
|
}
|
|
event.consume();
|
|
});
|
|
table.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
|
|
if (popup != null) {
|
|
popup.hide();
|
|
}
|
|
});
|
|
table.addEventHandler(KeyEvent.KEY_PRESSED, event -> {
|
|
List<PlayerStartedEvent> selectedModels = table.getSelectionModel().getSelectedItems();
|
|
if (event.getCode() == KeyCode.DELETE) {
|
|
delete(selectedModels);
|
|
}
|
|
});
|
|
|
|
var idx = 0;
|
|
TableColumn<PlayerStartedEvent, String> name = createTableColumn("Model", 200, idx++);
|
|
name.setId("name");
|
|
name.setCellValueFactory(cdf -> new SimpleStringProperty(cdf.getValue().getModel().getDisplayName()));
|
|
name.setCellFactory(new ClickableCellFactory<>());
|
|
table.getColumns().add(name);
|
|
|
|
TableColumn<PlayerStartedEvent, String> url = createTableColumn("URL", 400, idx);
|
|
url.setCellValueFactory(cdf -> new SimpleStringProperty(cdf.getValue().getModel().getUrl()));
|
|
url.setCellFactory(new ClickableCellFactory<>());
|
|
url.setEditable(false);
|
|
url.setId("url");
|
|
table.getColumns().add(url);
|
|
|
|
TableColumn<PlayerStartedEvent, Instant> timestamp = createTableColumn("Timestamp", 150, idx);
|
|
timestamp.setId("timestamp");
|
|
timestamp.setCellValueFactory(cdf -> new SimpleObjectProperty<>(cdf.getValue().getTimestamp()));
|
|
timestamp.setCellFactory(new DateTimeCellFactory<>(config.getDateTimeFormatter()));
|
|
timestamp.setEditable(false);
|
|
timestamp.setSortType(SortType.DESCENDING);
|
|
table.getColumns().add(timestamp);
|
|
table.getSortOrder().add(timestamp);
|
|
|
|
var scrollPane = new ScrollPane();
|
|
scrollPane.setFitToHeight(true);
|
|
scrollPane.setFitToWidth(true);
|
|
scrollPane.setContent(table);
|
|
scrollPane.setStyle("-fx-background-color: -fx-background");
|
|
layout.setCenter(scrollPane);
|
|
setContent(layout);
|
|
}
|
|
|
|
private <T> TableColumn<PlayerStartedEvent, T> createTableColumn(String text, int width, int idx) {
|
|
TableColumn<PlayerStartedEvent, T> tc = new TableColumn<>(text);
|
|
tc.setPrefWidth(width);
|
|
tc.setUserData(idx);
|
|
return tc;
|
|
}
|
|
|
|
private void filter(String filter) {
|
|
lock.lock();
|
|
try {
|
|
if (StringUtil.isBlank(filter)) {
|
|
observableModels.addAll(filteredModels);
|
|
filteredModels.clear();
|
|
return;
|
|
}
|
|
|
|
String[] tokens = filter.split(" ");
|
|
observableModels.addAll(filteredModels);
|
|
filteredModels.clear();
|
|
for (var i = 0; i < table.getItems().size(); i++) {
|
|
var sb = new StringBuilder();
|
|
for (TableColumn<PlayerStartedEvent, ?> tc : table.getColumns()) {
|
|
Object cellData = tc.getCellData(i);
|
|
if(cellData != null) {
|
|
var content = cellData.toString();
|
|
sb.append(content).append(' ');
|
|
}
|
|
}
|
|
var searchText = sb.toString();
|
|
|
|
var tokensMissing = false;
|
|
for (String token : tokens) {
|
|
if(!searchText.toLowerCase().contains(token.toLowerCase())) {
|
|
tokensMissing = true;
|
|
break;
|
|
}
|
|
}
|
|
if(tokensMissing) {
|
|
PlayerStartedEvent sessionState = table.getItems().get(i);
|
|
filteredModels.add(sessionState);
|
|
}
|
|
}
|
|
observableModels.removeAll(filteredModels);
|
|
} finally {
|
|
lock.unlock();
|
|
}
|
|
}
|
|
|
|
private ContextMenu createContextMenu() {
|
|
ObservableList<PlayerStartedEvent> selectedRows = table.getSelectionModel().getSelectedItems();
|
|
if (selectedRows.isEmpty()) {
|
|
return null;
|
|
}
|
|
|
|
List<Model> selectedModels = table.getSelectionModel().getSelectedItems().stream().map(PlayerStartedEvent::getModel).collect(Collectors.toList());
|
|
ContextMenu menu = new CustomMouseBehaviorContextMenu();
|
|
|
|
ModelMenuContributor.newContributor(getTabPane(), Config.getInstance(), recorder) //
|
|
.withStartStopCallback(m -> getTabPane().setCursor(Cursor.DEFAULT)) //
|
|
.afterwards(table::refresh)
|
|
.contributeToMenu(selectedModels, menu);
|
|
|
|
menu.getItems().add(new SeparatorMenuItem());
|
|
var delete = new MenuItem("Delete");
|
|
delete.setOnAction(e -> delete(selectedRows));
|
|
var clearHistory = new MenuItem("Clear history");
|
|
clearHistory.setOnAction(e -> clearHistory());
|
|
menu.getItems().addAll(delete, clearHistory);
|
|
|
|
return menu;
|
|
}
|
|
|
|
private void clearHistory() {
|
|
observableModels.clear();
|
|
filteredModels.clear();
|
|
}
|
|
|
|
private void delete(List<PlayerStartedEvent> selectedRows) {
|
|
observableModels.removeAll(selectedRows);
|
|
}
|
|
|
|
private void subscribeToPlayerEvents() {
|
|
EventBusHolder.BUS.register(new Object() {
|
|
@Subscribe
|
|
public void handleEvent(PlayerStartedEvent evt) {
|
|
table.getItems().add(evt);
|
|
table.sort();
|
|
}
|
|
});
|
|
}
|
|
|
|
private class ClickableCellFactory<S, T> implements Callback<TableColumn<S, T>, TableCell<S, T>> {
|
|
@Override
|
|
public TableCell<S, T> call(TableColumn<S, T> param) {
|
|
TableCell<S, T> cell = new TableCell<>() {
|
|
@Override
|
|
protected void updateItem(Object item, boolean empty) {
|
|
setText(empty ? "" : Objects.toString(item));
|
|
}
|
|
};
|
|
|
|
cell.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> {
|
|
if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) {
|
|
var selectedModel = table.getSelectionModel().getSelectedItem().getModel();
|
|
if(selectedModel != null) {
|
|
new PlayAction(table, selectedModel).execute();
|
|
}
|
|
}
|
|
});
|
|
return cell;
|
|
}
|
|
}
|
|
|
|
private void saveHistory() throws IOException {
|
|
var moshi = new Moshi.Builder()
|
|
.add(Model.class, new ModelJsonAdapter(sites))
|
|
.add(Instant.class, new InstantJsonAdapter())
|
|
.build();
|
|
Type type = Types.newParameterizedType(List.class, PlayerStartedEvent.class);
|
|
JsonAdapter<List<PlayerStartedEvent>> adapter = moshi.adapter(type);
|
|
String json = adapter.indent(" ").toJson(observableModels);
|
|
var recentlyWatched = new File(Config.getInstance().getConfigDir(), "recently_watched.json");
|
|
LOG.debug("Saving recently watched models to {}", recentlyWatched.getAbsolutePath());
|
|
Files.createDirectories(recentlyWatched.getParentFile().toPath());
|
|
Files.write(recentlyWatched.toPath(), json.getBytes(UTF_8), CREATE, WRITE, TRUNCATE_EXISTING);
|
|
}
|
|
|
|
private void loadHistory() {
|
|
var recentlyWatched = new File(Config.getInstance().getConfigDir(), "recently_watched.json");
|
|
if(!recentlyWatched.exists()) {
|
|
return;
|
|
}
|
|
|
|
LOG.debug("Loading recently watched models from {}", recentlyWatched.getAbsolutePath());
|
|
var moshi = new Moshi.Builder()
|
|
.add(Model.class, new ModelJsonAdapter(sites))
|
|
.add(Instant.class, new InstantJsonAdapter())
|
|
.build();
|
|
Type type = Types.newParameterizedType(List.class, PlayerStartedEvent.class);
|
|
JsonAdapter<List<PlayerStartedEvent>> adapter = moshi.adapter(type);
|
|
try {
|
|
List<PlayerStartedEvent> fromJson = adapter.fromJson(Files.readString(recentlyWatched.toPath(), UTF_8));
|
|
observableModels.addAll(fromJson);
|
|
} catch (IOException e) {
|
|
LOG.error("Couldn't load recently watched models", e);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void onShutdown() {
|
|
try {
|
|
saveHistory();
|
|
} catch (IOException e) {
|
|
LOG.error("Couldn't safe recently watched models", e);
|
|
}
|
|
}
|
|
}
|