Add tab for recently watched models

This commit is contained in:
0xb00bface 2021-01-16 18:28:49 +01:00
parent 66d234e668
commit 368120e8e6
10 changed files with 436 additions and 6 deletions

View File

@ -1,5 +1,6 @@
3.12.3 3.12.3
======================== ========================
* Added "Recently watched" tab. Can be disabled in Settings -> General
* Recording size now takes all associated files into account * Recording size now takes all associated files into account
* Removed restriction of download thread pool size (was 100 before) * Removed restriction of download thread pool size (was 100 before)

View File

@ -63,6 +63,7 @@ import ctbrec.ui.news.NewsTab;
import ctbrec.ui.settings.SettingsTab; import ctbrec.ui.settings.SettingsTab;
import ctbrec.ui.tabs.DonateTabFx; import ctbrec.ui.tabs.DonateTabFx;
import ctbrec.ui.tabs.HelpTab; import ctbrec.ui.tabs.HelpTab;
import ctbrec.ui.tabs.RecentlyWatchedTab;
import ctbrec.ui.tabs.RecordedTab; import ctbrec.ui.tabs.RecordedTab;
import ctbrec.ui.tabs.RecordingsTab; import ctbrec.ui.tabs.RecordingsTab;
import ctbrec.ui.tabs.SiteTab; import ctbrec.ui.tabs.SiteTab;
@ -216,6 +217,9 @@ public class CamrecApplication extends Application {
tabPane.getTabs().add(modelsTab); tabPane.getTabs().add(modelsTab);
recordingsTab = new RecordingsTab("Recordings", recorder, config); recordingsTab = new RecordingsTab("Recordings", recorder, config);
tabPane.getTabs().add(recordingsTab); 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 SettingsTab(sites, recorder));
tabPane.getTabs().add(new NewsTab()); tabPane.getTabs().add(new NewsTab());
tabPane.getTabs().add(new DonateTabFx()); tabPane.getTabs().add(new DonateTabFx());
@ -299,8 +303,11 @@ public class CamrecApplication extends Application {
final boolean immediately = shutdownNow; final boolean immediately = shutdownNow;
new Thread(() -> { new Thread(() -> {
modelsTab.saveState(); for (Tab tab : tabPane.getTabs()) {
recordingsTab.saveState(); if (tab instanceof ShutdownListener) {
((ShutdownListener) tab).onShutdown();
}
}
onlineMonitor.shutdown(); onlineMonitor.shutdown();
recorder.shutdown(immediately); recorder.shutdown(immediately);
for (Site site : sites) { for (Site site : sites) {

View File

@ -25,10 +25,12 @@ import ctbrec.Config;
import ctbrec.Model; import ctbrec.Model;
import ctbrec.OS; import ctbrec.OS;
import ctbrec.Recording; import ctbrec.Recording;
import ctbrec.event.EventBusHolder;
import ctbrec.io.StreamRedirector; import ctbrec.io.StreamRedirector;
import ctbrec.io.UrlUtil; import ctbrec.io.UrlUtil;
import ctbrec.recorder.download.StreamSource; import ctbrec.recorder.download.StreamSource;
import ctbrec.ui.controls.Dialogs; import ctbrec.ui.controls.Dialogs;
import ctbrec.ui.event.PlayerStartedEvent;
import javafx.scene.Scene; import javafx.scene.Scene;
public class Player { public class Player {
@ -85,6 +87,7 @@ public class Player {
} }
String playlistUrl = getPlaylistUrl(model); String playlistUrl = getPlaylistUrl(model);
LOG.debug("Playing {}", playlistUrl); LOG.debug("Playing {}", playlistUrl);
EventBusHolder.BUS.post(new PlayerStartedEvent(model));
return Player.play(playlistUrl, async); return Player.play(playlistUrl, async);
} else { } else {
Dialogs.showError(scene, "Room not public", "Room is currently not public", null); Dialogs.showError(scene, "Room not public", "Room is currently not public", null);

View File

@ -0,0 +1,5 @@
package ctbrec.ui;
public interface ShutdownListener {
void onShutdown();
}

View File

@ -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;
}
}
}

View File

@ -129,6 +129,7 @@ public class SettingsTab extends Tab implements TabSelectionListener {
private SimpleBooleanProperty transportLayerSecurity; private SimpleBooleanProperty transportLayerSecurity;
private SimpleBooleanProperty fastScrollSpeed; private SimpleBooleanProperty fastScrollSpeed;
private SimpleBooleanProperty useHlsdl; private SimpleBooleanProperty useHlsdl;
private SimpleBooleanProperty recentlyWatched;
private SimpleFileProperty hlsdlExecutable; private SimpleFileProperty hlsdlExecutable;
private ExclusiveSelectionProperty recordLocal; private ExclusiveSelectionProperty recordLocal;
private SimpleIntegerProperty postProcessingThreads; private SimpleIntegerProperty postProcessingThreads;
@ -191,6 +192,7 @@ public class SettingsTab extends Tab implements TabSelectionListener {
confirmationDialogs = new SimpleBooleanProperty(null, "confirmationForDangerousActions", settings.confirmationForDangerousActions); confirmationDialogs = new SimpleBooleanProperty(null, "confirmationForDangerousActions", settings.confirmationForDangerousActions);
useHlsdl = new SimpleBooleanProperty(null, "useHlsdl", settings.useHlsdl); useHlsdl = new SimpleBooleanProperty(null, "useHlsdl", settings.useHlsdl);
hlsdlExecutable = new SimpleFileProperty(null, "hlsdlExecutable", settings.hlsdlExecutable); hlsdlExecutable = new SimpleFileProperty(null, "hlsdlExecutable", settings.hlsdlExecutable);
recentlyWatched = new SimpleBooleanProperty(null, "recentlyWatched", settings.recentlyWatched);
} }
private void createGui() { private void createGui() {
@ -214,6 +216,7 @@ public class SettingsTab extends Tab implements TabSelectionListener {
Setting.of("Display stream resolution in overview", determineResolution), 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("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 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("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("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"), Setting.of("Show confirmation dialogs", confirmationDialogs, "Show confirmation dialogs for irreversible actions"),

View File

@ -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<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() {
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<PlayerStartedEvent> selectedModels = table.getSelectionModel().getSelectedItems();
if (event.getCode() == KeyCode.DELETE) {
delete(selectedModels);
}
});
int 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<Instant>(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 <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 (int i = 0; i < table.getItems().size(); i++) {
StringBuilder sb = new StringBuilder();
for (TableColumn<PlayerStartedEvent, ?> 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<PlayerStartedEvent> selectedRows = table.getSelectionModel().getSelectedItems();
if (selectedRows.isEmpty()) {
return null;
}
List<Model> 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<PlayerStartedEvent> selectedRows) {
observableModels.removeAll(selectedRows);
}
private void startRecording(List<Model> 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<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) {
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<List<PlayerStartedEvent>> 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<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);
}
}
}

View File

@ -4,12 +4,13 @@ import java.util.List;
import ctbrec.recorder.Recorder; import ctbrec.recorder.Recorder;
import ctbrec.sites.Site; import ctbrec.sites.Site;
import ctbrec.ui.ShutdownListener;
import javafx.beans.value.ChangeListener; import javafx.beans.value.ChangeListener;
import javafx.geometry.Side; import javafx.geometry.Side;
import javafx.scene.control.Tab; import javafx.scene.control.Tab;
import javafx.scene.control.TabPane; import javafx.scene.control.TabPane;
public class RecordedTab extends Tab implements TabSelectionListener { public class RecordedTab extends Tab implements TabSelectionListener, ShutdownListener {
private TabPane tabPane; private TabPane tabPane;
private RecordedModelsTab recordedModelsTab; private RecordedModelsTab recordedModelsTab;
@ -54,7 +55,8 @@ public class RecordedTab extends Tab implements TabSelectionListener {
} }
} }
public void saveState() { @Override
public void onShutdown() {
recordedModelsTab.saveState(); recordedModelsTab.saveState();
recordLaterTab.saveState(); recordLaterTab.saveState();
} }

View File

@ -46,6 +46,7 @@ import ctbrec.ui.DesktopIntegration;
import ctbrec.ui.FileDownload; import ctbrec.ui.FileDownload;
import ctbrec.ui.JavaFxRecording; import ctbrec.ui.JavaFxRecording;
import ctbrec.ui.Player; import ctbrec.ui.Player;
import ctbrec.ui.ShutdownListener;
import ctbrec.ui.action.FollowAction; import ctbrec.ui.action.FollowAction;
import ctbrec.ui.action.PauseAction; import ctbrec.ui.action.PauseAction;
import ctbrec.ui.action.PlayAction; import ctbrec.ui.action.PlayAction;
@ -91,7 +92,7 @@ import javafx.scene.text.Font;
import javafx.stage.FileChooser; import javafx.stage.FileChooser;
import javafx.util.Duration; 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 String ERROR_WHILE_DOWNLOADING_RECORDING = "Error while downloading recording";
private static final Logger LOG = LoggerFactory.getLogger(RecordingsTab.class); private static final Logger LOG = LoggerFactory.getLogger(RecordingsTab.class);
@ -822,7 +823,8 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
deleteThread.start(); deleteThread.start();
} }
public void saveState() { @Override
public void onShutdown() {
if (!table.getSortOrder().isEmpty()) { if (!table.getSortOrder().isEmpty()) {
TableColumn<JavaFxRecording, ?> col = table.getSortOrder().get(0); TableColumn<JavaFxRecording, ?> col = table.getSortOrder().get(0);
Config.getInstance().getSettings().recordingsSortColumn = col.getText(); Config.getInstance().getSettings().recordingsSortColumn = col.getText();

View File

@ -120,6 +120,7 @@ public class Settings {
public String proxyPort; public String proxyPort;
public ProxyType proxyType = ProxyType.DIRECT; public ProxyType proxyType = ProxyType.DIRECT;
public String proxyUser; public String proxyUser;
public boolean recentlyWatched = true;
public double[] recordLaterColumnWidths = new double[0]; public double[] recordLaterColumnWidths = new double[0];
public String[] recordLaterColumnIds = new String[0]; public String[] recordLaterColumnIds = new String[0];
public String recordLaterSortColumn = ""; public String recordLaterSortColumn = "";