diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b46e195..bfd0fd0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ 3.12.3 ======================== +* Added "Recently watched" tab. Can be disabled in Settings -> General * Recording size now takes all associated files into account * Removed restriction of download thread pool size (was 100 before) diff --git a/client/src/main/java/ctbrec/ui/CamrecApplication.java b/client/src/main/java/ctbrec/ui/CamrecApplication.java index 1e860240..00c82846 100644 --- a/client/src/main/java/ctbrec/ui/CamrecApplication.java +++ b/client/src/main/java/ctbrec/ui/CamrecApplication.java @@ -63,6 +63,7 @@ import ctbrec.ui.news.NewsTab; import ctbrec.ui.settings.SettingsTab; import ctbrec.ui.tabs.DonateTabFx; import ctbrec.ui.tabs.HelpTab; +import ctbrec.ui.tabs.RecentlyWatchedTab; import ctbrec.ui.tabs.RecordedTab; import ctbrec.ui.tabs.RecordingsTab; import ctbrec.ui.tabs.SiteTab; @@ -216,6 +217,9 @@ public class CamrecApplication extends Application { tabPane.getTabs().add(modelsTab); recordingsTab = new RecordingsTab("Recordings", recorder, config); tabPane.getTabs().add(recordingsTab); + if (config.getSettings().recentlyWatched) { + tabPane.getTabs().add(new RecentlyWatchedTab(recorder, sites)); + } tabPane.getTabs().add(new SettingsTab(sites, recorder)); tabPane.getTabs().add(new NewsTab()); tabPane.getTabs().add(new DonateTabFx()); @@ -299,8 +303,11 @@ public class CamrecApplication extends Application { final boolean immediately = shutdownNow; new Thread(() -> { - modelsTab.saveState(); - recordingsTab.saveState(); + for (Tab tab : tabPane.getTabs()) { + if (tab instanceof ShutdownListener) { + ((ShutdownListener) tab).onShutdown(); + } + } onlineMonitor.shutdown(); recorder.shutdown(immediately); for (Site site : sites) { diff --git a/client/src/main/java/ctbrec/ui/Player.java b/client/src/main/java/ctbrec/ui/Player.java index 468ab883..34671404 100644 --- a/client/src/main/java/ctbrec/ui/Player.java +++ b/client/src/main/java/ctbrec/ui/Player.java @@ -25,10 +25,12 @@ import ctbrec.Config; import ctbrec.Model; import ctbrec.OS; import ctbrec.Recording; +import ctbrec.event.EventBusHolder; import ctbrec.io.StreamRedirector; import ctbrec.io.UrlUtil; import ctbrec.recorder.download.StreamSource; import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.event.PlayerStartedEvent; import javafx.scene.Scene; public class Player { @@ -85,6 +87,7 @@ public class Player { } String playlistUrl = getPlaylistUrl(model); LOG.debug("Playing {}", playlistUrl); + EventBusHolder.BUS.post(new PlayerStartedEvent(model)); return Player.play(playlistUrl, async); } else { Dialogs.showError(scene, "Room not public", "Room is currently not public", null); diff --git a/client/src/main/java/ctbrec/ui/ShutdownListener.java b/client/src/main/java/ctbrec/ui/ShutdownListener.java new file mode 100644 index 00000000..cb5d95d4 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/ShutdownListener.java @@ -0,0 +1,5 @@ +package ctbrec.ui; + +public interface ShutdownListener { + void onShutdown(); +} diff --git a/client/src/main/java/ctbrec/ui/event/PlayerStartedEvent.java b/client/src/main/java/ctbrec/ui/event/PlayerStartedEvent.java new file mode 100644 index 00000000..6b040f60 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/event/PlayerStartedEvent.java @@ -0,0 +1,60 @@ +package ctbrec.ui.event; + +import java.time.Instant; +import java.util.Objects; + +import ctbrec.Model; +import ctbrec.ui.JavaFxModel; + +public class PlayerStartedEvent { + + private Model model; + private Instant timestamp; + + public PlayerStartedEvent(Model model) { + this(model, Instant.now()); + } + + public PlayerStartedEvent(Model model, Instant timestamp) { + this.model = unwrap(model); + this.timestamp = timestamp; + } + + public Model getModel() { + return model; + } + + public Instant getTimestamp() { + return timestamp; + } + + @Override + public int hashCode() { + return Objects.hash(timestamp); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + PlayerStartedEvent other = (PlayerStartedEvent) obj; + return Objects.equals(timestamp, other.timestamp); + } + + @Override + public String toString() { + return "PlayerStartedEvent [model=" + model + ", timestamp=" + timestamp + "]"; + } + + private Model unwrap(Model model) { + if (model instanceof JavaFxModel) { + return ((JavaFxModel) model).getDelegate(); + } else { + return model; + } + } +} diff --git a/client/src/main/java/ctbrec/ui/settings/SettingsTab.java b/client/src/main/java/ctbrec/ui/settings/SettingsTab.java index ed3452b9..368d992d 100644 --- a/client/src/main/java/ctbrec/ui/settings/SettingsTab.java +++ b/client/src/main/java/ctbrec/ui/settings/SettingsTab.java @@ -129,6 +129,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { private SimpleBooleanProperty transportLayerSecurity; private SimpleBooleanProperty fastScrollSpeed; private SimpleBooleanProperty useHlsdl; + private SimpleBooleanProperty recentlyWatched; private SimpleFileProperty hlsdlExecutable; private ExclusiveSelectionProperty recordLocal; private SimpleIntegerProperty postProcessingThreads; @@ -191,6 +192,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { confirmationDialogs = new SimpleBooleanProperty(null, "confirmationForDangerousActions", settings.confirmationForDangerousActions); useHlsdl = new SimpleBooleanProperty(null, "useHlsdl", settings.useHlsdl); hlsdlExecutable = new SimpleFileProperty(null, "hlsdlExecutable", settings.hlsdlExecutable); + recentlyWatched = new SimpleBooleanProperty(null, "recentlyWatched", settings.recentlyWatched); } private void createGui() { @@ -214,6 +216,7 @@ public class SettingsTab extends Tab implements TabSelectionListener { Setting.of("Display stream resolution in overview", determineResolution), Setting.of("Manually select stream quality", chooseStreamQuality, "Opens a dialog to select the video resolution before recording"), Setting.of("Enable live previews (experimental)", livePreviews), + Setting.of("Enable recently watched tab", recentlyWatched).needsRestart(), Setting.of("Add models from clipboard", monitorClipboard, "Monitor clipboard for model URLs and automatically add them to the recorder").needsRestart(), Setting.of("Fast scroll speed", fastScrollSpeed, "Makes the thumbnail overviews scroll faster with the mouse wheel").needsRestart(), Setting.of("Show confirmation dialogs", confirmationDialogs, "Show confirmation dialogs for irreversible actions"), diff --git a/client/src/main/java/ctbrec/ui/tabs/RecentlyWatchedTab.java b/client/src/main/java/ctbrec/ui/tabs/RecentlyWatchedTab.java new file mode 100644 index 00000000..b8f8f285 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/RecentlyWatchedTab.java @@ -0,0 +1,346 @@ +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.DesktopIntegration; +import ctbrec.ui.ShutdownListener; +import ctbrec.ui.action.FollowAction; +import ctbrec.ui.action.PlayAction; +import ctbrec.ui.action.StartRecordingAction; +import ctbrec.ui.controls.CustomMouseBehaviorContextMenu; +import ctbrec.ui.controls.DateTimeCellFactory; +import ctbrec.ui.controls.SearchBox; +import ctbrec.ui.event.PlayerStartedEvent; +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.control.ContextMenu; +import javafx.scene.control.MenuItem; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.SelectionMode; +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.Clipboard; +import javafx.scene.input.ClipboardContent; +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() { + BorderPane layout = new BorderPane(); + layout.setPadding(new Insets(5, 10, 10, 10)); + + SearchBox 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); + HBox 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); + 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); + } + }); + + int 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<>()); + timestamp.setEditable(false); + timestamp.setSortType(SortType.DESCENDING); + table.getColumns().add(timestamp); + table.getSortOrder().add(timestamp); + + ScrollPane 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 (int i = 0; i < table.getItems().size(); i++) { + StringBuilder sb = new StringBuilder(); + for (TableColumn tc : table.getColumns()) { + Object cellData = tc.getCellData(i); + if(cellData != null) { + String content = cellData.toString(); + sb.append(content).append(' '); + } + } + String searchText = sb.toString(); + + boolean 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 = selectedRows.stream().map(PlayerStartedEvent::getModel).collect(Collectors.toList()); + MenuItem copyUrl = new MenuItem("Copy URL"); + copyUrl.setOnAction(e -> { + Model selected = selectedModels.get(0); + final Clipboard clipboard = Clipboard.getSystemClipboard(); + final ClipboardContent content = new ClipboardContent(); + content.putString(selected.getUrl()); + clipboard.setContent(content); + }); + + MenuItem startRecording = new MenuItem("Start Recording"); + startRecording.setOnAction(e -> startRecording(selectedModels)); + MenuItem openInBrowser = new MenuItem("Open in Browser"); + openInBrowser.setOnAction(e -> DesktopIntegration.open(selectedModels.get(0).getUrl())); + MenuItem openInPlayer = new MenuItem("Open in Player"); + openInPlayer.setOnAction(e -> openInPlayer(selectedModels.get(0))); + MenuItem follow = new MenuItem("Follow"); + follow.setOnAction(e -> new FollowAction(getTabPane(), selectedModels).execute()); + MenuItem delete = new MenuItem("Delete"); + delete.setOnAction(e -> delete(selectedRows)); + + MenuItem clearHistory = new MenuItem("Clear history"); + clearHistory.setOnAction(e -> clearHistory()); + + ContextMenu menu = new CustomMouseBehaviorContextMenu(); + menu.getItems().addAll(startRecording, copyUrl, openInPlayer, openInBrowser, follow, delete, clearHistory); + + if (selectedModels.size() > 1) { + copyUrl.setDisable(true); + openInPlayer.setDisable(true); + openInBrowser.setDisable(true); + } + + return menu; + } + + private void clearHistory() { + observableModels.clear(); + filteredModels.clear(); + } + + private void delete(List selectedRows) { + observableModels.removeAll(selectedRows); + } + + private void startRecording(List selectedModels) { + new StartRecordingAction(getTabPane(), selectedModels, recorder).execute(); + } + + private void openInPlayer(Model selectedModel) { + new PlayAction(getTabPane(), selectedModel).execute(); + } + + 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) { + Model selectedModel = table.getSelectionModel().getSelectedItem().getModel(); + if(selectedModel != null) { + new PlayAction(table, selectedModel).execute(); + } + } + }); + return cell; + } + } + + private void saveHistory() throws IOException { + Moshi 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); + File 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() { + File recentlyWatched = new File(Config.getInstance().getConfigDir(), "recently_watched.json"); + if(!recentlyWatched.exists()) { + return; + } + + LOG.debug("Loading recently watched models from {}", recentlyWatched.getAbsolutePath()); + Moshi 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); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/RecordedTab.java b/client/src/main/java/ctbrec/ui/tabs/RecordedTab.java index bc33c61a..38a112d4 100644 --- a/client/src/main/java/ctbrec/ui/tabs/RecordedTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/RecordedTab.java @@ -4,12 +4,13 @@ import java.util.List; import ctbrec.recorder.Recorder; import ctbrec.sites.Site; +import ctbrec.ui.ShutdownListener; import javafx.beans.value.ChangeListener; import javafx.geometry.Side; import javafx.scene.control.Tab; import javafx.scene.control.TabPane; -public class RecordedTab extends Tab implements TabSelectionListener { +public class RecordedTab extends Tab implements TabSelectionListener, ShutdownListener { private TabPane tabPane; private RecordedModelsTab recordedModelsTab; @@ -54,7 +55,8 @@ public class RecordedTab extends Tab implements TabSelectionListener { } } - public void saveState() { + @Override + public void onShutdown() { recordedModelsTab.saveState(); recordLaterTab.saveState(); } diff --git a/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java b/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java index d7e51802..b8fda61e 100644 --- a/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java @@ -46,6 +46,7 @@ import ctbrec.ui.DesktopIntegration; import ctbrec.ui.FileDownload; import ctbrec.ui.JavaFxRecording; import ctbrec.ui.Player; +import ctbrec.ui.ShutdownListener; import ctbrec.ui.action.FollowAction; import ctbrec.ui.action.PauseAction; import ctbrec.ui.action.PlayAction; @@ -91,7 +92,7 @@ import javafx.scene.text.Font; import javafx.stage.FileChooser; import javafx.util.Duration; -public class RecordingsTab extends Tab implements TabSelectionListener { +public class RecordingsTab extends Tab implements TabSelectionListener, ShutdownListener { private static final String ERROR_WHILE_DOWNLOADING_RECORDING = "Error while downloading recording"; private static final Logger LOG = LoggerFactory.getLogger(RecordingsTab.class); @@ -822,7 +823,8 @@ public class RecordingsTab extends Tab implements TabSelectionListener { deleteThread.start(); } - public void saveState() { + @Override + public void onShutdown() { if (!table.getSortOrder().isEmpty()) { TableColumn col = table.getSortOrder().get(0); Config.getInstance().getSettings().recordingsSortColumn = col.getText(); diff --git a/common/src/main/java/ctbrec/Settings.java b/common/src/main/java/ctbrec/Settings.java index 70e59939..9fb7a42a 100644 --- a/common/src/main/java/ctbrec/Settings.java +++ b/common/src/main/java/ctbrec/Settings.java @@ -120,6 +120,7 @@ public class Settings { public String proxyPort; public ProxyType proxyType = ProxyType.DIRECT; public String proxyUser; + public boolean recentlyWatched = true; public double[] recordLaterColumnWidths = new double[0]; public String[] recordLaterColumnIds = new String[0]; public String recordLaterSortColumn = "";