package ctbrec.ui.tabs; 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.Icon; import ctbrec.ui.SiteUiFactory; import ctbrec.ui.action.EditGroupAction; import ctbrec.ui.action.PlayAction; import ctbrec.ui.action.StopRecordingAction; 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.Label; import javafx.scene.control.Tooltip; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.input.MouseButton; 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.Text; import javafx.scene.text.TextAlignment; import javafx.util.Duration; import okhttp3.Request; import okhttp3.Response; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.InputStream; import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import static ctbrec.Model.State.OFFLINE; import static ctbrec.Model.State.ONLINE; import static ctbrec.io.HttpConstants.*; import static ctbrec.ui.Icon.*; public class ThumbCell extends StackPane { 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 final Image imgRecordIndicator = new Image(MEDIA_RECORD_16.url()); private static final Image imgForceRecordIndicator = new Image(MEDIA_FORCE_RECORD_16.url()); private static final Image imgPauseIndicator = new Image(MEDIA_PLAYBACK_PAUSE_16.url()); private static final Image imgBookmarkIndicator = new Image(BOOKMARK_16.url()); private static final Image imgGroupIndicator = new Image(Icon.GROUP_16.url()); private ModelRecordingState modelRecordingState = ModelRecordingState.NOT; private final Model model; private final StreamPreview streamPreview; private final ImageView iv; private final 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 final Rectangle nameBackground; private final Rectangle topicBackground; private final Rectangle selectionOverlay; private final Text name; private final Text topic; private final Text resolutionTag; private final Recorder recorder; private final RecordingIndicator recordingIndicator; private final Tooltip recordingIndicatorTooltip; private StackPane previewTrigger; private final StackPane groupIndicator; private final Label groupIndicatorTooltipTrigger; private int index = 0; 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 final SimpleBooleanProperty selectionProperty = new SimpleBooleanProperty(false); private double imgAspectRatio; private final SimpleBooleanProperty preserveAspectRatio = new SimpleBooleanProperty(true); private final ObservableList thumbCellList; private boolean mouseHovering = false; private boolean recording; static LoadingCache resolutionCache = CacheBuilder.newBuilder() .expireAfterAccess(5, TimeUnit.MINUTES) .maximumSize(10000) .build(CacheLoader.from(ThumbCell::getStreamResolution)); private final 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)); model.setForcePriority(recorder.isForcePriority(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); 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); topic = new Text(); String txt = recording ? " " : ""; txt += model.getDescription(); topic.setText(txt); topic.setFill(Color.WHITE); 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); nameBackground = new Rectangle(); nameBackground.setFill(recording ? colorRecording : colorNormal); nameBackground.setOpacity(.7); StackPane.setAlignment(nameBackground, Pos.BOTTOM_CENTER); getChildren().add(nameBackground); name = new Text(model.getDisplayName()); name.setFill(Color.WHITE); name.setTextAlignment(TextAlignment.CENTER); name.getStyleClass().add("thumbcell-name"); StackPane.setAlignment(name, Pos.BOTTOM_CENTER); getChildren().add(name); 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 StackPane(); groupIndicator.setMaxSize(24, 24); var groupIndicatorImg = new ImageView(imgGroupIndicator); groupIndicatorImg.setVisible(false); groupIndicatorImg.visibleProperty().bind(groupIndicator.visibleProperty()); groupIndicatorTooltipTrigger = new Label(); groupIndicatorTooltipTrigger.setPrefSize(16, 16); groupIndicatorTooltipTrigger.setMinSize(16, 16); groupIndicatorTooltipTrigger.visibleProperty().bind(groupIndicator.visibleProperty()); groupIndicatorTooltipTrigger.setCursor(Cursor.HAND); groupIndicatorTooltipTrigger.setOnMouseClicked(e -> { if (e.getButton() == MouseButton.PRIMARY) { new EditGroupAction(this, recorder, model).execute(); e.consume(); } }); var groupIndicatorBackground = new Circle(12, Color.WHITE); groupIndicatorBackground.visibleProperty().bind(groupIndicator.visibleProperty()); groupIndicatorBackground.setOpacity(0.7); groupIndicator.getChildren().addAll(groupIndicatorBackground, groupIndicatorImg, groupIndicatorTooltipTrigger); StackPane.setAlignment(groupIndicator, Pos.BOTTOM_RIGHT); getChildren().add(groupIndicator); 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(); new ParallelTransition(changeOpacity(nameBackground, 1), changeOpacity(name, 1)).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(); new ParallelTransition(changeOpacity(nameBackground, 0.7), changeOpacity(name, 0.7)).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); case PAUSED -> pauseResumeAction(false); case BOOKMARKED -> forgetModel(); } } private void forgetModel() { new StopRecordingAction(this, List.of(model), recorder) .execute() .thenAccept(r -> update()); } private Node createPreviewTrigger() { var s = 24; 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 (Boolean.TRUE.equals(result)) { 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("thumbcell-selection-background"); } else { selectionOverlay.getStyleClass().remove("thumbcell-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.getLayoutBounds().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 = model.getSite().getHttpClient().execute(req)) { if (resp.isSuccessful()) { double width = 480; double height = width * imgAspectRatio; InputStream bodyStream = Objects.requireNonNull(resp.body(), "HTTP body is null").byteStream(); var img = new Image(bodyStream, 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: {} {}", url, 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; if (model.isForcePriority()) { recordingIndicator.setImage(imgForceRecordIndicator); } else { 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 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)); } }); } 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()); } 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 += String.join("\n", group.getModelUrls()); 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) { return other.model == null; } else return model.equals(other.model); } 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(25); 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; } private enum ModelRecordingState { RECORDING, PAUSED, BOOKMARKED, NOT } @Override protected void layoutChildren() { nameBackground.setHeight(name.getLayoutBounds().getHeight()); resolutionBackground.setHeight(resolutionTag.getLayoutBounds().getHeight()); topicBackground.setHeight(getHeight() - nameBackground.getHeight()); StackPane.setMargin(groupIndicator, new Insets(0, 3, nameBackground.getHeight() + 4, 0)); if (Config.getInstance().getSettings().livePreviews) { StackPane.setMargin(previewTrigger, new Insets(0, 0, nameBackground.getHeight() + 4, 4)); } super.layoutChildren(); } }