ctbrec/client/src/main/java/ctbrec/ui/menu/ModelMenuContributor.java

475 lines
19 KiB
Java

package ctbrec.ui.menu;
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.function.Consumer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import ctbrec.Config;
import ctbrec.GlobalThreadPool;
import ctbrec.Model;
import ctbrec.ModelGroup;
import ctbrec.recorder.Recorder;
import ctbrec.ui.AutosizeAlert;
import ctbrec.ui.DesktopIntegration;
import ctbrec.ui.SiteUiFactory;
import ctbrec.ui.StreamSourceSelectionDialog;
import ctbrec.ui.action.AddToGroupAction;
import ctbrec.ui.action.EditNotesAction;
import ctbrec.ui.action.IgnoreModelsAction;
import ctbrec.ui.action.MarkForLaterRecordingAction;
import ctbrec.ui.action.OpenRecordingsDir;
import ctbrec.ui.action.PauseAction;
import ctbrec.ui.action.PlayAction;
import ctbrec.ui.action.RemoveTimeLimitAction;
import ctbrec.ui.action.ResumeAction;
import ctbrec.ui.action.SetStopDateAction;
import ctbrec.ui.action.StartRecordingAction;
import ctbrec.ui.action.StopRecordingAction;
import ctbrec.ui.action.TipAction;
import ctbrec.ui.action.TriConsumer;
import ctbrec.ui.controls.Dialogs;
import ctbrec.ui.tabs.FollowedTab;
import javafx.application.Platform;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.Alert;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem;
import javafx.scene.control.SeparatorMenuItem;
import javafx.scene.control.TabPane;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
public class ModelMenuContributor {
private static final Logger LOG = LoggerFactory.getLogger(ModelMenuContributor.class);
private static final String COULDNT_START_STOP_RECORDING = "Couldn't start/stop recording";
private static final String ERROR = "Error";
private Config config;
private Recorder recorder;
private Node source;
private Consumer<Model> startStopCallback;
private TriConsumer<Model, Boolean, Boolean> followCallback;
private Consumer<Model> ignoreCallback;
private boolean removeWithIgnore = false;
private Runnable callback;
private ModelMenuContributor(Node source, Config config, Recorder recorder) {
this.source = source;
this.config = config;
this.recorder = recorder;
}
public static ModelMenuContributor newContributor(Node source, Config config, Recorder recorder) {
return new ModelMenuContributor(source, config, recorder);
}
public ModelMenuContributor withStartStopCallback(Consumer<Model> callback) {
this.startStopCallback = callback;
return this;
}
public ModelMenuContributor withFollowCallback(TriConsumer<Model, Boolean, Boolean> callback) {
this.followCallback = callback;
return this;
}
public ModelMenuContributor withIgnoreCallback(Consumer<Model> ignoreCallback) {
this.ignoreCallback = ignoreCallback;
return this;
}
public ModelMenuContributor removeModelAfterIgnore(boolean yes) {
this.removeWithIgnore = yes;
return this;
}
public void contributeToMenu(List<Model> selectedModels, ContextMenu menu) {
startStopCallback = Optional.ofNullable(startStopCallback).orElse(m -> {});
followCallback = Optional.ofNullable(followCallback).orElse((m, f, s) -> {});
ignoreCallback = Optional.ofNullable(ignoreCallback).orElse(m -> {});
callback = Optional.ofNullable(callback).orElse(() -> {});
addOpenInPlayer(menu, selectedModels);
addOpenInBrowser(menu, selectedModels);
addCopyUrl(menu, selectedModels);
menu.getItems().add(new SeparatorMenuItem());
addStartOrStop(menu, selectedModels);
addStartRecordingWithTimeLimit(menu, selectedModels);
addRemoveTimeLimit(menu, selectedModels);
addSwitchStreamSource(menu, selectedModels);
addStartPaused(menu, selectedModels);
addRecordLater(menu, selectedModels);
addPauseResume(menu, selectedModels);
addGroupMenu(menu, selectedModels);
menu.getItems().add(new SeparatorMenuItem());
addFollowUnfollow(menu, selectedModels);
addSendTip(menu, selectedModels);
addIgnore(menu, selectedModels);
addOpenRecDir(menu, selectedModels);
addNotes(menu, selectedModels);
}
public ModelMenuContributor afterwards(Runnable callback) {
this.callback = callback;
return this;
}
private void addNotes(ContextMenu menu, List<Model> selectedModels) {
var notes = new MenuItem("Notes");
notes.setDisable(selectedModels.size() != 1);
notes.setOnAction(e -> new EditNotesAction(source, selectedModels.get(0), callback).execute());
menu.getItems().add(notes);
}
private void addOpenRecDir(ContextMenu menu, List<Model> selectedModels) {
if (selectedModels == null || selectedModels.isEmpty()) {
return;
}
var model = selectedModels.get(0);
var openRecDir = new MenuItem("Open recording directory");
openRecDir.setDisable(selectedModels.size() != 1);
openRecDir.setOnAction(e -> {
new OpenRecordingsDir(source, model).execute();
executeCallback();
});
menu.getItems().add(openRecDir);
}
private void addOpenInBrowser(ContextMenu menu, List<Model> selectedModels) {
var openInBrowser = new MenuItem("Open in browser");
openInBrowser.setOnAction(e -> selectedModels.forEach(model -> DesktopIntegration.open(model.getUrl())));
menu.getItems().add(openInBrowser);
}
private void addCopyUrl(ContextMenu menu, List<Model> selectedModels) {
if (selectedModels == null || selectedModels.isEmpty()) {
return;
}
var copyUrl = new MenuItem("Copy URL");
copyUrl.setOnAction(e -> {
var sb = new StringBuilder();
for (Model model : selectedModels) {
sb.append(model.getUrl()).append('\n');
}
sb.deleteCharAt(sb.length() - 1);
final var content = new ClipboardContent();
content.putString(sb.toString());
Clipboard.getSystemClipboard().setContent(content);
});
menu.getItems().add(copyUrl);
}
private void addSendTip(ContextMenu menu, List<Model> selectedModels) {
var model = selectedModels.get(0);
var site = model.getSite();
if (site.supportsTips()) {
var sendTip = new MenuItem("Send Tip");
sendTip.setOnAction(e -> new TipAction(model, source).execute());
sendTip.setDisable(!site.credentialsAvailable());
sendTip.setDisable(selectedModels.size() != 1);
menu.getItems().add(sendTip);
}
}
private void addIgnore(ContextMenu menu, List<Model> selectedModels) {
var ignore = new MenuItem("Ignore");
ignore.setOnAction(e -> ignore(selectedModels));
menu.getItems().add(ignore);
}
private void ignore(List<Model> selectedModels) {
new IgnoreModelsAction(source, selectedModels, recorder, removeWithIgnore).execute(ignoreCallback);
}
private void addFollowUnfollow(ContextMenu menu, List<Model> selectedModels) {
var site = selectedModels.get(0).getSite();
if (site.supportsFollow()) {
var follow = new MenuItem("Follow");
follow.setOnAction(e -> follow(selectedModels, true));
var unfollow = new MenuItem("Unfollow");
unfollow.setOnAction(e -> follow(selectedModels, false));
var followOrUnFollow = isFollowedTab() ? unfollow : follow;
followOrUnFollow.setDisable(!site.credentialsAvailable());
menu.getItems().add(followOrUnFollow);
followOrUnFollow.setDisable(!site.credentialsAvailable());
}
}
private boolean isFollowedTab() {
if (source instanceof TabPane) {
var tabPane = (TabPane) source;
return tabPane.getSelectionModel().getSelectedItem() instanceof FollowedTab;
}
return false;
}
protected void follow(List<Model> selectedModels, boolean follow) {
for (Model model : selectedModels) {
follow(model, follow);
}
}
CompletableFuture<Boolean> follow(Model model, boolean follow) {
source.setCursor(Cursor.WAIT);
return CompletableFuture.supplyAsync(() -> {
var success = true;
try {
if (follow) {
SiteUiFactory.getUi(model.getSite()).login();
boolean followed = model.follow();
if (followed) {
success = true;
} else {
Dialogs.showError(source.getScene(), "Couldn't follow model", "", null);
success = false;
}
} else {
SiteUiFactory.getUi(model.getSite()).login();
boolean unfollowed = model.unfollow();
if (unfollowed) {
success = true;
} else {
Dialogs.showError(source.getScene(), "Couldn't unfollow model", "", null);
success = false;
}
}
return success;
} catch (Exception e1) {
LOG.error("Couldn't follow/unfollow model {}", model.getName(), e1);
String msg = "I/O error while following/unfollowing model " + model.getName() + ": ";
Dialogs.showError(source.getScene(), "Couldn't follow/unfollow model", msg, e1);
return false;
} finally {
final boolean result = success;
Platform.runLater(() -> {
source.setCursor(Cursor.DEFAULT);
followCallback.accept(model, follow, result);
});
}
}, GlobalThreadPool.get());
}
private void addSwitchStreamSource(ContextMenu menu, List<Model> selectedModels) {
var model = selectedModels.get(0);
if (!recorder.isTracked(model)) {
return;
}
var switchStreamSource = new MenuItem("Switch resolution");
switchStreamSource.setOnAction(e -> switchStreamSource(selectedModels.get(0)));
menu.getItems().add(switchStreamSource);
switchStreamSource.setDisable(selectedModels.size() != 1);
}
private void switchStreamSource(Model selectedModel) {
var couldntSwitchHeaderText = "Couldn't switch stream resolution";
try {
if (!selectedModel.isOnline(true)) {
Dialogs.showError(source.getScene(), couldntSwitchHeaderText, "The resolution can only be changed, when the model is online", null);
return;
}
} catch (InterruptedException e1) {
Thread.currentThread().interrupt();
Dialogs.showError(source.getScene(), couldntSwitchHeaderText, "An error occured while checking, if the model is online", null);
return;
} catch (IOException | ExecutionException e1) {
Dialogs.showError(source.getScene(), couldntSwitchHeaderText, "An error occured while checking, if the model is online", null);
return;
}
Consumer<Model> onSuccess = m -> {
try {
recorder.switchStreamSource(m);
} catch (InvalidKeyException | NoSuchAlgorithmException | IllegalStateException | IOException e) {
LOG.error(couldntSwitchHeaderText, e);
showStreamSwitchErrorDialog(e);
}
};
Consumer<Throwable> onFail = t -> {
LOG.error(couldntSwitchHeaderText, t);
showStreamSwitchErrorDialog(t);
};
StreamSourceSelectionDialog.show(source.getScene(), selectedModel, onSuccess, onFail);
}
private void showStreamSwitchErrorDialog(Throwable throwable) {
showErrorDialog(throwable, "Couldn't switch stream resolution", "Error while switching stream resolution");
}
private void showErrorDialog(Throwable throwable, String header, String msg) {
Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, source.getScene());
alert.setTitle("Error");
alert.setHeaderText(header);
alert.setContentText(msg + ": " + throwable.getLocalizedMessage());
alert.showAndWait();
}
private void addGroupMenu(ContextMenu menu, List<Model> selectedModels) {
var model = selectedModels.get(0);
var addToGroup = new MenuItem("Add to group");
addToGroup.setOnAction(e -> new AddToGroupAction(source, recorder, selectedModels).execute(callback));
var groupSubMenu = new ModelGroupMenuBuilder() // @formatter:off
.model(model)
.recorder(recorder)
.node(source)
.callback(m -> callback.run())
.build(); // @formatter:on
Optional<ModelGroup> modelGroup = recorder.getModelGroup(model);
menu.getItems().add(modelGroup.isEmpty() ? addToGroup : groupSubMenu);
}
private void addPauseResume(ContextMenu menu, List<Model> selectedModels) {
var first = selectedModels.get(0);
if (recorder.isTracked(first)) {
var pause = new MenuItem("Pause Recording");
pause.setOnAction(e -> new PauseAction(source, selectedModels, recorder).execute(m -> executeCallback()));
var resume = new MenuItem("Resume Recording");
resume.setOnAction(e -> new ResumeAction(source, selectedModels, recorder).execute(m -> executeCallback()));
var pauseResume = recorder.isSuspended(first) ? resume : pause;
menu.getItems().add(pauseResume);
}
}
private void addRecordLater(ContextMenu menu, List<Model> selectedModels) {
var first = selectedModels.get(0);
var recordLater = new MenuItem("Record Later");
recordLater.setOnAction(e -> recordLater(selectedModels, true));
var removeRecordLater = new MenuItem("Forget Model");
removeRecordLater.setOnAction(e -> recordLater(selectedModels, false));
var addRemoveBookmark = recorder.isMarkedForLaterRecording(first) ? removeRecordLater : recordLater;
if (recorder.isTracked(first)) {
menu.getItems().add(recordLater);
} else {
menu.getItems().add(addRemoveBookmark);
}
}
private void recordLater(List<Model> selectedModels, boolean recordLater) {
selectedModels.forEach(m -> m.setMarkedForLaterRecording(recordLater));
new MarkForLaterRecordingAction(source, selectedModels, recordLater, recorder).execute(m -> executeCallback());
}
private void addStartPaused(ContextMenu menu, List<Model> selectedModels) {
if (!recorder.isTracked(selectedModels.get(0))) {
var addPaused = new MenuItem("Add in paused state");
menu.getItems().add(addPaused);
addPaused.setOnAction(e -> {
for (Model model : selectedModels) {
model.setMarkedForLaterRecording(false);
model.setSuspended(true);
}
startStopAction(selectedModels, true);
});
}
}
private void addStartRecordingWithTimeLimit(ContextMenu menu, List<Model> selectedModels) {
var model = selectedModels.get(0);
var text = recorder.isTracked(model) ? "Record Until" : "Start Recording Until";
var start = new MenuItem(text);
menu.getItems().add(start);
start.setOnAction(e -> {
selectedModels.forEach(m -> {
m.setMarkedForLaterRecording(false);
m.setSuspended(false);
});
startStopAction(selectedModels, true);
selectedModels.forEach(m -> new SetStopDateAction(source, m, recorder).execute() //
.thenAccept(b -> executeCallback()));
});
}
private void addRemoveTimeLimit(ContextMenu menu, List<Model> selectedModels) {
var model = selectedModels.get(0);
if (!model.isRecordingTimeLimited()) {
return;
}
var removeTimeLimit = new MenuItem("Remove Time Limit");
removeTimeLimit.setOnAction(e -> removeTimeLimit(model));
menu.getItems().add(removeTimeLimit);
}
private void removeTimeLimit(Model selectedModel) {
new RemoveTimeLimitAction(source, selectedModel, recorder) //
.execute() //
.whenComplete((result, exception) -> executeCallback());
}
private void addOpenInPlayer(ContextMenu menu, List<Model> selectedModels) {
var openInPlayer = new MenuItem("Open in Player");
openInPlayer.setOnAction(e -> selectedModels.forEach(m -> new PlayAction(source, m).execute()));
menu.getItems().add(openInPlayer);
if (config.getSettings().singlePlayer && selectedModels.size() > 1) {
openInPlayer.setDisable(true);
}
}
private void addStartOrStop(ContextMenu menu, List<Model> selectedModels) {
var start = new MenuItem("Start Recording");
start.setOnAction(e -> {
for (Model model : selectedModels) {
model.setMarkedForLaterRecording(false);
model.setSuspended(false);
}
startStopAction(selectedModels, true);
});
var stop = new MenuItem("Stop Recording");
stop.setOnAction(e -> startStopAction(selectedModels, false));
var startStop = recorder.isTracked(selectedModels.get(0)) ? stop : start;
menu.getItems().add(startStop);
}
private void startStopAction(List<Model> selection, boolean start) {
if (start) {
boolean selectSource = Config.getInstance().getSettings().chooseStreamQuality;
if (selectSource) {
for (Model model : selection) {
Consumer<Model> onSuccess = modl -> startRecording(List.of(modl));
Consumer<Throwable> onFail = throwable -> {
Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, source.getScene());
alert.setTitle(ERROR);
alert.setHeaderText(COULDNT_START_STOP_RECORDING);
alert.setContentText("I/O error while starting/stopping the recording: " + throwable.getLocalizedMessage());
alert.showAndWait();
};
StreamSourceSelectionDialog.show(source.getScene(), model, onSuccess, onFail);
}
} else {
startRecording(selection);
}
} else {
stopRecording(selection);
}
}
private void startRecording(List<Model> models) {
new StartRecordingAction(source, models, recorder).execute(startStopCallback);
}
private void stopRecording(List<Model> models) {
new StopRecordingAction(source, models, recorder).execute(startStopCallback);
}
private void executeCallback() {
try {
callback.run();
} catch (Exception e) {
LOG.error("Error while executing menu callback", e);
}
}
}