package ctbrec.ui; import java.io.InterruptedIOException; import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import ctbrec.Config; import ctbrec.io.HttpException; import ctbrec.recorder.download.StreamSource; import javafx.application.Platform; import javafx.event.EventHandler; import javafx.geometry.Insets; import javafx.geometry.Point2D; import javafx.scene.Node; import javafx.scene.control.ProgressIndicator; import javafx.scene.control.TableColumn; import javafx.scene.control.TableRow; import javafx.scene.control.TableView; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.input.MouseButton; import javafx.scene.input.MouseEvent; import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; import javafx.scene.media.Media; import javafx.scene.media.MediaPlayer; import javafx.scene.media.MediaView; import javafx.stage.Popup; public class PreviewPopupHandler implements EventHandler { private static final transient Logger LOG = LoggerFactory.getLogger(PreviewPopupHandler.class); private static final int offset = 10; private long timeForPopupOpen = TimeUnit.SECONDS.toMillis(1); private long timeForPopupClose = 400; private Popup popup = new Popup(); private Node parent; private ImageView preview = new ImageView(); private MediaView videoPreview; private MediaPlayer videoPlayer; private Media video; private JavaFxModel model; private volatile long openCountdown = -1; private volatile long closeCountdown = -1; private volatile long lastModelChange = -1; private volatile boolean changeModel = false; private ExecutorService executor = Executors.newSingleThreadExecutor(); private Future future; private ProgressIndicator progressIndicator; private StackPane pane; public PreviewPopupHandler(Node parent) { this.parent = parent; videoPreview = new MediaView(); videoPreview.setFitWidth(Config.getInstance().getSettings().thumbWidth); videoPreview.setFitHeight(videoPreview.getFitWidth() * 9 / 16); videoPreview.setPreserveRatio(true); StackPane.setMargin(videoPreview, new Insets(5)); preview.setFitWidth(Config.getInstance().getSettings().thumbWidth); preview.setPreserveRatio(true); preview.setSmooth(true); preview.setStyle("-fx-background-radius: 10px, 10px, 10px, 10px;"); preview.visibleProperty().bind(videoPreview.visibleProperty().not()); StackPane.setMargin(preview, new Insets(5)); progressIndicator = new ProgressIndicator(); progressIndicator.setVisible(false); progressIndicator.prefWidthProperty().bind(videoPreview.fitWidthProperty()); Region veil = new Region(); veil.setStyle("-fx-background-color: rgba(0, 0, 0, 0.8)"); veil.visibleProperty().bind(progressIndicator.visibleProperty()); StackPane.setMargin(veil, new Insets(5)); pane = new StackPane(); pane.getChildren().addAll(preview, videoPreview, veil, progressIndicator); pane.setStyle("-fx-background-color: -fx-outer-border, -fx-inner-border, -fx-base;"+ "-fx-background-insets: 0 0 -1 0, 0, 1, 2;" + "-fx-background-radius: 10px, 10px, 10px, 10px;" + "-fx-padding: 1;" + "-fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.8), 20, 0, 0, 0);"); popup.getContent().add(pane); createTimerThread(); } @Override public void handle(MouseEvent event) { if(!isInPreviewColumn(event)) { closeCountdown = timeForPopupClose; return; } if(event.getEventType() == MouseEvent.MOUSE_CLICKED && event.getButton() == MouseButton.PRIMARY) { model = getModel(event); popup.setX(event.getScreenX()+ offset); popup.setY(event.getScreenY()+ offset); showPopup(); openCountdown = -1; } else if(event.getEventType() == MouseEvent.MOUSE_ENTERED) { popup.setX(event.getScreenX()+ offset); popup.setY(event.getScreenY()+ offset); JavaFxModel model = getModel(event); if(model != null) { closeCountdown = -1; boolean modelChanged = model != this.model; this.model = model; if(popup.isShowing()) { openCountdown = -1; if(modelChanged) { lastModelChange = System.currentTimeMillis(); changeModel = true; future.cancel(true); progressIndicator.setVisible(true); } } else { openCountdown = timeForPopupOpen; } } } else if(event.getEventType() == MouseEvent.MOUSE_EXITED) { openCountdown = -1; closeCountdown = timeForPopupClose; model = null; } else if(event.getEventType() == MouseEvent.MOUSE_MOVED) { popup.setX(event.getScreenX() + offset); popup.setY(event.getScreenY() + offset); } } private boolean isInPreviewColumn(MouseEvent event) { @SuppressWarnings("unchecked") TableRow row = (TableRow) event.getSource(); TableView table = row.getTableView(); double offset = 0; double width = 0; for (TableColumn col : table.getColumns()) { offset += width; width = col.getWidth(); if(Objects.equals(col.getId(), "preview")) { Point2D screenToLocal = table.screenToLocal(event.getScreenX(), event.getScreenY()); double x = screenToLocal.getX(); return x >= offset && x <= offset + width; } } return false; } private JavaFxModel getModel(MouseEvent event) { @SuppressWarnings("unchecked") TableRow row = (TableRow) event.getSource(); TableView table = row.getTableView(); int rowIndex = row.getIndex(); if(rowIndex < table.getItems().size()) { return table.getItems().get(rowIndex); } else { return null; } } private void showPopup() { startStream(model); } private void startStream(JavaFxModel model) { if(future != null && !future.isDone()) { future.cancel(true); } future = executor.submit(() -> { try { Platform.runLater(() -> { progressIndicator.setVisible(true); popup.show(parent.getScene().getWindow()); }); List sources = model.getStreamSources(); Collections.sort(sources); StreamSource best = sources.get(0); checkInterrupt(); LOG.debug("Preview url for {} is {}", model.getName(), best.getMediaPlaylistUrl()); video = new Media(best.getMediaPlaylistUrl()); if(videoPlayer != null) { videoPlayer.dispose(); } videoPlayer = new MediaPlayer(video); videoPlayer.setMute(true); checkInterrupt(); videoPlayer.setOnReady(() -> { if(!future.isCancelled()) { Platform.runLater(() -> { double aspect = (double)video.getWidth() / video.getHeight(); double w = Config.getInstance().getSettings().thumbWidth; double h = w / aspect; progressIndicator.setVisible(false); videoPreview.setVisible(true); videoPreview.setMediaPlayer(videoPlayer); resize(w, h); videoPlayer.play(); }); } }); videoPlayer.setOnError(() -> onError(videoPlayer)); } catch (IllegalStateException e) { if(e.getMessage().equals("Stream url unknown")) { // fine hls url for mfc not known yet } else { LOG.warn("Couldn't start preview video: {}", e.getMessage()); } showTestImage(); } catch (HttpException e) { if(e.getResponseCode() != 404) { LOG.warn("Couldn't start preview video: {}", e.getMessage()); } showTestImage(); } catch (InterruptedException | InterruptedIOException e) { // future has been canceled, that's fine } catch (ExecutionException e) { if(e.getCause() instanceof InterruptedException || e.getCause() instanceof InterruptedIOException) { // future has been canceled, that's fine } else { LOG.warn("Couldn't start preview video: {}", e.getMessage()); showTestImage(); } } catch (Exception e) { LOG.warn("Couldn't start preview video: {}", e.getMessage()); showTestImage(); } }); } private void onError(MediaPlayer videoPlayer) { LOG.error("Error while starting preview stream", videoPlayer.getError()); if(videoPlayer.getError().getCause() != null) { LOG.error("Error while starting preview stream root cause:", videoPlayer.getError().getCause()); } Platform.runLater(() -> { showTestImage(); }); } private void resize(double w, double h) { preview.setFitWidth(w); preview.setFitHeight(h); videoPreview.setFitWidth(w); videoPreview.setFitHeight(h); pane.setPrefSize(w, h); popup.setWidth(w); popup.setHeight(h); } private void checkInterrupt() throws InterruptedException { if(Thread.interrupted()) { throw new InterruptedException(); } } private void showTestImage() { Platform.runLater(() -> { videoPreview.setVisible(false); Image img = new Image(getClass().getResource("/image_not_found.png").toString(), true); preview.setImage(img); double aspect = img.getWidth() / img.getHeight(); double w = Config.getInstance().getSettings().thumbWidth; double h = w / aspect; resize(w, h); progressIndicator.setVisible(false); }); } private void hidePopup() { Platform.runLater(() -> { popup.setX(-1000); popup.setY(-1000); popup.hide(); if(videoPlayer != null) { videoPlayer.dispose(); } }); } private void createTimerThread() { Thread timerThread = new Thread(() -> { while(true) { openCountdown--; if(openCountdown == 0) { openCountdown = -1; if(model != null) { showPopup(); } } closeCountdown--; if(closeCountdown == 0) { hidePopup(); closeCountdown = -1; } openCountdown = Math.max(openCountdown, -1); closeCountdown = Math.max(closeCountdown, -1); long now = System.currentTimeMillis(); long diff = (now - lastModelChange); if(changeModel && diff > 400) { changeModel = false; if(model != null) { startStream(model); } } try { Thread.sleep(1); } catch (InterruptedException e) { LOG.error("PreviewPopupTimer interrupted"); break; } } }); timerThread.setDaemon(true); timerThread.setPriority(Thread.MIN_PRIORITY); timerThread.setName("PreviewPopupTimer"); timerThread.start(); } }