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 startStopCallback; private TriConsumer followCallback; private Consumer 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 callback) { this.startStopCallback = callback; return this; } public ModelMenuContributor withFollowCallback(TriConsumer callback) { this.followCallback = callback; return this; } public ModelMenuContributor withIgnoreCallback(Consumer ignoreCallback) { this.ignoreCallback = ignoreCallback; return this; } public ModelMenuContributor removeModelAfterIgnore(boolean yes) { this.removeWithIgnore = yes; return this; } public void contributeToMenu(List 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 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 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 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 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 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 selectedModels) { var ignore = new MenuItem("Ignore"); ignore.setOnAction(e -> ignore(selectedModels)); menu.getItems().add(ignore); } private void ignore(List selectedModels) { new IgnoreModelsAction(source, selectedModels, recorder, removeWithIgnore).execute(ignoreCallback); } private void addFollowUnfollow(ContextMenu menu, List 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 selectedModels, boolean follow) { for (Model model : selectedModels) { follow(model, follow); } } CompletableFuture 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 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 onSuccess = m -> { try { recorder.switchStreamSource(m); } catch (InvalidKeyException | NoSuchAlgorithmException | IllegalStateException | IOException e) { LOG.error(couldntSwitchHeaderText, e); showStreamSwitchErrorDialog(e); } }; Consumer 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 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 = recorder.getModelGroup(model); menu.getItems().add(modelGroup.isEmpty() ? addToGroup : groupSubMenu); } private void addPauseResume(ContextMenu menu, List selectedModels) { var first = selectedModels.get(0); LOG.debug(first.toString()); 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 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 selectedModels, boolean recordLater) { selectedModels.forEach(m -> m.setMarkedForLaterRecording(recordLater)); new MarkForLaterRecordingAction(source, selectedModels, recordLater, recorder).execute(m -> executeCallback()); } private void addStartPaused(ContextMenu menu, List 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 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 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 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 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 selection, boolean start) { if (start) { boolean selectSource = Config.getInstance().getSettings().chooseStreamQuality; if (selectSource) { for (Model model : selection) { Consumer onSuccess = modl -> startRecording(List.of(modl)); Consumer 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 models) { new StartRecordingAction(source, models, recorder).execute(startStopCallback); } private void stopRecording(List 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); } } }