Last active
March 7, 2017 21:42
-
-
Save james-d/10800593 to your computer and use it in GitHub Desktop.
Simple MP3 player that uses Tomas Mikula's excellent EasyBind library (https://github.com/TomasMikula/EasyBind). (You will need EasyBind v1.0.0 to run this.)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?xml version="1.0" encoding="UTF-8"?> | |
<?import java.lang.*?> | |
<?import javafx.geometry.*?> | |
<?import javafx.scene.control.*?> | |
<?import javafx.scene.control.TitledPane?> | |
<?import javafx.scene.layout.*?> | |
<?import javafx.scene.layout.BorderPane?> | |
<BorderPane xmlns:fx="http://javafx.com/fxml" fx:controller="mp3player.MP3PlayerController"> | |
<bottom> | |
<VBox alignment="CENTER" prefHeight="-1.0" prefWidth="-1.0"> | |
<children> | |
<HBox alignment="CENTER" maxWidth="-1.0" prefHeight="-1.0" prefWidth="-1.0"> | |
<children> | |
<ProgressBar fx:id="currentTimeIndicator" maxWidth="1.7976931348623157E308" prefWidth="-1.0" progress="0.0" HBox.hgrow="ALWAYS" /> | |
</children> | |
<padding> | |
<Insets bottom="5.0" left="15.0" right="15.0" top="5.0" fx:id="x2" /> | |
</padding> | |
</HBox> | |
<Label fx:id="timeLabel" alignment="CENTER" text="Label" /> | |
<HBox alignment="CENTER" maxWidth="-1.0" padding="$x2" prefHeight="-1.0" prefWidth="-1.0"> | |
<children> | |
<Slider fx:id="volumeSlider" showTickLabels="true" showTickMarks="true" value="50.0" HBox.hgrow="ALWAYS" /> | |
</children> | |
</HBox> | |
<HBox alignment="CENTER" prefHeight="-1.0" prefWidth="-1.0"> | |
<children> | |
<Button fx:id="playButton" disable="true" mnemonicParsing="false" text="Play" /> | |
</children> | |
<padding> | |
<Insets bottom="5.0" left="5.0" right="5.0" top="5.0" fx:id="x1" /> | |
</padding> | |
</HBox> | |
</children> | |
</VBox> | |
</bottom> | |
<center> | |
<TitledPane fx:id="titledPane" animated="false" maxHeight="1.7976931348623157E308" text="Files"> | |
<BorderPane> | |
<center> | |
<ListView fx:id="musicList" prefHeight="200.0" prefWidth="200.0" /> | |
</center> | |
<top> | |
<HBox alignment="CENTER" padding="$x1" prefHeight="-1.0" prefWidth="-1.0" spacing="15.0" BorderPane.alignment="CENTER"> | |
<children> | |
<Label fx:id="currentDirectoryLabel" text="" wrapText="true" HBox.hgrow="NEVER" /> | |
<Button mnemonicParsing="false" onAction="#chooseDirectory" text="Browse..." /> | |
</children> | |
</HBox> | |
</top> | |
</BorderPane> | |
</TitledPane> | |
</center> | |
</BorderPane> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package mp3player; | |
import javafx.application.Application; | |
import javafx.stage.Stage; | |
import javafx.scene.Scene; | |
import javafx.scene.layout.BorderPane; | |
import javafx.fxml.FXMLLoader; | |
public class MP3Player extends Application { | |
@Override | |
public void start(Stage primaryStage) { | |
try { | |
FXMLLoader loader = new FXMLLoader(getClass().getResource("MP3Player.fxml")); | |
BorderPane root = loader.load(); | |
MP3PlayerController controller = loader.getController(); | |
Scene scene = new Scene(root, 300, controller.getWindowHeight()); | |
primaryStage.setScene(scene); | |
primaryStage.show(); | |
} catch(Exception e) { | |
e.printStackTrace(); | |
} | |
} | |
public static void main(String[] args) { | |
launch(args); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package mp3player; | |
import java.io.File; | |
import java.io.IOException; | |
import java.nio.file.FileSystems; | |
import java.nio.file.Path; | |
import java.nio.file.PathMatcher; | |
import java.nio.file.Paths; | |
import java.nio.file.Files; | |
import java.util.Arrays; | |
import java.util.stream.Collectors; | |
import java.util.stream.Stream; | |
import org.fxmisc.easybind.EasyBind; | |
import org.fxmisc.easybind.monadic.MonadicBinding; | |
import org.fxmisc.easybind.monadic.MonadicObservableValue; | |
import javafx.animation.KeyFrame; | |
import javafx.animation.KeyValue; | |
import javafx.animation.Timeline; | |
import javafx.beans.binding.Bindings; | |
import javafx.beans.binding.BooleanBinding; | |
import javafx.beans.property.ObjectProperty; | |
import javafx.beans.property.SimpleObjectProperty; | |
import javafx.collections.FXCollections; | |
import javafx.collections.ObservableList; | |
import javafx.event.ActionEvent; | |
import javafx.event.EventHandler; | |
import javafx.fxml.FXML; | |
import javafx.scene.Scene; | |
import javafx.scene.control.Button; | |
import javafx.scene.control.Label; | |
import javafx.scene.control.ListCell; | |
import javafx.scene.control.ListView; | |
import javafx.scene.control.ProgressBar; | |
import javafx.scene.control.Slider; | |
import javafx.scene.control.TitledPane; | |
import javafx.scene.media.Media; | |
import javafx.scene.media.MediaPlayer; | |
import javafx.scene.media.MediaPlayer.Status; | |
import javafx.stage.DirectoryChooser; | |
import javafx.stage.Window; | |
import javafx.util.Duration; | |
public class MP3PlayerController { | |
private static final Duration FADE_DURATION = Duration.seconds(2.5); | |
@FXML | |
private Slider volumeSlider ; | |
@FXML | |
private Button playButton ; | |
@FXML | |
private ListView<Path> musicList ; | |
@FXML | |
private Label currentDirectoryLabel ; | |
@FXML | |
private ProgressBar currentTimeIndicator ; | |
@FXML | |
private Label timeLabel ; | |
@FXML | |
private TitledPane titledPane ; | |
private DirectoryChooser directoryChooser = new DirectoryChooser(); | |
private MonadicObservableValue<MediaPlayer> player ; | |
private final ObjectProperty<Path> currentDirectory = new SimpleObjectProperty<>(); | |
// These will be used to keep track of the last known window height | |
// independently for when the titled pane is collapsed or expanded. | |
// When the expanded state changes, we will restore the window height to the | |
// last known value for the new state. | |
private double expandedWindowHeight = 400 ; | |
private double collapsedWindowHeight = 200 ; | |
double getExpandedWindowHeight() { | |
return expandedWindowHeight ; | |
} | |
double getCollapsedWindowHeight() { | |
return collapsedWindowHeight ; | |
} | |
double getWindowHeight() { | |
if (titledPane == null || titledPane.isExpanded()) { | |
return expandedWindowHeight ; | |
} else { | |
return collapsedWindowHeight; | |
} | |
} | |
public void initialize() { | |
player = createObservablePlayer(); | |
configureCurrentDirectory(); | |
// Cross-fade when changing players | |
player.addListener((obs, oldPlayer, newPlayer) -> { | |
fadeOut(oldPlayer); | |
fadeIn(newPlayer); | |
}); | |
configureSongTimeDisplay(); | |
// Automatically invoke stop and reset play location at end of media: | |
player.addListener((obs, oldPlayer, newPlayer) -> { | |
if (oldPlayer != null) { | |
oldPlayer.setOnEndOfMedia(null); | |
} | |
if (newPlayer != null) { | |
newPlayer.setOnEndOfMedia(() -> { | |
newPlayer.stop(); | |
newPlayer.seek(newPlayer.getStartTime()); | |
}); | |
newPlayer.seek(newPlayer.getStartTime()); | |
} | |
}); | |
// Bind text and disabled property of Play/Pause button, and register action listeners: | |
configurePlayButton(); | |
configureMusicList(); | |
resizeOnTitledPaneExpand(); | |
} | |
private void configureCurrentDirectory() { | |
// Possible directories under user's home directory for startup | |
Stream<String> searchPaths = Arrays.stream(new String[]{ | |
"Music/iTunes/iTunes Music", | |
"My Music/iTunes/iTunes Music", | |
"Music/iTunes", | |
"My Music/iTunes", | |
"Music", | |
"My Music", | |
"" | |
}); | |
// Configure current directory and label: | |
final String userHome = System.getProperty("user.home"); | |
Path file = searchPaths.map(name -> Paths.get(userHome, name)) | |
.filter(Files::exists) | |
.findFirst() | |
.orElse(Paths.get(userHome)); // should not happen... | |
currentDirectory.set(file); | |
currentDirectoryLabel.textProperty().bind(EasyBind.map(currentDirectory, Path::getFileName).map(Path::toString).map("Directory: "::concat)); | |
directoryChooser.initialDirectoryProperty().bind(EasyBind.map(currentDirectory, Path::toFile)); | |
} | |
private void configureMusicList() { | |
// Bind ListView's items to list of appropriate files in current directory | |
musicList.itemsProperty().bind(EasyBind.map(currentDirectory, this::listFiles) | |
.orElse(FXCollections.emptyObservableList())); | |
// custom cell factory to display only the file name, without the extension: | |
musicList.setCellFactory(lv -> { | |
// create a default cell | |
ListCell<Path> cell = new ListCell<Path>(); | |
// bind the text property | |
// the monadic takes care of null cell items and maps to null text | |
cell.textProperty().bind(EasyBind.monadic(cell.itemProperty()) | |
.map(Path::getFileName) | |
.map(Path::toString) | |
.map(name -> name.substring(0, name.lastIndexOf('.')))); | |
return cell ; | |
}); | |
} | |
// List of supported files in the given directory: | |
private ObservableList<Path> listFiles(Path dir) { | |
PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:**.{mp3,m4a,aiff,aif,wav}"); | |
try { | |
return Files.list(dir) | |
.filter(matcher::matches) | |
.collect(Collectors.toCollection(FXCollections::<Path>observableArrayList)); | |
} catch (IOException exc) { | |
return FXCollections.observableArrayList(); | |
} | |
} | |
private MonadicObservableValue<MediaPlayer> createObservablePlayer() { | |
// Bind player to selected item: | |
return EasyBind.monadic(musicList.getSelectionModel().selectedItemProperty()) | |
.map(this::createPlayer); | |
} | |
private MediaPlayer createPlayer(Path file) { | |
try { | |
return new MediaPlayer(new Media(file.toUri().toURL().toExternalForm())); | |
} catch (Exception exc) { | |
exc.printStackTrace(); | |
return null ; | |
} | |
} | |
private void configureSongTimeDisplay() { | |
// current position of play head for current song, in seconds, or 0 if no song: | |
final MonadicBinding<Double> currentTimeBinding = | |
player.flatMap(MediaPlayer::currentTimeProperty) | |
.map(Duration::toSeconds).orElse(0d); | |
// total duration of current song, in seconds, or 0 if no song: | |
final MonadicBinding<Double> totalTimeBinding = | |
player.flatMap(player -> player.getMedia().durationProperty()) | |
.map(Duration::toSeconds).orElse(0d); | |
// bind text of label to formatted play head position and total time: | |
timeLabel.textProperty().bind(EasyBind.combine( | |
currentTimeBinding, | |
totalTimeBinding, | |
this::formatTimeLabel)); | |
// bind progress bar to current play head position as proportion of total time: | |
currentTimeIndicator.progressProperty().bind(EasyBind.combine( | |
currentTimeBinding, | |
totalTimeBinding, | |
(currentTime, totalTime) -> currentTime / totalTime)); | |
} | |
private String formatTimeLabel(double currentTime, double totalTime) { | |
return String.format("%s / %s", formatTime(currentTime), formatTime(totalTime)); | |
} | |
private String formatTime(double time) { | |
int mins = (int)time / 60; | |
int secs = (int)time % 60 ; | |
return String.format("%d:%02d", mins, secs); | |
} | |
private void configurePlayButton() { | |
// Disable button if there's no player | |
playButton.disableProperty().bind(Bindings.isNull(player)); | |
BooleanBinding playing = Bindings.equal(player.flatMap(MediaPlayer::statusProperty), Status.PLAYING); | |
// Bind play button text to "Pause" if there's a player with PLAYING status, "Play" otherwise | |
playButton.textProperty().bind(Bindings.when(playing).then("Pause").otherwise("Play")); | |
// Bind onAction to pause() if playing and play() otherwise: | |
playButton.onActionProperty().bind(Bindings.when(playing) | |
.<EventHandler<ActionEvent>>then(event -> player.get().pause()) | |
.otherwise(event -> player.get().play())); | |
} | |
private void fadeOut(MediaPlayer mp) { | |
if (mp != null) { | |
mp.volumeProperty().unbind(); | |
Timeline fadeOut = new Timeline(new KeyFrame(FADE_DURATION, new KeyValue(mp.volumeProperty(), 0))); | |
fadeOut.setOnFinished(event -> mp.stop()); | |
fadeOut.play(); | |
} | |
} | |
private void fadeIn(MediaPlayer mp) { | |
if (mp != null) { | |
mp.volumeProperty().unbind(); | |
Timeline fadeIn = new Timeline(new KeyFrame(Duration.ZERO, new KeyValue(mp.volumeProperty(), 0)), | |
new KeyFrame(FADE_DURATION, new KeyValue(mp.volumeProperty(), volumeSlider.getValue() / 100 ))); | |
fadeIn.setOnFinished(event -> mp.volumeProperty().bind(volumeSlider.valueProperty().divide(100))); | |
mp.play(); | |
fadeIn.play(); | |
} | |
} | |
private void resizeOnTitledPaneExpand() { | |
// keep track of current window height (monadic binding will be empty if no scene or window yet): | |
MonadicBinding<Number> windowHeight = EasyBind.select(titledPane.sceneProperty()) | |
.select(Scene::windowProperty) | |
.selectObject(Window::heightProperty); | |
// track last height independently for when the titled pane is collapsed or expanded: | |
windowHeight.addListener((obs, oldValue, newValue) -> { | |
if (titledPane.isExpanded()) { | |
expandedWindowHeight = newValue.doubleValue(); | |
} else { | |
collapsedWindowHeight = newValue.doubleValue(); | |
} | |
}); | |
// restore window height to last known value for given expanded state | |
// when titled pane is expanded or collapsed: | |
titledPane.expandedProperty().addListener((obs, wasExpanded, isExpanded) -> { | |
Window stage = titledPane.getScene().getWindow() ; | |
if (isExpanded) { | |
stage.setHeight(expandedWindowHeight); | |
} else { | |
stage.setHeight(collapsedWindowHeight); | |
} | |
}); | |
} | |
// handler for button to change directory: | |
@FXML | |
private void chooseDirectory() { | |
File dir = directoryChooser.showDialog(musicList.getScene().getWindow()); | |
if (dir != null) { | |
currentDirectory.set(dir.toPath()); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Ah, yes, that is better in that case. I think I was just trying to see if I could eliminate all of the Bindings calls (which I understand is not the point, but it was a fun exercise).
The other statements in the same method feel a bit clunky too. Perhaps I should do
and then use a
construct