From b9f24a209ebd8bd88d42162ceb9365132d1e174e Mon Sep 17 00:00:00 2001 From: 0xboobface <0xboobface@gmail.com> Date: Fri, 23 Nov 2018 20:27:49 +0100 Subject: [PATCH] Implement search feature If a site supports searching, add a search field on the right side next to the filter input field. This search uses the sites search function to look for models and returns a list of matches in a popup window --- .../java/ctbrec/ui/CamrecApplication.java | 2 + .../java/ctbrec/ui/RecordedModelsTab.java | 2 +- client/src/main/java/ctbrec/ui/ThumbCell.java | 4 +- .../main/java/ctbrec/ui/ThumbOverviewTab.java | 144 +++++- .../AutoFillTextField.java | 2 +- .../main/java/ctbrec/ui/controls/Popover.css | 74 +++ .../main/java/ctbrec/ui/controls/Popover.java | 480 ++++++++++++++++++ .../ctbrec/ui/controls/PopoverTreeList.java | 112 ++++ .../java/ctbrec/ui/controls/SearchBox.css | 34 ++ .../java/ctbrec/ui/controls/SearchBox.java | 96 ++++ .../ctbrec/ui/controls/SearchPopover.java | 9 + .../ui/controls/SearchPopoverTreeList.java | 317 ++++++++++++ client/src/main/resources/anonymous.png | Bin 0 -> 3344 bytes client/src/main/resources/popover-arrow.png | Bin 0 -> 1104 bytes .../src/main/resources/popover-arrow@2x.png | Bin 0 -> 1112 bytes client/src/main/resources/popover-empty.png | Bin 0 -> 4624 bytes .../src/main/resources/popover-empty@2x.png | Bin 0 -> 9431 bytes .../main/java/ctbrec/sites/AbstractSite.java | 20 + common/src/main/java/ctbrec/sites/Site.java | 4 + .../java/ctbrec/sites/bonga/BongaCams.java | 57 +++ .../src/main/java/ctbrec/sites/cam4/Cam4.java | 62 ++- .../java/ctbrec/sites/camsoda/Camsoda.java | 46 ++ .../ctbrec/sites/chaturbate/Chaturbate.java | 43 ++ .../java/ctbrec/sites/mfc/MyFreeCams.java | 11 + .../ctbrec/sites/mfc/MyFreeCamsClient.java | 49 +- 25 files changed, 1545 insertions(+), 23 deletions(-) rename client/src/main/java/ctbrec/ui/{autofilltextbox => controls}/AutoFillTextField.java (98%) create mode 100644 client/src/main/java/ctbrec/ui/controls/Popover.css create mode 100644 client/src/main/java/ctbrec/ui/controls/Popover.java create mode 100644 client/src/main/java/ctbrec/ui/controls/PopoverTreeList.java create mode 100644 client/src/main/java/ctbrec/ui/controls/SearchBox.css create mode 100644 client/src/main/java/ctbrec/ui/controls/SearchBox.java create mode 100644 client/src/main/java/ctbrec/ui/controls/SearchPopover.java create mode 100644 client/src/main/java/ctbrec/ui/controls/SearchPopoverTreeList.java create mode 100644 client/src/main/resources/anonymous.png create mode 100644 client/src/main/resources/popover-arrow.png create mode 100644 client/src/main/resources/popover-arrow@2x.png create mode 100644 client/src/main/resources/popover-empty.png create mode 100644 client/src/main/resources/popover-empty@2x.png diff --git a/client/src/main/java/ctbrec/ui/CamrecApplication.java b/client/src/main/java/ctbrec/ui/CamrecApplication.java index 537eed29..d9814e87 100644 --- a/client/src/main/java/ctbrec/ui/CamrecApplication.java +++ b/client/src/main/java/ctbrec/ui/CamrecApplication.java @@ -123,6 +123,8 @@ public class CamrecApplication extends Application { switchToStartTab(); primaryStage.getScene().getStylesheets().add("/ctbrec/ui/ThumbCell.css"); + primaryStage.getScene().getStylesheets().add("/ctbrec/ui/controls/SearchBox.css"); + primaryStage.getScene().getStylesheets().add("/ctbrec/ui/controls/Popover.css"); primaryStage.getScene().widthProperty().addListener((observable, oldVal, newVal) -> Config.getInstance().getSettings().windowWidth = newVal.intValue()); primaryStage.getScene().heightProperty() .addListener((observable, oldVal, newVal) -> Config.getInstance().getSettings().windowHeight = newVal.intValue()); diff --git a/client/src/main/java/ctbrec/ui/RecordedModelsTab.java b/client/src/main/java/ctbrec/ui/RecordedModelsTab.java index 5874f8c2..63366614 100644 --- a/client/src/main/java/ctbrec/ui/RecordedModelsTab.java +++ b/client/src/main/java/ctbrec/ui/RecordedModelsTab.java @@ -24,7 +24,7 @@ import ctbrec.Model; import ctbrec.Recording; import ctbrec.recorder.Recorder; import ctbrec.sites.Site; -import ctbrec.ui.autofilltextbox.AutoFillTextField; +import ctbrec.ui.controls.AutoFillTextField; import javafx.application.Platform; import javafx.collections.FXCollections; import javafx.collections.ObservableList; diff --git a/client/src/main/java/ctbrec/ui/ThumbCell.java b/client/src/main/java/ctbrec/ui/ThumbCell.java index 8b563344..52a39f89 100644 --- a/client/src/main/java/ctbrec/ui/ThumbCell.java +++ b/client/src/main/java/ctbrec/ui/ThumbCell.java @@ -181,6 +181,8 @@ public class ThumbCell extends StackPane { if(Config.getInstance().getSettings().determineResolution) { determineResolution(); } + + update(); } public void setSelected(boolean selected) { @@ -478,7 +480,7 @@ public class ThumbCell extends StackPane { setRecording(recorder.isRecording(model)); setImage(model.getPreview()); String txt = recording ? " " : ""; - txt += model.getDescription(); + txt += model.getDescription() != null ? model.getDescription() : ""; topic.setText(txt); if(Config.getInstance().getSettings().determineResolution) { diff --git a/client/src/main/java/ctbrec/ui/ThumbOverviewTab.java b/client/src/main/java/ctbrec/ui/ThumbOverviewTab.java index 0aad00b2..5fb861e4 100644 --- a/client/src/main/java/ctbrec/ui/ThumbOverviewTab.java +++ b/client/src/main/java/ctbrec/ui/ThumbOverviewTab.java @@ -30,6 +30,9 @@ import ctbrec.recorder.Recorder; import ctbrec.sites.Site; import ctbrec.sites.mfc.MyFreeCamsClient; import ctbrec.sites.mfc.MyFreeCamsModel; +import ctbrec.ui.controls.SearchBox; +import ctbrec.ui.controls.SearchPopover; +import ctbrec.ui.controls.SearchPopoverTreeList; import javafx.animation.FadeTransition; import javafx.animation.Interpolator; import javafx.animation.ParallelTransition; @@ -37,8 +40,10 @@ import javafx.animation.ScaleTransition; import javafx.animation.Transition; import javafx.animation.TranslateTransition; import javafx.application.Platform; +import javafx.beans.value.ChangeListener; import javafx.collections.FXCollections; import javafx.collections.ObservableList; +import javafx.concurrent.Task; import javafx.concurrent.Worker.State; import javafx.concurrent.WorkerStateEvent; import javafx.event.EventHandler; @@ -61,11 +66,14 @@ import javafx.scene.image.ImageView; 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.FlowPane; import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.scene.transform.Transform; @@ -96,6 +104,9 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { ContextMenu popup; Site site; StackPane root = new StackPane(); + Task> searchTask; + SearchPopover popover; + SearchPopoverTreeList popoverTreelist = new SearchPopoverTreeList(); private ComboBox thumbWidth; @@ -113,10 +124,10 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { grid.setHgap(5); grid.setVgap(5); - TextField search = new TextField(); - search.setPromptText("Filter models on this page"); - search.textProperty().addListener( (observableValue, oldValue, newValue) -> { - filter = search.getText(); + SearchBox filterInput = new SearchBox(false); + filterInput.setPromptText("Filter models on this page"); + filterInput.textProperty().addListener( (observableValue, oldValue, newValue) -> { + filter = filterInput.getText(); gridLock.lock(); try { filter(); @@ -125,12 +136,41 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { gridLock.unlock(); } }); - Tooltip searchTooltip = new Tooltip("Filter the models by their name, stream description or #hashtags.\n\n" + Tooltip filterTooltip = new Tooltip("Filter the models by their name, stream description or #hashtags.\n\n" + "If the display of stream resolution is enabled, you can even filter for public rooms or by resolution.\n\n" + "Try \"1080\" or \">720\" or \"public\""); - search.setTooltip(searchTooltip); + filterInput.setTooltip(filterTooltip); + filterInput.getStyleClass().remove("search-box-icon"); + BorderPane.setMargin(filterInput, new Insets(5)); - BorderPane.setMargin(search, new Insets(5)); + SearchBox searchInput = new SearchBox(); + searchInput.setPromptText("Search Model"); + searchInput.prefWidth(200); + searchInput.textProperty().addListener(search()); + searchInput.addEventHandler(KeyEvent.KEY_PRESSED, evt -> { + if(evt.getCode() == KeyCode.ESCAPE) { + popover.hide(); + } + }); + BorderPane.setMargin(searchInput, new Insets(5)); + + popover = new SearchPopover(); + popover.maxWidthProperty().bind(popover.minWidthProperty()); + popover.prefWidthProperty().bind(popover.minWidthProperty()); + popover.setMinWidth(400); + popover.maxHeightProperty().bind(popover.minHeightProperty()); + popover.prefHeightProperty().bind(popover.minHeightProperty()); + popover.setMinHeight(400); + popover.pushPage(popoverTreelist); + StackPane.setAlignment(popover, Pos.TOP_RIGHT); + StackPane.setMargin(popover, new Insets(50, 50, 0, 0)); + + HBox topBar = new HBox(5); + HBox.setHgrow(filterInput, Priority.ALWAYS); + topBar.getChildren().add(filterInput); + if(site.supportsSearch()) { + topBar.getChildren().add(searchInput); + } scrollPane.setContent(grid); scrollPane.setFitToHeight(true); @@ -186,14 +226,69 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { BorderPane borderPane = new BorderPane(); borderPane.setPadding(new Insets(5)); - borderPane.setTop(search); + borderPane.setTop(topBar); borderPane.setCenter(scrollPane); borderPane.setBottom(bottomPane); root.getChildren().add(borderPane); + root.getChildren().add(popover); setContent(root); } + private ChangeListener search() { + return (observableValue, oldValue, newValue) -> { + if(searchTask != null) { + searchTask.cancel(true); + } + + if(newValue.length() < 2) { + return; + } + + + searchTask = new Task>() { + @Override + protected List call() throws Exception { + if(site.searchRequiresLogin()) { + boolean loggedin = false; + try { + loggedin = SiteUiFactory.getUi(site).login(); + } catch (IOException e) { + loggedin = false; + } + if(!loggedin) { + showError("Login failed", "Search won't work correctly without login", null); + } + } + return site.search(newValue); + } + + @Override + protected void failed() { + LOG.error("Search failed", getException()); + } + + @Override + protected void succeeded() { + Platform.runLater(() -> { + List models = getValue(); + LOG.debug("Search result {} {}", isCancelled(), models); + if(models.isEmpty()) { + popover.hide(); + } else { + popoverTreelist.getItems().clear(); + for (Model model : getValue()) { + popoverTreelist.getItems().add(model); + } + popover.show(); + } + }); + } + }; + new Thread(searchTask).start(); + }; + } + private void updateThumbSize() { int width = Config.getInstance().getSettings().thumbWidth; thumbWidth.getSelectionModel().select(Integer.valueOf(width)); @@ -375,18 +470,10 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { event.put("amount", tokens); CamrecApplication.bus.post(event); } catch (Exception e1) { - Alert alert = new AutosizeAlert(Alert.AlertType.ERROR); - alert.setTitle("Error"); - alert.setHeaderText("Couldn't send tip"); - alert.setContentText("An error occured while sending tip: " + e1.getLocalizedMessage()); - alert.showAndWait(); + showError("Couldn't send tip", "An error occured while sending tip:", e1); } } else { - Alert alert = new AutosizeAlert(Alert.AlertType.ERROR); - alert.setTitle("Error"); - alert.setHeaderText("Couldn't send tip"); - alert.setContentText("You entered an invalid amount of tokens"); - alert.showAndWait(); + showError("Couldn't send tip", "You entered an invalid amount of tokens", null); } } }); @@ -700,6 +787,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { public void setRecorder(Recorder recorder) { this.recorder = recorder; + popoverTreelist.setRecorder(recorder); } @Override @@ -731,4 +819,24 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener { selectedThumbCells.get(0).setSelected(false); } } + + private void showError(String header, String text, Exception e) { + Runnable r = () -> { + Alert alert = new AutosizeAlert(Alert.AlertType.ERROR); + alert.setTitle("Error"); + alert.setHeaderText(header); + String content = text; + if(e != null) { + content += " " + e.getLocalizedMessage(); + } + alert.setContentText(content); + alert.showAndWait(); + }; + + if(Platform.isFxApplicationThread()) { + r.run(); + } else { + Platform.runLater(r); + } + } } diff --git a/client/src/main/java/ctbrec/ui/autofilltextbox/AutoFillTextField.java b/client/src/main/java/ctbrec/ui/controls/AutoFillTextField.java similarity index 98% rename from client/src/main/java/ctbrec/ui/autofilltextbox/AutoFillTextField.java rename to client/src/main/java/ctbrec/ui/controls/AutoFillTextField.java index bf986360..ca772778 100644 --- a/client/src/main/java/ctbrec/ui/autofilltextbox/AutoFillTextField.java +++ b/client/src/main/java/ctbrec/ui/controls/AutoFillTextField.java @@ -1,4 +1,4 @@ -package ctbrec.ui.autofilltextbox; +package ctbrec.ui.controls; import javafx.collections.ObservableList; diff --git a/client/src/main/java/ctbrec/ui/controls/Popover.css b/client/src/main/java/ctbrec/ui/controls/Popover.css new file mode 100644 index 00000000..7fc3e632 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/Popover.css @@ -0,0 +1,74 @@ +.popover { + -fx-padding: 43 7 7 7; +} +.popover-frame { + -fx-border-image-source: url("/popover-empty.png"); + -fx-border-image-slice: 78 50 60 120 fill; + -fx-border-image-width: 78 50 60 120; + -fx-border-image-insets: -32 -37 -47 -37; +} +.popover.right-tooth .popover-frame { + -fx-border-image-slice: 78 120 60 50 fill; + -fx-border-image-width: 78 120 60 50; +} +.popover-title { + /*-fx-font-family: "Bree serif"; */ + -fx-font-family: "Source Sans Pro Light"; + -fx-font-size: 20px; + /* -fx-text-fill: white; + -fx-font-weight: bold; */ +} +.popover .button { + -fx-font-family: "Source Sans Pro"; + -fx-font-size: 12px; +} + +.popover-tree-list-cell { + -fx-background-color: white; + /* -fx-border-color: transparent transparent #dfdfdf transparent; */ + -fx-padding: 0 30 0 12; + /*-fx-font-family: "Bree Serif"; */ + -fx-font-size: 15px; + /* -fx-font-weight: bold; */ + -fx-text-fill: #363636; +} +#PopoverBackground { + -fx-background-color: white; +} +.search-result-cell { + -fx-background-color: white; + -fx-padding: 4 30 4 45; +} +.search-result-cell:selected { + /* -fx-background-color: white, #eeeeee; */ + -fx-background-insets: 0, 0 0 0 40; +} +.search-result-cell .title { + /*-fx-font-family: "Bree Serif"; */ + -fx-font-size: 15px; + /* -fx-font-weight: bold; */ + -fx-text-fill: #363636; +} +.search-result-cell .details { + -fx-font-size: 13px; + -fx-text-fill: #444444; +} +.search-icon-pane .label { + -fx-font-family: "Source Sans Pro Semibold"; + -fx-font-size: 16px; + -fx-background-color: #515151; + -fx-background-radius: 3px; + -fx-text-fill: white; + -fx-alignment: center; +} +.sample-tree-list-cell { + -fx-background-color: white; + -fx-border-color: transparent transparent #dfdfdf transparent; + -fx-padding: 0 30 0 20; + -fx-font-size: 15px; + -fx-text-fill: #363636; + -fx-graphic-text-gap: 20px; +} +#PopoverBackground { + -fx-background-color: white; +} \ No newline at end of file diff --git a/client/src/main/java/ctbrec/ui/controls/Popover.java b/client/src/main/java/ctbrec/ui/controls/Popover.java new file mode 100644 index 00000000..a6bec1f2 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/Popover.java @@ -0,0 +1,480 @@ +/* + * Copyright (c) 2008, 2014, Oracle and/or its affiliates. + * All rights reserved. Use is subject to license terms. + * + * This file is available and licensed under the following license: + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * - Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the distribution. + * - Neither the name of Oracle Corporation nor the names of its + * contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package ctbrec.ui.controls; + +import java.util.LinkedList; + +import javafx.animation.Animation; +import javafx.animation.FadeTransition; +import javafx.animation.Interpolator; +import javafx.animation.KeyFrame; +import javafx.animation.KeyValue; +import javafx.animation.ParallelTransition; +import javafx.animation.ScaleTransition; +import javafx.animation.Timeline; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.event.ActionEvent; +import javafx.event.Event; +import javafx.event.EventHandler; +import javafx.geometry.Insets; +import javafx.geometry.Point2D; +import javafx.geometry.VPos; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Region; +import javafx.scene.shape.Rectangle; +import javafx.scene.text.Text; +import javafx.util.Duration; + +/** + * A Popover is a mini-window that pops up and contains some application specific content. + * It's width is defined by the application, but defaults to a hard-coded pref width. + * The height will always be between a minimum height (determined by the application, but + * pre-set with a minimum value) and a maximum height (specified by the application, or + * based on the height of the scene). The value for the pref height is determined by + * inspecting the pref height of the current displayed page. At time this value is animated + * (when switching from page to page). + */ +public class Popover extends Region implements EventHandler{ + private static final int PAGE_GAP = 15; + + /** + * The visual frame of the popover is defined as an addition region, rather than simply styling + * the popover itself as one might expect. The reason for this is that our frame is styled via + * a border image, and it has an inner shadow associated with it, and we want to be able to ensure + * that the shadow is on top of whatever content is drawn within the popover. In addition, the inner + * edge of the frame is rounded, and we want the content to slide under it, only to be clipped beneath + * the frame. So it works best for the frame to be overlaid on top, even though it is not intuitive. + */ + private final Region frameBorder = new Region(); + private final Button leftButton = new Button("Left"); + private final Button rightButton = new Button("Right"); + private final LinkedList pages = new LinkedList(); + private final Pane pagesPane = new Pane(); + private final Rectangle pagesClipRect = new Rectangle(); + private final Pane titlesPane = new Pane(); + private Text title; // the current title + private final Rectangle titlesClipRect = new Rectangle(); + // private final EventHandler popoverScrollHandler; + private final EventHandler popoverHideHandler; + private Runnable onHideCallback = null; + private int maxPopupHeight = -1; + + private DoubleProperty popoverHeight = new SimpleDoubleProperty(400) { + @Override protected void invalidated() { + requestLayout(); + } + }; + + public Popover() { + // TODO Could pagesPane be a region instead? I need to draw some opaque background. Right now when + // TODO animating from one page to another you can see the background "shine through" because the + // TODO group background is transparent. That can't be good for performance either. + getStyleClass().setAll("popover"); + frameBorder.getStyleClass().setAll("popover-frame"); + frameBorder.setMouseTransparent(true); + // setup buttons + leftButton.setOnMouseClicked(this); + leftButton.getStyleClass().add("popover-left-button"); + leftButton.setMinWidth(USE_PREF_SIZE); + rightButton.setOnMouseClicked(this); + rightButton.getStyleClass().add("popover-right-button"); + rightButton.setMinWidth(USE_PREF_SIZE); + pagesClipRect.setSmooth(false); + pagesPane.setClip(pagesClipRect); + titlesClipRect.setSmooth(false); + titlesPane.setClip(titlesClipRect); + getChildren().addAll(pagesPane, frameBorder, titlesPane, leftButton, rightButton); + // always hide to start with + setVisible(false); + setOpacity(0); + setScaleX(.8); + setScaleY(.8); + // create handlers for auto hiding + popoverHideHandler = (MouseEvent t) -> { + // check if event is outside popup + Point2D mouseInFilterPane = sceneToLocal(t.getX(), t.getY()); + if (mouseInFilterPane.getX() < 0 || mouseInFilterPane.getX() > (getWidth()) || + mouseInFilterPane.getY() < 0 || mouseInFilterPane.getY() > (getHeight())) { + hide(); + t.consume(); + } + }; + // popoverScrollHandler = new EventHandler() { + // @Override public void handle(ScrollEvent t) { + // t.consume(); // consume all scroll events + // } + // }; + } + + /** + * Handle mouse clicks on the left and right buttons. + */ + @Override public void handle(Event event) { + if (event.getSource() == leftButton) { + pages.getFirst().handleLeftButton(); + } else if (event.getSource() == rightButton) { + pages.getFirst().handleRightButton(); + } + } + + @Override protected double computeMinWidth(double height) { + Page page = pages.isEmpty() ? null : pages.getFirst(); + if (page != null) { + Node n = page.getPageNode(); + if (n != null) { + Insets insets = getInsets(); + return insets.getLeft() + n.minWidth(-1) + insets.getRight(); + } + } + return 200; + } + + @Override protected double computeMinHeight(double width) { + Insets insets = getInsets(); + return insets.getLeft() + 100 + insets.getRight(); + } + + @Override protected double computePrefWidth(double height) { + Page page = pages.isEmpty() ? null : pages.getFirst(); + if (page != null) { + Node n = page.getPageNode(); + if (n != null) { + Insets insets = getInsets(); + return insets.getLeft() + n.prefWidth(-1) + insets.getRight(); + } + } + return 400; + } + + @Override protected double computePrefHeight(double width) { + double minHeight = minHeight(-1); + double maxHeight = maxHeight(-1); + double prefHeight = popoverHeight.get(); + if (prefHeight == -1) { + Page page = pages.getFirst(); + if (page != null) { + Insets inset = getInsets(); + if (width == -1) { + width = prefWidth(-1); + } + double contentWidth = width - inset.getLeft() - inset.getRight(); + double contentHeight = page.getPageNode().prefHeight(contentWidth); + prefHeight = inset.getTop() + contentHeight + inset.getBottom(); + popoverHeight.set(prefHeight); + } else { + prefHeight = minHeight; + } + } + return boundedSize(minHeight, prefHeight, maxHeight); + } + + static double boundedSize(double min, double pref, double max) { + double a = pref >= min ? pref : min; + double b = min >= max ? min : max; + return a <= b ? a : b; + } + + @Override protected double computeMaxWidth(double height) { + return Double.MAX_VALUE; + } + + @Override protected double computeMaxHeight(double width) { + Scene scene = getScene(); + if (scene != null) { + return scene.getHeight() - 100; + } else { + return Double.MAX_VALUE; + } + } + + @Override protected void layoutChildren() { + if (maxPopupHeight == -1) { + maxPopupHeight = (int)getScene().getHeight()-100; + } + final Insets insets = getInsets(); + final int width = (int)getWidth(); + final int height = (int)getHeight(); + final int top = (int)insets.getTop(); + final int right = (int)insets.getRight(); + final int bottom = (int)insets.getBottom(); + final int left = (int)insets.getLeft(); + + int pageWidth = width - left - right; + int pageHeight = height - top - bottom; + + frameBorder.resize(width, height); + + pagesPane.resizeRelocate(left, top, pageWidth, pageHeight); + pagesClipRect.setWidth(pageWidth); + pagesClipRect.setHeight(pageHeight); + + int pageX = 0; + for (Node page : pagesPane.getChildren()) { + page.resizeRelocate(pageX, 0, pageWidth, pageHeight); + pageX += pageWidth + PAGE_GAP; + } + + int buttonHeight = (int)(leftButton.prefHeight(-1)); + if (buttonHeight < 30) buttonHeight = 30; + final int buttonTop = (int)((top-buttonHeight)/2d); + final int leftButtonWidth = (int)snapSizeX(leftButton.prefWidth(-1)); + leftButton.resizeRelocate(left, buttonTop,leftButtonWidth,buttonHeight); + final int rightButtonWidth = (int)snapSizeX(rightButton.prefWidth(-1)); + rightButton.resizeRelocate(width-right-rightButtonWidth, buttonTop,rightButtonWidth,buttonHeight); + + final double leftButtonRight = leftButton.isVisible() ? (left + leftButtonWidth) : left; + final double rightButtonLeft = rightButton.isVisible() ? (right + rightButtonWidth) : right; + titlesClipRect.setX(leftButtonRight); + titlesClipRect.setWidth(pageWidth - leftButtonRight - rightButtonLeft); + titlesClipRect.setHeight(top); + + if (title != null) { + title.setTranslateY((int) (top / 2d)); + } + } + + public final void clearPages() { + while (!pages.isEmpty()) { + pages.pop().handleHidden(); + } + pagesPane.getChildren().clear(); + titlesPane.getChildren().clear(); + pagesClipRect.setX(0); + pagesClipRect.setWidth(400); + pagesClipRect.setHeight(400); + popoverHeight.set(400); + pagesPane.setTranslateX(0); + titlesPane.setTranslateX(0); + titlesClipRect.setTranslateX(0); + } + + public final void popPage() { + Page oldPage = pages.pop(); + oldPage.handleHidden(); + oldPage.setPopover(null); + Page page = pages.getFirst(); + leftButton.setVisible(page.leftButtonText() != null); + leftButton.setText(page.leftButtonText()); + rightButton.setVisible(page.rightButtonText() != null); + rightButton.setText(page.rightButtonText()); + if (pages.size() > 0) { + final Insets insets = getInsets(); + final int width = (int)prefWidth(-1); + final int right = (int)insets.getRight(); + final int left = (int)insets.getLeft(); + int pageWidth = width - left - right; + final int newPageX = (pageWidth+PAGE_GAP) * (pages.size()-1); + new Timeline( + new KeyFrame(Duration.millis(350), (ActionEvent t) -> { + pagesPane.setCache(false); + pagesPane.getChildren().remove(pagesPane.getChildren().size()-1); + titlesPane.getChildren().remove(titlesPane.getChildren().size()-1); + resizePopoverToNewPage(pages.getFirst().getPageNode()); + }, + new KeyValue(pagesPane.translateXProperty(), -newPageX, Interpolator.EASE_BOTH), + new KeyValue(titlesPane.translateXProperty(), -newPageX, Interpolator.EASE_BOTH), + new KeyValue(pagesClipRect.xProperty(), newPageX, Interpolator.EASE_BOTH), + new KeyValue(titlesClipRect.translateXProperty(), newPageX, Interpolator.EASE_BOTH) + ) + ).play(); + } else { + hide(); + } + } + + public final void pushPage(final Page page) { + final Node pageNode = page.getPageNode(); + pageNode.setManaged(false); + pagesPane.getChildren().add(pageNode); + final Insets insets = getInsets(); + final int pageWidth = (int)(prefWidth(-1) - insets.getLeft() - insets.getRight()); + final int newPageX = (pageWidth + PAGE_GAP) * pages.size(); + leftButton.setVisible(page.leftButtonText() != null); + leftButton.setText(page.leftButtonText()); + rightButton.setVisible(page.rightButtonText() != null); + rightButton.setText(page.rightButtonText()); + + title = new Text(page.getPageTitle()); + title.getStyleClass().add("popover-title"); + //debtest title.setFill(Color.WHITE); + title.setTextOrigin(VPos.CENTER); + title.setTranslateX(newPageX + (int) ((pageWidth - title.getLayoutBounds().getWidth()) / 2d)); + titlesPane.getChildren().add(title); + + if (!pages.isEmpty() && isVisible()) { + final Timeline timeline = new Timeline( + new KeyFrame(Duration.millis(350), (ActionEvent t) -> { + pagesPane.setCache(false); + resizePopoverToNewPage(pageNode); + }, + new KeyValue(pagesPane.translateXProperty(), -newPageX, Interpolator.EASE_BOTH), + new KeyValue(titlesPane.translateXProperty(), -newPageX, Interpolator.EASE_BOTH), + new KeyValue(pagesClipRect.xProperty(), newPageX, Interpolator.EASE_BOTH), + new KeyValue(titlesClipRect.translateXProperty(), newPageX, Interpolator.EASE_BOTH) + ) + ); + timeline.play(); + } + page.setPopover(this); + page.handleShown(); + pages.push(page); + } + + private void resizePopoverToNewPage(final Node newPageNode) { + final Insets insets = getInsets(); + final double width = prefWidth(-1); + final double contentWidth = width - insets.getLeft() - insets.getRight(); + double h = newPageNode.prefHeight(contentWidth); + h += insets.getTop() + insets.getBottom(); + new Timeline( + new KeyFrame(Duration.millis(200), + new KeyValue(popoverHeight, h, Interpolator.EASE_BOTH) + ) + ).play(); + } + + public void show(){ + show(null); + } + + private Animation fadeAnimation = null; + + public void show(Runnable onHideCallback){ + if (!isVisible() || fadeAnimation != null) { + this.onHideCallback = onHideCallback; + getScene().addEventFilter(MouseEvent.MOUSE_CLICKED, popoverHideHandler); + // getScene().addEventFilter(ScrollEvent.ANY,popoverScrollHandler); + + if (fadeAnimation != null) { + fadeAnimation.stop(); + setVisible(true); // for good measure + } else { + popoverHeight.set(-1); + setVisible(true); + } + + FadeTransition fade = new FadeTransition(Duration.seconds(.1), this); + fade.setToValue(1.0); + fade.setOnFinished((ActionEvent event) -> { + fadeAnimation = null; + }); + + ScaleTransition scale = new ScaleTransition(Duration.seconds(.1), this); + scale.setToX(1); + scale.setToY(1); + + ParallelTransition tx = new ParallelTransition(fade, scale); + fadeAnimation = tx; + tx.play(); + } + } + + public void hide(){ + if (isVisible() || fadeAnimation != null) { + getScene().removeEventFilter(MouseEvent.MOUSE_CLICKED, popoverHideHandler); + // getScene().removeEventFilter(ScrollEvent.ANY,popoverScrollHandler); + + if (fadeAnimation != null) { + fadeAnimation.stop(); + } + + FadeTransition fade = new FadeTransition(Duration.seconds(.1), this); + fade.setToValue(0); + fade.setOnFinished((ActionEvent event) -> { + fadeAnimation = null; + setVisible(false); + //clearPages(); + if (onHideCallback != null) onHideCallback.run(); + }); + + ScaleTransition scale = new ScaleTransition(Duration.seconds(.1), this); + scale.setToX(.8); + scale.setToY(.8); + + ParallelTransition tx = new ParallelTransition(fade, scale); + fadeAnimation = tx; + tx.play(); + } + } + + /** + * Represents a page in a popover. + */ + public static interface Page { + public void setPopover(Popover popover); + public Popover getPopover(); + + /** + * Get the node that represents the page. + * + * @return the page node. + */ + public Node getPageNode(); + + /** + * Get the title to display for this page. + * + * @return The page title + */ + public String getPageTitle(); + + /** + * The text for left button, if null then button will be hidden. + * @return The button text + */ + public String leftButtonText(); + + /** + * Called on a click of the left button of the popover. + */ + public void handleLeftButton(); + + /** + * The text for right button, if null then button will be hidden. + * @return The button text + */ + public String rightButtonText(); + + /** + * Called on a click of the right button of the popover. + */ + public void handleRightButton(); + + public void handleShown(); + public void handleHidden(); + } +} diff --git a/client/src/main/java/ctbrec/ui/controls/PopoverTreeList.java b/client/src/main/java/ctbrec/ui/controls/PopoverTreeList.java new file mode 100644 index 00000000..01f6aac1 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/PopoverTreeList.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2008, 2014, Oracle and/or its affiliates. + * All rights reserved. Use is subject to license terms. + * + * This file is available and licensed under the following license: + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * - Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the distribution. + * - Neither the name of Oracle Corporation nor the names of its + * contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package ctbrec.ui.controls; + +import javafx.event.EventHandler; +import javafx.geometry.Bounds; +import javafx.scene.control.ListCell; +import javafx.scene.control.ListView; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.input.MouseEvent; +import javafx.util.Callback; + +/** + * Special ListView designed to look like "Text... >" tree list. Perhaps we ought to have customized + * a TreeView instead of a ListView (as the TreeView already has the data model all defined). + * + * This implementation minimizes classes by just having the PopoverTreeList implementing everything + * (it is the Control, the Skin, and the CellFactory all in one). + */ +public class PopoverTreeList extends ListView implements Callback, ListCell> { + protected static final Image RIGHT_ARROW = new Image( + PopoverTreeList.class.getResource("/popover-arrow.png").toExternalForm()); + + public PopoverTreeList(){ + getStyleClass().clear(); + setCellFactory(this); + } + + @Override public ListCell call(ListView p) { + return new TreeItemListCell(); + } + + protected void itemClicked(T item) {} + + private class TreeItemListCell extends ListCell implements EventHandler { + private ImageView arrow = new ImageView(RIGHT_ARROW); + + private TreeItemListCell() { + super(); + getStyleClass().setAll("popover-tree-list-cell"); + setOnMouseClicked(this); + } + + @Override public void handle(MouseEvent t) { + itemClicked(getItem()); + } + + @Override protected double computePrefWidth(double height) { + return 100; + } + + @Override protected double computePrefHeight(double width) { + return 44; + } + + @Override protected void layoutChildren() { + if (getChildren().size() < 2) getChildren().add(arrow); + super.layoutChildren(); + final int w = (int)getWidth(); + final int h = (int)getHeight(); + //final int centerX = (int)(w/2d); + //final int centerY = (int)(h/2d); + final Bounds arrowBounds = arrow.getLayoutBounds(); + arrow.setLayoutX(w - arrowBounds.getWidth() - 12); + arrow.setLayoutY((int)((h - arrowBounds.getHeight())/2d)); + } + + // CELL METHODS + @Override protected void updateItem(T item, boolean empty) { + // let super do its work + super.updateItem(item,empty); + // update our state + if (item == null) { // empty item + setText(null); + arrow.setVisible(false); + } else { + setText(item.toString()); + arrow.setVisible(true); + } + } + } +} diff --git a/client/src/main/java/ctbrec/ui/controls/SearchBox.css b/client/src/main/java/ctbrec/ui/controls/SearchBox.css new file mode 100644 index 00000000..1ec1ebd5 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/SearchBox.css @@ -0,0 +1,34 @@ +.search-box-icon { + -fx-shape: "M10.728,9.893c0.889-1.081,1.375-2.435,1.375-3.842C12.103,2.714,9.388,0,6.051,0C2.715,0,0,2.714,0,6.051c0,3.338,2.715,6.052,6.051,6.052c0.954,0,1.898-0.227,2.744-0.656l3.479,3.478l1.743-1.742L10.728,9.893z M6.051,2.484c1.966,0,3.566,1.602,3.566,3.566c0,1.968-1.6,3.567-3.566,3.567c-1.967,0-3.566-1.6-3.566-3.567C2.485,4.086,4.084,2.484,6.051,2.484z"; + -fx-scale-shape: false; + -fx-background-color: #aaaaaa; +} +.search-box { + /*-fx-font-size: 16px;*/ + /*-fx-text-fill: #363636;*/ + /*-fx-background-radius: 15, 14;*/ + -fx-padding: 0 0 0 30; +} +.search-box:focused { + /*-fx-background-radius: 15,14,16,14;*/ +} +.search-clear-button { + -fx-shape: "M9.521,0.083c-5.212,0-9.438,4.244-9.438,9.479c0,5.234,4.225,9.479,9.438,9.479c5.212,0,9.437-4.244,9.437-9.479C18.958,4.327,14.733,0.083,9.521,0.083z M13.91,13.981c-0.367,0.369-0.963,0.369-1.329,0l-3.019-3.03l-3.019,3.03c-0.367,0.369-0.962,0.369-1.329,0c-0.367-0.368-0.366-0.965,0.001-1.334l3.018-3.031L5.216,6.585C4.849,6.217,4.849,5.618,5.217,5.25c0.366-0.369,0.961-0.368,1.328,0l3.018,3.031l3.019-3.031c0.366-0.368,0.961-0.369,1.328,0c0.366,0.368,0.366,0.967,0,1.335l-3.019,3.031l3.02,3.031C14.276,13.017,14.276,13.613,13.91,13.981z"; + -fx-scale-shape: false; + -fx-background-color: #aaaaaa; + -fx-padding: 9.5px; +} + +.search-tree-list-cell { + -fx-background-color: white; + -fx-border-color: transparent transparent #dfdfdf transparent; + -fx-padding: 0 30 0 20; + -fx-font-size: 15px; + -fx-text-fill: #363636; + -fx-graphic-text-gap: 20px; +} + +.highlight { + -fx-background-color: #0096c9; + -fx-text-fill: white; +} diff --git a/client/src/main/java/ctbrec/ui/controls/SearchBox.java b/client/src/main/java/ctbrec/ui/controls/SearchBox.java new file mode 100644 index 00000000..be893acd --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/SearchBox.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2008, 2014, Oracle and/or its affiliates. + * All rights reserved. Use is subject to license terms. + * + * This file is available and licensed under the following license: + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * - Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the distribution. + * - Neither the name of Oracle Corporation nor the names of its + * contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package ctbrec.ui.controls; + +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; +import javafx.scene.Cursor; +import javafx.scene.control.Button; +import javafx.scene.control.TextField; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.Region; + +/** + * Search field with styling and a clear button + */ +public class SearchBox extends TextField implements ChangeListener{ + private final Button clearButton = new Button(); + private final Region innerBackground = new Region(); + private final Region icon = new Region(); + private final int prefHeight = 26; + + public SearchBox() { + getStyleClass().addAll("search-box"); + icon.getStyleClass().setAll("search-box-icon"); + innerBackground.getStyleClass().setAll("search-box-inner"); + setPromptText("Search"); + textProperty().addListener(this); + setPrefHeight(prefHeight); + clearButton.getStyleClass().setAll("search-clear-button"); + clearButton.setCursor(Cursor.DEFAULT); + clearButton.setOnMouseClicked((MouseEvent t) -> { + setText(""); + requestFocus(); + }); + clearButton.setVisible(false); + clearButton.setManaged(false); + innerBackground.setManaged(false); + icon.setManaged(false); + } + + public SearchBox(boolean icon) { + this(); + this.icon.setVisible(false); + this.icon.getStyleClass().remove("search-box-icon"); + this.setStyle("-fx-padding: 5"); + } + + @Override protected void layoutChildren() { + super.layoutChildren(); + if (clearButton.getParent() != this) getChildren().add(clearButton); + if (innerBackground.getParent() != this) getChildren().add(0,innerBackground); + if (icon.getParent() != this) getChildren().add(icon); + innerBackground.setLayoutX(0); + innerBackground.setLayoutY(0); + innerBackground.resize(getWidth(), getHeight()); + icon.setLayoutX(0); + icon.setLayoutY(0); + icon.resize(35,prefHeight); + clearButton.setLayoutX(getWidth() - prefHeight); + clearButton.setLayoutY(0); + clearButton.resize(prefHeight, prefHeight); + } + + @Override public void changed(ObservableValue ov, String oldValue, String newValue) { + clearButton.setVisible(newValue.length() > 0); + } +} diff --git a/client/src/main/java/ctbrec/ui/controls/SearchPopover.java b/client/src/main/java/ctbrec/ui/controls/SearchPopover.java new file mode 100644 index 00000000..42222952 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/SearchPopover.java @@ -0,0 +1,9 @@ +package ctbrec.ui.controls; + +public class SearchPopover extends Popover { + + + public SearchPopover() { + getStyleClass().add("right-tooth"); + } +} diff --git a/client/src/main/java/ctbrec/ui/controls/SearchPopoverTreeList.java b/client/src/main/java/ctbrec/ui/controls/SearchPopoverTreeList.java new file mode 100644 index 00000000..722cca72 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/SearchPopoverTreeList.java @@ -0,0 +1,317 @@ +/* + * Copyright (c) 2008, 2014, Oracle and/or its affiliates. + * All rights reserved. Use is subject to license terms. + * + * This file is available and licensed under the following license: + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * - Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the distribution. + * - Neither the name of Oracle Corporation nor the names of its + * contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package ctbrec.ui.controls; + +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Model; +import ctbrec.recorder.Recorder; +import ctbrec.ui.Player; +import javafx.application.Platform; +import javafx.concurrent.Task; +import javafx.event.EventHandler; +import javafx.geometry.Insets; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.ListView; +import javafx.scene.control.Skin; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.input.MouseEvent; + +/** + * Popover page that displays a list of samples and sample categories for a given SampleCategory. + */ +public class SearchPopoverTreeList extends PopoverTreeList implements Popover.Page { + private static final transient Logger LOG = LoggerFactory.getLogger(SearchPopoverTreeList.class); + + private Popover popover; + + private Recorder recorder; + + public SearchPopoverTreeList() { + + } + + @Override + public ListCell call(ListView p) { + return new SearchItemListCell(); + } + + @Override + protected void itemClicked(Model model) { + if(model == null) { + return; + } + + setCursor(Cursor.WAIT); + new Thread(() -> { + Platform.runLater(() -> { + boolean started = Player.play(model); + if(started) { + Toast.makeText(getScene(), "Starting Player", 2000, 500, 500); + } + setCursor(Cursor.DEFAULT); + }); + }).start(); + } + + @Override + public void setPopover(Popover popover) { + this.popover = popover; + } + + @Override + public Popover getPopover() { + return popover; + } + + @Override + public Node getPageNode() { + return this; + } + + @Override + public String getPageTitle() { + return "Search Results"; + } + + @Override + public String leftButtonText() { + return null; + } + + @Override + public void handleLeftButton() { + } + + @Override + public String rightButtonText() { + return "Done"; + } + + @Override + public void handleRightButton() { + popover.hide(); + } + + @Override + public void handleShown() { + } + + @Override + public void handleHidden() { + } + + private class SearchItemListCell extends ListCell implements Skin, EventHandler { + + private Label title = new Label(); + private Button follow; + private Button record; + private Model model; + private ImageView thumb = new ImageView(); + private int thumbSize = 64; + private Node tallest = thumb; + + private SearchItemListCell() { + super(); + setSkin(this); + getStyleClass().setAll("search-tree-list-cell"); + setOnMouseClicked(this); + setOnMouseEntered(evt -> { + getStyleClass().add("highlight"); + title.getStyleClass().add("highlight"); + }); + setOnMouseExited(evt -> { + getStyleClass().remove("highlight"); + title.getStyleClass().remove("highlight"); + }); + thumb.setFitWidth(thumbSize); + thumb.setFitHeight(thumbSize); + + follow = new Button("Follow"); + follow.setOnAction((evt) -> { + setCursor(Cursor.WAIT); + new Thread(new Task() { + @Override + protected Boolean call() throws Exception { + return model.follow(); + } + + @Override + protected void done() { + try { + get(); + } catch (Exception e) { + LOG.warn("Search failed: {}", e.getMessage()); + } + Platform.runLater(() -> setCursor(Cursor.DEFAULT)); + } + }).start(); + }); + record = new Button("Record"); + record.setOnAction((evt) -> { + setCursor(Cursor.WAIT); + new Thread(new Task() { + @Override + protected Void call() throws Exception { + recorder.startRecording(model); + return null; + } + + @Override + protected void done() { + Platform.runLater(() -> setCursor(Cursor.DEFAULT)); + } + }).start(); + }); + getChildren().addAll(thumb, title, follow, record); + + record.visibleProperty().bind(title.visibleProperty()); + thumb.visibleProperty().bind(title.visibleProperty()); + } + + @Override + public void handle(MouseEvent t) { + itemClicked(getItem()); + } + + @Override + protected void updateItem(Model model, boolean empty) { + super.updateItem(model, empty); + if (empty) { + follow.setVisible(false); + title.setVisible(false); + this.model = null; + } else { + follow.setVisible(model.getSite().supportsFollow()); + title.setVisible(true); + title.setText(model.getName()); + this.model = model; + String previewUrl = Optional.ofNullable(model.getPreview()).orElse(getClass().getResource("/anonymous.png").toString()); + Image img = new Image(previewUrl, true); + thumb.setImage(img); + } + + } + + @Override + protected void layoutChildren() { + super.layoutChildren(); + final Insets insets = getInsets(); + final double left = insets.getLeft(); + final double top = insets.getTop(); + final double w = getWidth() - left - insets.getRight(); + final double h = getHeight() - top - insets.getBottom(); + + thumb.setLayoutX(left); + thumb.setLayoutY((h - thumbSize) / 2); + + final double titleHeight = title.prefHeight(w); + title.setLayoutX(left + thumbSize + 10); + title.setLayoutY((h - titleHeight) / 2); + title.resize(w, titleHeight); + + int buttonW = 50; + int buttonH = 24; + follow.setStyle("-fx-font-size: 10px;"); + follow.setLayoutX(w - buttonW - 20); + follow.setLayoutY((h - buttonH) / 2); + follow.resize(buttonW, buttonH); + + record.setStyle("-fx-font-size: 10px;"); + record.setLayoutX(w - 10); + record.setLayoutY((h - buttonH) / 2); + record.resize(buttonW, buttonH); + } + + @Override + protected double computeMinWidth(double height) { + final Insets insets = getInsets(); + final double h = height = insets.getBottom() - insets.getTop(); + return (int) ((insets.getLeft() + tallest.minWidth(h) + tallest.minWidth(h) + insets.getRight()) + 0.5d); + } + + @Override + protected double computePrefWidth(double height) { + final Insets insets = getInsets(); + final double h = height = insets.getBottom() - insets.getTop(); + return (int) ((insets.getLeft() + tallest.prefWidth(h) + tallest.prefWidth(h) + insets.getRight()) + 0.5d); + } + + @Override + protected double computeMaxWidth(double height) { + final Insets insets = getInsets(); + final double h = height = insets.getBottom() - insets.getTop(); + return (int) ((insets.getLeft() + tallest.maxWidth(h) + tallest.maxWidth(h) + insets.getRight()) + 0.5d); + } + + @Override + protected double computeMinHeight(double width) { + return thumbSize; + } + + @Override + protected double computePrefHeight(double width) { + return thumbSize + 20; + } + + @Override + protected double computeMaxHeight(double width) { + return thumbSize + 20; + } + + @Override + public SearchItemListCell getSkinnable() { + return this; + } + + @Override + public Node getNode() { + return null; + } + + @Override + public void dispose() { + } + } + + public void setRecorder(Recorder recorder) { + this.recorder = recorder; + } +} \ No newline at end of file diff --git a/client/src/main/resources/anonymous.png b/client/src/main/resources/anonymous.png new file mode 100644 index 0000000000000000000000000000000000000000..b0294b265e6abe826ab441fd4c037d92a4564ded GIT binary patch literal 3344 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
    UZ zx`8Hiz^%+kFST4WX3<2mVxHZZMO6&J=tN4bH#gFDO=Z_-BO2pnqd6`rQy1=Hw*U3O+?b!XejO?GE?b+IvBH{7VGaptTJI+te7wxiX9 zNkKOQF$jX_LP${ILr*2yJtUGuA@ZSuE>Jymd8w$VGwNCo+F_XUpTqxs|My?c;hO4- z`Ps{}F$|j@t>j~9pX1oGXQBUIjCZzWFr0vOdJ?8Z6JSAEZvc2y6&pbeh;r-h0Z@iv zSw%`b0TaR|R?<~cbbLr#H4qxZ%F1m+l$rs=8$hF?1&EI~`UzZ-14O-7a0>$y`K%2&zfXRtT)nin;u-mP z=wa21eJ!=#s-dlCkM@{(1tX8UF0_u$J{h?D1YTLTFu%Zm{L+piZ%^fa?`+??M#(+% zg?qYl`>S`&ayOSd*nfNfuT?#vYeyDTzs>IYR@^@{oYnBC@7%${$aQAV%^CBs_$}h% Sk7O<7yzywbn!g&_b>I)V%5zBo literal 0 HcmV?d00001 diff --git a/client/src/main/resources/popover-arrow@2x.png b/client/src/main/resources/popover-arrow@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..bf4d0d177420e3c05635938ab52098c351a7bcc9 GIT binary patch literal 1112 zcmaJ=T}TvB6dqWT%9S8NN+qsSL5aIFv+K^fgPW{7ySdhgtQ$#0HO^dh)cI-V>W)&; z&qF_KP%n{`;X^M`LqKQRT4+Ta)5cMDuSwvLyQqdiCwTHIL%>6lh-*?VE=iHNx z4O$&EaJ&iZ!Dqy%nWJ6A}?wZ-F48ifu3nMY(tX zB&;R~*9IlkjGBe*oTRH>(ZP6a)xd0msIIXMQR;*Uw7@n+3zMJkj*>u;!{p9@Knq3` zwkvggCT!|!NJ)L2Qb;Ciwt#Az!vZQqBCypi&Eo7ZIj76vyuf-igulkEr@ z97UzmX>Z!^)y+1F357zAhR^502#?jPA<_0|*7~dh4=u@545a89a1_NBy&Hu|9O?WO zRAW(Avlh~X6GqvhK`~z1xl$G=2>*ww>LS`gNw^g6KZUJSuK}qfwDfLM!i8&H@3=C! zs0l@+n<-uI%4V^#T}Qgrt{WiQ#DYzNC@Gp_*gVG&1TLXjNYo^l;KL-=@G6STMdI;j zD8~DuEQ3dki9~3BELh72tD?2BU_6-R^19TmLJeiP@)Fmdlj}5tYGBViG?gAG$4y-Y zbC>3n+_BI(^%l5t?pTxp)L9emA7{JRxcIQx`cT9DZsNwK>PnB{NAW1Z zM^g6d%g-MdjMMJCcT+8yb(5rfth}^phHxLAs3@%(2O~WMOKct5c(uNyrs7v}*S5Xn z?T+Kg`qkaz!*r9c=*0JBU&n@-SNS`p>t~-G+ws2Q%=G>8Zxc^WUs~ZR{#bVEOJ@B1 zfsQhF(F@I8{A2f;YZs?7_a6MLe7VP^+*lU zD6ju)(RA&(GbLvmmy4S+G|G_fHzjYo3duH9cz*4bn}xHd4vy4393@=D^TE0c`N64S P=dCAV4gB@meMkQQ>>+R< literal 0 HcmV?d00001 diff --git a/client/src/main/resources/popover-empty.png b/client/src/main/resources/popover-empty.png new file mode 100644 index 0000000000000000000000000000000000000000..c5808ec13a32322453573e04f7ff09279e39f19f GIT binary patch literal 4624 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 ZfzG=*137FSLqNa-Ro7K3SFwHk{{SL7K=1$n literal 0 HcmV?d00001 diff --git a/common/src/main/java/ctbrec/sites/AbstractSite.java b/common/src/main/java/ctbrec/sites/AbstractSite.java index 1d50d186..96d67005 100644 --- a/common/src/main/java/ctbrec/sites/AbstractSite.java +++ b/common/src/main/java/ctbrec/sites/AbstractSite.java @@ -1,5 +1,10 @@ package ctbrec.sites; +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +import ctbrec.Model; import ctbrec.recorder.Recorder; public abstract class AbstractSite implements Site { @@ -26,4 +31,19 @@ public abstract class AbstractSite implements Site { public Recorder getRecorder() { return recorder; } + + @Override + public boolean supportsSearch() { + return false; + } + + @Override + public List search(String q) throws IOException, InterruptedException { + return Collections.emptyList(); + } + + @Override + public boolean searchRequiresLogin() { + return false; + } } diff --git a/common/src/main/java/ctbrec/sites/Site.java b/common/src/main/java/ctbrec/sites/Site.java index 08fef0f4..cf6f3119 100644 --- a/common/src/main/java/ctbrec/sites/Site.java +++ b/common/src/main/java/ctbrec/sites/Site.java @@ -1,6 +1,7 @@ package ctbrec.sites; import java.io.IOException; +import java.util.List; import ctbrec.Model; import ctbrec.io.HttpClient; @@ -21,8 +22,11 @@ public interface Site { public void shutdown(); public boolean supportsTips(); public boolean supportsFollow(); + public boolean supportsSearch(); public boolean isSiteForModel(Model m); public boolean credentialsAvailable(); public void setEnabled(boolean enabled); public boolean isEnabled(); + public List search(String q) throws IOException, InterruptedException; + public boolean searchRequiresLogin(); } diff --git a/common/src/main/java/ctbrec/sites/bonga/BongaCams.java b/common/src/main/java/ctbrec/sites/bonga/BongaCams.java index f763bccc..6b2670d8 100644 --- a/common/src/main/java/ctbrec/sites/bonga/BongaCams.java +++ b/common/src/main/java/ctbrec/sites/bonga/BongaCams.java @@ -1,8 +1,15 @@ package ctbrec.sites.bonga; import java.io.IOException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.json.JSONArray; import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import ctbrec.Config; import ctbrec.Model; @@ -16,6 +23,8 @@ import okhttp3.Response; public class BongaCams extends AbstractSite { + private static final transient Logger LOG = LoggerFactory.getLogger(BongaCams.class); + public static final String BASE_URL = "https://bongacams.com"; private BongaCamsHttpClient httpClient; @@ -116,6 +125,54 @@ public class BongaCams extends AbstractSite { return false; } + @Override + public boolean supportsSearch() { + return true; + } + + @Override + public boolean searchRequiresLogin() { + return true; + } + + @Override + public List search(String q) throws IOException, InterruptedException { + String url = BASE_URL + "/tools/listing_v3.php?offset=0&model_search[display_name][text]=" + URLEncoder.encode(q, "utf-8"); + Request req = new Request.Builder() + .url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .addHeader("Accept", "application/json, text/javascript, */*") + .addHeader("Accept-Language", "en") + .addHeader("Referer", BongaCams.BASE_URL) + .addHeader("X-Requested-With", "XMLHttpRequest") + .build(); + try(Response response = getHttpClient().execute(req)) { + if(response.isSuccessful()) { + String body = response.body().string(); + JSONObject json = new JSONObject(body); + if(json.optString("status").equals("success")) { + List models = new ArrayList<>(); + JSONArray results = json.getJSONArray("models"); + for (int i = 0; i < results.length(); i++) { + JSONObject result = results.getJSONObject(i); + Model model = createModel(result.getString("username")); + String thumb = result.getString("thumb_image"); + if(thumb != null) { + model.setPreview("https:" + thumb); + } + models.add(model); + } + return models; + } else { + LOG.warn("Search result: " + json.toString(2)); + return Collections.emptyList(); + } + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + @Override public boolean isSiteForModel(Model m) { return m instanceof BongaCamsModel; diff --git a/common/src/main/java/ctbrec/sites/cam4/Cam4.java b/common/src/main/java/ctbrec/sites/cam4/Cam4.java index d63ef050..04b032f4 100644 --- a/common/src/main/java/ctbrec/sites/cam4/Cam4.java +++ b/common/src/main/java/ctbrec/sites/cam4/Cam4.java @@ -1,16 +1,25 @@ package ctbrec.sites.cam4; import java.io.IOException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.List; + +import org.json.JSONArray; +import org.json.JSONObject; import ctbrec.Config; import ctbrec.Model; +import ctbrec.StringUtil; import ctbrec.io.HttpClient; +import ctbrec.io.HttpException; import ctbrec.sites.AbstractSite; +import okhttp3.Request; +import okhttp3.Response; public class Cam4 extends AbstractSite { public static final String BASE_URI = "https://www.cam4.com"; - public static final String AFFILIATE_LINK = BASE_URI + "/?referrerId=1514a80d87b5effb456cca02f6743aa1"; private HttpClient httpClient; @@ -84,6 +93,57 @@ public class Cam4 extends AbstractSite { return true; } + @Override + public boolean supportsSearch() { + return true; + } + + @Override + public boolean searchRequiresLogin() { + return true; + } + + @Override + public List search(String q) throws IOException, InterruptedException { + List result = new ArrayList<>(); + search(q, false, result); + search(q, true, result); + return result; + } + + private void search(String q, boolean offline, List models) throws IOException { + String url = BASE_URI + "/usernameSearch?username=" + URLEncoder.encode(q, "utf-8"); + if(offline) { + url += "&offline=true"; + } + Request req = new Request.Builder() + .url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .build(); + try(Response response = getHttpClient().execute(req)) { + if(response.isSuccessful()) { + String body = response.body().string(); + JSONArray results = new JSONArray(body); + for (int i = 0; i < results.length(); i++) { + JSONObject result = results.getJSONObject(i); + Model model = createModel(result.getString("username")); + String thumb = null; + if(result.has("thumbnailId")) { + thumb = "https://snapshots.xcdnpro.com/thumbnails/" + model.getName() + "?s=" + result.getString("thumbnailId"); + } else { + thumb = result.getString("profileImageLink"); + } + if(StringUtil.isNotBlank(thumb)) { + model.setPreview(thumb); + } + models.add(model); + } + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + @Override public boolean isSiteForModel(Model m) { return m instanceof Cam4Model; diff --git a/common/src/main/java/ctbrec/sites/camsoda/Camsoda.java b/common/src/main/java/ctbrec/sites/camsoda/Camsoda.java index c8750bc5..e79688fa 100644 --- a/common/src/main/java/ctbrec/sites/camsoda/Camsoda.java +++ b/common/src/main/java/ctbrec/sites/camsoda/Camsoda.java @@ -1,8 +1,15 @@ package ctbrec.sites.camsoda; import java.io.IOException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.json.JSONArray; import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import ctbrec.Config; import ctbrec.Model; @@ -14,6 +21,7 @@ import okhttp3.Response; public class Camsoda extends AbstractSite { + private static final transient Logger LOG = LoggerFactory.getLogger(Camsoda.class); public static final String BASE_URI = "https://www.camsoda.com"; private HttpClient httpClient; @@ -105,6 +113,44 @@ public class Camsoda extends AbstractSite { return true; } + @Override + public boolean supportsSearch() { + return true; + } + + @Override + public List search(String q) throws IOException, InterruptedException { + String url = BASE_URI + "/api/v1/browse/autocomplete?s=" + URLEncoder.encode(q, "utf-8"); + Request req = new Request.Builder() + .url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .build(); + try(Response response = getHttpClient().execute(req)) { + if(response.isSuccessful()) { + JSONObject json = new JSONObject(response.body().string()); + if(json.optBoolean("status")) { + List models = new ArrayList<>(); + JSONArray results = json.getJSONArray("results"); + for (int i = 0; i < results.length(); i++) { + JSONObject result = results.getJSONObject(i); + CamsodaModel model = (CamsodaModel) createModel(result.getString("username")); + String thumb = result.getString("thumb"); + if(thumb != null) { + model.setPreview("https:" + thumb); + } + models.add(model); + } + return models; + } else { + LOG.warn("Search result: " + json.toString(2)); + return Collections.emptyList(); + } + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + @Override public boolean isSiteForModel(Model m) { return m instanceof CamsodaModel; diff --git a/common/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java b/common/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java index 105be013..36125f85 100644 --- a/common/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java +++ b/common/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java @@ -3,6 +3,10 @@ package ctbrec.sites.chaturbate; import java.io.EOFException; import java.io.IOException; import java.io.InputStream; +import java.net.URLEncoder; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -68,6 +72,7 @@ public class Chaturbate extends AbstractSite { ChaturbateModel m = new ChaturbateModel(this); m.setName(name); m.setUrl(getBaseUrl() + '/' + name + '/'); + m.setPreview("https://roomimg.stream.highwebmedia.com/ri/" + name + ".jpg?" + Instant.now().getEpochSecond()); return m; } @@ -124,6 +129,44 @@ public class Chaturbate extends AbstractSite { return true; } + @Override + public boolean supportsSearch() { + return true; + } + + @Override + public List search(String q) throws IOException, InterruptedException { + String url = BASE_URI + "?keywords=" + URLEncoder.encode(q, "utf-8"); + List result = new ArrayList<>(); + + // search online models + Request req = new Request.Builder() + .url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .build(); + try(Response resp = getHttpClient().execute(req)) { + if(resp.isSuccessful()) { + result.addAll(ChaturbateModelParser.parseModels(this, resp.body().string())); + } + } + + // since chaturbate does not return offline models, we at least try, if the profile page + // exists for the search string + url = BASE_URI + '/' + q; + req = new Request.Builder() + .url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .build(); + try(Response resp = getHttpClient().execute(req)) { + if(resp.isSuccessful()) { + Model model = createModel(q); + result.add(model); + } + } + + return result; + } + @Override public boolean isSiteForModel(Model m) { return m instanceof ChaturbateModel; diff --git a/common/src/main/java/ctbrec/sites/mfc/MyFreeCams.java b/common/src/main/java/ctbrec/sites/mfc/MyFreeCams.java index bb0d4c13..146c834a 100644 --- a/common/src/main/java/ctbrec/sites/mfc/MyFreeCams.java +++ b/common/src/main/java/ctbrec/sites/mfc/MyFreeCams.java @@ -1,6 +1,7 @@ package ctbrec.sites.mfc; import java.io.IOException; +import java.util.List; import org.jsoup.select.Elements; @@ -97,6 +98,16 @@ public class MyFreeCams extends AbstractSite { return true; } + @Override + public boolean supportsSearch() { + return true; + } + + @Override + public List search(String q) throws IOException, InterruptedException { + return client.search(q); + } + @Override public boolean isSiteForModel(Model m) { return m instanceof MyFreeCamsModel; diff --git a/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java b/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java index 78b9e864..04c168c4 100644 --- a/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java +++ b/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java @@ -7,7 +7,9 @@ import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.concurrent.ExecutorService; @@ -15,6 +17,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; import org.json.JSONArray; import org.json.JSONObject; @@ -60,6 +63,8 @@ public class MyFreeCamsClient { private int sessionId; private long heartBeat; private volatile boolean connecting = false; + private static int messageId = 31415; // starting with 31415 just for fun + private Map> responseHandlers = new HashMap<>(); private EvictingQueue receivedTextHistory = EvictingQueue.create(100); @@ -193,6 +198,7 @@ public class MyFreeCamsClient { case LOGIN: LOG.debug("LOGIN: {}", message); sessionId = message.getReceiver(); + LOG.debug("Session ID {}", sessionId); break; case DETAILS: case ROOMHELPER: @@ -201,7 +207,6 @@ public class MyFreeCamsClient { case CMESG: case PMESG: case TXPROFILE: - case USERNAMELOOKUP: case MYCAMSTATE: case MYWEBCAM: case JOINCHAN: @@ -217,6 +222,18 @@ public class MyFreeCamsClient { } } break; + case USERNAMELOOKUP: + // LOG.debug("{}", message.getType()); + // LOG.debug("{}", message.getSender()); + // LOG.debug("{}", message.getReceiver()); + // LOG.debug("{}", message.getArg1()); + // LOG.debug("{}", message.getArg2()); + // LOG.debug("{}", message.getMessage()); + Consumer responseHandler = responseHandlers.remove(message.getArg1()); + if(responseHandler != null) { + responseHandler.accept(message); + } + break; case TAGS: JSONObject json = new JSONObject(message.getMessage()); String[] names = JSONObject.getNames(json); @@ -571,4 +588,34 @@ public class MyFreeCamsClient { public ServerConfig getServerConfig() { return serverConfig; } + + public List search(String q) throws InterruptedException { + LOG.debug("Sending USERNAMELOOKUP for {}", q); + int msgId = messageId++; + Object monitor = new Object(); + List result = new ArrayList<>(); + responseHandlers.put(msgId, msg -> { + LOG.debug("Search result: " + msg); + if(StringUtil.isNotBlank(msg.getMessage()) && !Objects.equals(msg.getMessage(), q)) { + JSONObject json = new JSONObject(msg.getMessage()); + String name = json.getString("nm"); + MyFreeCamsModel model = mfc.createModel(name); + model.setUid(json.getInt("uid")); + model.setState(State.of(json.getInt("vs"))); + String uid = Integer.toString(model.getUid()); + String uidStart = uid.substring(0, 3); + String previewUrl = "https://img.mfcimg.com/photos2/"+uidStart+'/'+uid+"/avatar.90x90.jpg"; + model.setPreview(previewUrl); + result.add(model); + } + synchronized (monitor) { + monitor.notify(); + } + }); + ws.send("10 " + sessionId + " 0 " + msgId + " 0 " + q + "\n"); + synchronized (monitor) { + monitor.wait(); + } + return result; + } }