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 filteredModels = FXCollections.observableArrayList(); private ObservableList observableModels = FXCollections.observableArrayList(); private TableView table = new TableView<>(); private ContextMenu popup; private ReentrantLock lock = new ReentrantLock(); private Recorder recorder; private List sites; public RecentlyWatchedTab(Recorder recorder, List 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 selectedModels = table.getSelectionModel().getSelectedItems(); if (event.getCode() == KeyCode.DELETE) { delete(selectedModels); } }); var idx = 0; TableColumn 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 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 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 TableColumn createTableColumn(String text, int width, int idx) { TableColumn 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 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 selectedRows = table.getSelectionModel().getSelectedItems(); if (selectedRows.isEmpty()) { return null; } List 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 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 implements Callback, TableCell> { @Override public TableCell call(TableColumn param) { TableCell 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> 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> adapter = moshi.adapter(type); try { List 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); } } }