/*
* Copyright (c) 2014-2016 Jan Strauß <jan[at]over9000.eu>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package eu.over9000.skadi.ui;
import de.jensd.fx.glyphs.GlyphsDude;
import de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon;
import eu.over9000.skadi.handler.ChatHandler;
import eu.over9000.skadi.handler.StreamHandler;
import eu.over9000.skadi.io.PersistenceHandler;
import eu.over9000.skadi.lock.LockWakeupReceiver;
import eu.over9000.skadi.lock.SingleInstanceLock;
import eu.over9000.skadi.model.Channel;
import eu.over9000.skadi.model.ChannelStore;
import eu.over9000.skadi.model.StateContainer;
import eu.over9000.skadi.model.StreamQuality;
import eu.over9000.skadi.service.ForcedChannelUpdateService;
import eu.over9000.skadi.service.ImportFollowedService;
import eu.over9000.skadi.service.LivestreamerVersionCheckService;
import eu.over9000.skadi.service.VersionCheckerService;
import eu.over9000.skadi.ui.cells.ChannelGridCell;
import eu.over9000.skadi.ui.cells.LiveCell;
import eu.over9000.skadi.ui.cells.RightAlignedCell;
import eu.over9000.skadi.ui.cells.UptimeCell;
import eu.over9000.skadi.ui.dialogs.SettingsDialog;
import eu.over9000.skadi.ui.dialogs.SyncDialog;
import eu.over9000.skadi.ui.tray.Tray;
import eu.over9000.skadi.util.*;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.DoubleBinding;
import javafx.beans.property.*;
import javafx.collections.transformation.FilteredList;
import javafx.collections.transformation.SortedList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.TableColumn.SortType;
import javafx.scene.image.Image;
import javafx.scene.input.Dragboard;
import javafx.scene.input.MouseButton;
import javafx.scene.input.TransferMode;
import javafx.scene.layout.Border;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.stage.Modality;
import javafx.stage.Stage;
import javafx.util.Duration;
import org.apache.commons.lang3.StringUtils;
import org.controlsfx.control.SegmentedButton;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Optional;
import java.util.function.Predicate;
public class MainWindow extends Application implements LockWakeupReceiver {
public static final int TOOLBAR_HEIGHT = 32;
private static final Logger LOGGER = LoggerFactory.getLogger(MainWindow.class);
private final String darkCSS = getClass().getResource("/styles/dark.css").toExternalForm();
private final StatusBarWrapper statusBarWrapper = new StatusBarWrapper();
private ChannelStore channelStore;
private ChatHandler chatHandler;
private StreamHandler streamHandler;
private PersistenceHandler persistenceHandler;
private StateContainer applicationState;
private ObjectProperty<Channel> detailChannel;
private SplitPane splitPane;
private ChannelDetailPane detailPane;
private TableView<Channel> table;
private ChannelGrid grid;
private TableColumn<Channel, Boolean> liveCol;
private TableColumn<Channel, String> nameCol;
private TableColumn<Channel, String> titleCol;
private TableColumn<Channel, String> gameCol;
private TableColumn<Channel, Long> viewerCol;
private TableColumn<Channel, Long> uptimeCol;
private FilteredList<Channel> filteredChannelListTable;
private FilteredList<Channel> filteredChannelListGrid;
private Button add;
private TextField addName;
private Button details;
private Button remove;
private Button refresh;
private ToggleButton onlineOnly;
private ToolBar toolBarL;
private ToolBar toolBarR;
private TextField filterText;
private HandlerControlButton chatAndStreamButton;
private Stage stage;
private Tray tray;
private Scene scene;
private Channel lastSelected;
private Slider scaleSlider;
private DoubleProperty scalingGridCellWidth;
private DoubleProperty scalingGridCellHeight;
private HBox sliderBox;
private Button sync;
@Override
public void init() throws Exception {
persistenceHandler = new PersistenceHandler();
applicationState = persistenceHandler.loadState();
TwitchUtil.init(applicationState.getAuthToken());
channelStore = new ChannelStore(persistenceHandler, applicationState);
chatHandler = new ChatHandler(applicationState);
streamHandler = new StreamHandler(statusBarWrapper, channelStore, applicationState);
detailChannel = new SimpleObjectProperty<>();
}
@Override
public void start(final Stage stage) throws Exception {
Thread.currentThread().setUncaughtExceptionHandler((thread, throwable) -> {
LOGGER.error("Uncaught exception in JavaFX Application Thread: ", throwable);
LOGGER.error("Will exit");
try {
Thread.sleep(1000);
} catch (final InterruptedException e) {
e.printStackTrace();
}
Platform.exit();
});
Platform.setImplicitExit(false);
this.stage = stage;
scaleSlider = new Slider(0.0, 1.0, applicationState.getGridScale());
scaleSlider.valueProperty().addListener((observable, oldValue, newValue) -> {
applicationState.setGridScale(newValue.doubleValue());
persistenceHandler.saveState(applicationState);
});
scalingGridCellWidth = new SimpleDoubleProperty();
scalingGridCellHeight = new SimpleDoubleProperty();
scalingGridCellWidth.bind(Bindings.createDoubleBinding(() -> NumberUtil.scale(scaleSlider.getValue(), 0.0, 1.0, 200, 500), scaleSlider.valueProperty()));
scalingGridCellHeight.bind(Bindings.createDoubleBinding(() -> NumberUtil.scale(scaleSlider.getValue(), 0.0, 1.0, 200, 365), scaleSlider.valueProperty()));
sliderBox = new HBox(scaleSlider);
sliderBox.setAlignment(Pos.CENTER);
detailPane = new ChannelDetailPane(this);
final BorderPane borderPane = new BorderPane();
splitPane = new SplitPane();
splitPane.setBorder(Border.EMPTY);
splitPane.setPadding(Insets.EMPTY);
final StackPane stackPane = new StackPane();
stackPane.setBorder(Border.EMPTY);
stackPane.setPadding(Insets.EMPTY);
setupTable();
setupGrid();
stackPane.getChildren().add(grid);
stackPane.getChildren().add(table);
setupToolbarLeft(stage);
setupToolbarRight();
splitPane.getItems().add(stackPane);
final BorderPane toolbarPane = new BorderPane();
toolbarPane.setCenter(toolBarL);
toolbarPane.setRight(toolBarR);
borderPane.setTop(toolbarPane);
borderPane.setCenter(splitPane);
borderPane.setBottom(statusBarWrapper.getStatusBar());
scene = new Scene(borderPane);
scene.getStylesheets().add(getClass().getResource("/styles/copyable-label.css").toExternalForm());
scene.getStylesheets().add(getClass().getResource("/styles/common.css").toExternalForm());
if (applicationState.isUseDarkTheme()) {
scene.getStylesheets().add(darkCSS);
}
scene.setOnDragOver(event -> {
final Dragboard d = event.getDragboard();
if (d.hasUrl() || d.hasString()) {
event.acceptTransferModes(TransferMode.COPY);
} else {
event.consume();
}
});
scene.setOnDragDropped(event -> {
final Dragboard d = event.getDragboard();
boolean success = false;
if (d.hasUrl()) {
final String user = StringUtil.extractUsernameFromURL(d.getUrl());
if (user != null) {
success = channelStore.addChannel(user, statusBarWrapper);
} else {
statusBarWrapper.updateStatusText("dragged url is no twitch stream");
}
} else if (d.hasString()) {
success = channelStore.addChannel(d.getString(), statusBarWrapper);
}
event.setDropCompleted(success);
event.consume();
});
tray = new Tray(this);
NotificationUtil.init(applicationState);
restoreWindowState();
stage.setTitle("Skadi");
stage.getIcons().add(new Image(getClass().getResourceAsStream("/icons/skadi.png")));
stage.setScene(scene);
stage.show();
stage.iconifiedProperty().addListener((obs, oldV, newV) -> {
if (applicationState.isMinimizeToTray()) {
if (newV) {
saveWindowState();
stage.hide();
}
}
});
stage.setOnCloseRequest(event -> {
saveWindowState();
Platform.exit();
});
updateFilterPredicate();
updateLiveColumn();
bindColumnWidths();
final VersionCheckerService versionCheckerService = new VersionCheckerService(stage, statusBarWrapper);
versionCheckerService.start();
final LivestreamerVersionCheckService livestreamerVersionCheckService = new LivestreamerVersionCheckService(statusBarWrapper, applicationState);
livestreamerVersionCheckService.start();
SingleInstanceLock.addReceiver(this);
}
public void showStage() {
restoreWindowState();
stage.show();
stage.setIconified(false);
stage.toFront();
}
private void saveWindowState() {
applicationState.setWindowHeight(stage.getHeight());
applicationState.setWindowWidth(stage.getWidth());
persistenceHandler.saveState(applicationState);
}
private void restoreWindowState() {
final double width = applicationState.getWindowWidth();
final double height = applicationState.getWindowHeight();
stage.setWidth(width);
stage.setHeight(height);
}
private void setupGrid() {
grid = new ChannelGrid();
grid.setBorder(Border.EMPTY);
grid.setPadding(Insets.EMPTY);
grid.setCellFactory(gridView -> new ChannelGridCell(grid, this));
grid.cellHeightProperty().bind(scalingGridCellHeight);
grid.cellWidthProperty().bind(scalingGridCellWidth);
grid.setHorizontalCellSpacing(5);
grid.setVerticalCellSpacing(5);
filteredChannelListGrid = new FilteredList<>(channelStore.getChannels());
final SortedList<Channel> sortedChannelListGrid = new SortedList<>(filteredChannelListGrid);
sortedChannelListGrid.setComparator((channel1, channel2) -> Long.compare(channel2.getViewer(), channel1.getViewer()));
grid.setItems(sortedChannelListGrid);
}
private void setupToolbarRight() {
final ToggleButton tbTable = GlyphsDude.createIconToggleButton(FontAwesomeIcon.TABLE, null, null, ContentDisplay.GRAPHIC_ONLY);
final ToggleButton tbGrid = GlyphsDude.createIconToggleButton(FontAwesomeIcon.TH, null, null, ContentDisplay.GRAPHIC_ONLY);
tbTable.setTooltip(new Tooltip("Table view"));
tbGrid.setTooltip(new Tooltip("Grid view"));
tbTable.setOnAction(event -> {
table.toFront();
applicationState.setShowGrid(false);
persistenceHandler.saveState(applicationState);
toggleScaleSlider(false);
});
tbGrid.setOnAction(event -> {
grid.toFront();
applicationState.setShowGrid(true);
persistenceHandler.saveState(applicationState);
toggleScaleSlider(true);
});
final SegmentedButton segmentedButton = new SegmentedButton(tbTable, tbGrid);
final PersistentButtonToggleGroup toggleGroup = new PersistentButtonToggleGroup();
segmentedButton.setToggleGroup(toggleGroup);
toolBarR = new ToolBar(new Separator(), segmentedButton);
toolBarR.setPrefHeight(TOOLBAR_HEIGHT);
toolBarR.setMinHeight(TOOLBAR_HEIGHT);
if (applicationState.isShowGrid()) {
tbGrid.setSelected(true);
grid.toFront();
toggleScaleSlider(true);
} else {
tbTable.setSelected(true);
table.toFront();
toggleScaleSlider(false);
}
}
private void toggleScaleSlider(final boolean visible) {
if (visible) {
statusBarWrapper.getStatusBar().getRightItems().add(sliderBox);
} else {
statusBarWrapper.getStatusBar().getRightItems().remove(sliderBox);
}
}
@Override
public void stop() throws Exception {
super.stop();
tray.onShutdown();
ExecutorUtil.performShutdown();
NotificationUtil.onShutdown();
}
private void setupToolbarLeft(final Stage stage) {
add = GlyphsDude.createIconButton(FontAwesomeIcon.PLUS);
addName = new TextField();
addName.setOnAction(event -> add.fire());
add.setOnAction(event -> {
final String name = addName.getText().trim();
if (name.isEmpty()) {
return;
}
final String nameFromUrl = StringUtil.extractUsernameFromURL(name);
final boolean result;
if (nameFromUrl != null) {
result = channelStore.addChannel(nameFromUrl, statusBarWrapper);
} else {
result = channelStore.addChannel(name, statusBarWrapper);
}
if (result) {
addName.clear();
}
});
final Button imprt = GlyphsDude.createIconButton(FontAwesomeIcon.DOWNLOAD);
imprt.setOnAction(event -> {
final TextInputDialog dialog = new TextInputDialog();
dialog.initModality(Modality.APPLICATION_MODAL);
dialog.initOwner(stage);
dialog.setTitle("Import followed channels");
dialog.setHeaderText("Import followed channels from Twitch");
dialog.setGraphic(null);
dialog.setContentText("Twitch username:");
dialog.showAndWait().ifPresent(name -> {
final ImportFollowedService ifs = new ImportFollowedService(channelStore, name, statusBarWrapper);
ifs.start();
});
});
sync = GlyphsDude.createIconButton(FontAwesomeIcon.COLUMNS);
sync.setDisable(!applicationState.hasAuthCode());
sync.setOnAction(event -> {
final SyncDialog dialog = new SyncDialog(channelStore, statusBarWrapper);
dialog.initModality(Modality.APPLICATION_MODAL);
dialog.initOwner(stage);
final Optional<Boolean> result = dialog.showAndWait();
if (result.isPresent() && result.get()) {
persistenceHandler.saveState(applicationState);
}
});
details = GlyphsDude.createIconButton(FontAwesomeIcon.INFO);
details.setDisable(true);
details.setOnAction(event -> openDetailPage(lastSelected));
details.setTooltip(new Tooltip("Show channel information"));
remove = GlyphsDude.createIconButton(FontAwesomeIcon.TRASH);
remove.setDisable(true);
remove.setOnAction(event -> {
final Channel candidate = lastSelected;
final Alert alert = new Alert(AlertType.CONFIRMATION);
alert.initModality(Modality.APPLICATION_MODAL);
alert.initOwner(stage);
alert.setTitle("Delete channel");
alert.setHeaderText("Delete " + candidate.getName());
alert.setContentText("Do you really want to delete " + candidate.getName() + "?");
final Optional<ButtonType> result = alert.showAndWait();
if (result.isPresent() && result.get() == ButtonType.OK) {
channelStore.getChannels().remove(candidate);
statusBarWrapper.updateStatusText("Removed channel " + candidate.getName());
}
});
refresh = GlyphsDude.createIconButton(FontAwesomeIcon.REFRESH);
refresh.setTooltip(new Tooltip("Refresh all channels"));
refresh.setOnAction(event -> {
refresh.setDisable(true);
final ForcedChannelUpdateService service = new ForcedChannelUpdateService(channelStore, statusBarWrapper, refresh);
service.start();
});
final Button settings = GlyphsDude.createIconButton(FontAwesomeIcon.COG);
settings.setTooltip(new Tooltip("Settings"));
settings.setOnAction(event -> {
final SettingsDialog dialog = new SettingsDialog(applicationState, persistenceHandler);
dialog.initModality(Modality.APPLICATION_MODAL);
dialog.initOwner(stage);
final Optional<StateContainer> result = dialog.showAndWait();
if (result.isPresent()) {
persistenceHandler.saveState(result.get());
checkThemeChange();
checkAuthChange();
}
});
onlineOnly = new ToggleButton(null, GlyphsDude.createIcon(FontAwesomeIcon.VIDEO_CAMERA));
onlineOnly.setSelected(applicationState.isOnlineFilterActive());
onlineOnly.setTooltip(new Tooltip("Show only live channels"));
onlineOnly.setOnAction(event -> {
applicationState.setOnlineFilterActive(onlineOnly.isSelected());
persistenceHandler.saveState(applicationState);
updateFilterPredicate();
updateLiveColumn();
});
filterText = new TextField();
filterText.textProperty().addListener((obs, oldV, newV) -> updateFilterPredicate());
filterText.setTooltip(new Tooltip("Filter channels by name, status and game"));
toolBarL = new ToolBar();
toolBarL.getItems().addAll(addName, add, imprt, sync, new Separator(), refresh, settings, new Separator(), onlineOnly, filterText, new Separator(), details, remove);
toolBarL.setPrefHeight(TOOLBAR_HEIGHT);
toolBarL.setMinHeight(TOOLBAR_HEIGHT);
chatAndStreamButton = new HandlerControlButton(chatHandler, streamHandler, toolBarL, statusBarWrapper, applicationState);
}
private void updateLiveColumn() {
if (onlineOnly.isSelected()) {
table.getColumns().remove(liveCol);
table.getSortOrder().remove(liveCol);
} else {
table.getColumns().add(0, liveCol);
table.getSortOrder().add(0, liveCol);
}
}
private void checkAuthChange() {
final boolean hasAuth = applicationState.hasAuthCode();
sync.setDisable(!hasAuth);
}
private void checkThemeChange() {
final boolean useDark = applicationState.isUseDarkTheme();
final boolean isPresent = scene.getStylesheets().contains(darkCSS);
if (useDark == isPresent) {
return;
}
if (useDark) {
scene.getStylesheets().add(darkCSS);
} else {
scene.getStylesheets().remove(darkCSS);
}
}
private void updateFilterPredicate() {
final Predicate<Channel> channelPredicate = channel -> {
final boolean isOnlineResult;
final boolean containsTextResult;
// isOnline returns a Boolean, can be null
isOnlineResult = !onlineOnly.isSelected() || Boolean.TRUE.equals(channel.isOnline());
final String filter = filterText.getText().trim();
if (filter.isEmpty()) {
containsTextResult = true;
} else {
final boolean nameContains = StringUtils.containsIgnoreCase(channel.getName(), filter);
final boolean gameContains = StringUtils.containsIgnoreCase(channel.getGame(), filter);
final boolean titleContains = StringUtils.containsIgnoreCase(channel.getTitle(), filter);
containsTextResult = nameContains || gameContains || titleContains;
}
return isOnlineResult && containsTextResult;
};
filteredChannelListTable.setPredicate(channelPredicate);
filteredChannelListGrid.setPredicate(channelPredicate);
}
private void setupTable() {
table = new TableView<>();
table.setBorder(Border.EMPTY);
table.setPadding(Insets.EMPTY);
liveCol = new TableColumn<>("Live");
liveCol.setCellValueFactory(p -> p.getValue().onlineProperty());
liveCol.setSortType(SortType.DESCENDING);
liveCol.setCellFactory(p -> new LiveCell());
nameCol = new TableColumn<>("Channel");
nameCol.setCellValueFactory(p -> p.getValue().nameProperty());
titleCol = new TableColumn<>("Status");
titleCol.setCellValueFactory(p -> p.getValue().titleProperty());
gameCol = new TableColumn<>("Game");
gameCol.setCellValueFactory(p -> p.getValue().gameProperty());
viewerCol = new TableColumn<>("Viewer");
viewerCol.setCellValueFactory(p -> p.getValue().viewerProperty().asObject());
viewerCol.setSortType(SortType.DESCENDING);
viewerCol.setCellFactory(p -> new RightAlignedCell<>());
uptimeCol = new TableColumn<>("Uptime");
uptimeCol.setCellValueFactory(p -> p.getValue().uptimeProperty().asObject());
uptimeCol.setCellFactory(p -> new UptimeCell());
table.setPlaceholder(new Label("no channels added/matching the filters"));
//table.getColumns().add(liveCol);
table.getColumns().add(nameCol);
table.getColumns().add(titleCol);
table.getColumns().add(gameCol);
table.getColumns().add(viewerCol);
table.getColumns().add(uptimeCol);
//table.getSortOrder().add(liveCol);
table.getSortOrder().add(viewerCol);
table.getSortOrder().add(nameCol);
filteredChannelListTable = new FilteredList<>(channelStore.getChannels());
final SortedList<Channel> sortedChannelListTable = new SortedList<>(filteredChannelListTable);
sortedChannelListTable.comparatorProperty().bind(table.comparatorProperty());
table.setItems(sortedChannelListTable);
table.getSelectionModel().selectedItemProperty().addListener((obs, oldV, newV) -> {
onSelection(newV);
if ((newV == null) && splitPane.getItems().contains(detailPane)) {
doDetailSlide(false);
}
});
table.setOnMousePressed(event -> {
if (table.getSelectionModel().getSelectedItem() == null) {
return;
}
if (event.getButton() == MouseButton.MIDDLE) {
openStream(table.getSelectionModel().getSelectedItem());
} else if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) {
openDetailPage(table.getSelectionModel().getSelectedItem());
}
});
}
public void onSelection(final Channel channel) {
details.setDisable(channel == null);
remove.setDisable(channel == null);
chatAndStreamButton.setDisable(channel == null);
chatAndStreamButton.updateCandidate(channel);
lastSelected = channel;
}
public void openDetailPage(final Channel channel) {
if (channel == null) {
return;
}
detailChannel.set(channel);
if (!splitPane.getItems().contains(detailPane)) {
splitPane.getItems().add(detailPane);
doDetailSlide(true);
}
}
private void bindColumnWidths() {
final ScrollBar tsb = JavaFXUtil.getVerticalScrollbar(table);
final ReadOnlyDoubleProperty sbw = tsb.widthProperty();
final DoubleBinding tcw = table.widthProperty().subtract(sbw);
liveCol.prefWidthProperty().bind(tcw.multiply(0.05));
nameCol.prefWidthProperty().bind(tcw.multiply(0.15));
titleCol.prefWidthProperty().bind(tcw.multiply(0.4));
gameCol.prefWidthProperty().bind(tcw.multiply(0.2));
viewerCol.prefWidthProperty().bind(tcw.multiply(0.075));
uptimeCol.prefWidthProperty().bind(tcw.multiply(0.125));
}
public void doDetailSlide(final boolean doOpen) {
final KeyValue positionKeyValue = new KeyValue(splitPane.getDividers().get(0).positionProperty(), doOpen ? 0.15 : 1);
final KeyValue opacityKeyValue = new KeyValue(detailPane.opacityProperty(), doOpen ? 1 : 0);
final KeyFrame keyFrame = new KeyFrame(Duration.seconds(0.1), positionKeyValue, opacityKeyValue);
final Timeline timeline = new Timeline(keyFrame);
timeline.setOnFinished(evt -> {
if (!doOpen) {
splitPane.getItems().remove(detailPane);
detailPane.setOpacity(1);
}
});
timeline.play();
}
public ObjectProperty<Channel> getDetailChannel() {
return detailChannel;
}
@Override
public void onWakeupReceived() {
Platform.runLater(() -> {
statusBarWrapper.updateStatusText("Wakeup received");
showStage();
});
}
public void openStream(final Channel item) {
if (item == null) {
return;
}
streamHandler.openStream(item, StreamQuality.getBestQuality());
}
public DoubleProperty scalingGridCellWidthProperty() {
return scalingGridCellWidth;
}
public DoubleProperty scalingGridCellHeightProperty() {
return scalingGridCellHeight;
}
}