/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package net.angusi.sw.minidisc.gui;
import javafx.animation.AnimationTimer;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.concurrent.Task;
import javafx.geometry.Orientation;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.control.cell.PropertyValueFactory;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.*;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.text.Text;
import javafx.scene.web.HTMLEditor;
import javafx.stage.*;
import net.angusi.sw.minidisc.MiniDisc;
import net.angusi.sw.minidisc.audioobjects.Clip;
import org.controlsfx.dialog.Dialogs;
import java.io.File;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.concurrent.TimeUnit;
/**
* This is the main cue player window.
* It uses Split Frames to divide the window up into regions, which can be resized to
* the user's preference, but defaults to side-bar style layout:
* ===================
* | | | |
* |--| | |
* | | | |
* |--| | |
* | | | |
* |--| | |
* | | | |
* ===================
*/
class CueWindow {
//Window bits
private static CueWindow classHandle;
private final Stage stage;
//Cue table
private TableView<Clip> mainTable;
//Audio Related Stuff
private final ObservableList<Clip> audioClips;
private CueWindow() {
System.out.println("Setting up main frame");
stage = new Stage();
stage.setTitle(MiniDisc.getAppTitle()+" - Cue Player");
stage.setMinHeight(480);
stage.setMinWidth(640);
stage.setMaximized(true);
stage.setOnCloseRequest(event -> {
ModePicker.getStage().show();
stopAllTracks();
stage.hide();
});
//Setup audio stuff:
ArrayList<Clip> audioClipsArrayList = new ArrayList<>();
audioClips = FXCollections.observableArrayList(audioClipsArrayList);
//Setup everything else
this.initSceneAndPanes();
this.initKeyboardShortcuts();
}
/**
* Initialises global keyboard shortcuts.
* Useful for key triggering of stop and play.
*/
private void initKeyboardShortcuts() {
Map<KeyCombination, Runnable> keyEvents = new HashMap<>();
keyEvents.put(new KeyCodeCombination(KeyCode.SPACE), this::playSelectedTrack);
keyEvents.put(new KeyCodeCombination(KeyCode.ESCAPE), this::stopAllTracks);
stage.getScene().setOnKeyPressed(event -> keyEvents.keySet().stream().filter(thisCombo -> thisCombo.match(event)).forEach(thisCombo -> {
keyEvents.get(thisCombo).run();
event.consume();
}));
}
/**
* Begins setting up all the split frames
*/
private void initSceneAndPanes() {
System.out.println("Initialising Split Panes");
BorderPane container = new BorderPane();
Scene scene = new Scene(container);
stage.setScene(scene);
VBox controlsContainer = new VBox();
controlsContainer.getChildren().add(this.initMainMenu());
controlsContainer.getChildren().add(this.initToolBar());
container.setTop(controlsContainer);
SplitPane splitPanes = new SplitPane();
container.setCenter(splitPanes);
SplitPane leftPane = initLeftPanes();
Pane centerPane = initMainPane();
SplitPane rightPane = initRightPanes();
splitPanes.getItems().addAll(leftPane, centerPane, rightPane);
splitPanes.setDividerPositions(0.1f, 0.9f);
}
/**
* Sets up the left panes.
* This is where (by default) the control buttons and clock live.
*/
private SplitPane initLeftPanes() {
System.out.println("Initialising Left Panes");
SplitPane leftPanes = new SplitPane();
leftPanes.setOrientation(Orientation.VERTICAL);
leftPanes.setStyle("-fx-background-color:#000000");
//Set up the GO button
Text goButton = new Text("Go");
goButton.setFill(Color.GREEN);
BorderPane goPane = new BorderPane(goButton);
goButton.setOnMouseClicked(event -> playSelectedTrack());
leftPanes.getItems().add(goPane);
//Set up the STOP button
Text stopButton = new Text("Stop");
stopButton.setFill(Color.RED);
BorderPane stopPane = new BorderPane(stopButton);
stopButton.setOnMouseClicked(event -> stopSelectedTrack());
leftPanes.getItems().add(stopPane);
//Set up the FADE button
Text fadeButton = new Text("Fade");
fadeButton.setFill(Color.YELLOW);
BorderPane fadePane = new BorderPane(fadeButton);
fadeButton.setOnMouseClicked(event -> fadeSelectedTrack());
leftPanes.getItems().add(fadePane);
//Set up the clock
Text clock = new Text("00:00:00");
clock.setFill(Color.WHITE);
BorderPane clockPane = new BorderPane(clock);
leftPanes.getItems().add(clockPane);
//Make the clock tick:
new AnimationTimer() {
@Override
public void handle(long now) {
clock.setText(new SimpleDateFormat("HH:mm:ss").format(new Date()));
}
}.start();
//Evaluating text width is easy - we just ask the Text object for its width boundary.
// Similarly, the text height is done with the Text height boundary.
// The maximum width is easily done, too, actually. Just get the width of the pane!
// However, to calculate the height is a bit trickier...
// Typically, we'll need to find the position of the dividers ABOVE and BELOW the pane and subtract them.
// To get these positions, we can ask the dividers for their position - but that's actually a percentage of
// the entire SplitPane height, so we need to multiply the resulting subtraction by the SplitPane's height.
//First, let's do the vertical movement of the first divider, which affects panes 0 (Go) and 1 (Stop)
leftPanes.getDividers().get(0).positionProperty().addListener((observable, oldValue, newValue) -> {
//Note: Special case, as "ABOVE" is 0 (top of panes)
double scale = resizeText(goButton.getBoundsInLocal().getWidth(), goButton.getBoundsInLocal().getHeight(), goPane.getWidth(), leftPanes.getHeight() * newValue.doubleValue());
goButton.setScaleX(scale);
goButton.setScaleY(scale);
scale = resizeText(stopButton.getBoundsInLocal().getWidth(), stopButton.getBoundsInLocal().getHeight(), stopPane.getWidth(), leftPanes.getHeight() * (leftPanes.getDividers().get(1).getPosition() - newValue.doubleValue()));
stopButton.setScaleX(scale);
stopButton.setScaleY(scale);
});
//Then do the vertical movement of the second divider, which affects 1 (Stop) and 2 (Fade)
leftPanes.getDividers().get(1).positionProperty().addListener((observable, oldValue, newValue) -> {
double scale = resizeText(stopButton.getBoundsInLocal().getWidth(), stopButton.getBoundsInLocal().getHeight(), stopPane.getWidth(), leftPanes.getHeight() * (newValue.doubleValue() - leftPanes.getDividers().get(0).getPosition()));
stopButton.setScaleX(scale);
stopButton.setScaleY(scale);
scale = resizeText(fadeButton.getBoundsInLocal().getWidth(), fadeButton.getBoundsInLocal().getHeight(), fadePane.getWidth(), leftPanes.getHeight() * (leftPanes.getDividers().get(2).getPosition() - newValue.doubleValue()));
fadeButton.setScaleX(scale);
fadeButton.setScaleY(scale);
});
//Next, the third divider, affecting 2 (Fade) and 3 (Clock)
leftPanes.getDividers().get(2).positionProperty().addListener((observable, oldValue, newValue) -> {
double scale = resizeText(fadeButton.getBoundsInLocal().getWidth(), fadeButton.getBoundsInLocal().getHeight(), fadePane.getWidth(), leftPanes.getHeight() * (newValue.doubleValue() - leftPanes.getDividers().get(1).getPosition()));
fadeButton.setScaleX(scale);
fadeButton.setScaleY(scale);
//Note: Special case, as BELOW is leftPanes.getHeight (bottom of panes)
scale = resizeText(clock.getBoundsInLocal().getWidth(), clock.getBoundsInLocal().getHeight(), clockPane.getWidth(), leftPanes.getHeight() - (leftPanes.getHeight() * newValue.doubleValue()));
clock.setScaleX(scale);
clock.setScaleY(scale);
});
//Lastly do the horizontal width of the first pane. In fact, if this pane's width changes, all the panes do.
goPane.widthProperty().addListener((observable, oldValue, newValue) -> {
double scale = resizeText(goButton.getBoundsInLocal().getWidth(), goButton.getBoundsInLocal().getHeight(), newValue.doubleValue(), leftPanes.getHeight() * leftPanes.getDividers().get(0).getPosition());
goButton.setScaleX(scale);
goButton.setScaleY(scale);
});
stopPane.widthProperty().addListener((observable, oldValue, newValue) -> {
double scale = resizeText(stopButton.getBoundsInLocal().getWidth(), stopButton.getBoundsInLocal().getHeight(), newValue.doubleValue(), leftPanes.getHeight() * (leftPanes.getDividers().get(1).getPosition() - leftPanes.getDividers().get(0).getPosition()));
stopButton.setScaleX(scale);
stopButton.setScaleY(scale);
});
fadePane.widthProperty().addListener((observable, oldValue, newValue) -> {
double scale = resizeText(fadeButton.getBoundsInLocal().getWidth(), fadeButton.getBoundsInLocal().getHeight(), newValue.doubleValue(), leftPanes.getHeight() * (leftPanes.getDividers().get(2).getPosition() - leftPanes.getDividers().get(1).getPosition()));
fadeButton.setScaleX(scale);
fadeButton.setScaleY(scale);
});
clockPane.widthProperty().addListener((observable, oldValue, newValue) -> {
double scale = resizeText(clock.getBoundsInLocal().getWidth(), clock.getBoundsInLocal().getHeight(), newValue.doubleValue(), leftPanes.getHeight() - (leftPanes.getHeight() * leftPanes.getDividers().get(2).getPosition()));
clock.setScaleX(scale);
clock.setScaleY(scale);
});
return leftPanes;
}
private double resizeText(double textWidth, double textHeight, double maxWidth, double maxHeight) {
double scaleX = maxWidth/textWidth;
double scaleY = maxHeight/textHeight;
return scaleX > scaleY ? scaleY : scaleX;
}
/**
* Initialise the main pane.
* This is where (by default) the cue list lives
*/
private Pane initMainPane() {
System.out.println("Initialising Main Pane");
BorderPane mainFrame = new BorderPane();
//Set up the table
mainTable = new TableView<>();
mainTable.setItems(audioClips);
TableColumn<Clip, String> qTableColumn = new TableColumn<>("Q#");
qTableColumn.setCellValueFactory(new PropertyValueFactory<>("cueNumber"));
TableColumn<Clip, String> descTableColumn = new TableColumn<>("Description");
descTableColumn.setCellValueFactory(new PropertyValueFactory<>("description"));
TableColumn<Clip, Double> durationTableColumn = new TableColumn<>("Duration");
durationTableColumn.setCellFactory(param -> new TableCell<Clip, Double>() {
@Override
protected void updateItem(Double item, boolean empty) {
if(item != null) {
long timeLeft = item.longValue();
int hoursLeft = (int) TimeUnit.MILLISECONDS.toHours(timeLeft);
int minsLeft = (int) (TimeUnit.MILLISECONDS.toMinutes(timeLeft) - TimeUnit.HOURS.toMinutes(hoursLeft));
int secondsLeft = (int) (TimeUnit.MILLISECONDS.toSeconds(timeLeft) - TimeUnit.MINUTES.toSeconds(minsLeft) - TimeUnit.HOURS.toSeconds(hoursLeft));
int millisecondsLeft = (int) (TimeUnit.MILLISECONDS.toMillis(timeLeft) - TimeUnit.SECONDS.toMillis(secondsLeft) - TimeUnit.MINUTES.toMillis(minsLeft) - TimeUnit.HOURS.toMillis(hoursLeft));
setText(String.format("%02d:%02d:%02d.%03d", hoursLeft, minsLeft, secondsLeft, millisecondsLeft));
} else {
setText(null);
}
}
});
durationTableColumn.setCellValueFactory(new PropertyValueFactory<>("length"));
TableColumn<Clip, Double> remainingTableColumn = new TableColumn<>("Remaining");
remainingTableColumn.setCellValueFactory(new PropertyValueFactory<>("timeRemaining"));
remainingTableColumn.setCellFactory(clip -> new TableCell<Clip, Double>() {
@Override
protected void updateItem(Double item, boolean empty) {
if (item != null) {
long timeLeft = item.longValue();
int hoursLeft = (int) TimeUnit.MILLISECONDS.toHours(timeLeft);
int minsLeft = (int) (TimeUnit.MILLISECONDS.toMinutes(timeLeft) - TimeUnit.HOURS.toMinutes(hoursLeft));
int secondsLeft = (int) (TimeUnit.MILLISECONDS.toSeconds(timeLeft) - TimeUnit.MINUTES.toSeconds(minsLeft) - TimeUnit.HOURS.toSeconds(hoursLeft));
int millisecondsLeft = (int) (TimeUnit.MILLISECONDS.toMillis(timeLeft) - TimeUnit.SECONDS.toMillis(secondsLeft) - TimeUnit.MINUTES.toMillis(minsLeft) - TimeUnit.HOURS.toMillis(hoursLeft));
setText(String.format("%02d:%02d:%02d.%03d", hoursLeft, minsLeft, secondsLeft, millisecondsLeft));
} else {
setText(null);
}
}
});
TableColumn<Clip, String> gainTableColumn = new TableColumn<>("Gain");
gainTableColumn.setCellValueFactory(new PropertyValueFactory<>("gain"));
TableColumn<Clip, String> loopTableColumn = new TableColumn<>("Loop");
loopTableColumn.setCellValueFactory(new PropertyValueFactory<>("loop"));
mainTable.getColumns().setAll(qTableColumn, descTableColumn, durationTableColumn, remainingTableColumn, gainTableColumn, loopTableColumn);
mainTable.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
mainTable.setStyle("-fx-background-color: #000000; -fx-color:#FFFFFF");
mainFrame.setCenter(mainTable);
return mainFrame;
}
/**
* Set up the right pane.
* This is where (by default) the notes pane lives.
*/
private SplitPane initRightPanes() {
//TODO: Finish notes-per-cue
System.out.println("Initialising Right Panes");
SplitPane rightPanes = new SplitPane();
rightPanes.setOrientation(Orientation.VERTICAL);
rightPanes.setStyle("-fx-background-color:#000000");
HTMLEditor clipNotes = new HTMLEditor();
rightPanes.getItems().add(clipNotes);
//Set up the E-STOP button
Text stopButton = new Text("E-STOP");
stopButton.setFill(Color.RED);
BorderPane stopPane = new BorderPane(stopButton);
stopButton.setOnMouseClicked(event -> stopAllTracks());
rightPanes.getItems().add(stopPane);
stopPane.widthProperty().addListener((observable, oldValue, newValue) -> {
double scale = resizeText(stopButton.getBoundsInLocal().getWidth(), stopButton.getBoundsInLocal().getHeight(), newValue.doubleValue(), rightPanes.getHeight() - (rightPanes.getHeight() * rightPanes.getDividers().get(0).getPosition()));
stopButton.setScaleX(scale);
stopButton.setScaleY(scale);
});
rightPanes.getDividers().get(0).positionProperty().addListener((observable, oldValue, newValue) -> {
double scale = resizeText(stopButton.getBoundsInLocal().getWidth(), stopButton.getBoundsInLocal().getHeight(), stopPane.getWidth(), rightPanes.getHeight() - (rightPanes.getHeight() * rightPanes.getDividers().get(0).getPosition()));
stopButton.setScaleX(scale);
stopButton.setScaleY(scale);
});
rightPanes.setDividerPositions(0.9f);
return rightPanes;
}
/**
* Set up the menus.
* _File
* E_xit
* _Help
* _Wiki
* _Project Home
* _About MiniDisc
*/
private MenuBar initMainMenu() {
System.out.println("Initialising Main Menu Bar");
MenuBar mainMenuBar = new MenuBar();
//First the _F_ile menu
Menu fileMenu = new Menu("_File");
fileMenu.setMnemonicParsing(true);
mainMenuBar.getMenus().add(fileMenu);
//And its items:
MenuItem fileMenuExitMenuItem = new MenuItem("_Exit");
fileMenuExitMenuItem.setMnemonicParsing(true);
fileMenuExitMenuItem.setAccelerator(new KeyCodeCombination(KeyCode.F4, KeyCombination.ALT_DOWN));
fileMenuExitMenuItem.setOnAction(event -> MiniDisc.quitApplication());
fileMenu.getItems().add(fileMenuExitMenuItem);
//Then, the _H_elp menu
Menu helpMenu = new Menu("_Help");
helpMenu.setMnemonicParsing(true);
mainMenuBar.getMenus().add(helpMenu);
//And its items:
MenuItem helpMenuWikiItem = new MenuItem("_Wiki");
helpMenuWikiItem.setMnemonicParsing(true);
helpMenuWikiItem.setOnAction(e -> MiniDisc.getHandle().getHostServices().showDocument("http://code.angusi.net/minidisc/wiki"));
helpMenu.getItems().add(helpMenuWikiItem);
MenuItem helpMenuProjectHomeItem = new MenuItem("_Project Home");
helpMenuProjectHomeItem.setMnemonicParsing(true);
helpMenuProjectHomeItem.setOnAction(e -> MiniDisc.getHandle().getHostServices().showDocument("http://sw.angusi.net/MiniDisc"));
helpMenu.getItems().add(helpMenuProjectHomeItem);
MenuItem helpMenuAboutItem = new MenuItem("_About MiniDisc");
helpMenuAboutItem.setMnemonicParsing(true);
helpMenuAboutItem.setOnAction(e -> {
String aboutMessage = String.format("MiniDisc, a lightweight, cross-platform clone of Multiplay.%n" +
"MiniDisc was written by Angus Ireland - http://angusi.net%n%n"+
"For more information, see the project's home at%n" +
" http://sw.angusi.net/minidisc%n%n" +
"Please see LICENSES.TXT (or the project Wiki) for applicable licenses.");
Dialogs.create()
.owner(stage)
.title("About MiniDisc")
.masthead("This is " + MiniDisc.getAppTitle() + " version " + MiniDisc.getAppVersion())
.message(aboutMessage)
.showInformation();
});
helpMenu.getItems().add(helpMenuAboutItem);
return mainMenuBar;
}
/**
* Set up the toolbar
* This toolbar has the add/remove clip buttons
*/
private ToolBar initToolBar() {
System.out.println("Initialising Toolbar");
ToolBar mainToolBar = new ToolBar();
Button addButton = new Button("Add", new ImageView(new Image(this.getClass().getResourceAsStream("/net/angusi/sw/minidisc/res/icons/plusicon.png"))));
addButton.setOnAction(event -> addClipToCueList());
mainToolBar.getItems().add(addButton);
Button removeButton = new Button("Remove", new ImageView(new Image(this.getClass().getResourceAsStream("/net/angusi/sw/minidisc/res/icons/minusicon.png"))));
removeButton.setOnAction(event -> removeClipFromCueList());
mainToolBar.getItems().add(removeButton);
Button propertiesButton = new Button("Properties", new ImageView(new Image(this.getClass().getResourceAsStream("/net/angusi/sw/minidisc/res/icons/propertiesicon.png"))));
propertiesButton.setOnAction(event -> {
//TODO: Clip Properties
});
mainToolBar.getItems().add(propertiesButton);
propertiesButton.setDisable(true);
return mainToolBar;
}
/**
* Adds a clip to the cue list.
* Creates a new Clip object, adding it to the Set of clips
*/
private void addClipToCueList() {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Add Clip");
fileChooser.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("All Audio Files", "*.MID", "*.MIDI", "*.M4A", "*.WAV", "*.SND", "*.AU", "*.AIF", "*.AIFF", "*.MP2", "*.MP3", "*.AAC", "*.WMA"),
new FileChooser.ExtensionFilter("Midi Files", "*.MID", "*.MIDI"),
new FileChooser.ExtensionFilter("M4A Files", "*.M4A"),
new FileChooser.ExtensionFilter("Wave Files", "*.WAV"),
new FileChooser.ExtensionFilter("Snd Files", "*.SND"),
new FileChooser.ExtensionFilter("AU Files", "*.AU"),
new FileChooser.ExtensionFilter("AIFF Files", "*.AIF", "*.AIFF"),
new FileChooser.ExtensionFilter("MP2/MP3 Files", "*.MP2", "*.MP3"),
new FileChooser.ExtensionFilter("AAC Files", "*.AAC"),
new FileChooser.ExtensionFilter("WMA Files", "*.WMA"),
new FileChooser.ExtensionFilter("All Files", "*.*")
);
File file = fileChooser.showOpenDialog(stage);
if (file != null) {
int insertAt = mainTable.getSelectionModel().getSelectedIndex() == -1 ? mainTable.getItems().size() : mainTable.getSelectionModel().getSelectedIndex();
System.out.println("Adding " + file.getName() + " to cue list.");
Clip newClip = new Clip(file);
audioClips.add(insertAt, newClip);
}
}
private void removeClipFromCueList() {
audioClips.remove(mainTable.getSelectionModel().getSelectedItem());
}
private void playSelectedTrack() {
Clip selectedClip = mainTable.getSelectionModel().getSelectedItem();
System.out.println("Asking clip "+(selectedClip.getDescription())+" to play");
new Thread(new Task<Void>() {
@Override
protected Void call() throws Exception {
selectedClip.playClip();
return null;
}
}).start();
mainTable.getSelectionModel().selectBelowCell();
}
private void stopSelectedTrack() {
Clip selectedClip = mainTable.getSelectionModel().getSelectedItem();
selectedClip.stopClip();
}
private void stopAllTracks() {
System.out.println("Stopping all clips");
for (Clip audioClip : audioClips) {
audioClip.stopClip();
}
}
private void fadeSelectedTrack() {
Clip selectedClip = mainTable.getSelectionModel().getSelectedItem();
selectedClip.fadeClip();
}
public static Stage getStage() {
if(classHandle == null) {
classHandle = new CueWindow();
}
return classHandle.stage;
}
}