diff --git a/client/src/main/java/ctbrec/ui/action/SetPortraitAction.java b/client/src/main/java/ctbrec/ui/action/SetPortraitAction.java new file mode 100644 index 00000000..c543da32 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/SetPortraitAction.java @@ -0,0 +1,108 @@ +package ctbrec.ui.action; + +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.UUID; +import java.util.function.Consumer; + +import javax.imageio.ImageIO; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.controls.FileSelectionBox; +import javafx.scene.Cursor; +import javafx.scene.Node; + +public class SetPortraitAction { + private static final Logger LOG = LoggerFactory.getLogger(SetPortraitAction.class); + public static final String FORMAT = "jpg"; + + private Node source; + private Model model; + private Consumer callback; + + public SetPortraitAction(Node source, Model selectedModel, Consumer callback) { + this.source = source; + this.model = selectedModel; + this.callback = callback; + } + + public void execute() { + source.setCursor(Cursor.WAIT); + String portraitId = Config.getInstance().getSettings().modelPortraits.getOrDefault(model.getUrl(), UUID.randomUUID().toString()); + + FileSelectionBox portraitSelectionBox = new FileSelectionBox(); + boolean accepted = Dialogs.showCustomInput(source.getScene(), "Select a portrait image", portraitSelectionBox); + if (!accepted) { + return; + } + String selectedFile = portraitSelectionBox.fileProperty().getValue(); + + LOG.debug("User selected {}", selectedFile); + boolean success = processImageFile(portraitId, selectedFile); + if (success) { + Config.getInstance().getSettings().modelPortraits.put(model.getUrl(), portraitId); + try { + Config.getInstance().save(); + runCallback(); + } catch (IOException e) { + Dialogs.showError("Set Portrait", "Couldn't change portrait image: ", e); + } + } + source.setCursor(Cursor.DEFAULT); + } + + private void runCallback() { + if (callback != null) { + try { + callback.accept(model); + } catch (Exception e) { + LOG.error("Error while executing callback", e); + } + } + } + + private boolean processImageFile(String portraitId, String selectedFile) { + try { + BufferedImage portrait = convertToScaledJpg(selectedFile); + boolean success = copyToCacheAsJpg(portraitId, portrait); + if (!success) { + LOG.debug("Available formats: {}", Arrays.toString(ImageIO.getWriterFormatNames())); + throw new IOException("No suitable writer found for image format " + FORMAT); + } + return success; + } catch (IOException e) { + LOG.error("Error while changing portrait image", e); + Dialogs.showError("Set Portrait", "Couldn't change portrait image: ", e); + return false; + } + } + + private BufferedImage convertToScaledJpg(String file) throws IOException { + BufferedImage portrait = ImageIO.read(new File(file)); + Image scaledPortrait = portrait.getScaledInstance(-1, 256, Image.SCALE_SMOOTH); + BufferedImage bimage = new BufferedImage(scaledPortrait.getWidth(null), scaledPortrait.getHeight(null), BufferedImage.TYPE_INT_RGB); + Graphics2D bGr = bimage.createGraphics(); + bGr.drawImage(scaledPortrait, 0, 0, null); + bGr.dispose(); + return bimage; + } + + private boolean copyToCacheAsJpg(String portraitId, BufferedImage portrait) throws IOException { + File configDir = Config.getInstance().getConfigDir(); + File portraitDir = new File(configDir, "portraits"); + Files.createDirectories(portraitDir.toPath()); + File output = new File(portraitDir, portraitId + '.' + FORMAT); + LOG.debug("Writing scaled portrait to {}", output); + return ImageIO.write(portrait, FORMAT, output); + } +} diff --git a/client/src/main/java/ctbrec/ui/controls/AbstractFileSelectionBox.java b/client/src/main/java/ctbrec/ui/controls/AbstractFileSelectionBox.java index b094a3c4..5c6477cd 100644 --- a/client/src/main/java/ctbrec/ui/controls/AbstractFileSelectionBox.java +++ b/client/src/main/java/ctbrec/ui/controls/AbstractFileSelectionBox.java @@ -45,6 +45,7 @@ public abstract class AbstractFileSelectionBox extends HBox { }); var browse = createBrowseButton(); browse.disableProperty().bind(disableProperty()); + browse.prefHeightProperty().bind(fileInput.prefWidthProperty()); fileInput.disableProperty().bind(disableProperty()); fileInput.textProperty().bindBidirectional(fileProperty); getChildren().addAll(fileInput, browse); diff --git a/client/src/main/java/ctbrec/ui/menu/ModelMenuContributor.java b/client/src/main/java/ctbrec/ui/menu/ModelMenuContributor.java index 9920c12a..694e2adf 100644 --- a/client/src/main/java/ctbrec/ui/menu/ModelMenuContributor.java +++ b/client/src/main/java/ctbrec/ui/menu/ModelMenuContributor.java @@ -30,6 +30,7 @@ import ctbrec.ui.action.PauseAction; import ctbrec.ui.action.PlayAction; import ctbrec.ui.action.RemoveTimeLimitAction; import ctbrec.ui.action.ResumeAction; +import ctbrec.ui.action.SetPortraitAction; import ctbrec.ui.action.SetStopDateAction; import ctbrec.ui.action.StartRecordingAction; import ctbrec.ui.action.StopRecordingAction; @@ -61,6 +62,7 @@ public class ModelMenuContributor { private Consumer startStopCallback; private TriConsumer followCallback; private Consumer ignoreCallback; + private Consumer portraitCallback; private boolean removeWithIgnore = false; private Runnable callback; @@ -89,6 +91,12 @@ public class ModelMenuContributor { return this; } + public ModelMenuContributor withPortraitCallback(Consumer portraitCallback) { + this.portraitCallback = portraitCallback; + return this; + } + + public ModelMenuContributor removeModelAfterIgnore(boolean yes) { this.removeWithIgnore = yes; return this; @@ -98,6 +106,7 @@ public class ModelMenuContributor { startStopCallback = Optional.ofNullable(startStopCallback).orElse(m -> {}); followCallback = Optional.ofNullable(followCallback).orElse((m, f, s) -> {}); ignoreCallback = Optional.ofNullable(ignoreCallback).orElse(m -> {}); + portraitCallback = Optional.ofNullable(portraitCallback).orElse(m -> {}); callback = Optional.ofNullable(callback).orElse(() -> {}); addOpenInPlayer(menu, selectedModels); addOpenInBrowser(menu, selectedModels); @@ -119,6 +128,7 @@ public class ModelMenuContributor { addIgnore(menu, selectedModels); addOpenRecDir(menu, selectedModels); addNotes(menu, selectedModels); + addPortrait(menu, selectedModels); } public ModelMenuContributor afterwards(Runnable callback) { @@ -133,6 +143,13 @@ public class ModelMenuContributor { menu.getItems().add(notes); } + private void addPortrait(ContextMenu menu, List selectedModels) { + var portrait = new MenuItem("Portrait"); + portrait.setDisable(selectedModels.size() != 1); + portrait.setOnAction(e -> new SetPortraitAction(source, selectedModels.get(0), portraitCallback).execute()); + menu.getItems().add(portrait); + } + private void addOpenRecDir(ContextMenu menu, List selectedModels) { if (selectedModels == null || selectedModels.isEmpty()) { return; diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/ImageTableCell.java b/client/src/main/java/ctbrec/ui/tabs/recorded/ImageTableCell.java new file mode 100644 index 00000000..030d5c0b --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/ImageTableCell.java @@ -0,0 +1,31 @@ +package ctbrec.ui.tabs.recorded; + +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; + +public class ImageTableCell extends ClickableTableCell { + + protected ImageView imageView; + + public ImageTableCell() { + imageView = new ImageView(); + imageView.setSmooth(true); + imageView.setPreserveRatio(true); + imageView.prefHeight(64); + imageView.setFitHeight(64); + setGraphic(imageView); + } + + @Override + public void requestLayout() { + double columnWidth = getTableColumn().getWidth(); + imageView.prefHeight(columnWidth); + imageView.setFitHeight(columnWidth); + super.requestLayout(); + } + + @Override + protected void updateItem(Image image, boolean empty) { + imageView.setImage(empty ? null : image); + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/RecordedModelsTab.java b/client/src/main/java/ctbrec/ui/tabs/recorded/RecordedModelsTab.java index 094df8c7..30bf30a8 100644 --- a/client/src/main/java/ctbrec/ui/tabs/recorded/RecordedModelsTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/RecordedModelsTab.java @@ -2,6 +2,9 @@ package ctbrec.ui.tabs.recorded; import static ctbrec.Recording.State.*; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.IOException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; @@ -11,6 +14,7 @@ import java.util.Iterator; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; @@ -21,6 +25,10 @@ import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; + import ctbrec.Config; import ctbrec.Model; import ctbrec.ModelGroup; @@ -35,6 +43,7 @@ import ctbrec.ui.action.CheckModelAccountAction; import ctbrec.ui.action.PauseAction; import ctbrec.ui.action.PlayAction; import ctbrec.ui.action.ResumeAction; +import ctbrec.ui.action.SetPortraitAction; import ctbrec.ui.action.StopRecordingAction; import ctbrec.ui.action.ToggleRecordingAction; import ctbrec.ui.controls.CustomMouseBehaviorContextMenu; @@ -78,6 +87,7 @@ import javafx.scene.control.Tooltip; import javafx.scene.control.cell.CheckBoxTableCell; import javafx.scene.control.cell.PropertyValueFactory; import javafx.scene.control.cell.TextFieldTableCell; +import javafx.scene.image.Image; import javafx.scene.input.ContextMenuEvent; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; @@ -119,6 +129,11 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { Button checkModelAccountExistance = new Button("Check URLs"); TextField filter; + LoadingCache portraitCache = CacheBuilder.newBuilder() + .expireAfterAccess(1, TimeUnit.DAYS) + .maximumSize(1000) + .build(CacheLoader.from(RecordedModelsTab::loadModelPortrait)); + public RecordedModelsTab(String title, Recorder recorder, List sites) { super(title); this.recorder = recorder; @@ -155,6 +170,23 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { if (!Config.getInstance().getSettings().livePreviews) { preview.setVisible(false); } + + TableColumn portrait = new TableColumn<>("Portrait"); + portrait.setPrefWidth(80); + portrait.setCellValueFactory(param -> { + Model mdl = param.getValue().getDelegate(); + Image image = null; + try { + image = portraitCache.get(mdl); + } catch (ExecutionException e) { + LOG.error("Error while loading portrait from cache for {}", mdl, e); + } + return new SimpleObjectProperty(image); + }); + portrait.setCellFactory(param -> new ImageTableCell()); + portrait.setEditable(false); + portrait.setId("portrait"); + TableColumn name = new TableColumn<>("Model"); name.setPrefWidth(200); name.setCellValueFactory(param -> { @@ -235,7 +267,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { notes.setPrefWidth(400); notes.setEditable(false); notes.setId("notes"); - table.getColumns().addAll(preview, name, url, online, recording, paused, priority, lastSeen, lastRecorded, notes); + table.getColumns().addAll(preview, portrait, name, url, online, recording, paused, priority, lastSeen, lastRecorded, notes); table.setItems(observableModels); table.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, event -> { popup = createContextMenu(); @@ -632,6 +664,10 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { ModelMenuContributor.newContributor(getTabPane(), Config.getInstance(), recorder) // .withStartStopCallback(m -> getTabPane().setCursor(Cursor.DEFAULT)) // .removeModelAfterIgnore(true) // + .withPortraitCallback(m -> { + portraitCache.invalidate(m); + table.refresh(); + }) .afterwards(table::refresh) // .contributeToMenu(selectedModels, menu); @@ -796,4 +832,19 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener { return s; } } + + private static Image loadModelPortrait(Model model) { + String portraitId = Config.getInstance().getSettings().modelPortraits.get(model.getUrl()); + if (StringUtil.isNotBlank(portraitId)) { + File configDir = Config.getInstance().getConfigDir(); + File portraitDir = new File(configDir, "portraits"); + File portraitFile = new File(portraitDir, portraitId + '.' + SetPortraitAction.FORMAT); + try { + return new Image(new FileInputStream(portraitFile)); + } catch (FileNotFoundException e) { + LOG.error("Couldn't load portrait file {}", portraitFile, e); + } + } + return new Image(RecordedModelsTab.class.getResourceAsStream("/silhouette_256.png")); + } } diff --git a/client/src/main/resources/silhouette_256.png b/client/src/main/resources/silhouette_256.png new file mode 100644 index 00000000..5a05ba3f Binary files /dev/null and b/client/src/main/resources/silhouette_256.png differ diff --git a/common/src/main/java/ctbrec/AbstractModel.java b/common/src/main/java/ctbrec/AbstractModel.java index 216f4c72..700a529b 100644 --- a/common/src/main/java/ctbrec/AbstractModel.java +++ b/common/src/main/java/ctbrec/AbstractModel.java @@ -84,7 +84,7 @@ public abstract class AbstractModel implements Model { @Override public String getSanitizedNamed() { String sanitizedName = Optional.ofNullable(getName()).orElse(""); - return sanitizedName.replace(' ', '_').replace('\\', '_').replace('/', '_'); + return sanitizedName.replaceAll("[^a-zA-Z0-9.-]", "_"); } @Override diff --git a/common/src/main/java/ctbrec/Settings.java b/common/src/main/java/ctbrec/Settings.java index aa05d35c..f2af3d49 100644 --- a/common/src/main/java/ctbrec/Settings.java +++ b/common/src/main/java/ctbrec/Settings.java @@ -113,9 +113,10 @@ public class Settings { @Deprecated public int minimumLengthInSeconds = 0; public long minimumSpaceLeftInBytes = 0; - public Map modelNotes = new HashMap<>(); public List models = new ArrayList<>(); public Set modelGroups = new HashSet<>(); + public Map modelNotes = new HashMap<>(); + public Map modelPortraits = new HashMap<>(); @Deprecated public List modelsIgnored = new ArrayList<>(); public boolean monitorClipboard = false;