package ctbrec.ui; import java.io.IOException; import java.util.HashMap; import java.util.Map; import java.util.Objects; import java.util.function.Function; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.iheartradio.m3u8.ParseException; import com.iheartradio.m3u8.PlaylistException; import ctbrec.Config; import ctbrec.HttpClient; import ctbrec.Model; import ctbrec.recorder.Chaturbate; import ctbrec.recorder.Recorder; import ctbrec.recorder.StreamInfo; import javafx.animation.FadeTransition; import javafx.animation.FillTransition; import javafx.animation.Interpolator; import javafx.animation.ParallelTransition; import javafx.animation.Transition; import javafx.application.Platform; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.collections.ObservableList; import javafx.event.EventHandler; 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.MenuItem; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.input.Clipboard; import javafx.scene.input.ClipboardContent; import javafx.scene.input.ContextMenuEvent; import javafx.scene.input.MouseButton; import javafx.scene.input.MouseEvent; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.scene.shape.Circle; 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.RequestBody; import okhttp3.Response; public class ThumbCell extends StackPane { private static final transient Logger LOG = LoggerFactory.getLogger(ThumbCell.class); public static int width = 180; private static final Duration ANIMATION_DURATION = new Duration(250); // this acts like a cache, once the stream resolution for a model has been determined, we don't do it again (until ctbrec is restarted) private static Map resolutions = new HashMap<>(); private Model model; private ImageView iv; private Rectangle resolutionBackground; private Rectangle nameBackground; private Rectangle topicBackground; private Text name; private Text topic; private Text resolutionTag; private Recorder recorder; private Circle recordingIndicator; private FadeTransition recordingAnimation; private int index = 0; ContextMenu popup; private Color colorNormal = Color.BLACK; private Color colorHighlight = Color.WHITE; private Color colorRecording = new Color(0.8, 0.28, 0.28, 1); private HttpClient client; private ThumbOverviewTab parent; private ObservableList thumbCellList; public ThumbCell(ThumbOverviewTab parent, Model model, Recorder recorder, HttpClient client) { this.parent = parent; this.thumbCellList = parent.grid.getChildren(); this.model = model; this.recorder = recorder; this.client = client; boolean recording = recorder.isRecording(model); iv = new ImageView(); setImage(model.getPreview()); iv.setSmooth(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(new Color(0.22, 0.8, 0.29, 1)); 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.getName()); 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(model.getDescription()); 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); StackPane.setMargin(recordingIndicator, new Insets(3)); StackPane.setAlignment(recordingIndicator, Pos.TOP_LEFT); getChildren().add(recordingIndicator); recordingAnimation = new FadeTransition(Duration.millis(1000), recordingIndicator); recordingAnimation.setInterpolator(Interpolator.EASE_BOTH); recordingAnimation.setFromValue(1.0); recordingAnimation.setToValue(0); recordingAnimation.setCycleCount(FadeTransition.INDEFINITE); recordingAnimation.setAutoReverse(true); setOnMouseEntered((e) -> { new ParallelTransition(changeColor(nameBackground, colorNormal, colorHighlight), changeColor(name, colorHighlight, colorNormal)).playFromStart(); new ParallelTransition(changeOpacity(topicBackground, 0.7), changeOpacity(topic, 0.7)).playFromStart(); }); setOnMouseExited((e) -> { new ParallelTransition(changeColor(nameBackground, colorHighlight, colorNormal), changeColor(name, colorNormal, colorHighlight)).playFromStart(); new ParallelTransition(changeOpacity(topicBackground, 0), changeOpacity(topic, 0)).playFromStart(); }); setOnMouseClicked(doubleClickListener); addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, event -> { parent.suspendUpdates(true); popup = createContextMenu(); popup.show(ThumbCell.this, event.getScreenX(), event.getScreenY()); popup.setOnHidden((e) -> parent.suspendUpdates(false)); event.consume(); }); addEventHandler(MouseEvent.MOUSE_PRESSED, event -> { if(popup != null) { popup.hide(); } }); setThumbWidth(width); setRecording(recording); if(Config.getInstance().getSettings().determineResolution) { determineResolution(); } } private void determineResolution() { if(ThumbOverviewTab.resolutionProcessing.contains(model)) { LOG.debug("Already fetching resolution for model {}. Queue size {}", model.getName(), ThumbOverviewTab.resolutionProcessing.size()); return; } ThumbOverviewTab.resolutionProcessing.add(model); int[] res = resolutions.get(model.getName()); if(res == null) { ThumbOverviewTab.threadPool.submit(() -> { try { Thread.sleep(500); // throttle down, so that we don't do too many requests int[] resolution = Chaturbate.getResolution(model, client); resolutions.put(model.getName(), resolution); if (resolution[1] > 0) { LOG.trace("Model resolution {} {}x{}", model.getName(), resolution[0], resolution[1]); LOG.trace("Resolution queue size: {}", ThumbOverviewTab.queue.size()); final int w = resolution[1]; Platform.runLater(() -> { String _res = Integer.toString(w); resolutionTag.setText(_res); resolutionTag.setVisible(true); resolutionBackground.setVisible(true); resolutionBackground.setWidth(resolutionTag.getBoundsInLocal().getWidth() + 4); model.setStreamResolution(w); }); } } catch (IOException | ParseException | PlaylistException | InterruptedException e) { LOG.error("Coulnd't get resolution for model {}", model, e); } finally { ThumbOverviewTab.resolutionProcessing.remove(model); } }); } else { ThumbOverviewTab.resolutionProcessing.remove(model); String _res = Integer.toString(res[1]); model.setStreamResolution(res[1]); Platform.runLater(() -> { resolutionTag.setText(_res); resolutionTag.setVisible(true); resolutionBackground.setVisible(true); resolutionBackground.setWidth(resolutionTag.getBoundsInLocal().getWidth() + 4); }); // the model is online, but the resolution is 0. probably something went wrong // when we first requested the stream info, so we remove this invalid value from the "cache" // so that it is requested again if(model.isOnline() && res[1] == 0) { ThumbOverviewTab.threadPool.submit(() -> { try { Chaturbate.getStreamInfo(model, client); if(model.isOnline()) { LOG.debug("Removing invalid resolution value for {}", model.getName()); resolutions.remove(model.getName()); } } catch (IOException e) { LOG.error("Coulnd't get resolution for model {}", model, e); } }); } } } private void setImage(String url) { if(!Objects.equals(System.getenv("CTBREC_DEV"), "1")) { Image img = new Image(url, true); // wait for the image to load, otherwise the ImageView replaces the current image with an "empty" image, // which causes to show the grey background until the image is loaded img.progressProperty().addListener(new ChangeListener() { @Override public void changed(ObservableValue observable, Number oldValue, Number newValue) { if(newValue.doubleValue() == 1.0) { iv.setImage(img); } } }); } } private ContextMenu createContextMenu() { MenuItem openInPlayer = new MenuItem("Open in Player"); openInPlayer.setOnAction((e) -> startPlayer()); MenuItem start = new MenuItem("Start Recording"); start.setOnAction((e) -> startStopAction(true)); MenuItem stop = new MenuItem("Stop Recording"); stop.setOnAction((e) -> startStopAction(false)); MenuItem startStop = recorder.isRecording(model) ? stop : start; MenuItem follow = new MenuItem("Follow"); follow.setOnAction((e) -> follow(true)); MenuItem unfollow = new MenuItem("Unfollow"); unfollow.setOnAction((e) -> follow(false)); MenuItem copyUrl = new MenuItem("Copy URL"); copyUrl.setOnAction((e) -> { final Clipboard clipboard = Clipboard.getSystemClipboard(); final ClipboardContent content = new ClipboardContent(); content.putString(model.getUrl()); clipboard.setContent(content); }); ContextMenu contextMenu = new ContextMenu(); contextMenu.setAutoHide(true); contextMenu.setHideOnEscape(true); contextMenu.setAutoFix(true); MenuItem followOrUnFollow = parent instanceof FollowedTab ? unfollow : follow; contextMenu.getItems().addAll(openInPlayer, startStop , followOrUnFollow, copyUrl); return contextMenu; } 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; } private EventHandler doubleClickListener = new EventHandler() { @Override public void handle(MouseEvent e) { if(e.getButton() == MouseButton.PRIMARY && e.getClickCount() == 2) { startPlayer(); } } }; private void startPlayer() { // TODO if manual choice of stream quality is enabled, do the same thing as starting a download here?!? // or maybe not, because the player should automatically switch between resolutions depending on the // network bandwidth try { StreamInfo streamInfo = Chaturbate.getStreamInfo(model, client); if(streamInfo.room_status.equals("public")) { LOG.debug("Playing {}", streamInfo.url); Player.play(streamInfo.url); } else { Alert alert = new AutosizeAlert(Alert.AlertType.INFORMATION); alert.setTitle("Room not public"); alert.setHeaderText("Room is currently not public"); alert.showAndWait(); } } catch (IOException e1) { LOG.error("Couldn't get stream information for model {}", model, e1); Alert alert = new AutosizeAlert(Alert.AlertType.ERROR); alert.setTitle("Error"); alert.setHeaderText("Couldn't determine stream URL"); alert.showAndWait(); } } private void setRecording(boolean recording) { if(recording) { //recordingAnimation.playFromStart(); colorNormal = colorRecording; nameBackground.setFill(colorNormal); } else { colorNormal = Color.BLACK; nameBackground.setFill(colorNormal); //recordingAnimation.stop(); } recordingIndicator.setVisible(recording); } private void startStopAction(boolean start) { setCursor(Cursor.WAIT); boolean selectSource = Config.getInstance().getSettings().chooseStreamQuality; if(selectSource && start) { Function onSuccess = (model) -> { _startStopAction(model, start); return null; }; Function onFail = (throwable) -> { Alert alert = new AutosizeAlert(Alert.AlertType.ERROR); alert.setTitle("Error"); alert.setHeaderText("Couldn't start/stop recording"); alert.setContentText("I/O error while starting/stopping the recording: " + throwable.getLocalizedMessage()); alert.showAndWait(); return null; }; StreamSourceSelectionDialog.show(model, client, onSuccess, onFail); } else { _startStopAction(model, start); } } private void _startStopAction(Model model, boolean start) { new Thread() { @Override public void run() { try { if(start) { recorder.startRecording(model); } else { recorder.stopRecording(model); } } catch (Exception e1) { LOG.error("Couldn't start/stop recording", e1); Platform.runLater(() -> { Alert alert = new AutosizeAlert(Alert.AlertType.ERROR); alert.setTitle("Error"); alert.setHeaderText("Couldn't start/stop recording"); alert.setContentText("I/O error while starting/stopping the recording: " + e1.getLocalizedMessage()); alert.showAndWait(); }); } finally { setCursor(Cursor.DEFAULT); } } }.start(); } private void follow(boolean follow) { setCursor(Cursor.WAIT); new Thread() { @Override public void run() { try { Request req = new Request.Builder().url(model.getUrl()).build(); Response resp = HttpClient.getInstance().execute(req); resp.close(); String url = null; if(follow) { url = CtbrecApplication.BASE_URI + "/follow/follow/" + model.getName() + "/"; } else { url = CtbrecApplication.BASE_URI + "/follow/unfollow/" + model.getName() + "/"; } RequestBody body = RequestBody.create(null, new byte[0]); req = new Request.Builder() .url(url) .method("POST", body) .header("Accept", "*/*") //.header("Accept-Encoding", "gzip, deflate, br") .header("Accept-Language", "en-US,en;q=0.5") .header("Referer", model.getUrl()) .header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:59.0) Gecko/20100101 Firefox/59.0") .header("X-CSRFToken", HttpClient.getInstance().getToken()) .header("X-Requested-With", "XMLHttpRequest") .build(); resp = HttpClient.getInstance().execute(req, true); if(resp.isSuccessful()) { String msg = resp.body().string(); if(!msg.equalsIgnoreCase("ok")) { LOG.debug(msg); throw new IOException("Response was " + msg.substring(0, Math.min(msg.length(), 500))); } else { LOG.debug("Follow/Unfollow -> {}", msg); if(!follow) { Platform.runLater(() -> thumbCellList.remove(ThumbCell.this)); } } } else { resp.close(); throw new IOException("HTTP status " + resp.code() + " " + resp.message()); } } catch (Exception e1) { LOG.error("Couldn't follow/unfollow model {}", model.getName(), e1); Platform.runLater(() -> { Alert alert = new AutosizeAlert(Alert.AlertType.ERROR); 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(); }); } finally { setCursor(Cursor.DEFAULT); } } }.start(); } public Model getModel() { return model; } public void setModel(Model model) { //this.model = model; this.model.setName(model.getName()); this.model.setDescription(model.getDescription()); this.model.setOnline(model.isOnline()); this.model.setPreview(model.getPreview()); this.model.setStreamResolution(model.getStreamResolution()); this.model.setTags(model.getTags()); this.model.setUrl(model.getUrl()); update(); } public int getIndex() { return index; } public void setIndex(int index) { this.index = index; } private void update() { setRecording(recorder.isRecording(model)); setImage(model.getPreview()); topic.setText(model.getDescription()); //Tooltip t = new Tooltip(model.getDescription()); //Tooltip.install(this, t); if(Config.getInstance().getSettings().determineResolution) { determineResolution(); } 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 = width * 3 / 4; setSize(width, height); } private void setSize(int w, int h) { iv.setFitWidth(w); 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(h-25); topic.maxHeight(h-25); int margin = 4; topic.maxWidth(w-margin*2); topic.setWrappingWidth(w-margin*2); } }