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 00000000..b0294b26 Binary files /dev/null and b/client/src/main/resources/anonymous.png differ diff --git a/client/src/main/resources/popover-arrow.png b/client/src/main/resources/popover-arrow.png new file mode 100644 index 00000000..289e753c Binary files /dev/null and b/client/src/main/resources/popover-arrow.png differ 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 00000000..bf4d0d17 Binary files /dev/null and b/client/src/main/resources/popover-arrow@2x.png differ diff --git a/client/src/main/resources/popover-empty.png b/client/src/main/resources/popover-empty.png new file mode 100644 index 00000000..c5808ec1 Binary files /dev/null and b/client/src/main/resources/popover-empty.png differ diff --git a/client/src/main/resources/popover-empty@2x.png b/client/src/main/resources/popover-empty@2x.png new file mode 100644 index 00000000..74149f1f Binary files /dev/null and b/client/src/main/resources/popover-empty@2x.png differ 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; + } }