package ctbrec.ui.tabs; import static ctbrec.Model.State.*; import static ctbrec.io.HttpConstants.*; import java.io.IOException; import java.util.Locale; import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.function.Function; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; import ctbrec.Config; import ctbrec.Model; import ctbrec.Model.State; import ctbrec.io.HttpException; import ctbrec.recorder.Recorder; import ctbrec.ui.AutosizeAlert; import ctbrec.ui.CamrecApplication; import ctbrec.ui.SiteUiFactory; import ctbrec.ui.StreamSourceSelectionDialog; import ctbrec.ui.action.PlayAction; import ctbrec.ui.controls.PausedIndicator; import ctbrec.ui.controls.StreamPreview; import javafx.animation.FadeTransition; import javafx.animation.FillTransition; import javafx.animation.ParallelTransition; import javafx.animation.Transition; import javafx.application.Platform; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.collections.ObservableList; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Cursor; import javafx.scene.Node; import javafx.scene.control.Alert; import javafx.scene.control.ContextMenu; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.scene.paint.Paint; import javafx.scene.shape.Circle; import javafx.scene.shape.Polygon; import javafx.scene.shape.Rectangle; import javafx.scene.shape.Shape; import javafx.scene.text.Font; import javafx.scene.text.Text; import javafx.scene.text.TextAlignment; import javafx.util.Duration; import okhttp3.Request; import okhttp3.Response; public class ThumbCell extends StackPane { private static final String COULDNT_START_STOP_RECORDING = "Couldn't start/stop recording"; private static final String ERROR = "Error"; private static final Logger LOG = LoggerFactory.getLogger(ThumbCell.class); private static final Duration ANIMATION_DURATION = new Duration(250); private Model model; private StreamPreview streamPreview; private ImageView iv; private Rectangle resolutionBackground; private final Paint resolutionOnlineColor = new Color(0.22, 0.8, 0.29, 1); private final Color resolutionOfflineColor = new Color(0.8, 0.28, 0.28, 1); private Rectangle nameBackground; private Rectangle topicBackground; private Rectangle selectionOverlay; private Text name; private Text topic; private Text resolutionTag; private Recorder recorder; private Circle recordingIndicator; private PausedIndicator pausedIndicator; private int index = 0; ContextMenu popup; private static final Color colorNormal = Color.BLACK; private static final Color colorHighlight = Color.WHITE; private final Color colorRecording = new Color(0.8, 0.28, 0.28, .8); private SimpleBooleanProperty selectionProperty = new SimpleBooleanProperty(false); private double imgAspectRatio = 3.0 / 4.0; private SimpleBooleanProperty preserveAspectRatio = new SimpleBooleanProperty(true); private ObservableList thumbCellList; private boolean mouseHovering = false; private boolean recording = false; private static ExecutorService imageLoadingThreadPool = Executors.newFixedThreadPool(30); static LoadingCache resolutionCache = CacheBuilder.newBuilder() .expireAfterAccess(5, TimeUnit.MINUTES) .maximumSize(10000) .build(CacheLoader.from(ThumbCell::getStreamResolution)); private ThumbOverviewTab parent; public ThumbCell(ThumbOverviewTab parent, Model model, Recorder recorder, double aspectRatio) { this.parent = parent; this.thumbCellList = parent.grid.getChildren(); this.model = model; this.recorder = recorder; this.imgAspectRatio = aspectRatio; recording = recorder.isTracked(model); model.setSuspended(recorder.isSuspended(model)); this.setStyle("-fx-background-color: -fx-base"); streamPreview = new StreamPreview(); streamPreview.prefWidthProperty().bind(widthProperty()); streamPreview.prefHeightProperty().bind(heightProperty()); getChildren().add(streamPreview); iv = new ImageView(); iv.setSmooth(true); iv.setPreserveRatio(true); getChildren().add(iv); nameBackground = new Rectangle(); nameBackground.setFill(recording ? colorRecording : colorNormal); nameBackground.setOpacity(.7); StackPane.setAlignment(nameBackground, Pos.BOTTOM_CENTER); getChildren().add(nameBackground); topicBackground = new Rectangle(); topicBackground.setFill(Color.BLACK); topicBackground.setOpacity(0); StackPane.setAlignment(topicBackground, Pos.TOP_LEFT); getChildren().add(topicBackground); resolutionBackground = new Rectangle(34, 16); resolutionBackground.setFill(resolutionOnlineColor); resolutionBackground.setVisible(false); resolutionBackground.setArcHeight(5); resolutionBackground.setArcWidth(resolutionBackground.getArcHeight()); StackPane.setAlignment(resolutionBackground, Pos.TOP_RIGHT); StackPane.setMargin(resolutionBackground, new Insets(2)); getChildren().add(resolutionBackground); name = new Text(model.getDisplayName()); name.setFill(Color.WHITE); name.setFont(new Font("Sansserif", 16)); name.setTextAlignment(TextAlignment.CENTER); name.prefHeight(25); StackPane.setAlignment(name, Pos.BOTTOM_CENTER); getChildren().add(name); topic = new Text(); String txt = recording ? " " : ""; txt += model.getDescription(); topic.setText(txt); topic.setFill(Color.WHITE); topic.setFont(new Font("Sansserif", 13)); topic.setTextAlignment(TextAlignment.LEFT); topic.setOpacity(0); int margin = 4; StackPane.setMargin(topic, new Insets(margin)); StackPane.setAlignment(topic, Pos.TOP_CENTER); getChildren().add(topic); resolutionTag = new Text(); resolutionTag.setFill(Color.WHITE); resolutionTag.setVisible(false); StackPane.setAlignment(resolutionTag, Pos.TOP_RIGHT); StackPane.setMargin(resolutionTag, new Insets(2, 4, 2, 2)); getChildren().add(resolutionTag); recordingIndicator = new Circle(8); recordingIndicator.setFill(colorRecording); recordingIndicator.setCursor(Cursor.HAND); recordingIndicator.setOnMouseClicked(e -> pauseResumeAction(true)); StackPane.setMargin(recordingIndicator, new Insets(3)); StackPane.setAlignment(recordingIndicator, Pos.TOP_LEFT); getChildren().add(recordingIndicator); pausedIndicator = new PausedIndicator(16, colorRecording); pausedIndicator.setOnMouseClicked(e -> pauseResumeAction(false)); getChildren().add(pausedIndicator); if (Config.getInstance().getSettings().livePreviews) { getChildren().add(createPreviewTrigger()); } selectionOverlay = new Rectangle(); selectionOverlay.visibleProperty().bind(selectionProperty); selectionOverlay.widthProperty().bind(widthProperty()); selectionOverlay.heightProperty().bind(heightProperty()); StackPane.setAlignment(selectionOverlay, Pos.TOP_LEFT); getChildren().add(selectionOverlay); setOnMouseEntered(e -> { mouseHovering = true; Color normal = recording ? colorRecording : colorNormal; new ParallelTransition(changeColor(nameBackground, normal, colorHighlight), changeColor(name, colorHighlight, normal)).playFromStart(); new ParallelTransition(changeOpacity(topicBackground, 0.7), changeOpacity(topic, 0.7)).playFromStart(); if (Config.getInstance().getSettings().determineResolution) { resolutionBackground.setVisible(false); resolutionTag.setVisible(false); } }); setOnMouseExited(e -> { mouseHovering = false; Color normal = recording ? colorRecording : colorNormal; new ParallelTransition(changeColor(nameBackground, colorHighlight, normal), changeColor(name, normal, colorHighlight)).playFromStart(); new ParallelTransition(changeOpacity(topicBackground, 0), changeOpacity(topic, 0)).playFromStart(); if (Config.getInstance().getSettings().determineResolution && !resolutionTag.getText().isEmpty()) { resolutionBackground.setVisible(true); resolutionTag.setVisible(true); } }); setThumbWidth(Config.getInstance().getSettings().thumbWidth); setRecording(recording); update(); } private Node createPreviewTrigger() { int s = 32; StackPane previewTrigger = new StackPane(); previewTrigger.setStyle("-fx-background-color: white;"); previewTrigger.setOpacity(.8); previewTrigger.setMaxSize(s, s); Polygon play = new Polygon(16, 8, 26, 15, 16, 22); StackPane.setMargin(play, new Insets(0, 0, 0, 3)); play.setStyle("-fx-background-color: black;"); previewTrigger.getChildren().add(play); Circle clip = new Circle(s / 2); clip.setTranslateX(clip.getRadius()); clip.setTranslateY(clip.getRadius()); previewTrigger.setClip(clip); StackPane.setAlignment(previewTrigger, Pos.BOTTOM_LEFT); StackPane.setMargin(previewTrigger, new Insets(0, 0, 24, 4)); previewTrigger.setOnMouseEntered(evt -> setPreviewVisible(previewTrigger, true)); previewTrigger.setOnMouseExited(evt -> setPreviewVisible(previewTrigger, false)); return previewTrigger; } private void setPreviewVisible(Node previewTrigger, boolean visible) { parent.suspendUpdates(visible); iv.setVisible(!visible); topic.setVisible(!visible); topicBackground.setVisible(!visible); name.setVisible(!visible); nameBackground.setVisible(!visible); streamPreview.setVisible(visible); streamPreview.startStream(model); recordingIndicator.setVisible(!visible); pausedIndicator.setVisible(!visible); if (!visible) { updateRecordingIndicator(); } previewTrigger.setCursor(visible ? Cursor.HAND : Cursor.DEFAULT); } public void setSelected(boolean selected) { selectionProperty.set(selected); selectionOverlay.getStyleClass().add("selection-background"); selectionOverlay.setOpacity(selected ? .75 : 0); } public boolean isSelected() { return selectionProperty.get(); } public ObservableValue selectionProperty() { return selectionProperty; } private void updateResolutionTag() { ThumbOverviewTab.threadPool.submit(() -> { int[] resolution; String tagText; Paint resolutionBackgroundColor; try { resolution = resolutionCache.get(model); resolutionBackgroundColor = resolutionOnlineColor; final int w = resolution[1]; String width = w != Integer.MAX_VALUE ? Integer.toString(w) : "HD"; tagText = width; if (w == 0) { State state = model.getOnlineState(false); tagText = state.name(); if (model.isOnline() && state == ONLINE) { resolutionCache.invalidate(model); } else { resolutionBackgroundColor = resolutionOfflineColor; if (state == ONLINE) { // state can't be ONLINE while the model is offline tagText = OFFLINE.name(); } } } else { State state = model.getOnlineState(true); if (state != ONLINE) { tagText = state.name(); resolutionBackgroundColor = resolutionOfflineColor; } } } catch (InterruptedException e) { Thread.currentThread().interrupt(); tagText = "error"; resolutionBackgroundColor = resolutionOfflineColor; } catch (ExecutionException | IOException e) { tagText = "error"; resolutionBackgroundColor = resolutionOfflineColor; } final String text = tagText; final Paint c = resolutionBackgroundColor; Platform.runLater(() -> { String oldText = resolutionTag.getText(); resolutionTag.setText(text); if (!mouseHovering) { resolutionTag.setVisible(true); resolutionBackground.setVisible(true); } resolutionBackground.setWidth(resolutionTag.getBoundsInLocal().getWidth() + 4); resolutionBackground.setFill(c); if (!Objects.equals(oldText, text)) { parent.filter(); } }); }); } private void setImage(String url) { if (!Objects.equals(System.getenv("CTBREC_DEV"), "1")) { boolean updateThumbs = Config.getInstance().getSettings().updateThumbnails; if (updateThumbs || iv.getImage() == null) { imageLoadingThreadPool.submit(createThumbDownload(url)); } } } private Runnable createThumbDownload(String url) { return () -> { Request req = new Request.Builder() .url(url) .header(ACCEPT, "*/*") .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) .header(REFERER, getModel().getSite().getBaseUrl()) .build(); try (Response resp = CamrecApplication.httpClient.execute(req)) { if (resp.isSuccessful()) { double width = 480; double height = width * imgAspectRatio; Image img = new Image(resp.body().byteStream(), width, height, preserveAspectRatio.get(), true); if (img.progressProperty().get() == 1.0) { Platform.runLater(() -> { iv.setImage(img); setThumbWidth(Config.getInstance().getSettings().thumbWidth); }); } else { img.progressProperty().addListener((ChangeListener) (observable, oldValue, newValue) -> { if (newValue.doubleValue() == 1.0) { iv.setImage(img); setThumbWidth(Config.getInstance().getSettings().thumbWidth); } }); } } else { throw new HttpException(resp.code(), resp.message()); } } catch (IOException e) { LOG.warn("Error loading thumbnail: {}", e.getLocalizedMessage()); } }; } Image getImage() { return iv.getImage(); } private Transition changeColor(Shape shape, Color from, Color to) { FillTransition transition = new FillTransition(ANIMATION_DURATION, from, to); transition.setShape(shape); return transition; } private Transition changeOpacity(Shape shape, double opacity) { FadeTransition transition = new FadeTransition(ANIMATION_DURATION, shape); transition.setFromValue(shape.getOpacity()); transition.setToValue(opacity); return transition; } void startPlayer() { new PlayAction(this, model).execute(); } private void setRecording(boolean recording) { this.recording = recording; if (recording) { Color c = mouseHovering ? colorHighlight : colorRecording; nameBackground.setFill(c); } else { Color c = mouseHovering ? colorHighlight : colorNormal; nameBackground.setFill(c); } updateRecordingIndicator(); } private void updateRecordingIndicator() { if (recording) { recordingIndicator.setVisible(!model.isSuspended()); pausedIndicator.setVisible(model.isSuspended()); } else { recordingIndicator.setVisible(false); pausedIndicator.setVisible(false); } } void startStopAction(boolean start) { setCursor(Cursor.WAIT); boolean selectSource = Config.getInstance().getSettings().chooseStreamQuality; if (selectSource && start) { Function onSuccess = modl -> { startStopActionAsync(modl, start); return null; }; Function onFail = throwable -> { Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, getScene()); alert.setTitle(ERROR); alert.setHeaderText(COULDNT_START_STOP_RECORDING); alert.setContentText("I/O error while starting/stopping the recording: " + throwable.getLocalizedMessage()); alert.showAndWait(); return null; }; StreamSourceSelectionDialog.show(getScene(), model, onSuccess, onFail); } else { startStopActionAsync(model, start); } } void pauseResumeAction(boolean pause) { setCursor(Cursor.WAIT); new Thread(() -> { try { if (pause) { recorder.suspendRecording(model); } else { recorder.resumeRecording(model); } setRecording(recording); } catch (Exception e1) { LOG.error("Couldn't pause/resume recording", e1); Platform.runLater(() -> { Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, getScene()); alert.setTitle(ERROR); alert.setHeaderText("Couldn't pause/resume recording"); alert.setContentText("I/O error while pausing/resuming the recording: " + e1.getLocalizedMessage()); alert.showAndWait(); }); } finally { setCursor(Cursor.DEFAULT); } }).start(); } private void startStopActionAsync(Model model, boolean start) { new Thread(() -> { try { if (start) { recorder.startRecording(model); setRecording(true); } else { recorder.stopRecording(model); setRecording(false); } } catch (Exception e1) { LOG.error(COULDNT_START_STOP_RECORDING, e1); Platform.runLater(() -> { Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, getScene()); alert.setTitle(ERROR); alert.setHeaderText(COULDNT_START_STOP_RECORDING); alert.setContentText("I/O error while starting/stopping the recording: " + e1.getLocalizedMessage()); alert.showAndWait(); }); } finally { setCursor(Cursor.DEFAULT); } }).start(); } CompletableFuture follow(boolean follow) { setCursor(Cursor.WAIT); return CompletableFuture.supplyAsync(() -> { try { if (follow) { SiteUiFactory.getUi(model.getSite()).login(); boolean followed = model.follow(); if (followed) { return true; } else { Platform.runLater(() -> { Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, getScene()); alert.setTitle(ERROR); alert.setHeaderText("Couldn't follow model"); alert.setContentText(""); alert.showAndWait(); }); return false; } } else { SiteUiFactory.getUi(model.getSite()).login(); boolean unfollowed = model.unfollow(); if (unfollowed) { Platform.runLater(() -> thumbCellList.remove(ThumbCell.this)); return true; } else { Platform.runLater(() -> { Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, getScene()); alert.setTitle(ERROR); alert.setHeaderText("Couldn't unfollow model"); alert.setContentText(""); alert.showAndWait(); }); return false; } } } catch (Exception e1) { LOG.error("Couldn't follow/unfollow model {}", model.getName(), e1); Platform.runLater(() -> { Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, getScene()); alert.setTitle(ERROR); alert.setHeaderText("Couldn't follow/unfollow model"); alert.setContentText("I/O error while following/unfollowing model " + model.getName() + ": " + e1.getLocalizedMessage()); alert.showAndWait(); }); return false; } finally { setCursor(Cursor.DEFAULT); } }); } public Model getModel() { return model; } public void setModel(Model model) { this.model.setName(model.getName()); this.model.setDescription(model.getDescription()); this.model.setPreview(model.getPreview()); this.model.setTags(model.getTags()); this.model.setUrl(model.getUrl()); this.model.setSuspended(recorder.isSuspended(model)); update(); } public int getIndex() { return index; } public void setIndex(int index) { this.index = index; } private void update() { model.setSuspended(recorder.isSuspended(model)); setRecording(recorder.isTracked(model)); setImage(model.getPreview()); String txt = recording ? " " : ""; txt += model.getDescription() != null ? model.getDescription() : ""; topic.setText(txt); if (Config.getInstance().getSettings().determineResolution) { updateResolutionTag(); } else { resolutionBackground.setVisible(false); resolutionTag.setVisible(false); } requestLayout(); } @Override public int hashCode() { final int prime = 31; int result = 1; result = prime * result + ((model == null) ? 0 : model.hashCode()); return result; } @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (getClass() != obj.getClass()) return false; ThumbCell other = (ThumbCell) obj; if (model == null) { if (other.model != null) return false; } else if (!model.equals(other.model)) return false; return true; } public void setThumbWidth(int width) { int height = (int) (width * imgAspectRatio); setSize(width, height); iv.prefHeight(height); iv.prefWidth(width); } private void setSize(int w, int h) { if (iv.getImage() != null) { double aspectRatio = iv.getImage().getWidth() / iv.getImage().getHeight(); if (aspectRatio > 1) { iv.setFitWidth(w); } else { iv.setFitHeight(h); } } setMinSize(w, h); setPrefSize(w, h); nameBackground.setWidth(w); nameBackground.setHeight(20); topicBackground.setWidth(w); topicBackground.setHeight(h - nameBackground.getHeight()); topic.prefHeight(getHeight() - 25); topic.maxHeight(getHeight() - 25); int margin = 4; topic.maxWidth(w - margin * 2); topic.setWrappingWidth(w - margin * 2); streamPreview.resizeTo(w, h); Rectangle clip = new Rectangle(w, h); clip.setArcWidth(10); clip.arcHeightProperty().bind(clip.arcWidthProperty()); this.setClip(clip); } private static int[] getStreamResolution(Model model) { try { return model.getStreamResolution(false); } catch (ExecutionException e) { LOG.warn("Error loading stream resolution for model {}", model, e); return new int[2]; } } public void releaseResources() { iv.setImage(null); } public void setImageAspectRatio(double imageAspectRatio) { this.imgAspectRatio = imageAspectRatio; } public BooleanProperty preserveAspectRatioProperty() { return preserveAspectRatio; } }