3

I am making a media player and am trying to get the playback slider value at the cursor position when hovering over the slider bar. In an attempt to do this, i have used the following:

    timeSlider.addEventFilter(MouseEvent.MOUSE_MOVED, event -> System.out.println("hovering"));

which prints "hovering" whenever the mouse changes position over the slider. Can anyone please show me how to get the value of the slider at the current cursor position? I can only figure out how to get the value at the thumb position.

Thanks in advance.

Raptop
  • 169
  • 2
  • 13
  • interestingly, there doesn't seem to be any api to convert location to value (nor vice-versa) – kleopatra Dec 02 '15 at 15:26
  • So maybe for some more information... I am wanting to achieve two things. 1) Know the value of the mouse under the cursor at any time to call the seek() method of the MediaPlayer when I click on the progress bar (and not the thumb), as I am finding the slider fairly unresponsive when trying to seek to new position by clicking. 2) to display the time at the cursors position whilst hovering in something like a tooltip balloon, so users will know what time they will be seeking to before they click on the progress bar. If there are better ways to achieve this outcome, I would be glad to hear them. – Raptop Dec 03 '15 at 02:03

2 Answers2

1

Here is a bit (maybe more than a bit) of a hack that works if you are showing the axis under the slider. It relies on looking up the axis via its css class, converting the mouse coordinates to coordinates relative to the axis, and then using API from ValueAxis to convert to the value:

import javafx.application.Application;
import javafx.geometry.Point2D;
import javafx.scene.Scene;
import javafx.scene.chart.NumberAxis;
import javafx.scene.control.Label;
import javafx.scene.control.Slider;
import javafx.scene.layout.StackPane;
import javafx.stage.Popup;
import javafx.stage.Stage;

public class TooltipOnSlider extends Application {

    @Override
    public void start(Stage primaryStage) {
        Slider slider = new Slider(5, 25, 15);
        slider.setShowTickMarks(true);
        slider.setShowTickLabels(true);
        slider.setMajorTickUnit(5);

        Label label = new Label();
        Popup popup = new Popup();
        popup.getContent().add(label);

        double offset = 10 ;

        slider.setOnMouseMoved(e -> {
            NumberAxis axis = (NumberAxis) slider.lookup(".axis");
            Point2D locationInAxis = axis.sceneToLocal(e.getSceneX(), e.getSceneY());
            double mouseX = locationInAxis.getX() ;
            double value = axis.getValueForDisplay(mouseX).doubleValue() ;
            if (value >= slider.getMin() && value <= slider.getMax()) {
                label.setText(String.format("Value: %.1f", value));
            } else {
                label.setText("Value: ---");
            }
            popup.setAnchorX(e.getScreenX());
            popup.setAnchorY(e.getScreenY() + offset);
        });

        slider.setOnMouseEntered(e -> popup.show(slider, e.getScreenX(), e.getScreenY() + offset));
        slider.setOnMouseExited(e -> popup.hide());

        StackPane root = new StackPane(slider);
        primaryStage.setScene(new Scene(root, 350, 80));
        primaryStage.show();

    }

    public static void main(String[] args) {
        launch(args);
    }
}
James_D
  • 201,275
  • 16
  • 291
  • 322
  • great idea - though only working if showing at least one of ticks or labels. A pity that they don't use the axis (and its conversion api) internally at all – kleopatra Dec 03 '15 at 10:28
  • I tried it using the track instead of the axis, but it appears the track is offset by some arbitrary amount. Maybe if the OP's general intention is to help the user locate specific time points on the slider, an axis wouldn't be a bad thing anyway. – James_D Dec 03 '15 at 10:40
  • Maybe I wasn't clear enough: using the axis is The-Right-Thing, IMO :-) – kleopatra Dec 03 '15 at 11:55
  • moved my comments into an answer ... well ... and deleted it again, the coordinates were still wrong :-( – kleopatra Dec 05 '15 at 07:03
  • Thanks James_D but i don't want to display anything on the slider other than the current position before the slider and total length of the media after the slider. are you able to show me the code using the track as mentioned above? I may be able to solve the offset issue. – Raptop Dec 06 '15 at 03:29
  • Just do the same thing, but lookup `".track"` instead of `".axis"`. Then look at the `boundsInParent` of the track. – James_D Dec 06 '15 at 03:30
0

This is mostly a bug-track-down: James's answer is perfect - only hampered by 2 issues:

  1. the axis has to be visible, that is at least one of ticks or labels must be showing (in practice not a big obstacle: if you want to get the values at mouseOver you'r most probably showing the ticks anyway)

  2. A bug in SliderSkin which introduce a slight skew of axis value vs slider value.

To see the latter, here's a slight variation of James's code. To see the asynchronicity, move the mouse over the slider then click. We expect the value of the popup to be the same as the value of the slider (shown in the label at the bottom). With core SliderSkin, they differ slightly.

public class TooltipOnSlider extends Application {

    private boolean useAxis;
    @Override
    public void start(Stage primaryStage) {
        Slider slider = new Slider(5, 25, 15);
        useAxis = true;
        // force an axis to be used
        slider.setShowTickMarks(true);
        slider.setShowTickLabels(true);
        slider.setMajorTickUnit(5);

        // slider.setOrientation(Orientation.VERTICAL);
        // hacking around the bugs in a custom skin
        //  slider.setSkin(new MySliderSkin(slider));
        //  slider.setSkin(new XSliderSkin(slider));

        Label label = new Label();
        Popup popup = new Popup();
        popup.getContent().add(label);

        double offset = 30 ;

        slider.setOnMouseMoved(e -> {
            NumberAxis axis = (NumberAxis) slider.lookup(".axis");
            StackPane track = (StackPane) slider.lookup(".track");
            StackPane thumb = (StackPane) slider.lookup(".thumb");
            if (useAxis) {
                // James: use axis to convert value/position
                Point2D locationInAxis = axis.sceneToLocal(e.getSceneX(), e.getSceneY());
                boolean isHorizontal = slider.getOrientation() == Orientation.HORIZONTAL;
                double mouseX = isHorizontal ? locationInAxis.getX() : locationInAxis.getY() ;
                double value = axis.getValueForDisplay(mouseX).doubleValue() ;
                if (value >= slider.getMin() && value <= slider.getMax()) {
                    label.setText("" + value);
                } else {
                    label.setText("Value: ---");
                }

            } else {
                // this can't work because we don't know the internals of the track
                Point2D locationInAxis = track.sceneToLocal(e.getSceneX(), e.getSceneY());
                double mouseX = locationInAxis.getX();
                double trackLength = track.getWidth();
                double percent = mouseX / trackLength;
                double value = slider.getMin() + ((slider.getMax() - slider.getMin()) * percent);
                if (value >= slider.getMin() && value <= slider.getMax()) {
                    label.setText("" + value);
                } else {
                    label.setText("Value: ---");
                }
            }
            popup.setAnchorX(e.getScreenX());
            popup.setAnchorY(e.getScreenY() + offset);
        });

        slider.setOnMouseEntered(e -> popup.show(slider, e.getScreenX(), e.getScreenY() + offset));
        slider.setOnMouseExited(e -> popup.hide());

        Label valueLabel = new Label("empty");
        valueLabel.textProperty().bind(slider.valueProperty().asString());
        BorderPane root = new BorderPane(slider);
        root.setBottom(valueLabel);
        primaryStage.setScene(new Scene(root, 350, 100));
        primaryStage.show();
        primaryStage.setTitle("useAxis: " + useAxis + " mySkin: " + slider.getSkin().getClass().getSimpleName());
    }

    public static void main(String[] args) {
        launch(args);
    }

    @SuppressWarnings("unused")
    private static final Logger LOG = Logger.getLogger(TooltipOnSlider.class
            .getName());
}

Note that there's an open issue which reports a similar behavior (though not so easy to see)

Looking into the code of SliderSkin, the culprit seems to be an incorrect calculation of the relative value from a mouse event on the track:

track.setOnMousePressed(me -> {
    ... 
    double relPosition = (me.getX() / trackLength);
    getBehavior().trackPress(me, relPosition);
    ...
});

where track is positioned in the slider as:

// layout track 
track.resizeRelocate((int)(trackStart - trackRadius),
                     trackTop ,
                     (int)(trackLength + trackRadius + trackRadius),
                     trackHeight);

Note that the active width (aka: trackLenght) of the track is offset by trackRadius, thus calculating the relative distance with the raw mousePosition on the track gives a slight error.

Below is a crude custom skin that replaces the calc simply as a test if the little application behaves as expected. Looks terrible due the need to use reflection to access super's fields/methods but now has slider and axis value in synch.

The quick hack:

/**
 * Trying to work around down to the slight offset.
 */
public static class MySliderSkin extends SliderSkin {

    /**
     * Hook for replacing the mouse pressed handler that's installed by super.
     */
    protected void installListeners() {
        StackPane track = (StackPane) getSkinnable().lookup(".track");
        track.setOnMousePressed(me -> {
            invokeSetField("trackClicked", true);
            double trackLength = invokeGetField("trackLength");
            double trackStart = invokeGetField("trackStart");
            // convert coordinates into slider
            MouseEvent e = me.copyFor(getSkinnable(), getSkinnable());
            double mouseX = e.getX(); 
            double position;
            if (mouseX < trackStart) {
                position = 0;
            } else if (mouseX > trackStart + trackLength) {
                position = 1;
            } else {
               position = (mouseX - trackStart) / trackLength;
            }
            getBehavior().trackPress(e, position);
            invokeSetField("trackClicked", false);
        });
    }

    private double invokeGetField(String name) {
        Class clazz = SliderSkin.class;
        Field field;
        try {
            field = clazz.getDeclaredField(name);
            field.setAccessible(true);
            return field.getDouble(this);
        } catch (NoSuchFieldException | SecurityException | IllegalArgumentException | IllegalAccessException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
        return 0.;
    }

    private void invokeSetField(String name, Object value) {
        Class clazz = SliderSkin.class;
        try {
            Field field = clazz.getDeclaredField(name);
            field.setAccessible(true);
            field.set(this, value);
        } catch (NoSuchFieldException | SecurityException | IllegalArgumentException | IllegalAccessException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
    /**
     * Constructor - replaces listener on track.
     * @param slider
     */
    public MySliderSkin(Slider slider) {
        super(slider);
        installListeners();
    }

}

A deeper fix might be to delegate all the dirty coordinate/value transformations to the axis - that's what it is designed to do. This requires the axis to be part of the scenegraph always and only toggle its visibilty with ticks/labels showing. A first experiment looks promising.

kleopatra
  • 51,061
  • 28
  • 99
  • 211