package ctbrec.ui.tabs; import static ctbrec.Model.State.*; import static ctbrec.io.HttpConstants.*; import static ctbrec.ui.Icon.*; 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.TimeUnit; import java.util.function.Consumer; import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; import ctbrec.Config; import ctbrec.GlobalThreadPool; import ctbrec.Model; import ctbrec.io.HttpException; import ctbrec.recorder.Recorder; import ctbrec.ui.AutosizeAlert; import ctbrec.ui.CamrecApplication; import ctbrec.ui.Icon; import ctbrec.ui.SiteUiFactory; import ctbrec.ui.StreamSourceSelectionDialog; import ctbrec.ui.action.EditGroupAction; import ctbrec.ui.action.PlayAction; import ctbrec.ui.controls.Dialogs; import ctbrec.ui.controls.RecordingIndicator; 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.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.control.Label; import javafx.scene.control.Tooltip; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.input.MouseEvent; 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 static Image imgRecordIndicator = new Image(MEDIA_RECORD_16.url()); private static Image imgPauseIndicator = new Image(MEDIA_PLAYBACK_PAUSE_16.url()); private static Image imgBookmarkIndicator = new Image(BOOKMARK_16.url()); private static Image imgGroupIndicator = new Image(Icon.GROUP_16.url()); private ModelRecordingState modelRecordingState = ModelRecordingState.NOT; 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 RecordingIndicator recordingIndicator; private Tooltip recordingIndicatorTooltip; private StackPane previewTrigger; private Label groupIndicatorTooltipTrigger; private ImageView groupIndicator; 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; static LoadingCache resolutionCache = CacheBuilder.newBuilder() .expireAfterAccess(5, TimeUnit.MINUTES) .maximumSize(10000) .build(CacheLoader.from(ThumbCell::getStreamResolution)); private ThumbOverviewTab parent; private CompletableFuture startPreview; 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); var 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 RecordingIndicator(16); recordingIndicator.setCursor(Cursor.HAND); recordingIndicator.setOnMouseClicked(this::recordingInidicatorClicked); recordingIndicatorTooltip = new Tooltip("Pause Recording"); Tooltip.install(recordingIndicator, recordingIndicatorTooltip); StackPane.setMargin(recordingIndicator, new Insets(3)); StackPane.setAlignment(recordingIndicator, Pos.TOP_LEFT); getChildren().add(recordingIndicator); groupIndicator = new ImageView(imgGroupIndicator); groupIndicator.setVisible(false); groupIndicatorTooltipTrigger = new Label(); groupIndicatorTooltipTrigger.setPrefSize(16, 16); groupIndicatorTooltipTrigger.setMinSize(16, 16); groupIndicatorTooltipTrigger.visibleProperty().bind(groupIndicator.visibleProperty()); groupIndicatorTooltipTrigger.setCursor(Cursor.HAND); groupIndicatorTooltipTrigger.setOnMouseClicked(e -> new EditGroupAction(this, recorder, model).execute()); StackPane.setMargin(groupIndicatorTooltipTrigger, new Insets(0, 3, 23, 0)); StackPane.setAlignment(groupIndicatorTooltipTrigger, Pos.BOTTOM_RIGHT); StackPane.setMargin(groupIndicator, new Insets(0, 3, 23, 0)); StackPane.setAlignment(groupIndicator, Pos.BOTTOM_RIGHT); getChildren().add(groupIndicator); getChildren().add(groupIndicatorTooltipTrigger); 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 void recordingInidicatorClicked(MouseEvent evt) { switch(modelRecordingState) { case RECORDING: pauseResumeAction(true); break; case PAUSED: pauseResumeAction(false); break; case BOOKMARKED: recordLater(false); break; default: } } private Node createPreviewTrigger() { var s = 32; previewTrigger = new StackPane(); previewTrigger.setStyle("-fx-background-color: white;"); previewTrigger.setOpacity(.8); previewTrigger.setMaxSize(s, s); var 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); var clip = new Circle(s / 2.0); 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 -> startPreview()); previewTrigger.setOnMouseExited(evt -> stopPreview()); return previewTrigger; } private void stopPreview() { if (startPreview != null) { startPreview.cancel(true); } setPreviewVisible(previewTrigger, false); } private void startPreview() { previewTrigger.setCursor(Cursor.HAND); startPreview = CompletableFuture.supplyAsync(() -> { try { Thread.sleep(500); return true; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } }, GlobalThreadPool.get()).whenComplete((result, exception) -> { startPreview = null; if (result.booleanValue()) { setPreviewVisible(previewTrigger, true); } }); } 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); if (visible) { streamPreview.startStream(model); } else { streamPreview.stop(); } recordingIndicator.setVisible(!visible); if (!visible) { updateRecordingIndicator(); } previewTrigger.setCursor(visible ? Cursor.HAND : Cursor.DEFAULT); } public void setSelected(boolean selected) { selectionProperty.set(selected); selectionOverlay.setOpacity(selected ? .75 : 0); if (selected) { selectionOverlay.getStyleClass().add("selection-background"); } else { selectionOverlay.getStyleClass().remove("selection-background"); } } 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]; tagText = w != Integer.MAX_VALUE ? Integer.toString(w) : "HD"; if (w == 0) { var 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 { var 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) { GlobalThreadPool.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; var 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((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) { var transition = new FillTransition(ANIMATION_DURATION, from, to); transition.setShape(shape); return transition; } private Transition changeOpacity(Shape shape, double opacity) { var 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; Color c; if (recording) { c = mouseHovering ? colorHighlight : colorRecording; } else { c = mouseHovering ? colorHighlight : colorNormal; } nameBackground.setFill(c); updateRecordingIndicator(); } private void updateRecordingIndicator() { if (recording) { recordingIndicator.setVisible(true); if (model.isSuspended()) { modelRecordingState = ModelRecordingState.PAUSED; recordingIndicator.setImage(imgPauseIndicator); recordingIndicatorTooltip.setText("Resume Recording"); } else { modelRecordingState = ModelRecordingState.RECORDING; recordingIndicator.setImage(imgRecordIndicator); recordingIndicatorTooltip.setText("Pause Recording"); } } else { if (model.isMarkedForLaterRecording()) { recordingIndicator.setVisible(true); modelRecordingState = ModelRecordingState.BOOKMARKED; recordingIndicator.setImage(imgBookmarkIndicator); recordingIndicatorTooltip.setText("Forget Model"); } else { recordingIndicator.setVisible(false); modelRecordingState = ModelRecordingState.NOT; recordingIndicator.setImage(null); } } } void startStopAction(boolean start) { setCursor(Cursor.WAIT); boolean selectSource = Config.getInstance().getSettings().chooseStreamQuality; if (selectSource && start) { Consumer onSuccess = modl -> startStopActionAsync(modl, true); Consumer 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(); }; StreamSourceSelectionDialog.show(getScene(), model, onSuccess, onFail); } else { startStopActionAsync(model, start); } } void pauseResumeAction(boolean pause) { setCursor(Cursor.WAIT); GlobalThreadPool.submit(() -> { 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 { Platform.runLater(() -> setCursor(Cursor.DEFAULT)); } }); } private void startStopActionAsync(Model model, boolean start) { GlobalThreadPool.submit(() -> { try { if (start) { recorder.addModel(model); setRecording(!model.isMarkedForLaterRecording()); } else { recorder.stopRecording(model); setRecording(false); } update(); } catch (Exception e1) { LOG.error(COULDNT_START_STOP_RECORDING, e1); Dialogs.showError(getScene(), COULDNT_START_STOP_RECORDING, "I/O error while starting/stopping the recording: ", e1); } finally { Platform.runLater(() -> setCursor(Cursor.DEFAULT)); } }); } 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 { Dialogs.showError(getScene(), "Couldn't follow model", "", null); return false; } } else { SiteUiFactory.getUi(model.getSite()).login(); boolean unfollowed = model.unfollow(); if (unfollowed) { Platform.runLater(() -> thumbCellList.remove(ThumbCell.this)); return true; } else { Dialogs.showError(getScene(), "Couldn't unfollow model", "", null); return false; } } } catch (Exception e1) { LOG.error("Couldn't follow/unfollow model {}", model.getName(), e1); String msg = "I/O error while following/unfollowing model " + model.getName() + ": "; Dialogs.showError(getScene(), "Couldn't follow/unfollow model", msg, e1); return false; } finally { Platform.runLater(() -> setCursor(Cursor.DEFAULT)); } }, GlobalThreadPool.get()); } void recordLater(boolean recordLater) { model.setMarkedForLaterRecording(recordLater); startStopAction(recordLater); } 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()); update(); } public int getIndex() { return index; } public void setIndex(int index) { this.index = index; } protected void update() { model.setSuspended(recorder.isSuspended(model)); model.setMarkedForLaterRecording(recorder.isMarkedForLaterRecording(model)); setRecording(recorder.isTracked(model)); updateRecordingIndicator(); setImage(model.getPreview()); String txt = (modelRecordingState != ModelRecordingState.NOT) ? " " : ""; txt += model.getDescription() != null ? model.getDescription() : ""; topic.setText(txt); recorder.getModelGroup(model).ifPresentOrElse(group -> { var tooltip = group.getName() + ": " + group.getModelUrls().size() + " models:\n"; tooltip += group.getModelUrls().stream().collect(Collectors.joining("\n")); groupIndicatorTooltipTrigger.setTooltip(new Tooltip(tooltip)); groupIndicator.setVisible(true); }, () -> groupIndicator.setVisible(false)); if (Config.getInstance().getSettings().determineResolution) { updateResolutionTag(); } else { resolutionBackground.setVisible(false); resolutionTag.setVisible(false); } requestLayout(); } @Override public int hashCode() { final var prime = 31; var 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(width); iv.prefWidth(height); } 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); var margin = 4; topic.maxWidth(w - margin * 2.0); topic.setWrappingWidth(w - margin * 2.0); streamPreview.resizeTo(w, h); var 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.trace("Error loading stream resolution for model {}: {}", model, e.getLocalizedMessage()); return new int[2]; } } public void releaseResources() { iv.setImage(null); } public void setImageAspectRatio(double imageAspectRatio) { this.imgAspectRatio = imageAspectRatio; } public BooleanProperty preserveAspectRatioProperty() { return preserveAspectRatio; } void addInPausedState() { model.setSuspended(true); model.setMarkedForLaterRecording(false); startStopAction(true); } private enum ModelRecordingState { RECORDING, PAUSED, BOOKMARKED, NOT } }