diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 00000000..5978a115
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,23 @@
+---
+name: Bug report
+about: Create a report to help us improve
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+**Desktop (please complete the following information):**
+ - OS: [e.g. Windows, Mac, Linux]
+ - Ctbrec Version [e.g. 1.12.1 JRE]
+ - Standalone or Client / Server mode
+
+**Log**
+If there are any errors in the ctbrec.log, please add them here. You can find the ctbrec.log next to the ctbrec.exe.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 00000000..066b2d92
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,17 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
diff --git a/.github/ISSUE_TEMPLATE/other.md b/.github/ISSUE_TEMPLATE/other.md
new file mode 100644
index 00000000..3204c4f9
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/other.md
@@ -0,0 +1,7 @@
+---
+name: Other
+about: Anything else
+
+---
+
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6c9fc55b..41422077 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,78 @@
+1.16.0
+========================
+* Thumbnails can show a live preview. Can be switched off in the settings.
+* Added Streamate (metcams, xhamstercams, pornhublive)
+* Maximum resolution can be an arbitrary value now
+* Added setting for minimal recording length. Recordings, which are shorter
+ than this value, get deleted automatically.
+* Double-click in Recording tab starts the player
+* Fix: BongaCams friends tab not working
+* Fix: In some cases MFC models got confused
+
+1.15.0
+========================
+* Fix: BongaCams overview didn't work anymore
+* Fix: CamSoda overview didn't work anymore
+* Fix: Multi selection of thumbnails didn't work when a tab was opened the
+ first time
+* Fix: Cam4 online detection was to restrictive
+* Added tabular view for MFC, which shows all online models
+
+1.14.0
+========================
+* Added setting for MFC to ignore the upscaled (960p) stream
+* Added event system. You can define to show a notification, play a sound or
+ execute a program, when the state of a model or recording changes
+* Added "follow" menu entry on the Recording tab
+* Fix: Recordings change from suspended to recording by their own when a
+ thumbnail tab is opened and the model is showing
+* Fix: Linux scripts don't work on systems where bash isn't the default shell
+* Improved loading and display of resolution tags. They are not re-loaded
+ everytime you switch between tabs
+
+1.13.0
+========================
+* Added possibility to open small live previews of online models
+ in the Recording tab
+* Added setting to toggle "Player Starting" message
+* Added possibility to add models by their URL
+* Added pause / resume all buttons
+* Setting to define the base URL for MFC and CTB
+* The paused checkbox are now clickable
+* Implemented multi-selection for Recording and Recordings tab
+* Fix: Don't throw exceptions for unknown attributes in PlaylistParser
+* Fix: Don't do space check, if minimum is set to 0
+* Fix: Player not starting when path contains spaces
+
+1.12.1
+========================
+* Fixed downloads in client / server mode
+
+1.12.0
+========================
+* Added threshold setting to keep free space on the recording device.
+ This is useful, if you don't want to use up all of your storage.
+ The free space is also shown on the recordings tab
+* Tweaked the download internals a lot. Downloads should not hang
+ in RECORDING state without actually recording. Downloads should
+ be more robust in general.
+* Fixed and improved split recordings
+* Improved detection of online state for Cam4 models
+* Accelerated the initial loading of the "Recording" tab for many
+ Chaturbate models
+* Recordings tab now shows smaller size units (Bytes, KiB, MiB, GiB)
+
+1.11.0
+========================
+* Added model search function
+* Added color settings to change the appearance of the application
+* Added setting for the online check interval
+* Added setting to define the tab the application opens on start
+* Double-click starts playback of recordings
+* Refresh of thumbnails can be disabled
+* Changed settings are saved immediately (including changes of the
+ list of recorded models)
+
1.10.0
========================
* Fix: HMAC authentication didn't work for playing and downloading of a
@@ -113,4 +188,4 @@
* Added proxy settings
* Made playlist generator more robust
* Fixed some issues with the file merging
-* Fixed memory leak caused by the model filter function
\ No newline at end of file
+* Fixed memory leak caused by the model filter function
diff --git a/client/.gitignore b/client/.gitignore
index fc247909..2c4e8e27 100644
--- a/client/.gitignore
+++ b/client/.gitignore
@@ -2,7 +2,7 @@
/target/
*~
*.bak
-/ctbrec.log
+/*.log
/ctbrec-tunnel.sh
/jre/
/server-local.sh
diff --git a/client/pom.xml b/client/pom.xml
index 5042475d..9ea0e01e 100644
--- a/client/pom.xml
+++ b/client/pom.xml
@@ -8,7 +8,7 @@
ctbrec
master
- 1.10.0
+ 1.15.0
../master
@@ -118,10 +118,11 @@
false
anything
+ https://jdk.java.net/
jre
true
- 1.8.0
+ 10
512
diff --git a/client/src/assembly/ctbrec-linux-jre.sh b/client/src/assembly/ctbrec-linux-jre.sh
index 6c68d4d8..e6880968 100755
--- a/client/src/assembly/ctbrec-linux-jre.sh
+++ b/client/src/assembly/ctbrec-linux-jre.sh
@@ -1,4 +1,4 @@
-#!/bin/sh
+#!/bin/bash
pushd $(dirname $0)
JAVA=./jre/bin/java
diff --git a/client/src/assembly/ctbrec-linux.sh b/client/src/assembly/ctbrec-linux.sh
index df9c22eb..6ddab21e 100755
--- a/client/src/assembly/ctbrec-linux.sh
+++ b/client/src/assembly/ctbrec-linux.sh
@@ -1,4 +1,4 @@
-#!/bin/sh
+#!/bin/bash
pushd $(dirname $0)
JAVA=java
diff --git a/client/src/main/java/ctbrec/ui/CamrecApplication.java b/client/src/main/java/ctbrec/ui/CamrecApplication.java
index 27f35fb7..93b26cef 100644
--- a/client/src/main/java/ctbrec/ui/CamrecApplication.java
+++ b/client/src/main/java/ctbrec/ui/CamrecApplication.java
@@ -1,6 +1,9 @@
package ctbrec.ui;
+
import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
@@ -9,21 +12,24 @@ import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
-import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import com.google.common.eventbus.AsyncEventBus;
-import com.google.common.eventbus.EventBus;
import com.squareup.moshi.JsonAdapter;
import com.squareup.moshi.Moshi;
import com.squareup.moshi.Types;
import ctbrec.Config;
+import ctbrec.StringUtil;
import ctbrec.Version;
+import ctbrec.event.EventBusHolder;
+import ctbrec.event.EventHandler;
+import ctbrec.event.EventHandlerConfiguration;
import ctbrec.io.HttpClient;
import ctbrec.recorder.LocalRecorder;
+import ctbrec.recorder.OnlineMonitor;
import ctbrec.recorder.Recorder;
import ctbrec.recorder.RemoteRecorder;
import ctbrec.sites.Site;
@@ -33,6 +39,8 @@ import ctbrec.sites.camsoda.Camsoda;
import ctbrec.sites.chaturbate.Chaturbate;
import ctbrec.sites.fc2live.Fc2Live;
import ctbrec.sites.mfc.MyFreeCams;
+import ctbrec.sites.streamate.Streamate;
+import ctbrec.ui.settings.SettingsTab;
import javafx.application.Application;
import javafx.application.HostServices;
import javafx.application.Platform;
@@ -43,6 +51,7 @@ import javafx.scene.control.Alert;
import javafx.scene.control.Tab;
import javafx.scene.control.TabPane;
import javafx.scene.image.Image;
+import javafx.scene.paint.Color;
import javafx.stage.Stage;
import okhttp3.Request;
import okhttp3.Response;
@@ -53,12 +62,13 @@ public class CamrecApplication extends Application {
private Config config;
private Recorder recorder;
+ private OnlineMonitor onlineMonitor;
static HostServices hostServices;
private SettingsTab settingsTab;
private TabPane rootPane = new TabPane();
- static EventBus bus;
private List sites = new ArrayList<>();
public static HttpClient httpClient;
+ public static String title;
@Override
public void start(Stage primaryStage) throws Exception {
@@ -69,11 +79,14 @@ public class CamrecApplication extends Application {
sites.add(new Chaturbate());
sites.add(new Fc2Live());
sites.add(new MyFreeCams());
+ sites.add(new Streamate());
loadConfig();
+ registerAlertSystem();
createHttpClient();
- bus = new AsyncEventBus(Executors.newSingleThreadExecutor());
hostServices = getHostServices();
createRecorder();
+ onlineMonitor = new OnlineMonitor(recorder);
+ onlineMonitor.start();
for (Site site : sites) {
if(site.isEnabled()) {
try {
@@ -96,7 +109,8 @@ public class CamrecApplication extends Application {
private void createGui(Stage primaryStage) throws IOException {
LOG.debug("Creating GUI");
- primaryStage.setTitle("CTB Recorder " + getVersion());
+ CamrecApplication.title = "CTB Recorder " + getVersion();
+ primaryStage.setTitle(title);
InputStream icon = getClass().getResourceAsStream("/icon.png");
primaryStage.getIcons().add(new Image(icon));
int windowWidth = Config.getInstance().getSettings().windowWidth;
@@ -112,19 +126,26 @@ public class CamrecApplication extends Application {
rootPane.getTabs().add(siteTab);
}
}
- try {
- ((SiteTab)rootPane.getTabs().get(0)).selected();
- } catch(ClassCastException | IndexOutOfBoundsException e) {}
RecordedModelsTab modelsTab = new RecordedModelsTab("Recording", recorder, sites);
rootPane.getTabs().add(modelsTab);
RecordingsTab recordingsTab = new RecordingsTab("Recordings", recorder, config, sites);
rootPane.getTabs().add(recordingsTab);
- settingsTab = new SettingsTab(sites);
+ settingsTab = new SettingsTab(sites, recorder);
rootPane.getTabs().add(settingsTab);
rootPane.getTabs().add(new DonateTabFx());
+ switchToStartTab();
+ writeColorSchemeStyleSheet(primaryStage);
+ Color base = Color.web(Config.getInstance().getSettings().colorBase);
+ if(!base.equals(Color.WHITE)) {
+ loadStyleSheet(primaryStage, "color.css");
+ }
+ loadStyleSheet(primaryStage, "style.css");
+ primaryStage.getScene().getStylesheets().add("/ctbrec/ui/settings/ColorSettingsPane.css");
primaryStage.getScene().getStylesheets().add("/ctbrec/ui/ThumbCell.css");
+ primaryStage.getScene().getStylesheets().add("/ctbrec/ui/controls/SearchBox.css");
+ primaryStage.getScene().getStylesheets().add("/ctbrec/ui/controls/Popover.css");
primaryStage.getScene().widthProperty().addListener((observable, oldVal, newVal) -> Config.getInstance().getSettings().windowWidth = newVal.intValue());
primaryStage.getScene().heightProperty()
.addListener((observable, oldVal, newVal) -> Config.getInstance().getSettings().windowHeight = newVal.intValue());
@@ -146,7 +167,10 @@ public class CamrecApplication extends Application {
new Thread() {
@Override
public void run() {
+ modelsTab.saveState();
+ recordingsTab.saveState();
settingsTab.saveConfig();
+ onlineMonitor.shutdown();
recorder.shutdown();
for (Site site : sites) {
if(site.isEnabled()) {
@@ -156,9 +180,13 @@ public class CamrecApplication extends Application {
try {
Config.getInstance().save();
LOG.info("Shutdown complete. Goodbye!");
- Platform.exit();
- // This is needed, because OkHttp?! seems to block the shutdown with its writer threads. They are not daemon threads :(
- System.exit(0);
+ Platform.runLater(() -> {
+ primaryStage.close();
+ shutdownInfo.close();
+ Platform.exit();
+ // This is needed, because OkHttp?! seems to block the shutdown with its writer threads. They are not daemon threads :(
+ System.exit(0);
+ });
} catch (IOException e1) {
Platform.runLater(() -> {
Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
@@ -186,6 +214,64 @@ public class CamrecApplication extends Application {
});
}
+ private void registerAlertSystem() {
+ new Thread(() -> {
+ try {
+ // don't register before 1 minute has passed, because directly after
+ // the start of ctbrec, an event for every online model would be fired,
+ // which is annoying as f
+ Thread.sleep(TimeUnit.MINUTES.toMillis(1));
+
+ for (EventHandlerConfiguration config : Config.getInstance().getSettings().eventHandlers) {
+ EventHandler handler = new EventHandler(config);
+ EventBusHolder.register(handler);
+ LOG.debug("Registered event handler for {} {}", config.getEvent(), config.getName());
+ }
+ LOG.debug("Alert System registered");
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ }
+ }).start();
+ }
+
+ private void writeColorSchemeStyleSheet(Stage primaryStage) {
+ File colorCss = new File(Config.getInstance().getConfigDir(), "color.css");
+ try(FileOutputStream fos = new FileOutputStream(colorCss)) {
+ String content = ".root {\n" +
+ " -fx-base: "+Config.getInstance().getSettings().colorBase+";\n" +
+ " -fx-accent: "+Config.getInstance().getSettings().colorAccent+";\n" +
+ " -fx-default-button: -fx-accent;\n" +
+ " -fx-focus-color: -fx-accent;\n" +
+ " -fx-control-inner-background-alt: derive(-fx-base, 95%);\n" +
+ "}";
+ fos.write(content.getBytes("utf-8"));
+ } catch(Exception e) {
+ LOG.error("Couldn't write stylesheet for user defined color theme");
+ }
+ }
+
+ private void loadStyleSheet(Stage primaryStage, String filename) {
+ File css = new File(Config.getInstance().getConfigDir(), filename);
+ if(css.exists() && css.isFile()) {
+ primaryStage.getScene().getStylesheets().add(css.toURI().toString());
+ }
+ }
+
+ private void switchToStartTab() {
+ String startTab = Config.getInstance().getSettings().startTab;
+ if(StringUtil.isNotBlank(startTab)) {
+ for (Tab tab : rootPane.getTabs()) {
+ if(Objects.equals(startTab, tab.getText())) {
+ rootPane.getSelectionModel().select(tab);
+ break;
+ }
+ }
+ }
+ if(rootPane.getSelectionModel().getSelectedItem() instanceof TabSelectionListener) {
+ ((TabSelectionListener)rootPane.getSelectionModel().getSelectedItem()).selected();
+ }
+ }
+
private void createRecorder() {
if (config.getSettings().localRecording) {
recorder = new LocalRecorder(config);
@@ -201,9 +287,8 @@ public class CamrecApplication extends Application {
LOG.error("Couldn't load settings", e);
Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
alert.setTitle("Whoopsie");
- alert.setContentText("Couldn't load settings.");
+ alert.setContentText("Couldn't load settings. Falling back to defaults. A backup of your settings has been created.");
alert.showAndWait();
- System.exit(1);
}
config = Config.getInstance();
}
diff --git a/client/src/main/java/ctbrec/ui/DonateTabFx.java b/client/src/main/java/ctbrec/ui/DonateTabFx.java
index f00ea63a..311fa515 100644
--- a/client/src/main/java/ctbrec/ui/DonateTabFx.java
+++ b/client/src/main/java/ctbrec/ui/DonateTabFx.java
@@ -9,13 +9,9 @@ import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.Tab;
import javafx.scene.image.ImageView;
-import javafx.scene.layout.Background;
-import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.BorderPane;
-import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
-import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.TextAlignment;
@@ -26,7 +22,6 @@ public class DonateTabFx extends Tab {
setText("Donate");
BorderPane container = new BorderPane();
container.setPadding(new Insets(10));
- container.setBackground(new Background(new BackgroundFill(Color.WHITE, CornerRadii.EMPTY, new Insets(0))));
setContent(container);
VBox headerVbox = new VBox(10);
@@ -53,21 +48,21 @@ public class DonateTabFx extends Tab {
tokenDesc.setTextAlignment(TextAlignment.CENTER);
tokenBox.getChildren().addAll(tokenImage, tokenButton, tokenDesc);
- ImageView coffeeImage = new ImageView(getClass().getResource("/html/buymeacoffee-fancy.png").toString());
+ ImageView coffeeImage = new ImageView(getClass().getResource("/buymeacoffee-round.png").toString());
Button coffeeButton = new Button("Buy me a coffee");
coffeeButton.setOnMouseClicked((e) -> { DesktopIntegration.open("https://www.buymeacoffee.com/0xboobface"); });
VBox buyCoffeeBox = new VBox(5);
buyCoffeeBox.setAlignment(Pos.TOP_CENTER);
buyCoffeeBox.getChildren().addAll(coffeeImage, coffeeButton);
- ImageView paypalImage = new ImageView(getClass().getResource("/html/pp196.png").toString());
+ ImageView paypalImage = new ImageView(getClass().getResource("/paypal-round.png").toString());
Button paypalButton = new Button("PayPal");
paypalButton.setOnMouseClicked((e) -> { DesktopIntegration.open("https://www.paypal.me/0xb00bface"); });
VBox paypalBox = new VBox(5);
paypalBox.setAlignment(Pos.TOP_CENTER);
paypalBox.getChildren().addAll(paypalImage, paypalButton);
- ImageView patreonImage = new ImageView(getClass().getResource("/html/patreon-logo.png").toString());
+ ImageView patreonImage = new ImageView(getClass().getResource("/patreon-round.png").toString());
Button patreonButton = new Button("Become a Patron");
patreonButton.setOnMouseClicked((e) -> { DesktopIntegration.open("https://www.patreon.com/0xb00bface"); });
VBox patreonBox = new VBox(5);
diff --git a/client/src/main/java/ctbrec/ui/JavaFxModel.java b/client/src/main/java/ctbrec/ui/JavaFxModel.java
index fccdd9e3..0f9019d4 100644
--- a/client/src/main/java/ctbrec/ui/JavaFxModel.java
+++ b/client/src/main/java/ctbrec/ui/JavaFxModel.java
@@ -110,7 +110,7 @@ public class JavaFxModel implements Model {
}
@Override
- public String getOnlineState(boolean failFast) throws IOException, ExecutionException {
+ public State getOnlineState(boolean failFast) throws IOException, ExecutionException {
return delegate.getOnlineState(failFast);
}
@@ -197,4 +197,19 @@ public class JavaFxModel implements Model {
delegate.setSuspended(suspended);
pausedProperty.set(suspended);
}
+
+ @Override
+ public String getDisplayName() {
+ return delegate.getDisplayName();
+ }
+
+ @Override
+ public void setDisplayName(String name) {
+ delegate.setDisplayName(name);
+ }
+
+ @Override
+ public int compareTo(Model o) {
+ return delegate.compareTo(o);
+ }
}
diff --git a/client/src/main/java/ctbrec/ui/JavaFxRecording.java b/client/src/main/java/ctbrec/ui/JavaFxRecording.java
index 313a7e75..e737ce8e 100644
--- a/client/src/main/java/ctbrec/ui/JavaFxRecording.java
+++ b/client/src/main/java/ctbrec/ui/JavaFxRecording.java
@@ -1,10 +1,11 @@
package ctbrec.ui;
-import java.text.DecimalFormat;
import java.time.Instant;
import ctbrec.Config;
import ctbrec.Recording;
+import javafx.beans.property.LongProperty;
+import javafx.beans.property.SimpleLongProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
@@ -12,9 +13,10 @@ public class JavaFxRecording extends Recording {
private transient StringProperty statusProperty = new SimpleStringProperty();
private transient StringProperty progressProperty = new SimpleStringProperty();
- private transient StringProperty sizeProperty = new SimpleStringProperty();
+ private transient LongProperty sizeProperty = new SimpleLongProperty();
private Recording delegate;
+ private long lastValue = 0;
public JavaFxRecording(Recording recording) {
this.delegate = recording;
@@ -41,7 +43,7 @@ public class JavaFxRecording extends Recording {
}
@Override
- public STATUS getStatus() {
+ public State getStatus() {
return delegate.getStatus();
}
@@ -50,7 +52,7 @@ public class JavaFxRecording extends Recording {
}
@Override
- public void setStatus(STATUS status) {
+ public void setStatus(State status) {
delegate.setStatus(status);
switch(status) {
case RECORDING:
@@ -65,8 +67,14 @@ public class JavaFxRecording extends Recording {
case DOWNLOADING:
statusProperty.set("downloading");
break;
- case MERGING:
- statusProperty.set("merging");
+ case POST_PROCESSING:
+ statusProperty.set("post-processing");
+ break;
+ case STOPPED:
+ statusProperty.set("stopped");
+ break;
+ case UNKNOWN:
+ statusProperty.set("unknown");
break;
}
}
@@ -89,9 +97,7 @@ public class JavaFxRecording extends Recording {
@Override
public void setSizeInByte(long sizeInByte) {
delegate.setSizeInByte(sizeInByte);
- double sizeInGiB = sizeInByte / 1024.0 / 1024 / 1024;
- DecimalFormat df = new DecimalFormat("0.00");
- sizeProperty.setValue(df.format(sizeInGiB) + " GiB");
+ sizeProperty.set(sizeInByte);
}
public StringProperty getProgressProperty() {
@@ -115,7 +121,7 @@ public class JavaFxRecording extends Recording {
public void update(Recording updated) {
if(!Config.getInstance().getSettings().localRecording) {
- if(getStatus() == STATUS.DOWNLOADING && updated.getStatus() != STATUS.DOWNLOADING) {
+ if(getStatus() == State.DOWNLOADING && updated.getStatus() != State.DOWNLOADING) {
// ignore, because the the status coming from the server is FINISHED and we are
// overriding it with DOWNLOADING
return;
@@ -151,8 +157,13 @@ public class JavaFxRecording extends Recording {
return delegate.getSizeInByte();
}
- public StringProperty getSizeProperty() {
+ public LongProperty getSizeProperty() {
return sizeProperty;
}
+ public boolean valueChanged() {
+ boolean changed = getSizeInByte() != lastValue;
+ lastValue = getSizeInByte();
+ return changed;
+ }
}
diff --git a/client/src/main/java/ctbrec/ui/Player.java b/client/src/main/java/ctbrec/ui/Player.java
index 593e379b..bace7e78 100644
--- a/client/src/main/java/ctbrec/ui/Player.java
+++ b/client/src/main/java/ctbrec/ui/Player.java
@@ -23,7 +23,7 @@ public class Player {
private static final transient Logger LOG = LoggerFactory.getLogger(Player.class);
private static PlayerThread playerThread;
- public static void play(String url) {
+ public static boolean play(String url) {
boolean singlePlayer = Config.getInstance().getSettings().singlePlayer;
try {
if (singlePlayer && playerThread != null && playerThread.isRunning()) {
@@ -31,12 +31,14 @@ public class Player {
}
playerThread = new PlayerThread(url);
+ return true;
} catch (Exception e1) {
LOG.error("Couldn't start player", e1);
+ return false;
}
}
- public static void play(Recording rec) {
+ public static boolean play(Recording rec) {
boolean singlePlayer = Config.getInstance().getSettings().singlePlayer;
try {
if (singlePlayer && playerThread != null && playerThread.isRunning()) {
@@ -44,12 +46,14 @@ public class Player {
}
playerThread = new PlayerThread(rec);
+ return true;
} catch (Exception e1) {
LOG.error("Couldn't start player", e1);
+ return false;
}
}
- public static void play(Model model) {
+ public static boolean play(Model model) {
try {
if(model.isOnline(true)) {
boolean singlePlayer = Config.getInstance().getSettings().singlePlayer;
@@ -60,7 +64,7 @@ public class Player {
Collections.sort(sources);
StreamSource best = sources.get(sources.size()-1);
LOG.debug("Playing {}", best.getMediaPlaylistUrl());
- Player.play(best.getMediaPlaylistUrl());
+ return Player.play(best.getMediaPlaylistUrl());
} else {
Platform.runLater(() -> {
Alert alert = new AutosizeAlert(Alert.AlertType.INFORMATION);
@@ -68,6 +72,7 @@ public class Player {
alert.setHeaderText("Room is currently not public");
alert.showAndWait();
});
+ return false;
}
} catch (Exception e1) {
LOG.error("Couldn't get stream information for model {}", model, e1);
@@ -78,6 +83,7 @@ public class Player {
alert.setContentText(e1.getLocalizedMessage());
alert.showAndWait();
});
+ return false;
}
}
@@ -114,7 +120,11 @@ public class Player {
try {
if (Config.getInstance().getSettings().localRecording && rec != null) {
File file = new File(Config.getInstance().getSettings().recordingsDir, rec.getPath());
- playerProcess = rt.exec(Config.getInstance().getSettings().mediaPlayer + " " + file, OS.getEnvironment(), file.getParentFile());
+ String[] args = new String[] {
+ Config.getInstance().getSettings().mediaPlayer,
+ file.getName()
+ };
+ playerProcess = rt.exec(args, OS.getEnvironment(), file.getParentFile());
} else {
if(Config.getInstance().getSettings().requireAuthentication) {
URL u = new URL(url);
@@ -130,10 +140,12 @@ public class Player {
// create threads, which read stdout and stderr of the player process. these are needed,
// because otherwise the internal buffer for these streams fill up and block the process
Thread std = new Thread(new StreamRedirectThread(playerProcess.getInputStream(), new DevNull()));
+ //Thread std = new Thread(new StreamRedirectThread(playerProcess.getInputStream(), System.out));
std.setName("Player stdout pipe");
std.setDaemon(true);
std.start();
Thread err = new Thread(new StreamRedirectThread(playerProcess.getErrorStream(), new DevNull()));
+ //Thread err = new Thread(new StreamRedirectThread(playerProcess.getErrorStream(), System.err));
err.setName("Player stderr pipe");
err.setDaemon(true);
err.start();
diff --git a/client/src/main/java/ctbrec/ui/PreviewPopupHandler.java b/client/src/main/java/ctbrec/ui/PreviewPopupHandler.java
new file mode 100644
index 00000000..16de2224
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/PreviewPopupHandler.java
@@ -0,0 +1,188 @@
+package ctbrec.ui;
+
+import java.util.Objects;
+import java.util.concurrent.TimeUnit;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.ui.controls.StreamPreview;
+import javafx.application.Platform;
+import javafx.event.EventHandler;
+import javafx.geometry.Insets;
+import javafx.geometry.Point2D;
+import javafx.scene.Node;
+import javafx.scene.control.TableColumn;
+import javafx.scene.control.TableRow;
+import javafx.scene.control.TableView;
+import javafx.scene.input.MouseButton;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.layout.StackPane;
+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 StreamPreview streamPreview;
+ private JavaFxModel model;
+ private volatile long openCountdown = -1;
+ private volatile long closeCountdown = -1;
+ private volatile long lastModelChange = -1;
+ private volatile boolean changeModel = false;
+
+ public PreviewPopupHandler(Node parent) {
+ this.parent = parent;
+
+ streamPreview = new StreamPreview();
+ streamPreview.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(streamPreview);
+ StackPane.setMargin(streamPreview, new Insets(5));
+
+ 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;
+ streamPreview.stop();
+ }
+ } 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) {
+ Platform.runLater(() -> {
+ streamPreview.startStream(model);
+ popup.show(parent.getScene().getWindow());
+ });
+
+ }
+
+ private void hidePopup() {
+ Platform.runLater(() -> {
+ popup.setX(-1000);
+ popup.setY(-1000);
+ popup.hide();
+ streamPreview.stop();
+ });
+ }
+
+ 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();
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/RecordedModelsTab.java b/client/src/main/java/ctbrec/ui/RecordedModelsTab.java
index 5874f8c2..ab693d3f 100644
--- a/client/src/main/java/ctbrec/ui/RecordedModelsTab.java
+++ b/client/src/main/java/ctbrec/ui/RecordedModelsTab.java
@@ -3,16 +3,15 @@ package ctbrec.ui;
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
-import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
-import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadFactory;
-import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -20,27 +19,36 @@ import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import ctbrec.Config;
import ctbrec.Model;
import ctbrec.Recording;
+import ctbrec.StringUtil;
import ctbrec.recorder.Recorder;
import ctbrec.sites.Site;
-import ctbrec.ui.autofilltextbox.AutoFillTextField;
-import javafx.application.Platform;
+import ctbrec.ui.action.FollowAction;
+import ctbrec.ui.action.PauseAction;
+import ctbrec.ui.action.PlayAction;
+import ctbrec.ui.action.ResumeAction;
+import ctbrec.ui.action.StopRecordingAction;
+import ctbrec.ui.controls.AutoFillTextField;
+import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import javafx.event.ActionEvent;
import javafx.geometry.Insets;
-import javafx.scene.Cursor;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Label;
import javafx.scene.control.MenuItem;
import javafx.scene.control.ScrollPane;
+import javafx.scene.control.SelectionMode;
import javafx.scene.control.Tab;
import javafx.scene.control.TableColumn;
+import javafx.scene.control.TableColumn.SortType;
+import javafx.scene.control.TableRow;
import javafx.scene.control.TableView;
import javafx.scene.control.Tooltip;
import javafx.scene.control.cell.CheckBoxTableCell;
@@ -50,6 +58,7 @@ import javafx.scene.input.ClipboardContent;
import javafx.scene.input.ContextMenuEvent;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
+import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.FlowPane;
@@ -59,9 +68,6 @@ import javafx.util.Duration;
public class RecordedModelsTab extends Tab implements TabSelectionListener {
private static final transient Logger LOG = LoggerFactory.getLogger(RecordedModelsTab.class);
- static BlockingQueue queue = new LinkedBlockingQueue<>();
- static ExecutorService threadPool = new ThreadPoolExecutor(2, 2, 10, TimeUnit.MINUTES, queue);
-
private ScheduledService> updateService;
private Recorder recorder;
private List sites;
@@ -75,6 +81,8 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener {
Label modelLabel = new Label("Model");
AutoFillTextField model;
Button addModelButton = new Button("Record");
+ Button pauseAll = new Button("Pause All");
+ Button resumeAll = new Button("Resume All");
public RecordedModelsTab(String title, Recorder recorder, List sites) {
super(title);
@@ -96,42 +104,74 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener {
scrollPane.setFitToWidth(true);
BorderPane.setMargin(scrollPane, new Insets(5));
- table.setEditable(false);
+
+ table.setEditable(true);
+ table.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
+ PreviewPopupHandler previewPopupHandler = new PreviewPopupHandler(table);
+ table.setRowFactory((tableview) -> {
+ TableRow row = new TableRow<>();
+ row.addEventHandler(MouseEvent.ANY, previewPopupHandler);
+ return row;
+ });
+ TableColumn preview = new TableColumn<>("🎥");
+ preview.setPrefWidth(35);
+ preview.setCellValueFactory(cdf -> new SimpleStringProperty(" â–¶ "));
+ preview.setEditable(false);
+ preview.setId("preview");
TableColumn name = new TableColumn<>("Model");
name.setPrefWidth(200);
- name.setCellValueFactory(new PropertyValueFactory("name"));
+ name.setCellValueFactory(new PropertyValueFactory("displayName"));
+ name.setEditable(false);
TableColumn url = new TableColumn<>("URL");
url.setCellValueFactory(new PropertyValueFactory("url"));
url.setPrefWidth(400);
+ url.setEditable(false);
TableColumn online = new TableColumn<>("Online");
- online.setCellValueFactory((cdf) -> cdf.getValue().getOnlineProperty());
+ online.setCellValueFactory(cdf -> cdf.getValue().getOnlineProperty());
online.setCellFactory(CheckBoxTableCell.forTableColumn(online));
online.setPrefWidth(100);
+ online.setEditable(false);
TableColumn recording = new TableColumn<>("Recording");
- recording.setCellValueFactory((cdf) -> cdf.getValue().getRecordingProperty());
+ recording.setCellValueFactory(cdf -> cdf.getValue().getRecordingProperty());
recording.setCellFactory(CheckBoxTableCell.forTableColumn(recording));
recording.setPrefWidth(100);
+ recording.setEditable(false);
TableColumn paused = new TableColumn<>("Paused");
- paused.setCellValueFactory((cdf) -> cdf.getValue().getPausedProperty());
+ paused.setCellValueFactory(cdf -> cdf.getValue().getPausedProperty());
paused.setCellFactory(CheckBoxTableCell.forTableColumn(paused));
paused.setPrefWidth(100);
- table.getColumns().addAll(name, url, online, recording, paused);
+ paused.setEditable(true);
+ table.getColumns().addAll(preview, name, url, online, recording, paused);
table.setItems(observableModels);
table.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, event -> {
popup = createContextMenu();
- if(popup != null) {
+ if (popup != null) {
popup.show(table, event.getScreenX(), event.getScreenY());
}
event.consume();
});
+ table.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
+ if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) {
+ JavaFxModel model = table.getSelectionModel().getSelectedItem();
+ if(model != null) {
+ new PlayAction(table, model).execute();
+ }
+ }
+ });
table.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
- if(popup != null) {
+ if (popup != null) {
popup.hide();
}
});
- table.addEventFilter(KeyEvent.KEY_PRESSED, event -> {
- if(event.getCode() == KeyCode.DELETE) {
- stopAction();
+ table.addEventHandler(KeyEvent.KEY_PRESSED, event -> {
+ List selectedModels = table.getSelectionModel().getSelectedItems();
+ if (event.getCode() == KeyCode.DELETE) {
+ stopAction(selectedModels);
+ } else if (event.getCode() == KeyCode.P) {
+ List pausedModels = selectedModels.stream().filter(m -> m.isSuspended()).collect(Collectors.toList());
+ List runningModels = selectedModels.stream().filter(m -> !m.isSuspended()).collect(Collectors.toList());
+ resumeRecording(pausedModels);
+ pauseRecording(runningModels);
}
});
scrollPane.setContent(table);
@@ -141,23 +181,65 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener {
ObservableList suggestions = FXCollections.observableArrayList();
sites.forEach(site -> suggestions.add(site.getName()));
model = new AutoFillTextField(suggestions);
- model.setPrefWidth(300);
- model.setPromptText("e.g. MyFreeCams:ModelName");
- model.onActionHandler(e -> addModel(e));
+ model.setPrefWidth(600);
+ model.setPromptText("e.g. MyFreeCams:ModelName or an URL like https://chaturbate.com/modelname/");
+ model.onActionHandler(this::addModel);
model.setTooltip(new Tooltip("To add a model enter SiteName:ModelName\n" +
"press ENTER to confirm a suggested site name"));
BorderPane.setMargin(addModelBox, new Insets(5));
- addModelButton.setOnAction((e) -> addModel(e));
- addModelBox.getChildren().addAll(modelLabel, model, addModelButton);
+ addModelButton.setOnAction(this::addModel);
+ addModelBox.getChildren().addAll(modelLabel, model, addModelButton, pauseAll, resumeAll);
+ HBox.setMargin(pauseAll, new Insets(0, 0, 0, 20));
+ pauseAll.setOnAction(this::pauseAll);
+ resumeAll.setOnAction(this::resumeAll);
BorderPane root = new BorderPane();
root.setPadding(new Insets(5));
root.setTop(addModelBox);
root.setCenter(scrollPane);
setContent(root);
+
+ restoreState();
}
private void addModel(ActionEvent e) {
+ String input = model.getText();
+ if (StringUtil.isBlank(input)) {
+ return;
+ }
+
+ if (input.startsWith("http")) {
+ addModelByUrl(input);
+ } else {
+ addModelByName(input);
+ }
+ };
+
+ private void addModelByUrl(String url) {
+ for (Site site : sites) {
+ Model model = site.createModelFromUrl(url);
+ if (model != null) {
+ try {
+ recorder.startRecording(model);
+ } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) {
+ Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
+ alert.setTitle("Error");
+ alert.setHeaderText("Couldn't add model");
+ alert.setContentText("The model " + model.getName() + " could not be added: " + e1.getLocalizedMessage());
+ alert.showAndWait();
+ }
+ return;
+ }
+ }
+
+ Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
+ alert.setTitle("Unknown URL format");
+ alert.setHeaderText("Couldn't add model");
+ alert.setContentText("The URL you entered has an unknown format or the function does not support this site, yet");
+ alert.showAndWait();
+ }
+
+ private void addModelByName(String siteModelCombo) {
String[] parts = model.getText().trim().split(":");
if (parts.length != 2) {
Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
@@ -191,15 +273,22 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener {
alert.setHeaderText("Couldn't add model");
alert.setContentText("The site you entered is unknown");
alert.showAndWait();
- };
+ }
+ private void pauseAll(ActionEvent evt) {
+ new PauseAction(getTabPane(), recorder.getModelsRecording(), recorder).execute();
+ }
+
+ private void resumeAll(ActionEvent evt) {
+ new ResumeAction(getTabPane(), recorder.getModelsRecording(), recorder).execute();
+ }
void initializeUpdateService() {
updateService = createUpdateService();
updateService.setPeriod(new Duration(TimeUnit.SECONDS.toMillis(2)));
updateService.setOnSucceeded((event) -> {
List models = updateService.getValue();
- if(models == null) {
+ if (models == null) {
return;
}
@@ -207,6 +296,17 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener {
int index = observableModels.indexOf(updatedModel);
if (index == -1) {
observableModels.add(updatedModel);
+ updatedModel.getPausedProperty().addListener((obs, oldV, newV) -> {
+ if (newV) {
+ if(!recorder.isSuspended(updatedModel)) {
+ pauseRecording(Collections.singletonList(updatedModel));
+ }
+ } else {
+ if(recorder.isSuspended(updatedModel)) {
+ resumeRecording(Collections.singletonList(updatedModel));
+ }
+ }
+ });
} else {
// make sure to update the JavaFX online property, so that the table cell is updated
JavaFxModel oldModel = observableModels.get(index);
@@ -222,6 +322,8 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener {
iterator.remove();
}
}
+
+ table.sort();
});
updateService.setOnFailed((event) -> {
LOG.info("Couldn't get list of models from recorder", event.getSource().getException());
@@ -243,7 +345,7 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener {
.map(m -> new JavaFxModel(m))
.peek(fxm -> {
for (Recording recording : recordings) {
- if(recording.getStatus() == Recording.STATUS.RECORDING &&
+ if(recording.getStatus() == Recording.State.RECORDING &&
recording.getModelName().equals(fxm.getName()))
{
fxm.getRecordingProperty().set(true);
@@ -292,16 +394,16 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener {
}
private ContextMenu createContextMenu() {
- JavaFxModel selectedModel = table.getSelectionModel().getSelectedItem();
- if(selectedModel == null) {
+ ObservableList selectedModels = table.getSelectionModel().getSelectedItems();
+ if (selectedModels.isEmpty()) {
return null;
}
MenuItem stop = new MenuItem("Remove Model");
- stop.setOnAction((e) -> stopAction());
+ stop.setOnAction((e) -> stopAction(selectedModels));
MenuItem copyUrl = new MenuItem("Copy URL");
copyUrl.setOnAction((e) -> {
- Model selected = selectedModel;
+ Model selected = selectedModels.get(0);
final Clipboard clipboard = Clipboard.getSystemClipboard();
final ClipboardContent content = new ClipboardContent();
content.putString(selected.getUrl());
@@ -309,33 +411,47 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener {
});
MenuItem pauseRecording = new MenuItem("Pause Recording");
- pauseRecording.setOnAction((e) -> pauseRecording());
+ pauseRecording.setOnAction((e) -> pauseRecording(selectedModels));
MenuItem resumeRecording = new MenuItem("Resume Recording");
- resumeRecording.setOnAction((e) -> resumeRecording());
+ resumeRecording.setOnAction((e) -> resumeRecording(selectedModels));
MenuItem openInBrowser = new MenuItem("Open in Browser");
- openInBrowser.setOnAction((e) -> DesktopIntegration.open(selectedModel.getUrl()));
+ openInBrowser.setOnAction((e) -> DesktopIntegration.open(selectedModels.get(0).getUrl()));
MenuItem openInPlayer = new MenuItem("Open in Player");
- openInPlayer.setOnAction((e) -> openInPlayer(selectedModel));
+ openInPlayer.setOnAction((e) -> openInPlayer(selectedModels.get(0)));
MenuItem switchStreamSource = new MenuItem("Switch resolution");
- switchStreamSource.setOnAction((e) -> switchStreamSource(selectedModel));
+ switchStreamSource.setOnAction((e) -> switchStreamSource(selectedModels.get(0)));
+ MenuItem follow = new MenuItem("Follow");
+ follow.setOnAction((e) -> follow(selectedModels));
ContextMenu menu = new ContextMenu(stop);
- menu.getItems().add(selectedModel.isSuspended() ? resumeRecording : pauseRecording);
- menu.getItems().addAll(copyUrl, openInPlayer, openInBrowser, switchStreamSource);
+ if (selectedModels.size() == 1) {
+ menu.getItems().add(selectedModels.get(0).isSuspended() ? resumeRecording : pauseRecording);
+ } else {
+ menu.getItems().addAll(resumeRecording, pauseRecording);
+ }
+ menu.getItems().addAll(copyUrl, openInPlayer, openInBrowser, switchStreamSource, follow);
+
+ if (selectedModels.size() > 1) {
+ copyUrl.setDisable(true);
+ openInPlayer.setDisable(true);
+ openInBrowser.setDisable(true);
+ switchStreamSource.setDisable(true);
+ }
+
return menu;
}
+ private void follow(ObservableList selectedModels) {
+ new FollowAction(getTabPane(), new ArrayList(selectedModels)).execute();
+ }
+
private void openInPlayer(JavaFxModel selectedModel) {
- table.setCursor(Cursor.WAIT);
- new Thread(() -> {
- Player.play(selectedModel);
- Platform.runLater(() -> table.setCursor(Cursor.DEFAULT));
- }).start();
+ new PlayAction(getTabPane(), selectedModel).execute();
}
private void switchStreamSource(JavaFxModel fxModel) {
try {
- if(!fxModel.isOnline()) {
+ if (!fxModel.isOnline()) {
Alert alert = new AutosizeAlert(Alert.AlertType.INFORMATION);
alert.setTitle("Switch resolution");
alert.setHeaderText("Couldn't switch stream resolution");
@@ -370,93 +486,65 @@ public class RecordedModelsTab extends Tab implements TabSelectionListener {
}
private void showStreamSwitchErrorDialog(Throwable throwable) {
+ showErrorDialog(throwable, "Couldn't switch stream resolution", "Error while switching stream resolution");
+ }
+
+ private void showErrorDialog(Throwable throwable, String header, String msg) {
Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
alert.setTitle("Error");
- alert.setHeaderText("Couldn't switch stream resolution");
- alert.setContentText("Error while switching stream resolution: " + throwable.getLocalizedMessage());
+ alert.setHeaderText(header);
+ alert.setContentText(msg + ": " + throwable.getLocalizedMessage());
alert.showAndWait();
}
- private void stopAction() {
- Model selected = table.getSelectionModel().getSelectedItem().getDelegate();
- if (selected != null) {
- table.setCursor(Cursor.WAIT);
- new Thread() {
- @Override
- public void run() {
- try {
- recorder.stopRecording(selected);
- observableModels.remove(selected);
- } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) {
- LOG.error("Couldn't stop recording", e1);
- Platform.runLater(() -> {
- Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
- alert.setTitle("Error");
- alert.setHeaderText("Couldn't stop recording");
- alert.setContentText("Error while stopping the recording: " + e1.getLocalizedMessage());
- alert.showAndWait();
- });
- } finally {
- table.setCursor(Cursor.DEFAULT);
- }
- }
- }.start();
- }
+ private void stopAction(List selectedModels) {
+ List models = selectedModels.stream().map(jfxm -> jfxm.getDelegate()).collect(Collectors.toList());
+ new StopRecordingAction(getTabPane(), models, recorder).execute((m) -> {
+ observableModels.remove(m);
+ });
};
- private void pauseRecording() {
- JavaFxModel model = table.getSelectionModel().getSelectedItem();
- Model delegate = table.getSelectionModel().getSelectedItem().getDelegate();
- if (delegate != null) {
- table.setCursor(Cursor.WAIT);
- new Thread() {
- @Override
- public void run() {
- try {
- recorder.suspendRecording(delegate);
- Platform.runLater(() -> model.setSuspended(true));
- } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) {
- LOG.error("Couldn't pause recording", e1);
- Platform.runLater(() -> {
- Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
- alert.setTitle("Error");
- alert.setHeaderText("Couldn't pause recording");
- alert.setContentText("Error while pausing the recording: " + e1.getLocalizedMessage());
- alert.showAndWait();
- });
- } finally {
- table.setCursor(Cursor.DEFAULT);
- }
- }
- }.start();
- }
+ private void pauseRecording(List selectedModels) {
+ List models = selectedModels.stream().map(jfxm -> jfxm.getDelegate()).collect(Collectors.toList());
+ new PauseAction(getTabPane(), models, recorder).execute();
};
- private void resumeRecording() {
- JavaFxModel model = table.getSelectionModel().getSelectedItem();
- Model delegate = table.getSelectionModel().getSelectedItem().getDelegate();
- if (delegate != null) {
- table.setCursor(Cursor.WAIT);
- new Thread() {
- @Override
- public void run() {
- try {
- recorder.resumeRecording(delegate);
- Platform.runLater(() -> model.setSuspended(false));
- } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) {
- LOG.error("Couldn't resume recording", e1);
- Platform.runLater(() -> {
- Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
- alert.setTitle("Error");
- alert.setHeaderText("Couldn't resume recording");
- alert.setContentText("Error while resuming the recording: " + e1.getLocalizedMessage());
- alert.showAndWait();
- });
- } finally {
- table.setCursor(Cursor.DEFAULT);
- }
- }
- }.start();
+ private void resumeRecording(List selectedModels) {
+ List models = selectedModels.stream().map(jfxm -> jfxm.getDelegate()).collect(Collectors.toList());
+ new ResumeAction(getTabPane(), models, recorder).execute();
+ }
+
+ public void saveState() {
+ if (!table.getSortOrder().isEmpty()) {
+ TableColumn col = table.getSortOrder().get(0);
+ Config.getInstance().getSettings().recordedModelsSortColumn = col.getText();
+ Config.getInstance().getSettings().recordedModelsSortType = col.getSortType().toString();
}
+ double[] columnWidths = new double[table.getColumns().size()];
+ for (int i = 0; i < columnWidths.length; i++) {
+ columnWidths[i] = table.getColumns().get(i).getWidth();
+ }
+ Config.getInstance().getSettings().recordedModelsColumnWidths = columnWidths;
};
+
+ private void restoreState() {
+ String sortCol = Config.getInstance().getSettings().recordedModelsSortColumn;
+ if (StringUtil.isNotBlank(sortCol)) {
+ for (TableColumn col : table.getColumns()) {
+ if (Objects.equals(sortCol, col.getText())) {
+ col.setSortType(SortType.valueOf(Config.getInstance().getSettings().recordedModelsSortType));
+ table.getSortOrder().clear();
+ table.getSortOrder().add(col);
+ break;
+ }
+ }
+ }
+
+ double[] columnWidths = Config.getInstance().getSettings().recordedModelsColumnWidths;
+ if (columnWidths != null && columnWidths.length == table.getColumns().size()) {
+ for (int i = 0; i < columnWidths.length; i++) {
+ table.getColumns().get(i).setPrefWidth(columnWidths[i]);
+ }
+ }
+ }
}
diff --git a/client/src/main/java/ctbrec/ui/RecordingsTab.java b/client/src/main/java/ctbrec/ui/RecordingsTab.java
index aba6a66e..46bfb00f 100644
--- a/client/src/main/java/ctbrec/ui/RecordingsTab.java
+++ b/client/src/main/java/ctbrec/ui/RecordingsTab.java
@@ -1,13 +1,16 @@
package ctbrec.ui;
+import static ctbrec.Recording.State.*;
import static javafx.scene.control.ButtonType.*;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.URL;
+import java.nio.file.NoSuchFileException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
+import java.text.DecimalFormat;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
@@ -16,10 +19,13 @@ import java.time.format.FormatStyle;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
+import java.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -29,10 +35,12 @@ import com.iheartradio.m3u8.PlaylistException;
import ctbrec.Config;
import ctbrec.Recording;
-import ctbrec.Recording.STATUS;
+import ctbrec.Recording.State;
+import ctbrec.StringUtil;
import ctbrec.recorder.Recorder;
import ctbrec.recorder.download.MergedHlsDownload;
import ctbrec.sites.Site;
+import ctbrec.ui.controls.Toast;
import javafx.application.Platform;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
@@ -44,19 +52,28 @@ import javafx.scene.Cursor;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.ButtonType;
import javafx.scene.control.ContextMenu;
+import javafx.scene.control.Label;
import javafx.scene.control.MenuItem;
+import javafx.scene.control.ProgressBar;
import javafx.scene.control.ScrollPane;
+import javafx.scene.control.SelectionMode;
import javafx.scene.control.Tab;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
+import javafx.scene.control.TableColumn.SortType;
import javafx.scene.control.TableView;
+import javafx.scene.control.Tooltip;
import javafx.scene.control.cell.PropertyValueFactory;
import javafx.scene.input.ContextMenuEvent;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
+import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.FlowPane;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.StackPane;
+import javafx.scene.text.Font;
import javafx.stage.FileChooser;
import javafx.util.Callback;
import javafx.util.Duration;
@@ -69,12 +86,17 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
private Recorder recorder;
@SuppressWarnings("unused")
private List sites;
+ private long spaceTotal = -1;
+ private long spaceFree = -1;
FlowPane grid = new FlowPane();
ScrollPane scrollPane = new ScrollPane();
TableView table = new TableView();
ObservableList observableRecordings = FXCollections.observableArrayList();
ContextMenu popup;
+ ProgressBar spaceLeft;
+ Label spaceLabel;
+ Lock recordingsLock = new ReentrantLock();
public RecordingsTab(String title, Recorder recorder, Config config, List sites) {
super(title);
@@ -98,6 +120,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
BorderPane.setMargin(scrollPane, new Insets(5));
table.setEditable(false);
+ table.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
TableColumn name = new TableColumn<>("Model");
name.setPrefWidth(200);
name.setCellValueFactory(new PropertyValueFactory("modelName"));
@@ -131,16 +154,42 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
TableColumn progress = new TableColumn<>("Progress");
progress.setCellValueFactory((cdf) -> cdf.getValue().getProgressProperty());
progress.setPrefWidth(100);
- TableColumn size = new TableColumn<>("Size");
- size.setCellValueFactory((cdf) -> cdf.getValue().getSizeProperty());
+ TableColumn size = new TableColumn<>("Size");
+ size.setStyle("-fx-alignment: CENTER-RIGHT;");
size.setPrefWidth(100);
+ size.setCellValueFactory(cdf -> cdf.getValue().getSizeProperty());
+ size.setCellFactory(new Callback, TableCell>() {
+ @Override
+ public TableCell call(TableColumn param) {
+ TableCell cell = new TableCell() {
+ @Override
+ protected void updateItem(Number sizeInByte, boolean empty) {
+ if(empty || sizeInByte == null) {
+ setText(null);
+ setStyle(null);
+ } else {
+ setText(StringUtil.formatSize(sizeInByte));
+ setStyle("-fx-alignment: CENTER-RIGHT;");
+ if(Objects.equals(System.getenv("CTBREC_DEV"), "1")) {
+ int row = this.getTableRow().getIndex();
+ JavaFxRecording rec = tableViewProperty().get().getItems().get(row);
+ if(!rec.valueChanged() && rec.getStatus() == State.RECORDING) {
+ setStyle("-fx-alignment: CENTER-RIGHT; -fx-background-color: red");
+ }
+ }
+ }
+ }
+ };
+ return cell;
+ }
+ });
table.getColumns().addAll(name, date, status, progress, size);
table.setItems(observableRecordings);
table.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, event -> {
- Recording recording = table.getSelectionModel().getSelectedItem();
- if(recording != null) {
- popup = createContextMenu(recording);
+ List recordings = table.getSelectionModel().getSelectedItems();
+ if(recordings != null && !recordings.isEmpty()) {
+ popup = createContextMenu(recordings);
if(!popup.getItems().isEmpty()) {
popup.show(table, event.getScreenX(), event.getScreenY());
}
@@ -152,35 +201,89 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
popup.hide();
}
});
+ table.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> {
+ if(event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) {
+ Recording recording = table.getSelectionModel().getSelectedItem();
+ if(recording != null) {
+ play(recording);
+ }
+ }
+ });
table.addEventFilter(KeyEvent.KEY_PRESSED, event -> {
- JavaFxRecording recording = table.getSelectionModel().getSelectedItem();
- if (recording != null) {
+ List recordings = table.getSelectionModel().getSelectedItems();
+ if (recordings != null && !recordings.isEmpty()) {
if (event.getCode() == KeyCode.DELETE) {
- delete(recording);
+ if(recordings.size() > 1 || recordings.get(0).getStatus() == State.FINISHED) {
+ delete(recordings);
+ }
} else if (event.getCode() == KeyCode.ENTER) {
- if(recording.getStatus() == STATUS.FINISHED) {
- play(recording);
+ if(recordings.get(0).getStatus() == State.FINISHED) {
+ play(recordings.get(0));
}
}
}
});
scrollPane.setContent(table);
+ HBox spaceBox = new HBox(5);
+ Label l = new Label("Space left on device");
+ HBox.setMargin(l, new Insets(2, 0, 0, 0));
+ spaceBox.getChildren().add(l);
+ spaceLeft = new ProgressBar(0);
+ spaceLeft.setPrefSize(200, 22);
+ spaceLabel = new Label();
+ spaceLabel.setFont(Font.font(11));
+ StackPane stack = new StackPane(spaceLeft, spaceLabel);
+ spaceBox.getChildren().add(stack);
+ BorderPane.setMargin(spaceBox, new Insets(5));
+
BorderPane root = new BorderPane();
root.setPadding(new Insets(5));
+ root.setTop(spaceBox);
root.setCenter(scrollPane);
setContent(root);
+
+ restoreState();
}
void initializeUpdateService() {
updateService = createUpdateService();
updateService.setPeriod(new Duration(TimeUnit.SECONDS.toMillis(2)));
updateService.setOnSucceeded((event) -> {
- List recordings = updateService.getValue();
- if (recordings == null) {
- return;
- }
+ updateRecordingsTable();
+ updateFreeSpaceDisplay();
+ });
+ updateService.setOnFailed((event) -> {
+ LOG.info("Couldn't get list of recordings from recorder", event.getSource().getException());
+ AutosizeAlert autosizeAlert = new AutosizeAlert(AlertType.ERROR);
+ autosizeAlert.setTitle("Whoopsie!");
+ autosizeAlert.setHeaderText("Recordings not available");
+ autosizeAlert.setContentText("An error occured while retrieving the list of recordings");
+ autosizeAlert.showAndWait();
+ });
+ }
+ private void updateFreeSpaceDisplay() {
+ if(spaceTotal != -1 && spaceFree != -1) {
+ double free = ((double)spaceFree) / spaceTotal;
+ spaceLeft.setProgress(free);
+ double totalGiB = ((double) spaceTotal) / 1024 / 1024 / 1024;
+ double freeGiB = ((double) spaceFree) / 1024 / 1024 / 1024;
+ DecimalFormat df = new DecimalFormat("0.00");
+ String tt = df.format(freeGiB) + " / " + df.format(totalGiB) + " GiB";
+ spaceLeft.setTooltip(new Tooltip(tt));
+ spaceLabel.setText(tt);
+ }
+ }
+
+ private void updateRecordingsTable() {
+ List recordings = updateService.getValue();
+ if (recordings == null) {
+ return;
+ }
+
+ recordingsLock.lock();
+ try {
for (Iterator iterator = observableRecordings.iterator(); iterator.hasNext();) {
JavaFxRecording old = iterator.next();
if (!recordings.contains(old)) {
@@ -199,16 +302,10 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
old.update(recording);
}
}
- table.sort();
- });
- updateService.setOnFailed((event) -> {
- LOG.info("Couldn't get list of recordings from recorder", event.getSource().getException());
- AutosizeAlert autosizeAlert = new AutosizeAlert(AlertType.ERROR);
- autosizeAlert.setTitle("Whoopsie!");
- autosizeAlert.setHeaderText("Recordings not available");
- autosizeAlert.setContentText("An error occured while retrieving the list of recordings");
- autosizeAlert.showAndWait();
- });
+ } finally {
+ recordingsLock.unlock();
+ }
+ table.sort();
}
private ScheduledService> createUpdateService() {
@@ -218,12 +315,27 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
return new Task>() {
@Override
public List call() throws IOException, InvalidKeyException, NoSuchAlgorithmException, IllegalStateException {
+ updateSpace();
+
List recordings = new ArrayList<>();
for (Recording rec : recorder.getRecordings()) {
recordings.add(new JavaFxRecording(rec));
}
return recordings;
}
+
+ private void updateSpace() {
+ try {
+ spaceTotal = recorder.getTotalSpaceBytes();
+ spaceFree = recorder.getFreeSpaceBytes();
+ Platform.runLater(() -> spaceLeft.setTooltip(new Tooltip()));
+ } catch (NoSuchFileException e) {
+ // recordings dir does not exist
+ Platform.runLater(() -> spaceLeft.setTooltip(new Tooltip("Recordings directory does not exist")));
+ } catch (IOException e) {
+ LOG.error("Couldn't update free space", e);
+ }
+ }
};
}
};
@@ -255,7 +367,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
}
}
- private ContextMenu createContextMenu(Recording recording) {
+ private ContextMenu createContextMenu(List recordings) {
ContextMenu contextMenu = new ContextMenu();
contextMenu.setHideOnEscape(true);
contextMenu.setAutoHide(true);
@@ -263,9 +375,9 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
MenuItem openInPlayer = new MenuItem("Open in Player");
openInPlayer.setOnAction((e) -> {
- play(recording);
+ play(recordings.get(0));
});
- if(recording.getStatus() == STATUS.FINISHED || Config.getInstance().getSettings().localRecording) {
+ if(recordings.get(0).getStatus() == State.FINISHED || Config.getInstance().getSettings().localRecording) {
contextMenu.getItems().add(openInPlayer);
}
@@ -285,16 +397,16 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
MenuItem deleteRecording = new MenuItem("Delete");
deleteRecording.setOnAction((e) -> {
- delete(recording);
+ delete(recordings);
});
- if(recording.getStatus() == STATUS.FINISHED) {
+ if(recordings.get(0).getStatus() == State.FINISHED || recordings.size() > 1) {
contextMenu.getItems().add(deleteRecording);
}
MenuItem openDir = new MenuItem("Open directory");
openDir.setOnAction((e) -> {
String recordingsDir = Config.getInstance().getSettings().recordingsDir;
- String path = recording.getPath();
+ String path = recordings.get(0).getPath();
File tsFile = new File(recordingsDir, path);
new Thread(() -> {
DesktopIntegration.open(tsFile.getParent());
@@ -307,16 +419,22 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
MenuItem downloadRecording = new MenuItem("Download");
downloadRecording.setOnAction((e) -> {
try {
- download(recording);
+ download(recordings.get(0));
} catch (IOException | ParseException | PlaylistException e1) {
showErrorDialog("Error while downloading recording", "The recording could not be downloaded", e1);
LOG.error("Error while downloading recording", e1);
}
});
- if (!Config.getInstance().getSettings().localRecording && recording.getStatus() == STATUS.FINISHED) {
+ if (!Config.getInstance().getSettings().localRecording && recordings.get(0).getStatus() == State.FINISHED) {
contextMenu.getItems().add(downloadRecording);
}
+ if(recordings.size() > 1) {
+ openInPlayer.setDisable(true);
+ openDir.setDisable(true);
+ downloadRecording.setDisable(true);
+ }
+
return contextMenu;
}
@@ -346,11 +464,11 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
download.start(url.toString(), target, (progress) -> {
Platform.runLater(() -> {
if (progress == 100) {
- recording.setStatus(STATUS.FINISHED);
+ recording.setStatus(FINISHED);
recording.setProgress(-1);
LOG.debug("Download finished for recording {}", recording.getPath());
} else {
- recording.setStatus(STATUS.DOWNLOADING);
+ recording.setStatus(DOWNLOADING);
recording.setProgress(progress);
}
});
@@ -365,7 +483,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
Platform.runLater(new Runnable() {
@Override
public void run() {
- recording.setStatus(STATUS.FINISHED);
+ recording.setStatus(FINISHED);
recording.setProgress(-1);
}
});
@@ -376,85 +494,11 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
t.setName("Download Thread " + recording.getPath());
t.start();
- recording.setStatus(STATUS.DOWNLOADING);
+ recording.setStatus(State.DOWNLOADING);
recording.setProgress(0);
}
}
- // private void download(Recording recording) throws IOException, ParseException, PlaylistException {
- // String filename = recording.getPath().replaceAll("/", "-") + ".ts";
- // FileChooser chooser = new FileChooser();
- // chooser.setInitialFileName(filename);
- // if(config.getSettings().lastDownloadDir != null && !config.getSettings().lastDownloadDir.equals("")) {
- // File dir = new File(config.getSettings().lastDownloadDir);
- // while(!dir.exists()) {
- // dir = dir.getParentFile();
- // }
- // chooser.setInitialDirectory(dir);
- // }
- // File target = chooser.showSaveDialog(null);
- // if(target != null) {
- // config.getSettings().lastDownloadDir = target.getParent();
- // String hlsBase = "http://" + config.getSettings().httpServer + ":" + config.getSettings().httpPort + "/hls";
- // URL url = new URL(hlsBase + "/" + recording.getPath() + "/playlist.m3u8");
- // LOG.info("Downloading {}", recording.getPath());
- //
- // PlaylistParser parser = new PlaylistParser(url.openStream(), Format.EXT_M3U, Encoding.UTF_8);
- // Playlist playlist = parser.parse();
- // MediaPlaylist mediaPlaylist = playlist.getMediaPlaylist();
- // List tracks = mediaPlaylist.getTracks();
- // List segmentUris = new ArrayList<>();
- // for (TrackData trackData : tracks) {
- // String segmentUri = hlsBase + "/" + recording.getPath() + "/" + trackData.getUri();
- // segmentUris.add(segmentUri);
- // }
- //
- // Thread t = new Thread() {
- // @Override
- // public void run() {
- // try(FileOutputStream fos = new FileOutputStream(target)) {
- // for (int i = 0; i < segmentUris.size(); i++) {
- // URL segment = new URL(segmentUris.get(i));
- // InputStream in = segment.openStream();
- // byte[] b = new byte[1024];
- // int length = -1;
- // while( (length = in.read(b)) >= 0 ) {
- // fos.write(b, 0, length);
- // }
- // in.close();
- // int progress = (int) (i * 100.0 / segmentUris.size());
- // Platform.runLater(new Runnable() {
- // @Override
- // public void run() {
- // recording.setStatus(STATUS.DOWNLOADING);
- // recording.setProgress(progress);
- // }
- // });
- // }
- //
- // } catch (FileNotFoundException e) {
- // showErrorDialog("Error while downloading recording", "The target file couldn't be created", e);
- // LOG.error("Error while downloading recording", e);
- // } catch (IOException e) {
- // showErrorDialog("Error while downloading recording", "The recording could not be downloaded", e);
- // LOG.error("Error while downloading recording", e);
- // } finally {
- // Platform.runLater(new Runnable() {
- // @Override
- // public void run() {
- // recording.setStatus(STATUS.FINISHED);
- // recording.setProgress(-1);
- // }
- // });
- // }
- // }
- // };
- // t.setDaemon(true);
- // t.setName("Download Thread " + recording.getPath());
- // t.start();
- // }
- // }
-
private void showErrorDialog(final String title, final String msg, final Exception e) {
Platform.runLater(new Runnable() {
@Override
@@ -474,7 +518,10 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
new Thread() {
@Override
public void run() {
- Player.play(recording);
+ boolean started = Player.play(recording);
+ if(started && Config.getInstance().getSettings().showPlayerStarting) {
+ Platform.runLater(() -> Toast.makeText(getTabPane().getScene(), "Starting Player", 2000, 500, 500));
+ }
}
}.start();
} else {
@@ -483,19 +530,26 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
new Thread() {
@Override
public void run() {
- Player.play(url);
+ boolean started = Player.play(url);
+ if(started && Config.getInstance().getSettings().showPlayerStarting) {
+ Platform.runLater(() -> Toast.makeText(getTabPane().getScene(), "Starting Player", 2000, 500, 500));
+ }
}
}.start();
}
}
- private void delete(Recording r) {
- if(r.getStatus() != STATUS.FINISHED) {
- return;
- }
+ private void delete(List recordings) {
table.setCursor(Cursor.WAIT);
- String msg = "Delete " + r.getModelName() + "/" + r.getStartDate() + " for good?";
+
+ String msg;
+ if(recordings.size() > 1) {
+ msg = "Delete " + recordings.size() + " recordings for good?";
+ } else {
+ Recording r = recordings.get(0);
+ msg = "Delete " + r.getModelName() + "/" + r.getStartDate() + " for good?";
+ }
AutosizeAlert confirm = new AutosizeAlert(AlertType.CONFIRMATION, msg, YES, NO);
confirm.setTitle("Delete recording?");
confirm.setHeaderText(msg);
@@ -505,14 +559,26 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
Thread deleteThread = new Thread() {
@Override
public void run() {
+ recordingsLock.lock();
try {
- recorder.delete(r);
- Platform.runLater(() -> observableRecordings.remove(r));
- } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) {
- LOG.error("Error while deleting recording", e1);
- showErrorDialog("Error while deleting recording", "Recording not deleted", e1);
+ List deleted = new ArrayList<>();
+ for (Iterator iterator = recordings.iterator(); iterator.hasNext();) {
+ JavaFxRecording r = iterator.next();
+ if(r.getStatus() != FINISHED) {
+ continue;
+ }
+ try {
+ recorder.delete(r);
+ deleted.add(r);
+ } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) {
+ LOG.error("Error while deleting recording", e1);
+ showErrorDialog("Error while deleting recording", "Recording not deleted", e1);
+ }
+ }
+ observableRecordings.removeAll(deleted);
} finally {
- table.setCursor(Cursor.DEFAULT);
+ recordingsLock.unlock();
+ Platform.runLater(() -> table.setCursor(Cursor.DEFAULT));
}
}
};
@@ -521,4 +587,38 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
table.setCursor(Cursor.DEFAULT);
}
}
+
+ public void saveState() {
+ if(!table.getSortOrder().isEmpty()) {
+ TableColumn col = table.getSortOrder().get(0);
+ Config.getInstance().getSettings().recordingsSortColumn = col.getText();
+ Config.getInstance().getSettings().recordingsSortType = col.getSortType().toString();
+ }
+ double[] columnWidths = new double[table.getColumns().size()];
+ for (int i = 0; i < columnWidths.length; i++) {
+ columnWidths[i] = table.getColumns().get(i).getWidth();
+ }
+ Config.getInstance().getSettings().recordingsColumnWidths = columnWidths;
+ };
+
+ private void restoreState() {
+ String sortCol = Config.getInstance().getSettings().recordingsSortColumn;
+ if(StringUtil.isNotBlank(sortCol)) {
+ for (TableColumn col : table.getColumns()) {
+ if(Objects.equals(sortCol, col.getText())) {
+ col.setSortType(SortType.valueOf(Config.getInstance().getSettings().recordingsSortType));
+ table.getSortOrder().clear();
+ table.getSortOrder().add(col);
+ break;
+ }
+ }
+ }
+
+ double[] columnWidths = Config.getInstance().getSettings().recordingsColumnWidths;
+ if(columnWidths != null && columnWidths.length == table.getColumns().size()) {
+ for (int i = 0; i < columnWidths.length; i++) {
+ table.getColumns().get(i).setPrefWidth(columnWidths[i]);
+ }
+ }
+ }
}
diff --git a/client/src/main/java/ctbrec/ui/SettingsTab.java b/client/src/main/java/ctbrec/ui/SettingsTab.java
deleted file mode 100644
index 87ecf4b9..00000000
--- a/client/src/main/java/ctbrec/ui/SettingsTab.java
+++ /dev/null
@@ -1,613 +0,0 @@
-package ctbrec.ui;
-
-import static ctbrec.Settings.DirectoryStructure.*;
-
-import java.io.File;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import ctbrec.Config;
-import ctbrec.Hmac;
-import ctbrec.Settings;
-import ctbrec.Settings.DirectoryStructure;
-import ctbrec.sites.ConfigUI;
-import ctbrec.sites.Site;
-import javafx.beans.value.ChangeListener;
-import javafx.beans.value.ObservableValue;
-import javafx.collections.FXCollections;
-import javafx.geometry.HPos;
-import javafx.geometry.Insets;
-import javafx.scene.Node;
-import javafx.scene.control.Accordion;
-import javafx.scene.control.Alert;
-import javafx.scene.control.Button;
-import javafx.scene.control.CheckBox;
-import javafx.scene.control.ComboBox;
-import javafx.scene.control.Label;
-import javafx.scene.control.RadioButton;
-import javafx.scene.control.ScrollPane;
-import javafx.scene.control.Tab;
-import javafx.scene.control.TextField;
-import javafx.scene.control.TextInputDialog;
-import javafx.scene.control.TitledPane;
-import javafx.scene.control.ToggleGroup;
-import javafx.scene.control.Tooltip;
-import javafx.scene.layout.Border;
-import javafx.scene.layout.BorderStroke;
-import javafx.scene.layout.BorderStrokeStyle;
-import javafx.scene.layout.BorderWidths;
-import javafx.scene.layout.ColumnConstraints;
-import javafx.scene.layout.CornerRadii;
-import javafx.scene.layout.GridPane;
-import javafx.scene.layout.Priority;
-import javafx.scene.layout.VBox;
-import javafx.scene.paint.Color;
-import javafx.scene.text.Font;
-import javafx.stage.DirectoryChooser;
-import javafx.stage.FileChooser;;
-
-public class SettingsTab extends Tab implements TabSelectionListener {
-
- private static final transient Logger LOG = LoggerFactory.getLogger(SettingsTab.class);
-
- public static final int CHECKBOX_MARGIN = 6;
- private TextField recordingsDirectory;
- private Button recordingsDirectoryButton;
- private Button postProcessingDirectoryButton;
- private TextField mediaPlayer;
- private TextField postProcessing;
- private TextField server;
- private TextField port;
- private CheckBox loadResolution;
- private CheckBox secureCommunication = new CheckBox();
- private CheckBox chooseStreamQuality = new CheckBox();
- private CheckBox multiplePlayers = new CheckBox();
- private RadioButton recordLocal;
- private RadioButton recordRemote;
- private ToggleGroup recordLocation;
- private ProxySettingsPane proxySettingsPane;
- private ComboBox maxResolution;
- private ComboBox splitAfter;
- private ComboBox directoryStructure;
- private List sites;
- private Label restartLabel;
- private Accordion credentialsAccordion = new Accordion();
-
- public SettingsTab(List sites) {
- this.sites = sites;
- setText("Settings");
- createGui();
- setClosable(false);
- setRecordingMode(recordLocal.isSelected());
- }
-
- private void createGui() {
- // set up main layout, 2 columns with VBoxes 50/50
- GridPane mainLayout = createGridLayout();
- mainLayout.setHgap(15);
- mainLayout.setVgap(15);
- mainLayout.setPadding(new Insets(15));
- ColumnConstraints cc = new ColumnConstraints();
- cc.setPercentWidth(50);
- mainLayout.getColumnConstraints().setAll(cc, cc);
- setContent(new ScrollPane(mainLayout));
- VBox leftSide = new VBox(15);
- VBox rightSide = new VBox(15);
- GridPane.setHgrow(leftSide, Priority.ALWAYS);
- GridPane.setHgrow(rightSide, Priority.ALWAYS);
- GridPane.setFillWidth(leftSide, true);
- GridPane.setFillWidth(rightSide, true);
- mainLayout.add(leftSide, 0, 1);
- mainLayout.add(rightSide, 1, 1);
-
- // restart info label
- restartLabel = new Label("A restart is required to apply changes you made!");
- restartLabel.setVisible(false);
- restartLabel.setFont(Font.font(24));
- restartLabel.setTextFill(Color.RED);
- mainLayout.add(restartLabel, 0, 0);
- GridPane.setColumnSpan(restartLabel, 2);
- GridPane.setHalignment(restartLabel, HPos.CENTER);
-
- // left side
- leftSide.getChildren().add(createGeneralPanel());
- leftSide.getChildren().add(createLocationsPanel());
- leftSide.getChildren().add(createRecordLocationPanel());
- proxySettingsPane = new ProxySettingsPane(this);
- leftSide.getChildren().add(proxySettingsPane);
-
- //right side
- rightSide.getChildren().add(createSiteSelectionPanel());
- rightSide.getChildren().add(credentialsAccordion);
- for (int i = 0; i < sites.size(); i++) {
- Site site = sites.get(i);
- ConfigUI siteConfig = SiteUiFactory.getUi(site).getConfigUI();
- if(siteConfig != null) {
- TitledPane pane = new TitledPane(site.getName(), siteConfig.createConfigPanel());
- credentialsAccordion.getPanes().add(pane);
- }
- }
- credentialsAccordion.setExpandedPane(credentialsAccordion.getPanes().get(0));
- }
-
- private Node createSiteSelectionPanel() {
- Settings settings = Config.getInstance().getSettings();
- GridPane layout = createGridLayout();
-
- int row = 0;
- for (Site site : sites) {
- Label l = new Label(site.getName());
- layout.add(l, 0, row);
- CheckBox enabled = new CheckBox();
- enabled.setSelected(!settings.disabledSites.contains(site.getName()));
- enabled.setOnAction((e) -> {
- if(enabled.isSelected()) {
- settings.disabledSites.remove(site.getName());
- } else {
- settings.disabledSites.add(site.getName());
- }
- showRestartRequired();
- });
- GridPane.setMargin(l, new Insets(CHECKBOX_MARGIN, 0, 0, 0));
- GridPane.setMargin(enabled, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN));
- layout.add(enabled, 1, row++);
- }
-
- TitledPane siteSelection = new TitledPane("Enabled Sites", layout);
- siteSelection.setCollapsible(false);
- return siteSelection;
- }
-
- private Node createRecordLocationPanel() {
- GridPane layout = createGridLayout();
- Label l = new Label("Record Location");
- layout.add(l, 0, 0);
- recordLocation = new ToggleGroup();
- recordLocal = new RadioButton("Local");
- recordRemote = new RadioButton("Remote");
- recordLocal.setToggleGroup(recordLocation);
- recordRemote.setToggleGroup(recordLocation);
- recordLocal.setSelected(Config.getInstance().getSettings().localRecording);
- recordRemote.setSelected(!recordLocal.isSelected());
- layout.add(recordLocal, 1, 0);
- layout.add(recordRemote, 2, 0);
- recordLocation.selectedToggleProperty().addListener((e) -> {
- Config.getInstance().getSettings().localRecording = recordLocal.isSelected();
- setRecordingMode(recordLocal.isSelected());
- showRestartRequired();
- });
- GridPane.setMargin(l, new Insets(0, 0, CHECKBOX_MARGIN, 0));
- GridPane.setMargin(recordLocal, new Insets(0, 0, CHECKBOX_MARGIN, 0));
- GridPane.setMargin(recordRemote, new Insets(0, 0, CHECKBOX_MARGIN, 0));
-
- layout.add(new Label("Server"), 0, 1);
- server = new TextField(Config.getInstance().getSettings().httpServer);
- server.focusedProperty().addListener((e) -> {
- if(!server.getText().isEmpty()) {
- Config.getInstance().getSettings().httpServer = server.getText();
- }
- });
- GridPane.setFillWidth(server, true);
- GridPane.setHgrow(server, Priority.ALWAYS);
- GridPane.setColumnSpan(server, 2);
- layout.add(server, 1, 1);
-
- layout.add(new Label("Port"), 0, 2);
- port = new TextField(Integer.toString(Config.getInstance().getSettings().httpPort));
- port.focusedProperty().addListener((e) -> {
- if(!port.getText().isEmpty()) {
- try {
- Config.getInstance().getSettings().httpPort = Integer.parseInt(port.getText());
- port.setBorder(Border.EMPTY);
- port.setTooltip(null);
- } catch (NumberFormatException e1) {
- port.setBorder(new Border(new BorderStroke(Color.RED, BorderStrokeStyle.DASHED, new CornerRadii(2), new BorderWidths(2))));
- port.setTooltip(new Tooltip("Port has to be a number in the range 1 - 65536"));
- }
- }
- });
- GridPane.setFillWidth(port, true);
- GridPane.setHgrow(port, Priority.ALWAYS);
- GridPane.setColumnSpan(port, 2);
- layout.add(port, 1, 2);
-
- l = new Label("Require authentication");
- layout.add(l, 0, 3);
- secureCommunication.setSelected(Config.getInstance().getSettings().requireAuthentication);
- secureCommunication.setOnAction((e) -> {
- Config.getInstance().getSettings().requireAuthentication = secureCommunication.isSelected();
- if(secureCommunication.isSelected()) {
- byte[] key = Config.getInstance().getSettings().key;
- if(key == null) {
- key = Hmac.generateKey();
- Config.getInstance().getSettings().key = key;
- }
- TextInputDialog keyDialog = new TextInputDialog();
- keyDialog.setResizable(true);
- keyDialog.setTitle("Server Authentication");
- keyDialog.setHeaderText("A key has been generated");
- keyDialog.setContentText("Add this setting to your server's config.json:\n");
- keyDialog.getEditor().setText("\"key\": " + Arrays.toString(key));
- keyDialog.getEditor().setEditable(false);
- keyDialog.setWidth(800);
- keyDialog.setHeight(200);
- keyDialog.show();
- }
- });
- GridPane.setMargin(l, new Insets(4, CHECKBOX_MARGIN, 0, 0));
- GridPane.setMargin(secureCommunication, new Insets(4, 0, 0, 0));
- layout.add(secureCommunication, 1, 3);
-
- TitledPane recordLocation = new TitledPane("Record Location", layout);
- recordLocation.setCollapsible(false);
- return recordLocation;
- }
-
- private Node createLocationsPanel() {
- int row = 0;
- GridPane layout = createGridLayout();
- layout.add(new Label("Recordings Directory"), 0, row);
- recordingsDirectory = new TextField(Config.getInstance().getSettings().recordingsDir);
- recordingsDirectory.focusedProperty().addListener(createRecordingsDirectoryFocusListener());
- recordingsDirectory.setPrefWidth(400);
- GridPane.setFillWidth(recordingsDirectory, true);
- GridPane.setHgrow(recordingsDirectory, Priority.ALWAYS);
- GridPane.setColumnSpan(recordingsDirectory, 2);
- GridPane.setMargin(recordingsDirectory, new Insets(0, 0, 0, CHECKBOX_MARGIN));
- layout.add(recordingsDirectory, 1, row);
- recordingsDirectoryButton = createRecordingsBrowseButton();
- layout.add(recordingsDirectoryButton, 3, row++);
-
- layout.add(new Label("Directory Structure"), 0, row);
- List options = new ArrayList<>();
- options.add(FLAT);
- options.add(ONE_PER_MODEL);
- options.add(ONE_PER_RECORDING);
- directoryStructure = new ComboBox<>(FXCollections.observableList(options));
- directoryStructure.setValue(Config.getInstance().getSettings().recordingsDirStructure);
- directoryStructure.setOnAction((evt) -> Config.getInstance().getSettings().recordingsDirStructure = directoryStructure.getValue());
- GridPane.setColumnSpan(directoryStructure, 2);
- GridPane.setMargin(directoryStructure, new Insets(0, 0, 0, CHECKBOX_MARGIN));
- layout.add(directoryStructure, 1, row++);
-
- layout.add(new Label("Post-Processing"), 0, row);
- postProcessing = new TextField(Config.getInstance().getSettings().postProcessing);
- postProcessing.focusedProperty().addListener(createPostProcessingFocusListener());
- GridPane.setFillWidth(postProcessing, true);
- GridPane.setHgrow(postProcessing, Priority.ALWAYS);
- GridPane.setColumnSpan(postProcessing, 2);
- GridPane.setMargin(postProcessing, new Insets(0, 0, 0, CHECKBOX_MARGIN));
- layout.add(postProcessing, 1, row);
- postProcessingDirectoryButton = createPostProcessingBrowseButton();
- layout.add(postProcessingDirectoryButton, 3, row++);
-
- layout.add(new Label("Player"), 0, row);
- mediaPlayer = new TextField(Config.getInstance().getSettings().mediaPlayer);
- mediaPlayer.focusedProperty().addListener(createMpvFocusListener());
- GridPane.setFillWidth(mediaPlayer, true);
- GridPane.setHgrow(mediaPlayer, Priority.ALWAYS);
- GridPane.setColumnSpan(mediaPlayer, 2);
- GridPane.setMargin(mediaPlayer, new Insets(0, 0, 0, CHECKBOX_MARGIN));
- layout.add(mediaPlayer, 1, row);
- layout.add(createMpvBrowseButton(), 3, row++);
-
- TitledPane locations = new TitledPane("Locations", layout);
- locations.setCollapsible(false);
- return locations;
- }
-
- private Node createGeneralPanel() {
- GridPane layout = createGridLayout();
- int row = 0;
- Label l = new Label("Display stream resolution in overview");
- layout.add(l, 0, row);
- loadResolution = new CheckBox();
- loadResolution.setSelected(Config.getInstance().getSettings().determineResolution);
- loadResolution.setOnAction((e) -> {
- Config.getInstance().getSettings().determineResolution = loadResolution.isSelected();
- if(!loadResolution.isSelected()) {
- ThumbOverviewTab.queue.clear();
- }
- });
- //GridPane.setMargin(l, new Insets(CHECKBOX_MARGIN, 0, 0, 0));
- GridPane.setMargin(loadResolution, new Insets(0, 0, 0, CHECKBOX_MARGIN));
- layout.add(loadResolution, 1, row++);
-
- l = new Label("Allow multiple players");
- layout.add(l, 0, row);
- multiplePlayers.setSelected(!Config.getInstance().getSettings().singlePlayer);
- multiplePlayers.setOnAction((e) -> Config.getInstance().getSettings().singlePlayer = !multiplePlayers.isSelected());
- GridPane.setMargin(l, new Insets(3, 0, 0, 0));
- GridPane.setMargin(multiplePlayers, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN));
- layout.add(multiplePlayers, 1, row++);
-
- l = new Label("Manually select stream quality");
- layout.add(l, 0, row);
- chooseStreamQuality.setSelected(Config.getInstance().getSettings().chooseStreamQuality);
- chooseStreamQuality.setOnAction((e) -> Config.getInstance().getSettings().chooseStreamQuality = chooseStreamQuality.isSelected());
- GridPane.setMargin(l, new Insets(CHECKBOX_MARGIN, 0, 0, 0));
- GridPane.setMargin(chooseStreamQuality, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN));
- layout.add(chooseStreamQuality, 1, row++);
-
- l = new Label("Maximum resolution (0 = unlimited)");
- layout.add(l, 0, row);
- List resolutionOptions = new ArrayList<>();
- resolutionOptions.add(1080);
- resolutionOptions.add(720);
- resolutionOptions.add(600);
- resolutionOptions.add(480);
- resolutionOptions.add(0);
- maxResolution = new ComboBox<>(FXCollections.observableList(resolutionOptions));
- setMaxResolutionValue();
- maxResolution.setOnAction((e) -> Config.getInstance().getSettings().maximumResolution = maxResolution.getSelectionModel().getSelectedItem());
- layout.add(maxResolution, 1, row++);
- GridPane.setMargin(l, new Insets(CHECKBOX_MARGIN, 0, 0, 0));
- GridPane.setMargin(maxResolution, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN));
-
- l = new Label("Split recordings after (minutes)");
- layout.add(l, 0, row);
- List options = new ArrayList<>();
- options.add(new SplitAfterOption("disabled", 0));
- options.add(new SplitAfterOption("10 min", 10 * 60));
- options.add(new SplitAfterOption("15 min", 15 * 60));
- options.add(new SplitAfterOption("20 min", 20 * 60));
- options.add(new SplitAfterOption("30 min", 30 * 60));
- options.add(new SplitAfterOption("60 min", 60 * 60));
- splitAfter = new ComboBox<>(FXCollections.observableList(options));
- layout.add(splitAfter, 1, row++);
- setSplitAfterValue();
- splitAfter.setOnAction((e) -> Config.getInstance().getSettings().splitRecordings = splitAfter.getSelectionModel().getSelectedItem().getValue());
- GridPane.setMargin(l, new Insets(0, 0, 0, 0));
- GridPane.setMargin(splitAfter, new Insets(0, 0, 0, CHECKBOX_MARGIN));
- maxResolution.prefWidthProperty().bind(splitAfter.widthProperty());
-
- TitledPane general = new TitledPane("General", layout);
- general.setCollapsible(false);
- return general;
- }
-
- private void setSplitAfterValue() {
- int value = Config.getInstance().getSettings().splitRecordings;
- for (SplitAfterOption option : splitAfter.getItems()) {
- if(option.getValue() == value) {
- splitAfter.getSelectionModel().select(option);
- }
- }
- }
-
- private void setMaxResolutionValue() {
- int value = Config.getInstance().getSettings().maximumResolution;
- for (Integer option : maxResolution.getItems()) {
- if(option == value) {
- maxResolution.getSelectionModel().select(option);
- }
- }
- }
-
- void showRestartRequired() {
- restartLabel.setVisible(true);
- }
-
- public static GridPane createGridLayout() {
- GridPane layout = new GridPane();
- layout.setPadding(new Insets(10));
- layout.setHgap(5);
- layout.setVgap(5);
- return layout;
- }
-
- private void setRecordingMode(boolean local) {
- server.setDisable(local);
- port.setDisable(local);
- secureCommunication.setDisable(local);
- recordingsDirectory.setDisable(!local);
- recordingsDirectoryButton.setDisable(!local);
- splitAfter.setDisable(!local);
- maxResolution.setDisable(!local);
- postProcessing.setDisable(!local);
- postProcessingDirectoryButton.setDisable(!local);
- directoryStructure.setDisable(!local);
- }
-
- private ChangeListener super Boolean> createRecordingsDirectoryFocusListener() {
- return new ChangeListener() {
- @Override
- public void changed(ObservableValue extends Boolean> arg0, Boolean oldPropertyValue, Boolean newPropertyValue) {
- if (newPropertyValue) {
- recordingsDirectory.setBorder(Border.EMPTY);
- recordingsDirectory.setTooltip(null);
- } else {
- String input = recordingsDirectory.getText();
- File newDir = new File(input);
- setRecordingsDir(newDir);
- }
- }
- };
- }
-
- private ChangeListener super Boolean> createMpvFocusListener() {
- return new ChangeListener() {
- @Override
- public void changed(ObservableValue extends Boolean> arg0, Boolean oldPropertyValue, Boolean newPropertyValue) {
- if (newPropertyValue) {
- mediaPlayer.setBorder(Border.EMPTY);
- mediaPlayer.setTooltip(null);
- } else {
- String input = mediaPlayer.getText();
- File program = new File(input);
- setMpv(program);
- }
- }
- };
- }
-
- private ChangeListener super Boolean> createPostProcessingFocusListener() {
- return new ChangeListener() {
- @Override
- public void changed(ObservableValue extends Boolean> arg0, Boolean oldPropertyValue, Boolean newPropertyValue) {
- if (newPropertyValue) {
- postProcessing.setBorder(Border.EMPTY);
- postProcessing.setTooltip(null);
- } else {
- String input = postProcessing.getText();
- if(!input.trim().isEmpty()) {
- File program = new File(input);
- setPostProcessing(program);
- } else {
- Config.getInstance().getSettings().postProcessing = "";
- }
- }
- }
- };
- }
-
- private void setMpv(File program) {
- String msg = validateProgram(program);
- if (msg != null) {
- mediaPlayer.setBorder(new Border(new BorderStroke(Color.RED, BorderStrokeStyle.DASHED, new CornerRadii(2), new BorderWidths(2))));
- mediaPlayer.setTooltip(new Tooltip(msg));
- } else {
- Config.getInstance().getSettings().mediaPlayer = mediaPlayer.getText();
- }
- }
-
- private void setPostProcessing(File program) {
- String msg = validateProgram(program);
- if (msg != null) {
- postProcessing.setBorder(new Border(new BorderStroke(Color.RED, BorderStrokeStyle.DASHED, new CornerRadii(2), new BorderWidths(2))));
- postProcessing.setTooltip(new Tooltip(msg));
- } else {
- Config.getInstance().getSettings().postProcessing = postProcessing.getText();
- }
- }
-
- private String validateProgram(File program) {
- if (program == null || !program.exists()) {
- return "File does not exist";
- } else if (!program.isFile() || !program.canExecute()) {
- return "This is not an executable application";
- }
- return null;
- }
-
- private Button createRecordingsBrowseButton() {
- Button button = new Button("Select");
- button.setOnAction((e) -> {
- DirectoryChooser chooser = new DirectoryChooser();
- File currentDir = new File(Config.getInstance().getSettings().recordingsDir);
- if (currentDir.exists() && currentDir.isDirectory()) {
- chooser.setInitialDirectory(currentDir);
- }
- File selectedDir = chooser.showDialog(null);
- if(selectedDir != null) {
- setRecordingsDir(selectedDir);
- }
- });
- return button;
- }
-
- private Node createMpvBrowseButton() {
- Button button = new Button("Select");
- button.setOnAction((e) -> {
- FileChooser chooser = new FileChooser();
- File program = chooser.showOpenDialog(null);
- if(program != null) {
- try {
- mediaPlayer.setText(program.getCanonicalPath());
- } catch (IOException e1) {
- LOG.error("Couldn't determine path", e1);
- Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
- alert.setTitle("Whoopsie");
- alert.setContentText("Couldn't determine path");
- alert.showAndWait();
- }
- setMpv(program);
- }
- });
- return button;
- }
-
- private Button createPostProcessingBrowseButton() {
- Button button = new Button("Select");
- button.setOnAction((e) -> {
- FileChooser chooser = new FileChooser();
- File program = chooser.showOpenDialog(null);
- if(program != null) {
- try {
- postProcessing.setText(program.getCanonicalPath());
- } catch (IOException e1) {
- LOG.error("Couldn't determine path", e1);
- Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
- alert.setTitle("Whoopsie");
- alert.setContentText("Couldn't determine path");
- alert.showAndWait();
- }
- setPostProcessing(program);
- }
- });
- return button;
- }
-
- private void setRecordingsDir(File dir) {
- if (dir != null && dir.isDirectory()) {
- try {
- String path = dir.getCanonicalPath();
- Config.getInstance().getSettings().recordingsDir = path;
- recordingsDirectory.setText(path);
- } catch (IOException e1) {
- LOG.error("Couldn't determine directory path", e1);
- Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
- alert.setTitle("Whoopsie");
- alert.setContentText("Couldn't determine directory path");
- alert.showAndWait();
- }
- } else {
- recordingsDirectory.setBorder(new Border(new BorderStroke(Color.RED, BorderStrokeStyle.DASHED, new CornerRadii(2), new BorderWidths(2))));
- if (!dir.isDirectory()) {
- recordingsDirectory.setTooltip(new Tooltip("This is not a directory"));
- }
- if (!dir.exists()) {
- recordingsDirectory.setTooltip(new Tooltip("Directory does not exist"));
- }
-
- }
- }
-
- @Override
- public void selected() {
- }
-
- @Override
- public void deselected() {
- saveConfig();
- }
-
- public void saveConfig() {
- proxySettingsPane.saveConfig();
- }
-
- public static class SplitAfterOption {
- private String label;
- private int value;
-
- public SplitAfterOption(String label, int value) {
- super();
- this.label = label;
- this.value = value;
- }
-
- public int getValue() {
- return value;
- }
-
- @Override
- public String toString() {
- return label;
- }
- }
-}
diff --git a/client/src/main/java/ctbrec/ui/SiteTab.java b/client/src/main/java/ctbrec/ui/SiteTab.java
index 04afc41f..1b0fc0b1 100644
--- a/client/src/main/java/ctbrec/ui/SiteTab.java
+++ b/client/src/main/java/ctbrec/ui/SiteTab.java
@@ -1,40 +1,18 @@
package ctbrec.ui;
import ctbrec.sites.Site;
-import javafx.geometry.Insets;
-import javafx.geometry.Pos;
import javafx.scene.Scene;
-import javafx.scene.control.Button;
import javafx.scene.control.Tab;
-import javafx.scene.layout.BorderPane;
-import javafx.scene.layout.HBox;
public class SiteTab extends Tab implements TabSelectionListener {
- private BorderPane rootPane = new BorderPane();
- private HBox tokenPanel;
private SiteTabPane siteTabPane;
public SiteTab(Site site, Scene scene) {
super(site.getName());
-
setClosable(false);
- setContent(rootPane);
siteTabPane = new SiteTabPane(site, scene);
- rootPane.setCenter(siteTabPane);
-
- if (site.supportsTips() && site.credentialsAvailable()) {
- Button buyTokens = new Button("Buy Tokens");
- buyTokens.setOnAction((e) -> DesktopIntegration.open(site.getBuyTokensLink()));
- TokenLabel tokenBalance = new TokenLabel(site);
- tokenPanel = new HBox(5, tokenBalance, buyTokens);
- tokenPanel.setAlignment(Pos.BASELINE_RIGHT);
- rootPane.setTop(tokenPanel);
- // HBox.setMargin(tokenBalance, new Insets(0, 5, 0, 0));
- // HBox.setMargin(buyTokens, new Insets(0, 5, 0, 0));
- tokenBalance.loadBalance();
- BorderPane.setMargin(tokenPanel, new Insets(5, 10, 0, 10));
- }
+ setContent(siteTabPane);
}
@Override
diff --git a/client/src/main/java/ctbrec/ui/SiteUiFactory.java b/client/src/main/java/ctbrec/ui/SiteUiFactory.java
index ef1ad64c..1e0c01e6 100644
--- a/client/src/main/java/ctbrec/ui/SiteUiFactory.java
+++ b/client/src/main/java/ctbrec/ui/SiteUiFactory.java
@@ -7,12 +7,14 @@ import ctbrec.sites.camsoda.Camsoda;
import ctbrec.sites.chaturbate.Chaturbate;
import ctbrec.sites.fc2live.Fc2Live;
import ctbrec.sites.mfc.MyFreeCams;
+import ctbrec.sites.streamate.Streamate;
import ctbrec.ui.sites.bonga.BongaCamsSiteUi;
import ctbrec.ui.sites.cam4.Cam4SiteUi;
import ctbrec.ui.sites.camsoda.CamsodaSiteUi;
import ctbrec.ui.sites.chaturbate.ChaturbateSiteUi;
import ctbrec.ui.sites.fc2live.Fc2LiveUi;
import ctbrec.ui.sites.myfreecams.MyFreeCamsSiteUi;
+import ctbrec.ui.sites.streamate.StreamateSiteUi;
public class SiteUiFactory {
@@ -22,8 +24,9 @@ public class SiteUiFactory {
private static ChaturbateSiteUi ctbSiteUi;
private static Fc2LiveUi fc2SiteUi;
private static MyFreeCamsSiteUi mfcSiteUi;
+ private static StreamateSiteUi streamateSiteUi;
- public static SiteUI getUi(Site site) {
+ public static synchronized SiteUI getUi(Site site) {
if (site instanceof BongaCams) {
if (bongaSiteUi == null) {
bongaSiteUi = new BongaCamsSiteUi((BongaCams) site);
@@ -54,6 +57,11 @@ public class SiteUiFactory {
mfcSiteUi = new MyFreeCamsSiteUi((MyFreeCams) site);
}
return mfcSiteUi;
+ } else if (site instanceof Streamate) {
+ if (streamateSiteUi == null) {
+ streamateSiteUi = new StreamateSiteUi((Streamate) site);
+ }
+ return streamateSiteUi;
}
throw new RuntimeException("Unknown site " + site.getName());
}
diff --git a/client/src/main/java/ctbrec/ui/StreamSourceSelectionDialog.java b/client/src/main/java/ctbrec/ui/StreamSourceSelectionDialog.java
index 76a90059..2ff98c50 100644
--- a/client/src/main/java/ctbrec/ui/StreamSourceSelectionDialog.java
+++ b/client/src/main/java/ctbrec/ui/StreamSourceSelectionDialog.java
@@ -1,5 +1,6 @@
package ctbrec.ui;
+import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
@@ -15,14 +16,17 @@ public class StreamSourceSelectionDialog {
Task> selectStreamSource = new Task>() {
@Override
protected List call() throws Exception {
- return model.getStreamSources();
+ List sources = model.getStreamSources();
+ Collections.sort(sources);
+ return sources;
}
};
selectStreamSource.setOnSucceeded((e) -> {
List sources;
try {
sources = selectStreamSource.get();
- ChoiceDialog choiceDialog = new ChoiceDialog(sources.get(sources.size()-1), sources);
+ int selectedIndex = model.getStreamUrlIndex() > -1 ? Math.min(model.getStreamUrlIndex(), sources.size()-1) : sources.size()-1;
+ ChoiceDialog choiceDialog = new ChoiceDialog(sources.get(selectedIndex), sources);
choiceDialog.setTitle("Stream Quality");
choiceDialog.setHeaderText("Select your preferred stream quality");
choiceDialog.setResizable(true);
diff --git a/client/src/main/java/ctbrec/ui/ThumbCell.java b/client/src/main/java/ctbrec/ui/ThumbCell.java
index 146cd680..fa3d4746 100644
--- a/client/src/main/java/ctbrec/ui/ThumbCell.java
+++ b/client/src/main/java/ctbrec/ui/ThumbCell.java
@@ -5,16 +5,24 @@ import java.io.IOException;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
import com.iheartradio.m3u8.ParseException;
import ctbrec.Config;
import ctbrec.Model;
+import ctbrec.io.HttpException;
import ctbrec.recorder.Recorder;
+import ctbrec.ui.action.PlayAction;
+import ctbrec.ui.controls.StreamPreview;
import javafx.animation.FadeTransition;
import javafx.animation.FillTransition;
import javafx.animation.ParallelTransition;
@@ -36,12 +44,15 @@ import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.shape.Circle;
+import javafx.scene.shape.Polygon;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.Shape;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.scene.text.TextAlignment;
import javafx.util.Duration;
+import okhttp3.Request;
+import okhttp3.Response;
public class ThumbCell extends StackPane {
@@ -49,6 +60,7 @@ public class ThumbCell extends StackPane {
private static final Duration ANIMATION_DURATION = new Duration(250);
private Model model;
+ private StreamPreview streamPreview;
private ImageView iv;
private Rectangle resolutionBackground;
private final Paint resolutionOnlineColor = new Color(0.22, 0.8, 0.29, 1);
@@ -73,14 +85,26 @@ public class ThumbCell extends StackPane {
private ObservableList thumbCellList;
private boolean mouseHovering = false;
private boolean recording = false;
+ private static ExecutorService imageLoadingThreadPool = Executors.newFixedThreadPool(30);
+ private static Cache resolutionCache = CacheBuilder.newBuilder()
+ .expireAfterAccess(4, TimeUnit.HOURS)
+ .maximumSize(1000)
+ .build();
+ private ThumbOverviewTab parent;
public ThumbCell(ThumbOverviewTab parent, Model model, Recorder recorder) {
+ this.parent = parent;
this.thumbCellList = parent.grid.getChildren();
this.model = model;
this.recorder = recorder;
recording = recorder.isRecording(model);
model.setSuspended(recorder.isSuspended(model));
- this.setStyle("-fx-background-color: lightgray");
+ this.setStyle("-fx-background-color: -fx-base");
+
+ streamPreview = new StreamPreview();
+ streamPreview.prefWidthProperty().bind(widthProperty());
+ streamPreview.prefHeightProperty().bind(heightProperty());
+ getChildren().add(streamPreview);
iv = new ImageView();
iv.setSmooth(true);
@@ -109,7 +133,7 @@ public class ThumbCell extends StackPane {
StackPane.setMargin(resolutionBackground, new Insets(2));
getChildren().add(resolutionBackground);
- name = new Text(model.getName());
+ name = new Text(model.getDisplayName());
name.setFill(Color.WHITE);
name.setFont(new Font("Sansserif", 16));
name.setTextAlignment(TextAlignment.CENTER);
@@ -150,8 +174,14 @@ public class ThumbCell extends StackPane {
StackPane.setAlignment(pausedIndicator, Pos.TOP_LEFT);
getChildren().add(pausedIndicator);
+ if(Config.getInstance().getSettings().previewInThumbnails) {
+ getChildren().add(createPreviewTrigger());
+ }
+
selectionOverlay = new Rectangle();
- selectionOverlay.setOpacity(0);
+ selectionOverlay.visibleProperty().bind(selectionProperty);
+ selectionOverlay.widthProperty().bind(widthProperty());
+ selectionOverlay.heightProperty().bind(heightProperty());
StackPane.setAlignment(selectionOverlay, Pos.TOP_LEFT);
getChildren().add(selectionOverlay);
@@ -178,9 +208,51 @@ public class ThumbCell extends StackPane {
setThumbWidth(Config.getInstance().getSettings().thumbWidth);
setRecording(recording);
- if(Config.getInstance().getSettings().determineResolution) {
- determineResolution();
+ update();
+ }
+
+ private Node createPreviewTrigger() {
+ int s = 32;
+ StackPane previewTrigger = new StackPane();
+ previewTrigger.setStyle("-fx-background-color: white;");
+ previewTrigger.setOpacity(.8);
+ previewTrigger.setMaxSize(s, s);
+
+ Polygon play = new Polygon(new double[] {
+ 16, 8,
+ 26, 15,
+ 16, 22
+ });
+ StackPane.setMargin(play, new Insets(0, 0, 0, 3));
+ play.setStyle("-fx-background-color: black;");
+ previewTrigger.getChildren().add(play);
+
+ Circle clip = new Circle(s / 2);
+ clip.setTranslateX(clip.getRadius());
+ clip.setTranslateY(clip.getRadius());
+ previewTrigger.setClip(clip);
+ StackPane.setAlignment(previewTrigger, Pos.BOTTOM_LEFT);
+ StackPane.setMargin(previewTrigger, new Insets(0, 0, 24, 4));
+ previewTrigger.setOnMouseEntered(evt -> setPreviewVisible(previewTrigger, true));
+ previewTrigger.setOnMouseExited(evt -> setPreviewVisible(previewTrigger, false));
+ return previewTrigger;
+ }
+
+ private void setPreviewVisible(Node previewTrigger, boolean visible) {
+ parent.suspendUpdates(visible);
+ iv.setVisible(!visible);
+ topic.setVisible(!visible);
+ topicBackground.setVisible(!visible);
+ name.setVisible(!visible);
+ nameBackground.setVisible(!visible);
+ streamPreview.setVisible(visible);
+ streamPreview.startStream(model);
+ recordingIndicator.setVisible(!visible);
+ pausedIndicator.setVisible(!visible);
+ if(!visible) {
+ updateRecordingIndicator();
}
+ previewTrigger.setCursor(visible ? Cursor.HAND : Cursor.DEFAULT);
}
public void setSelected(boolean selected) {
@@ -203,48 +275,60 @@ public class ThumbCell extends StackPane {
return;
}
- ThumbOverviewTab.threadPool.submit(() -> {
- try {
- ThumbOverviewTab.resolutionProcessing.add(model);
- int[] resolution = model.getStreamResolution(false);
- updateResolutionTag(resolution);
-
- // the model is online, but the resolution is 0. probably something went wrong
- // when we first requested the stream info, so we remove this invalid value from the "cache"
- // so that it is requested again
- if (model.isOnline() && resolution[1] == 0) {
- LOG.trace("Removing invalid resolution value for {}", model.getName());
- model.invalidateCacheEntries();
- }
-
- Thread.sleep(500);
- } catch (IOException | InterruptedException e1) {
- LOG.warn("Couldn't update resolution tag for model {}", model.getName(), e1);
- } catch(ExecutionException e) {
- if(e.getCause() instanceof EOFException) {
- LOG.warn("Couldn't update resolution tag for model {}. Playlist empty", model.getName());
- } else if(e.getCause() instanceof ParseException) {
- LOG.warn("Couldn't update resolution tag for model {} - {}", model.getName(), e.getMessage());
- } else {
+ int[] resolution = resolutionCache.getIfPresent(model);
+ if(resolution != null) {
+ ThumbOverviewTab.threadPool.submit(() -> {
+ try {
+ updateResolutionTag(resolution);
+ } catch(Exception e) {
LOG.warn("Couldn't update resolution tag for model {}", model.getName(), e);
}
- } finally {
- ThumbOverviewTab.resolutionProcessing.remove(model);
- }
- });
+ });
+ } else {
+ ThumbOverviewTab.threadPool.submit(() -> {
+ try {
+ ThumbOverviewTab.resolutionProcessing.add(model);
+ int[] _resolution = model.getStreamResolution(false);
+ resolutionCache.put(model, _resolution);
+ updateResolutionTag(_resolution);
+
+ // the model is online, but the resolution is 0. probably something went wrong
+ // when we first requested the stream info, so we remove this invalid value from the "cache"
+ // so that it is requested again
+ if (model.isOnline() && _resolution[1] == 0) {
+ LOG.trace("Removing invalid resolution value for {}", model.getName());
+ model.invalidateCacheEntries();
+ }
+
+ Thread.sleep(100);
+ } catch (IOException | InterruptedException e1) {
+ LOG.warn("Couldn't update resolution tag for model {}", model.getName(), e1);
+ } catch(ExecutionException e) {
+ if(e.getCause() instanceof EOFException) {
+ LOG.warn("Couldn't update resolution tag for model {}. Playlist empty", model.getName());
+ } else if(e.getCause() instanceof ParseException) {
+ LOG.warn("Couldn't update resolution tag for model {} - {}", model.getName(), e.getMessage());
+ } else {
+ LOG.warn("Couldn't update resolution tag for model {}", model.getName(), e);
+ }
+ } finally {
+ ThumbOverviewTab.resolutionProcessing.remove(model);
+ }
+ });
+ }
}
private void updateResolutionTag(int[] resolution) throws IOException, ExecutionException, InterruptedException {
String _res = "n/a";
Paint resolutionBackgroundColor = resolutionOnlineColor;
- String state = model.getOnlineState(false);
+ String state = model.getOnlineState(false).toString();
if (model.isOnline()) {
LOG.trace("Model resolution {} {}x{}", model.getName(), resolution[0], resolution[1]);
LOG.trace("Resolution queue size: {}", ThumbOverviewTab.queue.size());
final int w = resolution[1];
_res = w > 0 ? w != Integer.MAX_VALUE ? Integer.toString(w) : "HD" : state;
} else {
- _res = model.getOnlineState(false);
+ _res = model.getOnlineState(false).toString();
resolutionBackgroundColor = resolutionOfflineColor;
}
final String resText = _res;
@@ -262,20 +346,40 @@ public class ThumbCell extends StackPane {
private void setImage(String url) {
if(!Objects.equals(System.getenv("CTBREC_DEV"), "1")) {
- Image img = new Image(url, true);
-
- // wait for the image to load, otherwise the ImageView replaces the current image with an "empty" image,
- // which causes to show the grey background until the image is loaded
- img.progressProperty().addListener(new ChangeListener() {
- @Override
- public void changed(ObservableValue extends Number> observable, Number oldValue, Number newValue) {
- if(newValue.doubleValue() == 1.0) {
- //imgAspectRatio = img.getHeight() / img.getWidth();
- iv.setImage(img);
- setThumbWidth(Config.getInstance().getSettings().thumbWidth);
+ boolean updateThumbs = Config.getInstance().getSettings().updateThumbnails;
+ if(updateThumbs || iv.getImage() == null) {
+ imageLoadingThreadPool.submit(() -> {
+ Request req = new Request.Builder()
+ .url(url)
+ .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent)
+ .build();
+ try(Response resp = CamrecApplication.httpClient.execute(req)) {
+ if(resp.isSuccessful()) {
+ Image img = new Image(resp.body().byteStream());
+ if(img.progressProperty().get() == 1.0) {
+ Platform.runLater(() -> {
+ iv.setImage(img);
+ setThumbWidth(Config.getInstance().getSettings().thumbWidth);
+ });
+ } else {
+ img.progressProperty().addListener(new ChangeListener() {
+ @Override
+ public void changed(ObservableValue extends Number> observable, Number oldValue, Number newValue) {
+ if(newValue.doubleValue() == 1.0) {
+ iv.setImage(img);
+ setThumbWidth(Config.getInstance().getSettings().thumbWidth);
+ }
+ }
+ });
+ }
+ } else {
+ throw new HttpException(resp.code(), resp.message());
+ }
+ } catch (IOException e) {
+ LOG.error("Error loading image", e);
}
- }
- });
+ });
+ }
}
}
@@ -297,11 +401,7 @@ public class ThumbCell extends StackPane {
}
void startPlayer() {
- setCursor(Cursor.WAIT);
- new Thread(() -> {
- Player.play(model);
- Platform.runLater(() -> setCursor(Cursor.DEFAULT));
- }).start();
+ new PlayAction(this, model).execute();
}
private void setRecording(boolean recording) {
@@ -314,6 +414,10 @@ public class ThumbCell extends StackPane {
nameBackground.setFill(c);
}
+ updateRecordingIndicator();
+ }
+
+ private void updateRecordingIndicator() {
if(recording) {
recordingIndicator.setVisible(!model.isSuspended());
pausedIndicator.setVisible(model.isSuspended());
@@ -458,7 +562,7 @@ public class ThumbCell extends StackPane {
this.model.setPreview(model.getPreview());
this.model.setTags(model.getTags());
this.model.setUrl(model.getUrl());
- this.model.setSuspended(model.isSuspended());
+ this.model.setSuspended(recorder.isSuspended(model));
update();
}
@@ -475,7 +579,7 @@ public class ThumbCell extends StackPane {
setRecording(recorder.isRecording(model));
setImage(model.getPreview());
String txt = recording ? " " : "";
- txt += model.getDescription();
+ txt += model.getDescription() != null ? model.getDescription() : "";
topic.setText(txt);
if(Config.getInstance().getSettings().determineResolution) {
@@ -519,20 +623,31 @@ public class ThumbCell extends StackPane {
}
private void setSize(int w, int h) {
- iv.setFitWidth(w);
- iv.setFitHeight(h);
+ if(iv.getImage() != null) {
+ double aspectRatio = iv.getImage().getWidth() / iv.getImage().getHeight();
+ if(aspectRatio > 1) {
+ iv.setFitWidth(w);
+ } else {
+ iv.setFitHeight(h);
+ }
+ }
setMinSize(w, h);
setPrefSize(w, h);
nameBackground.setWidth(w);
nameBackground.setHeight(20);
topicBackground.setWidth(w);
- topicBackground.setHeight(getHeight()-nameBackground.getHeight());
+ topicBackground.setHeight(h - nameBackground.getHeight());
topic.prefHeight(getHeight()-25);
topic.maxHeight(getHeight()-25);
int margin = 4;
topic.maxWidth(w-margin*2);
topic.setWrappingWidth(w-margin*2);
- selectionOverlay.setWidth(w);
- selectionOverlay.setHeight(getHeight());
+
+ streamPreview.resizeTo(w, h);
+
+ Rectangle clip = new Rectangle(w, h);
+ clip.setArcWidth(10);
+ clip.arcHeightProperty().bind(clip.arcWidthProperty());
+ this.setClip(clip);
}
}
diff --git a/client/src/main/java/ctbrec/ui/ThumbOverviewTab.java b/client/src/main/java/ctbrec/ui/ThumbOverviewTab.java
index ba77291a..6ce0cdde 100644
--- a/client/src/main/java/ctbrec/ui/ThumbOverviewTab.java
+++ b/client/src/main/java/ctbrec/ui/ThumbOverviewTab.java
@@ -1,5 +1,6 @@
package ctbrec.ui;
+import static ctbrec.ui.controls.Dialogs.*;
import java.io.IOException;
import java.net.SocketTimeoutException;
@@ -26,18 +27,25 @@ import org.slf4j.LoggerFactory;
import ctbrec.Config;
import ctbrec.Model;
+import ctbrec.event.EventBusHolder;
import ctbrec.recorder.Recorder;
import ctbrec.sites.Site;
import ctbrec.sites.mfc.MyFreeCamsClient;
import ctbrec.sites.mfc.MyFreeCamsModel;
+import ctbrec.ui.controls.SearchBox;
+import ctbrec.ui.controls.SearchPopover;
+import ctbrec.ui.controls.SearchPopoverTreeList;
import javafx.animation.FadeTransition;
import javafx.animation.Interpolator;
import javafx.animation.ParallelTransition;
import javafx.animation.ScaleTransition;
+import javafx.animation.Transition;
import javafx.animation.TranslateTransition;
import javafx.application.Platform;
+import javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
+import javafx.concurrent.Task;
import javafx.concurrent.Worker.State;
import javafx.concurrent.WorkerStateEvent;
import javafx.event.EventHandler;
@@ -60,12 +68,16 @@ import javafx.scene.image.ImageView;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.input.ContextMenuEvent;
+import javafx.scene.input.KeyCode;
+import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.FlowPane;
import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
+import javafx.scene.paint.Color;
import javafx.scene.transform.Transform;
import javafx.util.Duration;
@@ -94,6 +106,9 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
ContextMenu popup;
Site site;
StackPane root = new StackPane();
+ Task> searchTask;
+ SearchPopover popover;
+ SearchPopoverTreeList popoverTreelist = new SearchPopoverTreeList();
private ComboBox thumbWidth;
@@ -111,10 +126,10 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
grid.setHgap(5);
grid.setVgap(5);
- TextField search = new TextField();
- search.setPromptText("Filter models on this page");
- search.textProperty().addListener( (observableValue, oldValue, newValue) -> {
- filter = search.getText();
+ SearchBox filterInput = new SearchBox(false);
+ filterInput.setPromptText("Filter models on this page");
+ filterInput.textProperty().addListener( (observableValue, oldValue, newValue) -> {
+ filter = filterInput.getText();
gridLock.lock();
try {
filter();
@@ -123,12 +138,49 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
gridLock.unlock();
}
});
- Tooltip searchTooltip = new Tooltip("Filter the models by their name, stream description or #hashtags.\n\n"
+ Tooltip filterTooltip = new Tooltip("Filter the models by their name, stream description or #hashtags.\n\n"
+ "If the display of stream resolution is enabled, you can even filter for public rooms or by resolution.\n\n"
+ "Try \"1080\" or \">720\" or \"public\"");
- search.setTooltip(searchTooltip);
+ filterInput.setTooltip(filterTooltip);
+ filterInput.getStyleClass().remove("search-box-icon");
- BorderPane.setMargin(search, new Insets(5));
+ SearchBox searchInput = new SearchBox();
+ searchInput.setPromptText("Search Model");
+ searchInput.prefWidth(200);
+ searchInput.textProperty().addListener(search());
+ searchInput.addEventHandler(KeyEvent.KEY_PRESSED, evt -> {
+ if(evt.getCode() == KeyCode.ESCAPE) {
+ popover.hide();
+ }
+ });
+
+ popover = new SearchPopover();
+ popover.maxWidthProperty().bind(popover.minWidthProperty());
+ popover.prefWidthProperty().bind(popover.minWidthProperty());
+ popover.setMinWidth(400);
+ popover.maxHeightProperty().bind(popover.minHeightProperty());
+ popover.prefHeightProperty().bind(popover.minHeightProperty());
+ popover.setMinHeight(450);
+ popover.pushPage(popoverTreelist);
+ StackPane.setAlignment(popover, Pos.TOP_RIGHT);
+ StackPane.setMargin(popover, new Insets(35, 50, 0, 0));
+
+ HBox topBar = new HBox(5);
+ HBox.setHgrow(filterInput, Priority.ALWAYS);
+ topBar.getChildren().add(filterInput);
+ if (site.supportsTips() && site.credentialsAvailable()) {
+ Button buyTokens = new Button("Buy Tokens");
+ buyTokens.setOnAction((e) -> DesktopIntegration.open(site.getBuyTokensLink()));
+ TokenLabel tokenBalance = new TokenLabel(site);
+ tokenBalance.setAlignment(Pos.CENTER_RIGHT);
+ tokenBalance.prefHeightProperty().bind(buyTokens.heightProperty());
+ topBar.getChildren().addAll(tokenBalance, buyTokens);
+ tokenBalance.loadBalance();
+ }
+ if(site.supportsSearch()) {
+ topBar.getChildren().add(searchInput);
+ }
+ BorderPane.setMargin(topBar, new Insets(0, 5, 0, 5));
scrollPane.setContent(grid);
scrollPane.setFitToHeight(true);
@@ -184,14 +236,69 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
BorderPane borderPane = new BorderPane();
borderPane.setPadding(new Insets(5));
- borderPane.setTop(search);
+ borderPane.setTop(topBar);
borderPane.setCenter(scrollPane);
borderPane.setBottom(bottomPane);
root.getChildren().add(borderPane);
+ root.getChildren().add(popover);
setContent(root);
}
+ private ChangeListener super String> search() {
+ return (observableValue, oldValue, newValue) -> {
+ if(searchTask != null) {
+ searchTask.cancel(true);
+ }
+
+ if(newValue.length() < 2) {
+ return;
+ }
+
+
+ searchTask = new Task>() {
+ @Override
+ protected List call() throws Exception {
+ if(site.searchRequiresLogin()) {
+ boolean loggedin = false;
+ try {
+ loggedin = SiteUiFactory.getUi(site).login();
+ } catch (IOException e) {
+ loggedin = false;
+ }
+ if(!loggedin) {
+ showError("Login failed", "Search won't work correctly without login", null);
+ }
+ }
+ return site.search(newValue);
+ }
+
+ @Override
+ protected void failed() {
+ LOG.error("Search failed", getException());
+ }
+
+ @Override
+ protected void succeeded() {
+ Platform.runLater(() -> {
+ List models = getValue();
+ LOG.debug("Search result {} {}", isCancelled(), models);
+ if(models.isEmpty()) {
+ popover.hide();
+ } else {
+ popoverTreelist.getItems().clear();
+ for (Model model : getValue()) {
+ popoverTreelist.getItems().add(model);
+ }
+ popover.show();
+ }
+ });
+ }
+ };
+ new Thread(searchTask).start();
+ };
+ }
+
private void updateThumbSize() {
int width = Config.getInstance().getSettings().thumbWidth;
thumbWidth.getSelectionModel().select(Integer.valueOf(width));
@@ -242,7 +349,6 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
}
List models = updateService.getValue();
updateGrid(models);
-
}
protected void updateGrid(List extends Model> models) {
@@ -371,20 +477,13 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
Map event = new HashMap<>();
event.put("event", "tokens.sent");
event.put("amount", tokens);
- CamrecApplication.bus.post(event);
+ EventBusHolder.BUS.post(event);
} catch (Exception e1) {
- Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
- alert.setTitle("Error");
- alert.setHeaderText("Couldn't send tip");
- alert.setContentText("An error occured while sending tip: " + e1.getLocalizedMessage());
- alert.showAndWait();
+ LOG.error("An error occured while sending tip", e1);
+ showError("Couldn't send tip", "An error occured while sending tip:", e1);
}
} else {
- Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
- alert.setTitle("Error");
- alert.setHeaderText("Couldn't send tip");
- alert.setContentText("You entered an invalid amount of tokens");
- alert.showAndWait();
+ showError("Couldn't send tip", "You entered an invalid amount of tokens", null);
}
}
});
@@ -468,7 +567,9 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
translate.setFromX(0);
translate.setFromY(0);
translate.setByX(-tx.getTx() - 200);
- translate.setByY(-offsetInViewPort + getFollowedTabYPosition());
+ TabProvider tabProvider = SiteUiFactory.getUi(site).getTabProvider();
+ Tab followedTab = tabProvider.getFollowedTab();
+ translate.setByY(-offsetInViewPort + getFollowedTabYPosition(followedTab));
StackPane.setMargin(iv, new Insets(offsetInViewPort, 0, 0, tx.getTx()));
translate.setInterpolator(Interpolator.EASE_BOTH);
FadeTransition fade = new FadeTransition(Duration.millis(duration), iv);
@@ -482,14 +583,42 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
pt.setOnFinished((evt) -> {
root.getChildren().remove(iv);
});
+
+ String normalStyle = followedTab.getStyle();
+ Color normal = Color.web("#f4f4f4");
+ Color highlight = Color.web("#2b8513");
+ Transition blink = new Transition() {
+ {
+ setCycleDuration(Duration.millis(500));
+ }
+ @Override
+ protected void interpolate(double frac) {
+ double rh = highlight.getRed();
+ double rn = normal.getRed();
+ double diff = rh - rn;
+ double r = (rn + diff * frac) * 255;
+ double gh = highlight.getGreen();
+ double gn = normal.getGreen();
+ diff = gh - gn;
+ double g = (gn + diff * frac) * 255;
+ double bh = highlight.getBlue();
+ double bn = normal.getBlue();
+ diff = bh - bn;
+ double b = (bn + diff * frac) * 255;
+ String style = "-fx-background-color: rgb(" + r + "," + g + "," + b + ")";
+ followedTab.setStyle(style);
+ }
+ };
+ blink.setCycleCount(6);
+ blink.setAutoReverse(true);
+ blink.setOnFinished((evt) -> followedTab.setStyle(normalStyle));
+ blink.play();
});
}
- private double getFollowedTabYPosition() {
- TabProvider tabProvider = SiteUiFactory.getUi(site).getTabProvider();
- Tab followedTab = tabProvider.getFollowedTab();
+ private double getFollowedTabYPosition(Tab followedTab) {
TabPane tabPane = getTabPane();
- int idx = tabPane.getTabs().indexOf(followedTab);
+ int idx = Math.max(0, tabPane.getTabs().indexOf(followedTab));
for (Node node : tabPane.getChildrenUnmodifiable()) {
Parent p = (Parent) node;
for (Node child : p.getChildrenUnmodifiable()) {
@@ -633,6 +762,8 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
String[] tokens = filter.split(" ");
StringBuilder searchTextBuilder = new StringBuilder(m.getName());
searchTextBuilder.append(' ');
+ searchTextBuilder.append(m.getDisplayName());
+ searchTextBuilder.append(' ');
for (String tag : m.getTags()) {
searchTextBuilder.append(tag).append(' ');
}
@@ -652,7 +783,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
tokensMissing = true;
}
} else if(token.equals("public")) {
- if(!m.getOnlineState(true).equals(token)) {
+ if(!m.getOnlineState(true).toString().equals(token)) {
tokensMissing = true;
}
} else if(!searchText.toLowerCase().contains(token.toLowerCase())) {
@@ -668,6 +799,7 @@ public class ThumbOverviewTab extends Tab implements TabSelectionListener {
public void setRecorder(Recorder recorder) {
this.recorder = recorder;
+ popoverTreelist.setRecorder(recorder);
}
@Override
diff --git a/client/src/main/java/ctbrec/ui/TipDialog.java b/client/src/main/java/ctbrec/ui/TipDialog.java
index 2b4dfcf0..8aaefb93 100644
--- a/client/src/main/java/ctbrec/ui/TipDialog.java
+++ b/client/src/main/java/ctbrec/ui/TipDialog.java
@@ -8,7 +8,6 @@ import org.slf4j.LoggerFactory;
import ctbrec.Model;
import ctbrec.sites.Site;
-import ctbrec.sites.chaturbate.Chaturbate;
import javafx.application.Platform;
import javafx.concurrent.Task;
import javafx.scene.control.Alert;
@@ -48,7 +47,7 @@ public class TipDialog extends TextInputDialog {
int tokens = get();
Platform.runLater(() -> {
if (tokens <= 0) {
- String msg = "Do you want to buy tokens now?\n\nIf you agree, Chaturbate will open in a browser. "
+ String msg = "Do you want to buy tokens now?\n\nIf you agree, "+site.getName()+" will open in a browser. "
+ "The used address is an affiliate link, which supports me, but doesn't cost you anything more.";
Alert buyTokens = new AutosizeAlert(Alert.AlertType.CONFIRMATION, msg, ButtonType.NO, ButtonType.YES);
buyTokens.setTitle("No tokens");
@@ -56,7 +55,7 @@ public class TipDialog extends TextInputDialog {
buyTokens.showAndWait();
TipDialog.this.close();
if(buyTokens.getResult() == ButtonType.YES) {
- DesktopIntegration.open(Chaturbate.AFFILIATE_LINK);
+ DesktopIntegration.open(site.getAffiliateLink());
}
} else {
getEditor().setDisable(false);
diff --git a/client/src/main/java/ctbrec/ui/TokenLabel.java b/client/src/main/java/ctbrec/ui/TokenLabel.java
index d19b16d0..2117c444 100644
--- a/client/src/main/java/ctbrec/ui/TokenLabel.java
+++ b/client/src/main/java/ctbrec/ui/TokenLabel.java
@@ -9,6 +9,7 @@ import org.slf4j.LoggerFactory;
import com.google.common.eventbus.Subscribe;
+import ctbrec.event.EventBusHolder;
import ctbrec.sites.Site;
import javafx.application.Platform;
import javafx.concurrent.Task;
@@ -24,7 +25,7 @@ public class TokenLabel extends Label {
public TokenLabel(Site site) {
this.site = site;
setText("Tokens: loading…");
- CamrecApplication.bus.register(new Object() {
+ EventBusHolder.BUS.register(new Object() {
@Subscribe
public void tokensUpdates(Map e) {
if (Objects.equals("tokens", e.get("event"))) {
diff --git a/client/src/main/java/ctbrec/ui/UpdateTab.java b/client/src/main/java/ctbrec/ui/UpdateTab.java
index c28c63a2..43eb3e61 100644
--- a/client/src/main/java/ctbrec/ui/UpdateTab.java
+++ b/client/src/main/java/ctbrec/ui/UpdateTab.java
@@ -3,6 +3,7 @@ package ctbrec.ui;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import ctbrec.Config;
import ctbrec.ui.CamrecApplication.Release;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
@@ -36,6 +37,7 @@ public class UpdateTab extends Tab {
try {
WebEngine webEngine = browser.getEngine();
webEngine.load("https://raw.githubusercontent.com/0xboobface/ctbrec/master/CHANGELOG.md");
+ webEngine.setUserDataDirectory(Config.getInstance().getConfigDir());
vbox.getChildren().add(browser);
VBox.setVgrow(browser, Priority.ALWAYS);
} catch (Exception e) {
diff --git a/client/src/main/java/ctbrec/ui/WebbrowserTab.java b/client/src/main/java/ctbrec/ui/WebbrowserTab.java
index 3def3feb..cf904a3e 100644
--- a/client/src/main/java/ctbrec/ui/WebbrowserTab.java
+++ b/client/src/main/java/ctbrec/ui/WebbrowserTab.java
@@ -1,15 +1,31 @@
package ctbrec.ui;
+import java.io.File;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.OS;
+import ctbrec.ui.controls.Dialogs;
import javafx.scene.control.Tab;
import javafx.scene.web.WebEngine;
import javafx.scene.web.WebView;
public class WebbrowserTab extends Tab {
+ private static final transient Logger LOG = LoggerFactory.getLogger(WebbrowserTab.class);
+
public WebbrowserTab(String uri) {
WebView browser = new WebView();
WebEngine webEngine = browser.getEngine();
+ webEngine.setUserDataDirectory(new File(OS.getConfigDir(), "webengine"));
+ webEngine.setJavaScriptEnabled(true);
webEngine.load(uri);
setContent(browser);
+
+ webEngine.setOnError(evt -> {
+ LOG.error("Couldn't load {}", uri, evt.getException());
+ Dialogs.showError("Error", "Couldn't load " + uri, evt.getException());
+ });
}
}
diff --git a/client/src/main/java/ctbrec/ui/action/FollowAction.java b/client/src/main/java/ctbrec/ui/action/FollowAction.java
new file mode 100644
index 00000000..d5bec925
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/action/FollowAction.java
@@ -0,0 +1,29 @@
+package ctbrec.ui.action;
+
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.Model;
+import ctbrec.ui.controls.Dialogs;
+import javafx.application.Platform;
+import javafx.scene.Node;
+
+public class FollowAction extends ModelMassEditAction {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(FollowAction.class);
+
+ public FollowAction(Node source, List extends Model> models) {
+ super(source, models);
+ action = (m) -> {
+ try {
+ m.follow();
+ } catch(Exception e) {
+ LOG.error("Couldn't follow model {}", m, e);
+ Platform.runLater(() ->
+ Dialogs.showError("Couldn't follow model", "Following " + m.getName() + " failed: " + e.getMessage(), e));
+ }
+ };
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/action/ModelMassEditAction.java b/client/src/main/java/ctbrec/ui/action/ModelMassEditAction.java
new file mode 100644
index 00000000..7da778be
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/action/ModelMassEditAction.java
@@ -0,0 +1,52 @@
+package ctbrec.ui.action;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+
+import ctbrec.Model;
+import javafx.application.Platform;
+import javafx.scene.Cursor;
+import javafx.scene.Node;
+
+public class ModelMassEditAction {
+
+ static BlockingQueue queue = new LinkedBlockingQueue<>();
+ static ExecutorService threadPool = new ThreadPoolExecutor(2, 2, 10, TimeUnit.MINUTES, queue);
+
+ protected List extends Model> models;
+ protected Consumer action;
+ protected Node source;
+
+ protected ModelMassEditAction(Node source, List extends Model> models) {
+ this.source = source;
+ this.models = models;
+ }
+
+ public ModelMassEditAction(Node source, List extends Model> models, Consumer action) {
+ this.source = source;
+ this.models = models;
+ this.action = action;
+ }
+
+ public void execute() {
+ execute((m) -> {});
+ }
+
+ public void execute(Consumer callback) {
+ Consumer cb = Objects.requireNonNull(callback);
+ source.setCursor(Cursor.WAIT);
+ threadPool.submit(() -> {
+ for (Model model : models) {
+ action.accept(model);
+ cb.accept(model);
+ }
+ Platform.runLater(() -> source.setCursor(Cursor.DEFAULT));
+ });
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/action/PauseAction.java b/client/src/main/java/ctbrec/ui/action/PauseAction.java
new file mode 100644
index 00000000..c1aea4fb
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/action/PauseAction.java
@@ -0,0 +1,24 @@
+package ctbrec.ui.action;
+
+import java.util.List;
+
+import ctbrec.Model;
+import ctbrec.recorder.Recorder;
+import ctbrec.ui.controls.Dialogs;
+import javafx.application.Platform;
+import javafx.scene.Node;
+
+public class PauseAction extends ModelMassEditAction {
+
+ public PauseAction(Node source, List extends Model> models, Recorder recorder) {
+ super(source, models);
+ action = (m) -> {
+ try {
+ recorder.suspendRecording(m);
+ } catch(Exception e) {
+ Platform.runLater(() ->
+ Dialogs.showError("Couldn't suspend recording of model", "Suspending recording of " + m.getName() + " failed", e));
+ }
+ };
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/action/PlayAction.java b/client/src/main/java/ctbrec/ui/action/PlayAction.java
new file mode 100644
index 00000000..06f9cc6b
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/action/PlayAction.java
@@ -0,0 +1,33 @@
+package ctbrec.ui.action;
+
+import ctbrec.Config;
+import ctbrec.Model;
+import ctbrec.ui.Player;
+import ctbrec.ui.controls.Toast;
+import javafx.application.Platform;
+import javafx.scene.Cursor;
+import javafx.scene.Node;
+
+public class PlayAction {
+
+ private Model selectedModel;
+ private Node source;
+
+ public PlayAction(Node source, Model selectedModel) {
+ this.source = source;
+ this.selectedModel = selectedModel;
+ }
+
+ public void execute() {
+ source.setCursor(Cursor.WAIT);
+ new Thread(() -> {
+ boolean started = Player.play(selectedModel);
+ Platform.runLater(() -> {
+ if (started && Config.getInstance().getSettings().showPlayerStarting) {
+ Toast.makeText(source.getScene(), "Starting Player", 2000, 500, 500);
+ }
+ source.setCursor(Cursor.DEFAULT);
+ });
+ }).start();
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/action/ResumeAction.java b/client/src/main/java/ctbrec/ui/action/ResumeAction.java
new file mode 100644
index 00000000..2215a67f
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/action/ResumeAction.java
@@ -0,0 +1,24 @@
+package ctbrec.ui.action;
+
+import java.util.List;
+
+import ctbrec.Model;
+import ctbrec.recorder.Recorder;
+import ctbrec.ui.controls.Dialogs;
+import javafx.application.Platform;
+import javafx.scene.Node;
+
+public class ResumeAction extends ModelMassEditAction {
+
+ public ResumeAction(Node source, List extends Model> models, Recorder recorder) {
+ super(source, models);
+ action = (m) -> {
+ try {
+ recorder.resumeRecording(m);
+ } catch(Exception e) {
+ Platform.runLater(() ->
+ Dialogs.showError("Couldn't resume recording of model", "Resuming recording of " + m.getName() + " failed", e));
+ }
+ };
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/action/StartRecordingAction.java b/client/src/main/java/ctbrec/ui/action/StartRecordingAction.java
new file mode 100644
index 00000000..56e3e67c
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/action/StartRecordingAction.java
@@ -0,0 +1,24 @@
+package ctbrec.ui.action;
+
+import java.util.List;
+
+import ctbrec.Model;
+import ctbrec.recorder.Recorder;
+import ctbrec.ui.controls.Dialogs;
+import javafx.application.Platform;
+import javafx.scene.Node;
+
+public class StartRecordingAction extends ModelMassEditAction {
+
+ public StartRecordingAction(Node source, List extends Model> models, Recorder recorder) {
+ super(source, models);
+ action = (m) -> {
+ try {
+ recorder.startRecording(m);
+ } catch(Exception e) {
+ Platform.runLater(() ->
+ Dialogs.showError("Couldn't start recording", "Starting recording of " + m.getName() + " failed", e));
+ }
+ };
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/action/StopRecordingAction.java b/client/src/main/java/ctbrec/ui/action/StopRecordingAction.java
new file mode 100644
index 00000000..a4dcab38
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/action/StopRecordingAction.java
@@ -0,0 +1,24 @@
+package ctbrec.ui.action;
+
+import java.util.List;
+
+import ctbrec.Model;
+import ctbrec.recorder.Recorder;
+import ctbrec.ui.controls.Dialogs;
+import javafx.application.Platform;
+import javafx.scene.Node;
+
+public class StopRecordingAction extends ModelMassEditAction {
+
+ public StopRecordingAction(Node source, List extends Model> models, Recorder recorder) {
+ super(source, models);
+ action = (m) -> {
+ try {
+ recorder.stopRecording(m);
+ } catch(Exception e) {
+ Platform.runLater(() ->
+ Dialogs.showError("Couldn't stop recording", "Stopping recording of " + m.getName() + " failed", e));
+ }
+ };
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/controls/AbstractFileSelectionBox.java b/client/src/main/java/ctbrec/ui/controls/AbstractFileSelectionBox.java
new file mode 100644
index 00000000..f377998b
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/controls/AbstractFileSelectionBox.java
@@ -0,0 +1,146 @@
+package ctbrec.ui.controls;
+
+import java.io.File;
+import java.io.IOException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.StringUtil;
+import ctbrec.ui.AutosizeAlert;
+import javafx.beans.property.SimpleStringProperty;
+import javafx.beans.property.StringProperty;
+import javafx.beans.value.ChangeListener;
+import javafx.geometry.Point2D;
+import javafx.scene.Node;
+import javafx.scene.control.Alert;
+import javafx.scene.control.Button;
+import javafx.scene.control.TextField;
+import javafx.scene.control.Tooltip;
+import javafx.scene.layout.Border;
+import javafx.scene.layout.BorderStroke;
+import javafx.scene.layout.BorderStrokeStyle;
+import javafx.scene.layout.BorderWidths;
+import javafx.scene.layout.CornerRadii;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
+import javafx.scene.paint.Color;
+import javafx.stage.FileChooser;
+
+public abstract class AbstractFileSelectionBox extends HBox {
+ private static final transient Logger LOG = LoggerFactory.getLogger(AbstractFileSelectionBox.class);
+
+ // private ObjectProperty fileProperty = new ObjectPropertyBase() {
+ // @Override
+ // public Object getBean() {
+ // return null;
+ // }
+ //
+ // @Override
+ // public String getName() {
+ // return "file";
+ // }
+ // };
+ private StringProperty fileProperty = new SimpleStringProperty();
+ protected TextField fileInput;
+ protected boolean allowEmptyValue = false;
+ private Tooltip validationError = new Tooltip();
+
+ public AbstractFileSelectionBox() {
+ super(5);
+ fileInput = new TextField();
+ fileInput.textProperty().addListener(textListener());
+ fileInput.focusedProperty().addListener((obs, o, n) -> {
+ if(!n) {
+ validationError.hide();
+ }
+ });
+ Node browse = createBrowseButton();
+ getChildren().addAll(fileInput, browse);
+ fileInput.disableProperty().bind(disableProperty());
+ browse.disableProperty().bind(disableProperty());
+ HBox.setHgrow(fileInput, Priority.ALWAYS);
+ }
+
+ public AbstractFileSelectionBox(String initialValue) {
+ this();
+ fileInput.setText(initialValue);
+ }
+
+ private ChangeListener super String> textListener() {
+ return (obs, o, n) -> {
+ String input = fileInput.getText();
+ if(StringUtil.isBlank(input) && allowEmptyValue) {
+ fileProperty.set("");
+ hideValidationHints();
+ return;
+ } else {
+ File program = new File(input);
+ setFile(program);
+ }
+ };
+ }
+
+ protected void setFile(File file) {
+ String msg = validate(file);
+ if (msg != null) {
+ fileInput.setBorder(new Border(new BorderStroke(Color.RED, BorderStrokeStyle.DASHED, new CornerRadii(2), new BorderWidths(2))));
+ validationError.setText(msg);
+ fileInput.setTooltip(validationError);
+ Point2D p = fileInput.localToScreen(fileInput.getTranslateY(), fileInput.getTranslateY());
+ if(!validationError.isShowing() && getScene() != null) {
+ validationError.show(getScene().getWindow(), p.getX(), p.getY() + fileInput.getHeight() + 4);
+ }
+ } else {
+ fileProperty.set(file.getAbsolutePath());
+ hideValidationHints();
+ }
+ }
+
+ private void hideValidationHints() {
+ fileInput.setBorder(Border.EMPTY);
+ fileInput.setTooltip(null);
+ validationError.hide();
+ }
+
+ protected String validate(File file) {
+ if (file == null || !file.exists()) {
+ return "File does not exist";
+ } else {
+ return null;
+ }
+ }
+
+ public void allowEmptyValue() {
+ this.allowEmptyValue = true;
+ }
+
+ private Button createBrowseButton() {
+ Button button = new Button("Select");
+ button.setOnAction((e) -> {
+ choose();
+ });
+ return button;
+ }
+
+ protected void choose() {
+ FileChooser chooser = new FileChooser();
+ File program = chooser.showOpenDialog(null);
+ if(program != null) {
+ try {
+ fileInput.setText(program.getCanonicalPath());
+ } catch (IOException e1) {
+ LOG.error("Couldn't determine path", e1);
+ Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
+ alert.setTitle("Whoopsie");
+ alert.setContentText("Couldn't determine path");
+ alert.showAndWait();
+ }
+ setFile(program);
+ }
+ }
+
+ public StringProperty fileProperty() {
+ return fileProperty;
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/autofilltextbox/AutoFillTextField.java b/client/src/main/java/ctbrec/ui/controls/AutoFillTextField.java
similarity index 98%
rename from client/src/main/java/ctbrec/ui/autofilltextbox/AutoFillTextField.java
rename to client/src/main/java/ctbrec/ui/controls/AutoFillTextField.java
index bf986360..ca772778 100644
--- a/client/src/main/java/ctbrec/ui/autofilltextbox/AutoFillTextField.java
+++ b/client/src/main/java/ctbrec/ui/controls/AutoFillTextField.java
@@ -1,4 +1,4 @@
-package ctbrec.ui.autofilltextbox;
+package ctbrec.ui.controls;
import javafx.collections.ObservableList;
diff --git a/client/src/main/java/ctbrec/ui/controls/Dialogs.java b/client/src/main/java/ctbrec/ui/controls/Dialogs.java
new file mode 100644
index 00000000..653bbc91
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/controls/Dialogs.java
@@ -0,0 +1,27 @@
+package ctbrec.ui.controls;
+
+import ctbrec.ui.AutosizeAlert;
+import javafx.application.Platform;
+import javafx.scene.control.Alert;
+
+public class Dialogs {
+ public static void showError(String header, String text, Throwable t) {
+ Runnable r = () -> {
+ Alert alert = new AutosizeAlert(Alert.AlertType.ERROR);
+ alert.setTitle("Error");
+ alert.setHeaderText(header);
+ String content = text;
+ if(t != null) {
+ content += " " + t.getLocalizedMessage();
+ }
+ alert.setContentText(content);
+ alert.showAndWait();
+ };
+
+ if(Platform.isFxApplicationThread()) {
+ r.run();
+ } else {
+ Platform.runLater(r);
+ }
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/controls/DirectorySelectionBox.java b/client/src/main/java/ctbrec/ui/controls/DirectorySelectionBox.java
new file mode 100644
index 00000000..ca65a7c4
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/controls/DirectorySelectionBox.java
@@ -0,0 +1,37 @@
+package ctbrec.ui.controls;
+
+import java.io.File;
+
+import javafx.stage.DirectoryChooser;
+
+public class DirectorySelectionBox extends AbstractFileSelectionBox {
+ public DirectorySelectionBox(String dir) {
+ super(dir);
+ }
+
+ @Override
+ protected void choose() {
+ DirectoryChooser chooser = new DirectoryChooser();
+ File currentDir = new File(fileProperty().get());
+ if (currentDir.exists() && currentDir.isDirectory()) {
+ chooser.setInitialDirectory(currentDir);
+ }
+ File selectedDir = chooser.showDialog(null);
+ if(selectedDir != null) {
+ fileInput.setText(selectedDir.getAbsolutePath());
+ setFile(selectedDir);
+ }
+ }
+
+ @Override
+ protected String validate(File file) {
+ String msg = super.validate(file);
+ if(msg != null) {
+ return msg;
+ } else if (!file.isDirectory()) {
+ return "This is not a directory";
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/controls/FileSelectionBox.java b/client/src/main/java/ctbrec/ui/controls/FileSelectionBox.java
new file mode 100644
index 00000000..c4f3dfe4
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/controls/FileSelectionBox.java
@@ -0,0 +1,24 @@
+package ctbrec.ui.controls;
+
+import java.io.File;
+
+public class FileSelectionBox extends AbstractFileSelectionBox {
+ public FileSelectionBox() {
+ }
+
+ public FileSelectionBox(String initialValue) {
+ super(initialValue);
+ }
+
+ @Override
+ protected String validate(File file) {
+ String msg = super.validate(file);
+ if(msg != null) {
+ return msg;
+ } else if (!file.isFile()) {
+ return "This is not a regular file";
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/controls/Popover.css b/client/src/main/java/ctbrec/ui/controls/Popover.css
new file mode 100644
index 00000000..bb0886ec
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/controls/Popover.css
@@ -0,0 +1,28 @@
+.popover {
+ -fx-padding: 43 7 7 7;
+}
+
+.popover-frame {
+ -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);
+}
+
+.popover.left-tooth .popover-frame {
+ -fx-shape: "m 33.34215,51.52967 4.782653,4.746482 4.333068,4.299995 h 94.637639 c 1.108,0 1.99987,0.891879 1.99987,1.999877 V 164.22046 c 0,1.10801 -0.89187,1.99988 -1.99987,1.99988 H 12.205971 c -1.107998,0 -2.000392,-0.89187 -2.000392,-1.99988 V 62.576024 c 0,-1.107998 0.892394,-1.999877 2.000392,-1.999877 h 12.020455 l 4.333071,-4.299995 z";
+}
+
+.popover.right-tooth .popover-frame {
+ -fx-shape: "M 438.26953 194.75781 L 420.19336 212.69727 L 403.81641 228.94922 L 46.130859 228.94922 C 41.943143 228.94922 38.572266 232.3201 38.572266 236.50781 L 38.572266 620.67578 C 38.572266 624.8635 41.943143 628.23438 46.130859 628.23438 L 518.1543 628.23438 C 522.34201 628.23438 525.71484 624.8635 525.71484 620.67578 L 525.71484 236.50781 C 525.71484 232.3201 522.34201 228.94922 518.1543 228.94922 L 472.72266 228.94922 L 456.3457 212.69727 L 438.26953 194.75781 z";
+}
+
+.popover-title {
+ -fx-font-size: 20px;
+ -fx-text-fill: -fx-text-background-color;
+}
+
+.popover .button {
+ -fx-font-size: 12px;
+}
\ No newline at end of file
diff --git a/client/src/main/java/ctbrec/ui/controls/Popover.java b/client/src/main/java/ctbrec/ui/controls/Popover.java
new file mode 100644
index 00000000..d738877b
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/controls/Popover.java
@@ -0,0 +1,469 @@
+/*
+ * Copyright (c) 2008, 2014, Oracle and/or its affiliates.
+ * All rights reserved. Use is subject to license terms.
+ *
+ * This file is available and licensed under the following license:
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * - Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * - Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in
+ * the documentation and/or other materials provided with the distribution.
+ * - Neither the name of Oracle Corporation nor the names of its
+ * contributors may be used to endorse or promote products derived
+ * from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package ctbrec.ui.controls;
+
+import java.util.LinkedList;
+
+import javafx.animation.Animation;
+import javafx.animation.FadeTransition;
+import javafx.animation.Interpolator;
+import javafx.animation.KeyFrame;
+import javafx.animation.KeyValue;
+import javafx.animation.ParallelTransition;
+import javafx.animation.ScaleTransition;
+import javafx.animation.Timeline;
+import javafx.beans.property.DoubleProperty;
+import javafx.beans.property.SimpleDoubleProperty;
+import javafx.event.ActionEvent;
+import javafx.event.Event;
+import javafx.event.EventHandler;
+import javafx.geometry.Insets;
+import javafx.geometry.Point2D;
+import javafx.scene.Node;
+import javafx.scene.Scene;
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.layout.Pane;
+import javafx.scene.layout.Region;
+import javafx.scene.shape.Rectangle;
+import javafx.scene.text.TextAlignment;
+import javafx.util.Duration;
+
+/**
+ * A Popover is a mini-window that pops up and contains some application specific content.
+ * It's width is defined by the application, but defaults to a hard-coded pref width.
+ * The height will always be between a minimum height (determined by the application, but
+ * pre-set with a minimum value) and a maximum height (specified by the application, or
+ * based on the height of the scene). The value for the pref height is determined by
+ * inspecting the pref height of the current displayed page. At time this value is animated
+ * (when switching from page to page).
+ */
+public class Popover extends Region implements EventHandler{
+ private static final int PAGE_GAP = 15;
+
+ /**
+ * The visual frame of the popover is defined as an addition region, rather than simply styling
+ * the popover itself as one might expect. The reason for this is that our frame is styled via
+ * a border image, and it has an inner shadow associated with it, and we want to be able to ensure
+ * that the shadow is on top of whatever content is drawn within the popover. In addition, the inner
+ * edge of the frame is rounded, and we want the content to slide under it, only to be clipped beneath
+ * the frame. So it works best for the frame to be overlaid on top, even though it is not intuitive.
+ */
+ private final Region frameBorder = new Region();
+ private final Button leftButton = new Button("Left");
+ private final Button rightButton = new Button("Right");
+ private final LinkedList pages = new LinkedList();
+ private final Pane pagesPane = new Pane();
+ private final Rectangle pagesClipRect = new Rectangle();
+ private final Pane titlesPane = new Pane();
+ private Label title; // the current title
+ private final EventHandler popoverHideHandler;
+ private Runnable onHideCallback = null;
+ private int maxPopupHeight = -1;
+
+ private DoubleProperty popoverHeight = new SimpleDoubleProperty(400) {
+ @Override protected void invalidated() {
+ requestLayout();
+ }
+ };
+
+ public Popover() {
+ getStyleClass().setAll("popover");
+ frameBorder.getStyleClass().setAll("popover-frame");
+ frameBorder.setMouseTransparent(true);
+ // setup buttons
+ leftButton.setOnMouseClicked(this);
+ leftButton.getStyleClass().add("popover-left-button");
+ leftButton.setMinWidth(USE_PREF_SIZE);
+ rightButton.setOnMouseClicked(this);
+ rightButton.getStyleClass().add("popover-right-button");
+ rightButton.setMinWidth(USE_PREF_SIZE);
+ pagesClipRect.setSmooth(false);
+ pagesClipRect.setArcHeight(10);
+ pagesClipRect.arcWidthProperty().bind(pagesClipRect.arcHeightProperty());
+ pagesPane.setClip(pagesClipRect);
+ getChildren().addAll(frameBorder, titlesPane, leftButton, rightButton, pagesPane);
+ // always hide to start with
+ setVisible(false);
+ setOpacity(0);
+ setScaleX(.8);
+ setScaleY(.8);
+ // create handlers for auto hiding
+ popoverHideHandler = (MouseEvent t) -> {
+ // check if event is outside popup
+ Point2D mouseInFilterPane = sceneToLocal(t.getX(), t.getY());
+ if (mouseInFilterPane.getX() < 0 || mouseInFilterPane.getX() > (getWidth()) ||
+ mouseInFilterPane.getY() < 0 || mouseInFilterPane.getY() > (getHeight())) {
+ hide();
+ t.consume();
+ }
+ };
+ // popoverScrollHandler = new EventHandler() {
+ // @Override public void handle(ScrollEvent t) {
+ // t.consume(); // consume all scroll events
+ // }
+ // };
+ }
+
+ /**
+ * Handle mouse clicks on the left and right buttons.
+ */
+ @Override public void handle(Event event) {
+ if (event.getSource() == leftButton) {
+ pages.getFirst().handleLeftButton();
+ } else if (event.getSource() == rightButton) {
+ pages.getFirst().handleRightButton();
+ }
+ }
+
+ @Override protected double computeMinWidth(double height) {
+ Page page = pages.isEmpty() ? null : pages.getFirst();
+ if (page != null) {
+ Node n = page.getPageNode();
+ if (n != null) {
+ Insets insets = getInsets();
+ return insets.getLeft() + n.minWidth(-1) + insets.getRight();
+ }
+ }
+ return 200;
+ }
+
+ @Override protected double computeMinHeight(double width) {
+ Insets insets = getInsets();
+ return insets.getLeft() + 100 + insets.getRight();
+ }
+
+ @Override protected double computePrefWidth(double height) {
+ Page page = pages.isEmpty() ? null : pages.getFirst();
+ if (page != null) {
+ Node n = page.getPageNode();
+ if (n != null) {
+ Insets insets = getInsets();
+ return insets.getLeft() + n.prefWidth(-1) + insets.getRight();
+ }
+ }
+ return 400;
+ }
+
+ @Override protected double computePrefHeight(double width) {
+ double minHeight = minHeight(-1);
+ double maxHeight = maxHeight(-1);
+ double prefHeight = popoverHeight.get();
+ if (prefHeight == -1) {
+ Page page = pages.getFirst();
+ if (page != null) {
+ Insets inset = getInsets();
+ if (width == -1) {
+ width = prefWidth(-1);
+ }
+ double contentWidth = width - inset.getLeft() - inset.getRight();
+ double contentHeight = page.getPageNode().prefHeight(contentWidth);
+ prefHeight = inset.getTop() + contentHeight + inset.getBottom();
+ popoverHeight.set(prefHeight);
+ } else {
+ prefHeight = minHeight;
+ }
+ }
+ return boundedSize(minHeight, prefHeight, maxHeight);
+ }
+
+ static double boundedSize(double min, double pref, double max) {
+ double a = pref >= min ? pref : min;
+ double b = min >= max ? min : max;
+ return a <= b ? a : b;
+ }
+
+ @Override protected double computeMaxWidth(double height) {
+ return Double.MAX_VALUE;
+ }
+
+ @Override protected double computeMaxHeight(double width) {
+ Scene scene = getScene();
+ if (scene != null) {
+ return scene.getHeight() - 100;
+ } else {
+ return Double.MAX_VALUE;
+ }
+ }
+
+ @Override protected void layoutChildren() {
+ if (maxPopupHeight == -1) {
+ maxPopupHeight = (int)getScene().getHeight()-100;
+ }
+ final Insets insets = getInsets();
+ final int width = (int)getWidth();
+ final int height = (int)getHeight();
+ final int top = (int)insets.getTop() + 40;
+ final int right = (int)insets.getRight();
+ final int bottom = (int)insets.getBottom();
+ final int left = (int)insets.getLeft();
+ final int offset = 18;
+
+ int pageWidth = width - left - right;
+ int pageHeight = height - top - bottom;
+
+ frameBorder.resize(width, height);
+
+ pagesPane.resizeRelocate(left, top, pageWidth, pageHeight);
+ pagesClipRect.setWidth(pageWidth);
+ pagesClipRect.setHeight(pageHeight);
+
+ int pageX = 0;
+ for (Node page : pagesPane.getChildren()) {
+ page.resizeRelocate(pageX, 0, pageWidth, pageHeight);
+ pageX += pageWidth + PAGE_GAP;
+ }
+
+ int buttonHeight = (int)(leftButton.prefHeight(-1));
+ if (buttonHeight < 30) buttonHeight = 30;
+ final int buttonTop = (int)((top-buttonHeight)/2d);
+ final int leftButtonWidth = (int)snapSizeX(leftButton.prefWidth(-1));
+ leftButton.resizeRelocate(left, buttonTop + offset,leftButtonWidth,buttonHeight);
+ final int rightButtonWidth = (int)snapSizeX(rightButton.prefWidth(-1));
+ rightButton.resizeRelocate(width-right-rightButtonWidth, buttonTop + offset,rightButtonWidth,buttonHeight);
+
+ if (title != null) {
+ double tw = title.getWidth();
+ double th = title.getHeight();
+ title.setTranslateX((width - tw) / 2);
+ title.setTranslateY((top - th) / 2 + offset);
+ }
+ }
+
+ public final void clearPages() {
+ while (!pages.isEmpty()) {
+ pages.pop().handleHidden();
+ }
+ pagesPane.getChildren().clear();
+ titlesPane.getChildren().clear();
+ pagesClipRect.setX(0);
+ pagesClipRect.setWidth(400);
+ pagesClipRect.setHeight(400);
+ popoverHeight.set(400);
+ pagesPane.setTranslateX(0);
+ titlesPane.setTranslateX(0);
+ }
+
+ public final void popPage() {
+ Page oldPage = pages.pop();
+ oldPage.handleHidden();
+ oldPage.setPopover(null);
+ Page page = pages.getFirst();
+ leftButton.setVisible(page.leftButtonText() != null);
+ leftButton.setText(page.leftButtonText());
+ rightButton.setVisible(page.rightButtonText() != null);
+ rightButton.setText(page.rightButtonText());
+ if (pages.size() > 0) {
+ final Insets insets = getInsets();
+ final int width = (int)prefWidth(-1);
+ final int right = (int)insets.getRight();
+ final int left = (int)insets.getLeft();
+ int pageWidth = width - left - right;
+ final int newPageX = (pageWidth+PAGE_GAP) * (pages.size()-1);
+ new Timeline(
+ new KeyFrame(Duration.millis(350), (ActionEvent t) -> {
+ pagesPane.setCache(false);
+ pagesPane.getChildren().remove(pagesPane.getChildren().size()-1);
+ titlesPane.getChildren().remove(titlesPane.getChildren().size()-1);
+ resizePopoverToNewPage(pages.getFirst().getPageNode());
+ },
+ new KeyValue(pagesPane.translateXProperty(), -newPageX, Interpolator.EASE_BOTH),
+ new KeyValue(titlesPane.translateXProperty(), -newPageX, Interpolator.EASE_BOTH),
+ new KeyValue(pagesClipRect.xProperty(), newPageX, Interpolator.EASE_BOTH)
+ )
+ ).play();
+ } else {
+ hide();
+ }
+ }
+
+ public final void pushPage(final Page page) {
+ final Node pageNode = page.getPageNode();
+ pageNode.setManaged(false);
+ pagesPane.getChildren().add(pageNode);
+ final Insets insets = getInsets();
+ final int pageWidth = (int)(prefWidth(-1) - insets.getLeft() - insets.getRight());
+ final int newPageX = (pageWidth + PAGE_GAP) * pages.size();
+ leftButton.setVisible(page.leftButtonText() != null);
+ leftButton.setText(page.leftButtonText());
+ rightButton.setVisible(page.rightButtonText() != null);
+ rightButton.setText(page.rightButtonText());
+
+ title = new Label(page.getPageTitle());
+ title.getStyleClass().add("popover-title");
+ title.setTextAlignment(TextAlignment.CENTER);
+ title.setTranslateX(newPageX + (int) ((pageWidth - title.getLayoutBounds().getWidth()) / 2d));
+ titlesPane.getChildren().add(title);
+
+ if (!pages.isEmpty() && isVisible()) {
+ final Timeline timeline = new Timeline(
+ new KeyFrame(Duration.millis(350), (ActionEvent t) -> {
+ pagesPane.setCache(false);
+ resizePopoverToNewPage(pageNode);
+ },
+ new KeyValue(pagesPane.translateXProperty(), -newPageX, Interpolator.EASE_BOTH),
+ new KeyValue(titlesPane.translateXProperty(), -newPageX, Interpolator.EASE_BOTH),
+ new KeyValue(pagesClipRect.xProperty(), newPageX, Interpolator.EASE_BOTH)
+ )
+ );
+ timeline.play();
+ }
+ page.setPopover(this);
+ page.handleShown();
+ pages.push(page);
+ }
+
+ private void resizePopoverToNewPage(final Node newPageNode) {
+ final Insets insets = getInsets();
+ final double width = prefWidth(-1);
+ final double contentWidth = width - insets.getLeft() - insets.getRight();
+ double h = newPageNode.prefHeight(contentWidth);
+ h += insets.getTop() + insets.getBottom();
+ new Timeline(
+ new KeyFrame(Duration.millis(200),
+ new KeyValue(popoverHeight, h, Interpolator.EASE_BOTH)
+ )
+ ).play();
+ }
+
+ public void show(){
+ show(null);
+ }
+
+ private Animation fadeAnimation = null;
+
+ public void show(Runnable onHideCallback){
+ if (!isVisible() || fadeAnimation != null) {
+ this.onHideCallback = onHideCallback;
+ getScene().addEventFilter(MouseEvent.MOUSE_CLICKED, popoverHideHandler);
+ // getScene().addEventFilter(ScrollEvent.ANY,popoverScrollHandler);
+
+ if (fadeAnimation != null) {
+ fadeAnimation.stop();
+ setVisible(true); // for good measure
+ } else {
+ popoverHeight.set(-1);
+ setVisible(true);
+ }
+
+ FadeTransition fade = new FadeTransition(Duration.seconds(.1), this);
+ fade.setToValue(1.0);
+ fade.setOnFinished((ActionEvent event) -> {
+ fadeAnimation = null;
+ });
+
+ ScaleTransition scale = new ScaleTransition(Duration.seconds(.1), this);
+ scale.setToX(1);
+ scale.setToY(1);
+
+ ParallelTransition tx = new ParallelTransition(fade, scale);
+ fadeAnimation = tx;
+ tx.play();
+ }
+ }
+
+ public void hide(){
+ if (isVisible() || fadeAnimation != null) {
+ getScene().removeEventFilter(MouseEvent.MOUSE_CLICKED, popoverHideHandler);
+ // getScene().removeEventFilter(ScrollEvent.ANY,popoverScrollHandler);
+
+ if (fadeAnimation != null) {
+ fadeAnimation.stop();
+ }
+
+ FadeTransition fade = new FadeTransition(Duration.seconds(.1), this);
+ fade.setToValue(0);
+ fade.setOnFinished((ActionEvent event) -> {
+ fadeAnimation = null;
+ setVisible(false);
+ //clearPages();
+ if (onHideCallback != null) onHideCallback.run();
+ });
+
+ ScaleTransition scale = new ScaleTransition(Duration.seconds(.1), this);
+ scale.setToX(.8);
+ scale.setToY(.8);
+
+ ParallelTransition tx = new ParallelTransition(fade, scale);
+ fadeAnimation = tx;
+ tx.play();
+ }
+ }
+
+ /**
+ * Represents a page in a popover.
+ */
+ public static interface Page {
+ public void setPopover(Popover popover);
+ public Popover getPopover();
+
+ /**
+ * Get the node that represents the page.
+ *
+ * @return the page node.
+ */
+ public Node getPageNode();
+
+ /**
+ * Get the title to display for this page.
+ *
+ * @return The page title
+ */
+ public String getPageTitle();
+
+ /**
+ * The text for left button, if null then button will be hidden.
+ * @return The button text
+ */
+ public String leftButtonText();
+
+ /**
+ * Called on a click of the left button of the popover.
+ */
+ public void handleLeftButton();
+
+ /**
+ * The text for right button, if null then button will be hidden.
+ * @return The button text
+ */
+ public String rightButtonText();
+
+ /**
+ * Called on a click of the right button of the popover.
+ */
+ public void handleRightButton();
+
+ public void handleShown();
+ public void handleHidden();
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/controls/PopoverTreeList.java b/client/src/main/java/ctbrec/ui/controls/PopoverTreeList.java
new file mode 100644
index 00000000..c914ba51
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/controls/PopoverTreeList.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright (c) 2008, 2014, Oracle and/or its affiliates.
+ * All rights reserved. Use is subject to license terms.
+ *
+ * This file is available and licensed under the following license:
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * - Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * - Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in
+ * the documentation and/or other materials provided with the distribution.
+ * - Neither the name of Oracle Corporation nor the names of its
+ * contributors may be used to endorse or promote products derived
+ * from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package ctbrec.ui.controls;
+
+import javafx.event.EventHandler;
+import javafx.scene.control.ListCell;
+import javafx.scene.control.ListView;
+import javafx.scene.input.MouseEvent;
+import javafx.util.Callback;
+
+/**
+ * Special ListView designed to look like "Text... >" tree list. Perhaps we ought to have customized
+ * a TreeView instead of a ListView (as the TreeView already has the data model all defined).
+ *
+ * This implementation minimizes classes by just having the PopoverTreeList implementing everything
+ * (it is the Control, the Skin, and the CellFactory all in one).
+ */
+public class PopoverTreeList extends ListView implements Callback, ListCell> {
+
+ public PopoverTreeList(){
+ getStyleClass().clear();
+ setCellFactory(this);
+ }
+
+ @Override public ListCell call(ListView p) {
+ return new TreeItemListCell();
+ }
+
+ protected void itemClicked(T item) {}
+
+ private class TreeItemListCell extends ListCell implements EventHandler {
+ private TreeItemListCell() {
+ super();
+ getStyleClass().setAll("popover-tree-list-cell");
+ setOnMouseClicked(this);
+ }
+
+ @Override public void handle(MouseEvent t) {
+ itemClicked(getItem());
+ }
+
+ @Override protected double computePrefWidth(double height) {
+ return 100;
+ }
+
+ @Override protected double computePrefHeight(double width) {
+ return 44;
+ }
+
+ // CELL METHODS
+ @Override protected void updateItem(T item, boolean empty) {
+ // let super do its work
+ super.updateItem(item,empty);
+ // update our state
+ if (item == null) { // empty item
+ setText(null);
+ } else {
+ setText(item.toString());
+ }
+ }
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/controls/ProgramSelectionBox.java b/client/src/main/java/ctbrec/ui/controls/ProgramSelectionBox.java
new file mode 100644
index 00000000..1ed2b85e
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/controls/ProgramSelectionBox.java
@@ -0,0 +1,24 @@
+package ctbrec.ui.controls;
+
+import java.io.File;
+
+public class ProgramSelectionBox extends FileSelectionBox {
+ public ProgramSelectionBox() {
+ }
+
+ public ProgramSelectionBox(String initialValue) {
+ super(initialValue);
+ }
+
+ @Override
+ protected String validate(File file) {
+ String msg = super.validate(file);
+ if(msg != null) {
+ return msg;
+ } else if (!file.canExecute()) {
+ return "This is not an executable application";
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/controls/SearchBox.css b/client/src/main/java/ctbrec/ui/controls/SearchBox.css
new file mode 100644
index 00000000..3877c82f
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/controls/SearchBox.css
@@ -0,0 +1,34 @@
+.search-box-icon {
+ -fx-shape: "M10.728,9.893c0.889-1.081,1.375-2.435,1.375-3.842C12.103,2.714,9.388,0,6.051,0C2.715,0,0,2.714,0,6.051c0,3.338,2.715,6.052,6.051,6.052c0.954,0,1.898-0.227,2.744-0.656l3.479,3.478l1.743-1.742L10.728,9.893z M6.051,2.484c1.966,0,3.566,1.602,3.566,3.566c0,1.968-1.6,3.567-3.566,3.567c-1.967,0-3.566-1.6-3.566-3.567C2.485,4.086,4.084,2.484,6.051,2.484z";
+ -fx-scale-shape: false;
+ -fx-background-color: -fx-mark-color;
+}
+.search-box {
+ /*-fx-font-size: 16px;*/
+ /*-fx-text-fill: #363636;*/
+ /*-fx-background-radius: 15, 14;*/
+ -fx-padding: 0 0 0 30;
+}
+.search-box:focused {
+ /*-fx-background-radius: 15,14,16,14;*/
+}
+.search-clear-button {
+ -fx-shape: "M9.521,0.083c-5.212,0-9.438,4.244-9.438,9.479c0,5.234,4.225,9.479,9.438,9.479c5.212,0,9.437-4.244,9.437-9.479C18.958,4.327,14.733,0.083,9.521,0.083z M13.91,13.981c-0.367,0.369-0.963,0.369-1.329,0l-3.019-3.03l-3.019,3.03c-0.367,0.369-0.962,0.369-1.329,0c-0.367-0.368-0.366-0.965,0.001-1.334l3.018-3.031L5.216,6.585C4.849,6.217,4.849,5.618,5.217,5.25c0.366-0.369,0.961-0.368,1.328,0l3.018,3.031l3.019-3.031c0.366-0.368,0.961-0.369,1.328,0c0.366,0.368,0.366,0.967,0,1.335l-3.019,3.031l3.02,3.031C14.276,13.017,14.276,13.613,13.91,13.981z";
+ -fx-scale-shape: false;
+ -fx-background-color: -fx-mark-color;
+ -fx-padding: 9.5px;
+}
+
+.search-tree-list-cell {
+ -fx-background-color: -fx-background;
+ -fx-border-color: transparent transparent -fx-base transparent;
+ -fx-padding: 0 30 0 20;
+ -fx-font-size: 15px;
+ -fx-text-fill: -fx-mid-text-color;
+ -fx-graphic-text-gap: 20px;
+}
+
+.highlight {
+ -fx-background-color: -fx-focus-color;
+ -fx-text-fill: -fx-light-text-color;
+}
diff --git a/client/src/main/java/ctbrec/ui/controls/SearchBox.java b/client/src/main/java/ctbrec/ui/controls/SearchBox.java
new file mode 100644
index 00000000..be893acd
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/controls/SearchBox.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2008, 2014, Oracle and/or its affiliates.
+ * All rights reserved. Use is subject to license terms.
+ *
+ * This file is available and licensed under the following license:
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * - Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * - Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in
+ * the documentation and/or other materials provided with the distribution.
+ * - Neither the name of Oracle Corporation nor the names of its
+ * contributors may be used to endorse or promote products derived
+ * from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package ctbrec.ui.controls;
+
+import javafx.beans.value.ChangeListener;
+import javafx.beans.value.ObservableValue;
+import javafx.scene.Cursor;
+import javafx.scene.control.Button;
+import javafx.scene.control.TextField;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.layout.Region;
+
+/**
+ * Search field with styling and a clear button
+ */
+public class SearchBox extends TextField implements ChangeListener{
+ private final Button clearButton = new Button();
+ private final Region innerBackground = new Region();
+ private final Region icon = new Region();
+ private final int prefHeight = 26;
+
+ public SearchBox() {
+ getStyleClass().addAll("search-box");
+ icon.getStyleClass().setAll("search-box-icon");
+ innerBackground.getStyleClass().setAll("search-box-inner");
+ setPromptText("Search");
+ textProperty().addListener(this);
+ setPrefHeight(prefHeight);
+ clearButton.getStyleClass().setAll("search-clear-button");
+ clearButton.setCursor(Cursor.DEFAULT);
+ clearButton.setOnMouseClicked((MouseEvent t) -> {
+ setText("");
+ requestFocus();
+ });
+ clearButton.setVisible(false);
+ clearButton.setManaged(false);
+ innerBackground.setManaged(false);
+ icon.setManaged(false);
+ }
+
+ public SearchBox(boolean icon) {
+ this();
+ this.icon.setVisible(false);
+ this.icon.getStyleClass().remove("search-box-icon");
+ this.setStyle("-fx-padding: 5");
+ }
+
+ @Override protected void layoutChildren() {
+ super.layoutChildren();
+ if (clearButton.getParent() != this) getChildren().add(clearButton);
+ if (innerBackground.getParent() != this) getChildren().add(0,innerBackground);
+ if (icon.getParent() != this) getChildren().add(icon);
+ innerBackground.setLayoutX(0);
+ innerBackground.setLayoutY(0);
+ innerBackground.resize(getWidth(), getHeight());
+ icon.setLayoutX(0);
+ icon.setLayoutY(0);
+ icon.resize(35,prefHeight);
+ clearButton.setLayoutX(getWidth() - prefHeight);
+ clearButton.setLayoutY(0);
+ clearButton.resize(prefHeight, prefHeight);
+ }
+
+ @Override public void changed(ObservableValue extends String> ov, String oldValue, String newValue) {
+ clearButton.setVisible(newValue.length() > 0);
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/controls/SearchPopover.java b/client/src/main/java/ctbrec/ui/controls/SearchPopover.java
new file mode 100644
index 00000000..42222952
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/controls/SearchPopover.java
@@ -0,0 +1,9 @@
+package ctbrec.ui.controls;
+
+public class SearchPopover extends Popover {
+
+
+ public SearchPopover() {
+ getStyleClass().add("right-tooth");
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/controls/SearchPopoverTreeList.java b/client/src/main/java/ctbrec/ui/controls/SearchPopoverTreeList.java
new file mode 100644
index 00000000..6cda44a6
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/controls/SearchPopoverTreeList.java
@@ -0,0 +1,323 @@
+/*
+ * Copyright (c) 2008, 2014, Oracle and/or its affiliates.
+ * All rights reserved. Use is subject to license terms.
+ *
+ * This file is available and licensed under the following license:
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * - Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * - Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in
+ * the documentation and/or other materials provided with the distribution.
+ * - Neither the name of Oracle Corporation nor the names of its
+ * contributors may be used to endorse or promote products derived
+ * from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package ctbrec.ui.controls;
+
+import java.net.URL;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.Model;
+import ctbrec.recorder.Recorder;
+import ctbrec.ui.action.PlayAction;
+import javafx.application.Platform;
+import javafx.concurrent.Task;
+import javafx.event.EventHandler;
+import javafx.geometry.Insets;
+import javafx.scene.Cursor;
+import javafx.scene.Node;
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.control.ListCell;
+import javafx.scene.control.ListView;
+import javafx.scene.control.Skin;
+import javafx.scene.image.Image;
+import javafx.scene.image.ImageView;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.shape.Rectangle;
+
+/**
+ * Popover page that displays a list of samples and sample categories for a given SampleCategory.
+ */
+public class SearchPopoverTreeList extends PopoverTreeList implements Popover.Page {
+ private static final transient Logger LOG = LoggerFactory.getLogger(SearchPopoverTreeList.class);
+
+ private Popover popover;
+
+ private Recorder recorder;
+
+ public SearchPopoverTreeList() {
+
+ }
+
+ @Override
+ public ListCell call(ListView p) {
+ return new SearchItemListCell();
+ }
+
+ @Override
+ protected void itemClicked(Model model) {
+ if(model == null) {
+ return;
+ }
+
+ new PlayAction(this, model).execute();
+ }
+
+ @Override
+ public void setPopover(Popover popover) {
+ this.popover = popover;
+ }
+
+ @Override
+ public Popover getPopover() {
+ return popover;
+ }
+
+ @Override
+ public Node getPageNode() {
+ return this;
+ }
+
+ @Override
+ public String getPageTitle() {
+ return "Search Results";
+ }
+
+ @Override
+ public String leftButtonText() {
+ return null;
+ }
+
+ @Override
+ public void handleLeftButton() {
+ }
+
+ @Override
+ public String rightButtonText() {
+ return "Done";
+ }
+
+ @Override
+ public void handleRightButton() {
+ popover.hide();
+ }
+
+ @Override
+ public void handleShown() {
+ }
+
+ @Override
+ public void handleHidden() {
+ }
+
+ private class SearchItemListCell extends ListCell implements Skin, EventHandler {
+
+ private Label title = new Label();
+ private Button follow;
+ private Button record;
+ private Model model;
+ private ImageView thumb = new ImageView();
+ private int thumbSize = 64;
+ private Node tallest = thumb;
+
+ private SearchItemListCell() {
+ super();
+ setSkin(this);
+ getStyleClass().setAll("search-tree-list-cell");
+ setOnMouseClicked(this);
+ setOnMouseEntered(evt -> {
+ getStyleClass().add("highlight");
+ title.getStyleClass().add("highlight");
+ });
+ setOnMouseExited(evt -> {
+ getStyleClass().remove("highlight");
+ title.getStyleClass().remove("highlight");
+ });
+
+ Rectangle clip = new Rectangle(thumbSize, thumbSize);
+ clip.setArcWidth(20);
+ clip.arcHeightProperty().bind(clip.arcWidthProperty());
+ thumb.setFitWidth(thumbSize);
+ thumb.setFitHeight(thumbSize);
+ thumb.setClip(clip);
+ thumb.setSmooth(true);
+
+ follow = new Button("Follow");
+ follow.setOnAction((evt) -> {
+ setCursor(Cursor.WAIT);
+ new Thread(new Task() {
+ @Override
+ protected Boolean call() throws Exception {
+ return model.follow();
+ }
+
+ @Override
+ protected void done() {
+ try {
+ get();
+ } catch (Exception e) {
+ LOG.warn("Search failed: {}", e.getMessage());
+ }
+ Platform.runLater(() -> setCursor(Cursor.DEFAULT));
+ }
+ }).start();
+ });
+ record = new Button("Record");
+ record.setOnAction((evt) -> {
+ setCursor(Cursor.WAIT);
+ new Thread(new Task() {
+ @Override
+ protected Void call() throws Exception {
+ recorder.startRecording(model);
+ return null;
+ }
+
+ @Override
+ protected void done() {
+ Platform.runLater(() -> setCursor(Cursor.DEFAULT));
+ }
+ }).start();
+ });
+ getChildren().addAll(thumb, title, follow, record);
+
+ record.visibleProperty().bind(title.visibleProperty());
+ thumb.visibleProperty().bind(title.visibleProperty());
+ }
+
+ @Override
+ public void handle(MouseEvent t) {
+ itemClicked(getItem());
+ }
+
+ @Override
+ protected void updateItem(Model model, boolean empty) {
+ super.updateItem(model, empty);
+ if (empty) {
+ follow.setVisible(false);
+ title.setVisible(false);
+ this.model = null;
+ } else {
+ follow.setVisible(model.getSite().supportsFollow());
+ title.setVisible(true);
+ title.setText(model.getDisplayName());
+ this.model = model;
+ URL anonymousPng = getClass().getResource("/anonymous.png");
+ String previewUrl = Optional.ofNullable(model.getPreview()).orElse(anonymousPng.toString());
+ if(!Objects.equals(System.getenv("CTBREC_DEV"), "1")) {
+ Image img = new Image(previewUrl, true);
+ thumb.setImage(img);
+ } else {
+ Image img = new Image(anonymousPng.toString(), true);
+ thumb.setImage(img);
+ }
+ }
+
+ }
+
+ @Override
+ protected void layoutChildren() {
+ super.layoutChildren();
+ final Insets insets = getInsets();
+ final double left = insets.getLeft();
+ final double top = insets.getTop();
+ final double w = getWidth() - left - insets.getRight();
+ final double h = getHeight() - top - insets.getBottom();
+
+ thumb.setLayoutX(left);
+ thumb.setLayoutY((h - thumbSize) / 2);
+
+ final double titleHeight = title.prefHeight(w);
+ title.setLayoutX(left + thumbSize + 10);
+ title.setLayoutY((h - titleHeight) / 2);
+ title.resize(w, titleHeight);
+
+ int buttonW = 50;
+ int buttonH = 24;
+ follow.setStyle("-fx-font-size: 10px;");
+ follow.setLayoutX(w - buttonW - 20);
+ follow.setLayoutY((h - buttonH) / 2);
+ follow.resize(buttonW, buttonH);
+
+ record.setStyle("-fx-font-size: 10px;");
+ record.setLayoutX(w - 10);
+ record.setLayoutY((h - buttonH) / 2);
+ record.resize(buttonW, buttonH);
+ }
+
+ @Override
+ protected double computeMinWidth(double height) {
+ final Insets insets = getInsets();
+ final double h = height = insets.getBottom() - insets.getTop();
+ return (int) ((insets.getLeft() + tallest.minWidth(h) + tallest.minWidth(h) + insets.getRight()) + 0.5d);
+ }
+
+ @Override
+ protected double computePrefWidth(double height) {
+ final Insets insets = getInsets();
+ final double h = height = insets.getBottom() - insets.getTop();
+ return (int) ((insets.getLeft() + tallest.prefWidth(h) + tallest.prefWidth(h) + insets.getRight()) + 0.5d);
+ }
+
+ @Override
+ protected double computeMaxWidth(double height) {
+ final Insets insets = getInsets();
+ final double h = height = insets.getBottom() - insets.getTop();
+ return (int) ((insets.getLeft() + tallest.maxWidth(h) + tallest.maxWidth(h) + insets.getRight()) + 0.5d);
+ }
+
+ @Override
+ protected double computeMinHeight(double width) {
+ return thumbSize;
+ }
+
+ @Override
+ protected double computePrefHeight(double width) {
+ return thumbSize + 20;
+ }
+
+ @Override
+ protected double computeMaxHeight(double width) {
+ return thumbSize + 20;
+ }
+
+ @Override
+ public SearchItemListCell getSkinnable() {
+ return this;
+ }
+
+ @Override
+ public Node getNode() {
+ return null;
+ }
+
+ @Override
+ public void dispose() {
+ }
+ }
+
+ public void setRecorder(Recorder recorder) {
+ this.recorder = recorder;
+ }
+}
\ No newline at end of file
diff --git a/client/src/main/java/ctbrec/ui/controls/StreamPreview.java b/client/src/main/java/ctbrec/ui/controls/StreamPreview.java
new file mode 100644
index 00000000..2dd21c52
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/controls/StreamPreview.java
@@ -0,0 +1,189 @@
+package ctbrec.ui.controls;
+
+import java.io.InterruptedIOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.Config;
+import ctbrec.Model;
+import ctbrec.io.HttpException;
+import ctbrec.recorder.download.StreamSource;
+import javafx.application.Platform;
+import javafx.geometry.Insets;
+import javafx.scene.control.ProgressIndicator;
+import javafx.scene.image.Image;
+import javafx.scene.image.ImageView;
+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;
+
+public class StreamPreview extends StackPane {
+ private static final transient Logger LOG = LoggerFactory.getLogger(StreamPreview.class);
+
+ private ImageView preview = new ImageView();
+ private MediaView videoPreview;
+ private MediaPlayer videoPlayer;
+ private Media video;
+ private ProgressIndicator progressIndicator;
+ private static ExecutorService executor = Executors.newSingleThreadExecutor();
+ private static Future> future;
+
+ public StreamPreview() {
+ 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);
+
+ 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));
+
+ getChildren().addAll(preview, videoPreview, veil, progressIndicator);
+ }
+
+ public void startStream(Model model) {
+ Platform.runLater(() -> {
+ progressIndicator.setVisible(true);
+ if(model.getPreview() != null) {
+ try {
+ videoPreview.setVisible(false);
+ Image img = new Image(model.getPreview(), true);
+ preview.setImage(img);
+ double aspect = img.getWidth() / img.getHeight();
+ double w = Config.getInstance().getSettings().thumbWidth;
+ double h = w / aspect;
+ resizeTo(w, h);
+ } catch (Exception e) {}
+ }
+ });
+ if(future != null && !future.isDone()) {
+ future.cancel(true);
+ }
+ future = executor.submit(() -> {
+ try {
+ 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());
+ video.setOnError(() -> onError(videoPlayer));
+ 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;
+ resizeTo(w, h);
+ progressIndicator.setVisible(false);
+ videoPreview.setVisible(true);
+ videoPreview.setMediaPlayer(videoPlayer);
+ 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();
+ }
+ });
+ }
+
+ public void resizeTo(double w, double h) {
+ preview.setFitWidth(w);
+ preview.setFitHeight(h);
+ videoPreview.setFitWidth(w);
+ videoPreview.setFitHeight(h);
+ progressIndicator.setPrefSize(w, h);
+ }
+
+ public void stop() {
+ MediaPlayer old = videoPlayer;
+ Future> oldFuture = future;
+ new Thread(() -> {
+ if(oldFuture != null && !oldFuture.isDone()) {
+ oldFuture.cancel(true);
+ }
+ if(old != null) {
+ old.dispose();
+ }
+ }).start();
+ }
+
+ 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());
+ }
+ showTestImage();
+ }
+
+ private void showTestImage() {
+ stop();
+ 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;
+ resizeTo(w, h);
+ progressIndicator.setVisible(false);
+ });
+ }
+
+ private void checkInterrupt() throws InterruptedException {
+ if(Thread.interrupted()) {
+ throw new InterruptedException();
+ }
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/controls/Toast.java b/client/src/main/java/ctbrec/ui/controls/Toast.java
new file mode 100644
index 00000000..53b26a75
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/controls/Toast.java
@@ -0,0 +1,53 @@
+package ctbrec.ui.controls;
+
+import javafx.animation.KeyFrame;
+import javafx.animation.KeyValue;
+import javafx.animation.Timeline;
+import javafx.scene.Scene;
+import javafx.scene.layout.StackPane;
+import javafx.scene.paint.Color;
+import javafx.scene.text.Font;
+import javafx.scene.text.Text;
+import javafx.stage.Stage;
+import javafx.stage.StageStyle;
+import javafx.util.Duration;
+
+public final class Toast {
+ public static void makeText(Scene owner, String toastMsg, int toastDelay, int fadeInDelay, int fadeOutDelay) {
+ Stage toastStage = new Stage();
+ toastStage.initOwner(owner.getWindow());
+ toastStage.setResizable(false);
+ toastStage.initStyle(StageStyle.TRANSPARENT);
+
+ Text text = new Text(toastMsg);
+ text.setFont(Font.font(30));
+ text.setFill(Color.WHITE);
+
+ StackPane root = new StackPane(text);
+ root.setStyle("-fx-background-radius: 20; -fx-background-color: rgba(0, 0, 0, 0.8); -fx-padding: 50px;");
+ root.setOpacity(0);
+
+ Scene scene = new Scene(root);
+ scene.setFill(Color.TRANSPARENT);
+ toastStage.setScene(scene);
+ toastStage.show();
+
+ Timeline fadeInTimeline = new Timeline();
+ KeyFrame fadeInKey1 = new KeyFrame(Duration.millis(fadeInDelay), new KeyValue(toastStage.getScene().getRoot().opacityProperty(), 1));
+ fadeInTimeline.getKeyFrames().add(fadeInKey1);
+ fadeInTimeline.setOnFinished((ae) -> {
+ new Thread(() -> {
+ try {
+ Thread.sleep(toastDelay);
+ } catch (InterruptedException e) {
+ }
+ Timeline fadeOutTimeline = new Timeline();
+ KeyFrame fadeOutKey1 = new KeyFrame(Duration.millis(fadeOutDelay), new KeyValue(toastStage.getScene().getRoot().opacityProperty(), 0));
+ fadeOutTimeline.getKeyFrames().add(fadeOutKey1);
+ fadeOutTimeline.setOnFinished((aeb) -> toastStage.close());
+ fadeOutTimeline.play();
+ }).start();
+ });
+ fadeInTimeline.play();
+ }
+}
\ No newline at end of file
diff --git a/client/src/main/java/ctbrec/ui/controls/Wizard.java b/client/src/main/java/ctbrec/ui/controls/Wizard.java
new file mode 100644
index 00000000..301cbeb4
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/controls/Wizard.java
@@ -0,0 +1,102 @@
+package ctbrec.ui.controls;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.control.Button;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Pane;
+import javafx.scene.layout.StackPane;
+import javafx.stage.Stage;
+
+public class Wizard extends BorderPane {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(Wizard.class);
+ private Pane[] pages;
+ private StackPane stack;
+ private Stage stage;
+ private int page = 0;
+ private Button next;
+ private Button prev;
+ private Button finish;
+ private boolean cancelled = true;
+ private Runnable validator;
+
+ public Wizard(Stage stage, Runnable validator, Pane... pages) {
+ this.stage = stage;
+ this.validator = validator;
+ this.pages = pages;
+
+ if (pages.length == 0) {
+ throw new IllegalArgumentException("Provide at least one page");
+ }
+
+ createUi();
+ }
+
+ private void createUi() {
+ stack = new StackPane();
+ setCenter(stack);
+
+ next = new Button("Next");
+ next.setOnAction(evt -> nextPage());
+ prev = new Button("Back");
+ prev.setOnAction(evt -> prevPage());
+ prev.visibleProperty().bind(next.visibleProperty());
+ next.setVisible(pages.length > 1);
+ Button cancel = new Button("Cancel");
+ cancel.setOnAction(evt -> stage.close());
+ finish = new Button("Finish");
+ finish.setOnAction(evt -> {
+ if(validator != null) {
+ try {
+ validator.run();
+ } catch(IllegalStateException e) {
+ Dialogs.showError("Settings invalid", e.getMessage(), null);
+ return;
+ }
+ }
+ cancelled = false;
+ stage.close();
+ });
+ HBox buttons = new HBox(5, prev, next, cancel, finish);
+ buttons.setAlignment(Pos.BASELINE_RIGHT);
+ setBottom(buttons);
+ BorderPane.setMargin(buttons, new Insets(10));
+
+ if (pages.length != 0) {
+ prevPage();
+ }
+ }
+
+ private void prevPage() {
+ page = Math.max(0, --page);
+ stack.getChildren().clear();
+ stack.getChildren().add(pages[page]);
+ updateState();
+ }
+
+ private void nextPage() {
+ page = Math.min(pages.length - 1, ++page);
+ stack.getChildren().clear();
+ stack.getChildren().add(pages[page]);
+ updateState();
+ }
+
+ private void updateState() {
+ prev.setDisable(page == 0);
+ next.setDisable(page == pages.length - 1);
+ finish.setDisable(page != pages.length - 1);
+ LOG.debug("Setting border");
+ pages[page].setStyle(
+ "-fx-background-color: -fx-inner-border, -fx-background;"+
+ "-fx-background-insets: 0 0 -1 0, 0, 1, 2;");
+ }
+
+ public boolean isCancelled() {
+ return cancelled;
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/event/PlaySound.java b/client/src/main/java/ctbrec/ui/event/PlaySound.java
new file mode 100644
index 00000000..aded0087
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/event/PlaySound.java
@@ -0,0 +1,35 @@
+package ctbrec.ui.event;
+
+import java.io.File;
+import java.net.URL;
+
+import ctbrec.event.Action;
+import ctbrec.event.Event;
+import ctbrec.event.EventHandlerConfiguration.ActionConfiguration;
+import javafx.scene.media.AudioClip;
+
+public class PlaySound extends Action {
+
+ private URL url;
+
+ public PlaySound() {
+ name = "play sound";
+ }
+
+ public PlaySound(URL url) {
+ this();
+ this.url = url;
+ }
+
+ @Override
+ public void accept(Event evt) {
+ AudioClip clip = new AudioClip(url.toString());
+ clip.play();
+ }
+
+ @Override
+ public void configure(ActionConfiguration config) throws Exception {
+ File file = new File((String) config.getConfiguration().get("file"));
+ url = file.toURI().toURL();
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/event/ShowNotification.java b/client/src/main/java/ctbrec/ui/event/ShowNotification.java
new file mode 100644
index 00000000..4d91350d
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/event/ShowNotification.java
@@ -0,0 +1,42 @@
+package ctbrec.ui.event;
+
+import ctbrec.Model;
+import ctbrec.OS;
+import ctbrec.event.Action;
+import ctbrec.event.Event;
+import ctbrec.event.EventHandlerConfiguration.ActionConfiguration;
+import ctbrec.event.ModelStateChangedEvent;
+import ctbrec.event.RecordingStateChangedEvent;
+import ctbrec.ui.CamrecApplication;
+
+public class ShowNotification extends Action {
+
+ public ShowNotification() {
+ name = "show notification";
+ }
+
+ @Override
+ public void accept(Event evt) {
+ String header = evt.getType().toString();
+ String msg;
+ switch(evt.getType()) {
+ case MODEL_STATUS_CHANGED:
+ ModelStateChangedEvent modelEvent = (ModelStateChangedEvent) evt;
+ Model m = modelEvent.getModel();
+ msg = m.getDisplayName() + " is now " + modelEvent.getNewState().toString();
+ break;
+ case RECORDING_STATUS_CHANGED:
+ RecordingStateChangedEvent recEvent = (RecordingStateChangedEvent) evt;
+ m = recEvent.getModel();
+ msg = "Recording for model " + m.getDisplayName() + " is now in state " + recEvent.getState().toString();
+ break;
+ default:
+ msg = evt.getDescription();
+ }
+ OS.notification(CamrecApplication.title, header, msg);
+ }
+
+ @Override
+ public void configure(ActionConfiguration config) throws Exception {
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/settings/ActionSettingsPanel.java b/client/src/main/java/ctbrec/ui/settings/ActionSettingsPanel.java
new file mode 100644
index 00000000..182ca2d5
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/settings/ActionSettingsPanel.java
@@ -0,0 +1,319 @@
+package ctbrec.ui.settings;
+
+import java.io.File;
+import java.io.InputStream;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.FormatStyle;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.Config;
+import ctbrec.Model;
+import ctbrec.OS;
+import ctbrec.Recording;
+import ctbrec.StringUtil;
+import ctbrec.event.Event;
+import ctbrec.event.EventBusHolder;
+import ctbrec.event.EventHandler;
+import ctbrec.event.EventHandlerConfiguration;
+import ctbrec.event.EventHandlerConfiguration.ActionConfiguration;
+import ctbrec.event.EventHandlerConfiguration.PredicateConfiguration;
+import ctbrec.event.ExecuteProgram;
+import ctbrec.event.ModelPredicate;
+import ctbrec.event.ModelStatePredicate;
+import ctbrec.event.RecordingStatePredicate;
+import ctbrec.recorder.Recorder;
+import ctbrec.ui.CamrecApplication;
+import ctbrec.ui.controls.FileSelectionBox;
+import ctbrec.ui.controls.ProgramSelectionBox;
+import ctbrec.ui.controls.Wizard;
+import ctbrec.ui.event.PlaySound;
+import ctbrec.ui.event.ShowNotification;
+import javafx.collections.ListChangeListener;
+import javafx.event.ActionEvent;
+import javafx.geometry.Insets;
+import javafx.geometry.Orientation;
+import javafx.geometry.VPos;
+import javafx.scene.Node;
+import javafx.scene.Scene;
+import javafx.scene.control.Button;
+import javafx.scene.control.CheckBox;
+import javafx.scene.control.ComboBox;
+import javafx.scene.control.Label;
+import javafx.scene.control.ListView;
+import javafx.scene.control.ScrollPane;
+import javafx.scene.control.SelectionMode;
+import javafx.scene.control.Separator;
+import javafx.scene.control.TextField;
+import javafx.scene.control.TitledPane;
+import javafx.scene.image.Image;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.GridPane;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Pane;
+import javafx.scene.layout.Priority;
+import javafx.stage.Modality;
+import javafx.stage.Stage;
+import javafx.stage.Window;
+
+public class ActionSettingsPanel extends TitledPane {
+ private static final transient Logger LOG = LoggerFactory.getLogger(ActionSettingsPanel.class);
+ private ListView actionTable;
+
+ private TextField name = new TextField();
+ private ComboBox event = new ComboBox<>();
+ private ComboBox modelState = new ComboBox<>();
+ private ComboBox recordingState = new ComboBox<>();
+
+ private CheckBox playSound = new CheckBox("Play sound");
+ private FileSelectionBox sound = new FileSelectionBox();
+ private CheckBox showNotification = new CheckBox("Notify me");
+ private Button testNotification = new Button("Test");
+ private CheckBox executeProgram = new CheckBox("Execute program");
+ private ProgramSelectionBox program = new ProgramSelectionBox();
+ private ListSelectionPane modelSelectionPane;
+
+ private Recorder recorder;
+
+ public ActionSettingsPanel(SettingsTab settingsTab, Recorder recorder) {
+ this.recorder = recorder;
+ setText("Events & Actions");
+ setExpanded(true);
+ setCollapsible(false);
+ createGui();
+ loadEventHandlers();
+ }
+
+ private void loadEventHandlers() {
+ actionTable.getItems().addAll(Config.getInstance().getSettings().eventHandlers);
+ }
+
+ private void createGui() {
+ BorderPane mainLayout = new BorderPane();
+ setContent(mainLayout);
+
+ actionTable = createActionTable();
+ ScrollPane scrollPane = new ScrollPane(actionTable);
+ scrollPane.setFitToHeight(true);
+ scrollPane.setFitToWidth(true);
+ scrollPane.setStyle("-fx-background-color: -fx-background");
+ mainLayout.setCenter(scrollPane);
+
+ Button add = new Button("Add");
+ add.setOnAction(this::add);
+ Button delete = new Button("Delete");
+ delete.setOnAction(this::delete);
+ delete.setDisable(true);
+ HBox buttons = new HBox(5, add, delete);
+ mainLayout.setBottom(buttons);
+ BorderPane.setMargin(buttons, new Insets(5, 0, 0, 0));
+
+ actionTable.getSelectionModel().getSelectedItems().addListener(new ListChangeListener() {
+ @Override
+ public void onChanged(Change extends EventHandlerConfiguration> change) {
+ delete.setDisable(change.getList().isEmpty());
+ }
+ });
+ }
+
+ private void add(ActionEvent evt) {
+ Pane actionPane = createActionPane();
+ Stage dialog = new Stage();
+ dialog.initModality(Modality.APPLICATION_MODAL);
+ dialog.initOwner(getScene().getWindow());
+ dialog.setTitle("New Action");
+ InputStream icon = getClass().getResourceAsStream("/icon.png");
+ dialog.getIcons().add(new Image(icon));
+ Wizard root = new Wizard(dialog, this::validateSettings, actionPane);
+ Scene scene = new Scene(root, 800, 540);
+ scene.getStylesheets().addAll(getScene().getStylesheets());
+ dialog.setScene(scene);
+ centerOnParent(dialog);
+ dialog.showAndWait();
+ if(!root.isCancelled()) {
+ createEventHandler();
+ }
+ }
+
+ private void createEventHandler() {
+ EventHandlerConfiguration config = new EventHandlerConfiguration();
+ config.setName(name.getText());
+ config.setEvent(event.getValue());
+ if(event.getValue() == Event.Type.MODEL_STATUS_CHANGED) {
+ PredicateConfiguration pc = new PredicateConfiguration();
+ pc.setType(ModelStatePredicate.class.getName());
+ pc.getConfiguration().put("state", modelState.getValue().name());
+ pc.setName("state = " + modelState.getValue().toString());
+ config.getPredicates().add(pc);
+ } else if(event.getValue() == Event.Type.RECORDING_STATUS_CHANGED) {
+ PredicateConfiguration pc = new PredicateConfiguration();
+ pc.setType(RecordingStatePredicate.class.getName());
+ pc.getConfiguration().put("state", recordingState.getValue().name());
+ pc.setName("state = " + recordingState.getValue().toString());
+ config.getPredicates().add(pc);
+ }
+ if(!modelSelectionPane.isAllSelected()) {
+ PredicateConfiguration pc = new PredicateConfiguration();
+ pc.setType(ModelPredicate.class.getName());
+ pc.setModels(modelSelectionPane.getSelectedItems());
+ pc.setName("model is one of:" + modelSelectionPane.getSelectedItems());
+ config.getPredicates().add(pc);
+ }
+ if(showNotification.isSelected()) {
+ ActionConfiguration ac = new ActionConfiguration();
+ ac.setType(ShowNotification.class.getName());
+ ac.setName("show notification");
+ config.getActions().add(ac);
+ }
+ if(playSound.isSelected()) {
+ ActionConfiguration ac = new ActionConfiguration();
+ ac.setType(PlaySound.class.getName());
+ File file = new File(sound.fileProperty().get());
+ ac.getConfiguration().put("file", file.getAbsolutePath());
+ ac.setName("play " + file.getName());
+ config.getActions().add(ac);
+ }
+ if(executeProgram.isSelected()) {
+ ActionConfiguration ac = new ActionConfiguration();
+ ac.setType(ExecuteProgram.class.getName());
+ File file = new File(program.fileProperty().get());
+ ac.getConfiguration().put("file", file.getAbsolutePath());
+ ac.setName("execute " + file.getName());
+ config.getActions().add(ac);
+ }
+
+ EventHandler handler = new EventHandler(config);
+ EventBusHolder.register(handler);
+ Config.getInstance().getSettings().eventHandlers.add(config);
+ actionTable.getItems().add(config);
+ LOG.debug("Registered event handler for {} {}", config.getEvent(), config.getName());
+ }
+
+ private void validateSettings() {
+ if(StringUtil.isBlank(name.getText())) {
+ throw new IllegalStateException("Name cannot be empty");
+ }
+ if(event.getValue() == Event.Type.MODEL_STATUS_CHANGED && modelState.getValue() == null) {
+ throw new IllegalStateException("Select a state");
+ }
+ if(event.getValue() == Event.Type.RECORDING_STATUS_CHANGED && recordingState.getValue() == null) {
+ throw new IllegalStateException("Select a state");
+ }
+ if(modelSelectionPane.getSelectedItems().isEmpty() && !modelSelectionPane.isAllSelected()) {
+ throw new IllegalStateException("Select one or more models or tick off \"all\"");
+ }
+ if(!(showNotification.isSelected() || playSound.isSelected() || executeProgram.isSelected())) {
+ throw new IllegalStateException("No action selected");
+ }
+ }
+
+ private void delete(ActionEvent evt) {
+ List selected = new ArrayList<>(actionTable.getSelectionModel().getSelectedItems());
+ for (EventHandlerConfiguration config : selected) {
+ EventBusHolder.unregister(config.getId());
+ Config.getInstance().getSettings().eventHandlers.remove(config);
+ actionTable.getItems().remove(config);
+ }
+ }
+
+ private Pane createActionPane() {
+ GridPane layout = SettingsTab.createGridLayout();
+ recordingState.prefWidthProperty().bind(event.widthProperty());
+ modelState.prefWidthProperty().bind(event.widthProperty());
+ name.prefWidthProperty().bind(event.widthProperty());
+
+ int row = 0;
+ layout.add(new Label("Name"), 0, row);
+ layout.add(name, 1, row++);
+
+ layout.add(new Label("Event"), 0, row);
+ event.getItems().add(Event.Type.MODEL_STATUS_CHANGED);
+ event.getItems().add(Event.Type.RECORDING_STATUS_CHANGED);
+ event.setOnAction(evt -> {
+ modelState.setVisible(event.getSelectionModel().getSelectedItem() == Event.Type.MODEL_STATUS_CHANGED);
+ });
+ event.getSelectionModel().select(Event.Type.MODEL_STATUS_CHANGED);
+ layout.add(event, 1, row++);
+
+ layout.add(new Label("State"), 0, row);
+ modelState.getItems().clear();
+ modelState.getItems().addAll(Model.State.values());
+ layout.add(modelState, 1, row);
+ recordingState.getItems().clear();
+ recordingState.getItems().addAll(Recording.State.values());
+ layout.add(recordingState, 1, row++);
+ recordingState.visibleProperty().bind(modelState.visibleProperty().not());
+
+ layout.add(createSeparator(), 0, row++);
+
+ Label l = new Label("Models");
+ layout.add(l, 0, row);
+ modelSelectionPane = new ListSelectionPane(recorder.getModelsRecording(), Collections.emptyList());
+ layout.add(modelSelectionPane, 1, row++);
+ GridPane.setValignment(l, VPos.TOP);
+ GridPane.setHgrow(modelSelectionPane, Priority.ALWAYS);
+ GridPane.setFillWidth(modelSelectionPane, true);
+
+ layout.add(createSeparator(), 0, row++);
+
+ layout.add(showNotification, 0, row);
+ layout.add(testNotification, 1, row++);
+ testNotification.setOnAction(evt -> {
+ DateTimeFormatter format = DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM);
+ ZonedDateTime time = ZonedDateTime.now();
+ OS.notification(CamrecApplication.title, "Test Notification", "Oi, what's up! " + format.format(time));
+ });
+ testNotification.disableProperty().bind(showNotification.selectedProperty().not());
+
+ layout.add(playSound, 0, row);
+ layout.add(sound, 1, row++);
+ sound.disableProperty().bind(playSound.selectedProperty().not());
+
+ layout.add(executeProgram, 0, row);
+ layout.add(program, 1, row);
+ program.disableProperty().bind(executeProgram.selectedProperty().not());
+
+ GridPane.setFillWidth(name, true);
+ GridPane.setHgrow(name, Priority.ALWAYS);
+ GridPane.setFillWidth(sound, true);
+
+ return layout;
+ }
+
+ private ListView createActionTable() {
+ ListView view = new ListView<>();
+ view.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
+ view.setPrefSize(300, 200);
+ return view;
+ }
+
+ private Node createSeparator() {
+ Separator divider = new Separator(Orientation.HORIZONTAL);
+ GridPane.setHgrow(divider, Priority.ALWAYS);
+ GridPane.setFillWidth(divider, true);
+ GridPane.setColumnSpan(divider, 2);
+ int tb = 20;
+ int lr = 0;
+ GridPane.setMargin(divider, new Insets(tb, lr, tb, lr));
+ return divider;
+ }
+
+ private void centerOnParent(Stage dialog) {
+ dialog.setWidth(dialog.getScene().getWidth());
+ dialog.setHeight(dialog.getScene().getHeight());
+ double w = dialog.getWidth();
+ double h = dialog.getHeight();
+ Window p = dialog.getOwner();
+ double px = p.getX();
+ double py = p.getY();
+ double pw = p.getWidth();
+ double ph = p.getHeight();
+ dialog.setX(px + (pw - w) / 2);
+ dialog.setY(py + (ph - h) / 2);
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/settings/ColorSettingsPane.css b/client/src/main/java/ctbrec/ui/settings/ColorSettingsPane.css
new file mode 100644
index 00000000..5b29c0fc
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/settings/ColorSettingsPane.css
@@ -0,0 +1,10 @@
+ColorSettingsPane .color-picker .color-picker-label .text {
+ visibility: false;
+}
+/*
+ColorSettingsPane .color-picker > .arrow-button,
+ColorSettingsPane .color-picker > .arrow-button:hover
+{
+ visibility: false;
+}
+*/
\ No newline at end of file
diff --git a/client/src/main/java/ctbrec/ui/settings/ColorSettingsPane.java b/client/src/main/java/ctbrec/ui/settings/ColorSettingsPane.java
new file mode 100644
index 00000000..3cb22604
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/settings/ColorSettingsPane.java
@@ -0,0 +1,77 @@
+package ctbrec.ui.settings;
+
+import ctbrec.Config;
+import javafx.scene.control.Button;
+import javafx.scene.control.ColorPicker;
+import javafx.scene.control.Tooltip;
+import javafx.scene.layout.Pane;
+import javafx.scene.paint.Color;
+
+public class ColorSettingsPane extends Pane {
+
+ ColorPicker baseColor = new ColorPicker();
+ ColorPicker accentColor = new ColorPicker();
+ Button reset = new Button("Reset");
+ Pane foobar = new Pane();
+
+ public ColorSettingsPane(SettingsTab settingsTab) {
+ getChildren().add(baseColor);
+ getChildren().add(accentColor);
+ getChildren().add(reset);
+
+ baseColor.setValue(Color.web(Config.getInstance().getSettings().colorBase));
+ baseColor.setTooltip(new Tooltip("Base Color"));
+ accentColor.setValue(Color.web(Config.getInstance().getSettings().colorAccent));
+ accentColor.setTooltip(new Tooltip("Accent Color"));
+
+ baseColor.setOnAction(evt -> {
+ Config.getInstance().getSettings().colorBase = toWeb(baseColor.getValue());
+ settingsTab.showRestartRequired();
+ settingsTab.saveConfig();
+ });
+ accentColor.setOnAction(evt -> {
+ Config.getInstance().getSettings().colorAccent = toWeb(accentColor.getValue());
+ settingsTab.showRestartRequired();
+ settingsTab.saveConfig();
+ });
+ reset.setOnAction(evt -> {
+ baseColor.setValue(Color.WHITE);
+ Config.getInstance().getSettings().colorBase = toWeb(Color.WHITE);
+ accentColor.setValue(Color.WHITE);
+ Config.getInstance().getSettings().colorAccent = toWeb(Color.WHITE);
+ settingsTab.showRestartRequired();
+ settingsTab.saveConfig();
+ });
+ }
+
+ private String toWeb(Color value) {
+ StringBuilder sb = new StringBuilder("#");
+ sb.append(toHex((int) (value.getRed() * 255)));
+ sb.append(toHex((int) (value.getGreen() * 255)));
+ sb.append(toHex((int) (value.getBlue() * 255)));
+ if(!value.isOpaque()) {
+ sb.append(toHex((int) (value.getOpacity() * 255)));
+ }
+ return sb.toString();
+ }
+
+ private CharSequence toHex(int v) {
+ StringBuilder sb = new StringBuilder();
+ if(v < 16) {
+ sb.append('0');
+ }
+ sb.append(Integer.toHexString(v));
+ return sb;
+ }
+
+ @Override
+ protected void layoutChildren() {
+ baseColor.resize(44, 25);
+ accentColor.resize(44, 25);
+ reset.resize(60, 25);
+
+ baseColor.setTranslateX(0);
+ accentColor.setTranslateX(baseColor.getTranslateX() + baseColor.getWidth() + 10);
+ reset.setTranslateX(accentColor.getTranslateX() + accentColor.getWidth() + 10);
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/settings/ListSelectionPane.java b/client/src/main/java/ctbrec/ui/settings/ListSelectionPane.java
new file mode 100644
index 00000000..e5cd9dc1
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/settings/ListSelectionPane.java
@@ -0,0 +1,121 @@
+package ctbrec.ui.settings;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import javafx.geometry.Pos;
+import javafx.scene.control.Button;
+import javafx.scene.control.CheckBox;
+import javafx.scene.control.Label;
+import javafx.scene.control.ListView;
+import javafx.scene.control.SelectionMode;
+import javafx.scene.layout.GridPane;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.VBox;
+
+public class ListSelectionPane> extends GridPane {
+
+ private ListView availableListView = new ListView<>();
+ private ListView selectedListView = new ListView<>();
+ private Button addModel = new Button(">");
+ private Button removeModel = new Button("<");
+ private CheckBox selectAll = new CheckBox("all");
+
+ public ListSelectionPane(List available, List selected) {
+ super();
+ setHgap(5);
+ setVgap(5);
+
+ createGui();
+ fillLists(available, selected);
+ }
+
+ private void fillLists(List available, List selected) {
+ ObservableList obsAvail = FXCollections.observableArrayList(available);
+ ObservableList obsSel = FXCollections.observableArrayList(selected);
+ for (Iterator iterator = obsAvail.iterator(); iterator.hasNext();) {
+ T t = iterator.next();
+ if(obsSel.contains(t)) {
+ iterator.remove();
+ }
+ }
+ Collections.sort(obsAvail);
+ Collections.sort(obsSel);
+ availableListView.setItems(obsAvail);
+ selectedListView.setItems(obsSel);
+ availableListView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
+ selectedListView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
+ }
+
+ private void createGui() {
+ Label labelAvailable = new Label("Available");
+ Label labelSelected = new Label("Selected");
+
+ add(labelAvailable, 0, 0);
+ add(availableListView, 0, 1);
+
+ VBox buttonBox = new VBox(5);
+ buttonBox.getChildren().add(addModel);
+ buttonBox.getChildren().add(removeModel);
+ buttonBox.setAlignment(Pos.CENTER);
+ add(buttonBox, 1, 1);
+
+ add(labelSelected, 2, 0);
+ add(selectedListView, 2, 1);
+
+ add(selectAll, 0, 2);
+
+ GridPane.setHgrow(availableListView, Priority.ALWAYS);
+ GridPane.setHgrow(selectedListView, Priority.ALWAYS);
+ GridPane.setFillWidth(availableListView, true);
+ GridPane.setFillWidth(selectedListView, true);
+
+ addModel.setOnAction(evt -> addSelectedItems());
+ removeModel.setOnAction(evt -> removeSelectedItems());
+
+ availableListView.disableProperty().bind(selectAll.selectedProperty());
+ selectedListView.disableProperty().bind(selectAll.selectedProperty());
+ addModel.disableProperty().bind(selectAll.selectedProperty());
+ removeModel.disableProperty().bind(selectAll.selectedProperty());
+ }
+
+ private void addSelectedItems() {
+ List selected = new ArrayList<>(availableListView.getSelectionModel().getSelectedItems());
+ for (T t : selected) {
+ if(!selectedListView.getItems().contains(t)) {
+ selectedListView.getItems().add(t);
+ availableListView.getItems().remove(t);
+ }
+ }
+ Collections.sort(selectedListView.getItems());
+ }
+
+ private void removeSelectedItems() {
+ List selected = new ArrayList<>(selectedListView.getSelectionModel().getSelectedItems());
+ for (T t : selected) {
+ if(!availableListView.getItems().contains(t)) {
+ availableListView.getItems().add(t);
+ selectedListView.getItems().remove(t);
+ }
+ }
+ Collections.sort(availableListView.getItems());
+ }
+
+ public List getSelectedItems() {
+ if(selectAll.isSelected()) {
+ List all = new ArrayList<>(availableListView.getItems());
+ all.addAll(selectedListView.getItems());
+ return all;
+ } else {
+ return selectedListView.getItems();
+ }
+ }
+
+ public boolean isAllSelected() {
+ return selectAll.isSelected();
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/ProxySettingsPane.java b/client/src/main/java/ctbrec/ui/settings/ProxySettingsPane.java
similarity index 89%
rename from client/src/main/java/ctbrec/ui/ProxySettingsPane.java
rename to client/src/main/java/ctbrec/ui/settings/ProxySettingsPane.java
index bd5c3f5b..c7de4c8e 100644
--- a/client/src/main/java/ctbrec/ui/ProxySettingsPane.java
+++ b/client/src/main/java/ctbrec/ui/settings/ProxySettingsPane.java
@@ -1,4 +1,4 @@
-package ctbrec.ui;
+package ctbrec.ui.settings;
import static ctbrec.Settings.ProxyType.*;
import java.util.ArrayList;
@@ -51,18 +51,23 @@ public class ProxySettingsPane extends TitledPane implements EventHandler settingsTab.saveConfig());
l = new Label("Port");
layout.add(l, 0, 2);
layout.add(proxyPort, 1, 2);
+ proxyPort.textProperty().addListener((ob, o, n) -> settingsTab.saveConfig());
l = new Label("Username");
layout.add(l, 0, 3);
layout.add(proxyUser, 1, 3);
+ proxyUser.textProperty().addListener((ob, o, n) -> settingsTab.saveConfig());
+
l = new Label("Password");
layout.add(l, 0, 4);
layout.add(proxyPassword, 1, 4);
+ proxyPassword.textProperty().addListener((ob, o, n) -> settingsTab.saveConfig());
}
private void loadConfig() {
@@ -86,6 +91,7 @@ public class ProxySettingsPane extends TitledPane implements EventHandler splitAfter;
+ private ComboBox directoryStructure;
+ private ComboBox startTab;
+ private List sites;
+ private Label restartLabel;
+ private Accordion siteConfigAccordion = new Accordion();
+ private Recorder recorder;
+
+ public SettingsTab(List sites, Recorder recorder) {
+ this.sites = sites;
+ this.recorder = recorder;
+ setText("Settings");
+ createGui();
+ setClosable(false);
+ setRecordingMode(recordLocal.isSelected());
+ }
+
+ private void createGui() {
+ // set up main layout, 2 columns with VBoxes 50/50
+ GridPane mainLayout = createGridLayout();
+ mainLayout.setHgap(15);
+ mainLayout.setVgap(15);
+ mainLayout.setPadding(new Insets(15));
+ ColumnConstraints cc = new ColumnConstraints();
+ cc.setPercentWidth(50);
+ mainLayout.getColumnConstraints().setAll(cc, cc);
+ ScrollPane scrollPane = new ScrollPane(mainLayout);
+ setContent(scrollPane);
+ GridPane.setFillHeight(scrollPane, true);
+ GridPane.setFillWidth(scrollPane, true);
+ GridPane.setHgrow(scrollPane, Priority.ALWAYS);
+ GridPane.setVgrow(scrollPane, Priority.ALWAYS);
+ VBox leftSide = new VBox(15);
+ leftSide.setFillWidth(true);
+ VBox rightSide = new VBox(15);
+ rightSide.setFillWidth(true);
+ GridPane.setHgrow(leftSide, Priority.ALWAYS);
+ GridPane.setHgrow(rightSide, Priority.ALWAYS);
+ GridPane.setFillWidth(leftSide, true);
+ GridPane.setFillWidth(rightSide, true);
+ mainLayout.add(leftSide, 0, 1);
+ mainLayout.add(rightSide, 1, 1);
+ mainLayout.prefWidthProperty().bind(scrollPane.widthProperty());
+
+ // restart info label
+ restartLabel = new Label("A restart is required to apply the changes you made!");
+ restartLabel.setVisible(false);
+ restartLabel.setFont(Font.font(24));
+ restartLabel.setTextFill(Color.RED);
+ mainLayout.add(restartLabel, 0, 0);
+ GridPane.setColumnSpan(restartLabel, 2);
+ GridPane.setHalignment(restartLabel, HPos.CENTER);
+
+ // left side
+ leftSide.getChildren().add(createGeneralPanel());
+ leftSide.getChildren().add(createRecorderPanel());
+ leftSide.getChildren().add(createRecordLocationPanel());
+
+ //right side
+ rightSide.getChildren().add(siteConfigAccordion);
+ ActionSettingsPanel actions = new ActionSettingsPanel(this, recorder);
+ rightSide.getChildren().add(actions);
+ proxySettingsPane = new ProxySettingsPane(this);
+ rightSide.getChildren().add(proxySettingsPane);
+ for (int i = 0; i < sites.size(); i++) {
+ Site site = sites.get(i);
+ ConfigUI siteConfig = SiteUiFactory.getUi(site).getConfigUI();
+ if(siteConfig != null) {
+ TitledPane pane = new TitledPane(site.getName(), siteConfig.createConfigPanel());
+ siteConfigAccordion.getPanes().add(pane);
+ }
+ }
+ }
+
+ private Node createRecordLocationPanel() {
+ GridPane layout = createGridLayout();
+ Label l = new Label("Record Location");
+ layout.add(l, 0, 0);
+ recordLocation = new ToggleGroup();
+ recordLocal = new RadioButton("Local");
+ recordRemote = new RadioButton("Remote");
+ recordLocal.setToggleGroup(recordLocation);
+ recordRemote.setToggleGroup(recordLocation);
+ recordLocal.setSelected(Config.getInstance().getSettings().localRecording);
+ recordRemote.setSelected(!recordLocal.isSelected());
+ layout.add(recordLocal, 1, 0);
+ layout.add(recordRemote, 2, 0);
+ recordLocation.selectedToggleProperty().addListener((e) -> {
+ Config.getInstance().getSettings().localRecording = recordLocal.isSelected();
+ setRecordingMode(recordLocal.isSelected());
+ showRestartRequired();
+ saveConfig();
+ });
+ GridPane.setMargin(l, new Insets(0, 0, CHECKBOX_MARGIN, 0));
+ GridPane.setMargin(recordLocal, new Insets(0, 0, CHECKBOX_MARGIN, 0));
+ GridPane.setMargin(recordRemote, new Insets(0, 0, CHECKBOX_MARGIN, 0));
+
+ layout.add(new Label("Server"), 0, 1);
+ server = new TextField(Config.getInstance().getSettings().httpServer);
+ server.textProperty().addListener((ob, o, n) -> {
+ if(!server.getText().isEmpty()) {
+ Config.getInstance().getSettings().httpServer = server.getText();
+ saveConfig();
+ }
+ });
+ GridPane.setFillWidth(server, true);
+ GridPane.setHgrow(server, Priority.ALWAYS);
+ GridPane.setColumnSpan(server, 2);
+ layout.add(server, 1, 1);
+
+ layout.add(new Label("Port"), 0, 2);
+ port = new TextField(Integer.toString(Config.getInstance().getSettings().httpPort));
+ port.textProperty().addListener((observable, oldValue, newValue) -> {
+ if (!newValue.matches("\\d*")) {
+ port.setText(newValue.replaceAll("[^\\d]", ""));
+ }
+ if(!port.getText().isEmpty()) {
+ Config.getInstance().getSettings().httpPort = Integer.parseInt(port.getText());
+ saveConfig();
+ }
+ });
+ GridPane.setFillWidth(port, true);
+ GridPane.setHgrow(port, Priority.ALWAYS);
+ GridPane.setColumnSpan(port, 2);
+ layout.add(port, 1, 2);
+
+ l = new Label("Require authentication");
+ layout.add(l, 0, 3);
+ secureCommunication.setSelected(Config.getInstance().getSettings().requireAuthentication);
+ secureCommunication.setOnAction((e) -> {
+ Config.getInstance().getSettings().requireAuthentication = secureCommunication.isSelected();
+ if(secureCommunication.isSelected()) {
+ byte[] key = Config.getInstance().getSettings().key;
+ if(key == null) {
+ key = Hmac.generateKey();
+ Config.getInstance().getSettings().key = key;
+ saveConfig();
+ }
+ TextInputDialog keyDialog = new TextInputDialog();
+ keyDialog.setResizable(true);
+ keyDialog.setTitle("Server Authentication");
+ keyDialog.setHeaderText("A key has been generated");
+ keyDialog.setContentText("Add this setting to your server's config.json:\n");
+ keyDialog.getEditor().setText("\"key\": " + Arrays.toString(key));
+ keyDialog.getEditor().setEditable(false);
+ keyDialog.setWidth(800);
+ keyDialog.setHeight(200);
+ keyDialog.show();
+ }
+ });
+ GridPane.setMargin(l, new Insets(4, CHECKBOX_MARGIN, 0, 0));
+ GridPane.setMargin(secureCommunication, new Insets(4, 0, 0, 0));
+ layout.add(secureCommunication, 1, 3);
+
+ TitledPane recordLocation = new TitledPane("Record Location", layout);
+ recordLocation.setCollapsible(false);
+ return recordLocation;
+ }
+
+ private Node createRecorderPanel() {
+ int row = 0;
+ GridPane layout = createGridLayout();
+
+ layout.add(new Label("Recordings Directory"), 0, row);
+ recordingsDirectory = new DirectorySelectionBox(Config.getInstance().getSettings().recordingsDir);
+ recordingsDirectory.prefWidth(400);
+ recordingsDirectory.fileProperty().addListener((obs, o, n) -> {
+ String path = n;
+ if(!Objects.equals(path, Config.getInstance().getSettings().recordingsDir)) {
+ Config.getInstance().getSettings().recordingsDir = path;
+ saveConfig();
+ }
+ });
+ GridPane.setFillWidth(recordingsDirectory, true);
+ GridPane.setHgrow(recordingsDirectory, Priority.ALWAYS);
+ GridPane.setMargin(recordingsDirectory, new Insets(0, 0, 0, CHECKBOX_MARGIN));
+ layout.add(recordingsDirectory, 1, row++);
+
+ layout.add(new Label("Directory Structure"), 0, row);
+ List options = new ArrayList<>();
+ options.add(FLAT);
+ options.add(ONE_PER_MODEL);
+ options.add(ONE_PER_RECORDING);
+ directoryStructure = new ComboBox<>(FXCollections.observableList(options));
+ directoryStructure.setValue(Config.getInstance().getSettings().recordingsDirStructure);
+ directoryStructure.setOnAction((evt) -> {
+ Config.getInstance().getSettings().recordingsDirStructure = directoryStructure.getValue();
+ saveConfig();
+ });
+ GridPane.setMargin(directoryStructure, new Insets(0, 0, 0, CHECKBOX_MARGIN));
+ layout.add(directoryStructure, 1, row++);
+ recordingsDirectory.prefWidthProperty().bind(directoryStructure.widthProperty());
+
+ Label l = new Label("Split recordings after (minutes)");
+ layout.add(l, 0, row);
+ List splitOptions = new ArrayList<>();
+ splitOptions.add(new SplitAfterOption("disabled", 0));
+ if(Config.isDevMode()) {
+ splitOptions.add(new SplitAfterOption( "1 min", 1 * 60));
+ splitOptions.add(new SplitAfterOption( "3 min", 3 * 60));
+ splitOptions.add(new SplitAfterOption( "5 min", 5 * 60));
+ }
+ splitOptions.add(new SplitAfterOption("10 min", 10 * 60));
+ splitOptions.add(new SplitAfterOption("15 min", 15 * 60));
+ splitOptions.add(new SplitAfterOption("20 min", 20 * 60));
+ splitOptions.add(new SplitAfterOption("30 min", 30 * 60));
+ splitOptions.add(new SplitAfterOption("60 min", 60 * 60));
+ splitAfter = new ComboBox<>(FXCollections.observableList(splitOptions));
+ layout.add(splitAfter, 1, row++);
+ setSplitAfterValue();
+ splitAfter.setOnAction((e) -> {
+ Config.getInstance().getSettings().splitRecordings = splitAfter.getSelectionModel().getSelectedItem().getValue();
+ saveConfig();
+ });
+ splitAfter.prefWidthProperty().bind(directoryStructure.widthProperty());
+ GridPane.setMargin(l, new Insets(0, 0, 0, 0));
+ GridPane.setMargin(splitAfter, new Insets(0, 0, 0, CHECKBOX_MARGIN));
+
+ l = new Label("Maximum resolution (0 = unlimited)");
+ layout.add(l, 0, row);
+ maxResolution = new TextField(Integer.toString(Config.getInstance().getSettings().maximumResolution));
+ maxResolution.textProperty().addListener((observable, oldValue, newValue) -> {
+ if (!newValue.matches("\\d*")) {
+ maxResolution.setText(newValue.replaceAll("[^\\d]", ""));
+ }
+ if (!maxResolution.getText().isEmpty()) {
+ int newRes = Integer.parseInt(maxResolution.getText());
+ if (newRes != Config.getInstance().getSettings().maximumResolution) {
+ Config.getInstance().getSettings().maximumResolution = newRes;
+ saveConfig();
+ }
+ }
+ });
+ maxResolution.prefWidthProperty().bind(directoryStructure.widthProperty());
+ layout.add(maxResolution, 1, row++);
+ GridPane.setMargin(l, new Insets(0, 0, 0, 0));
+ GridPane.setMargin(maxResolution, new Insets(0, 0, 0, CHECKBOX_MARGIN));
+
+ layout.add(new Label("Post-Processing"), 0, row);
+ postProcessing = new ProgramSelectionBox(Config.getInstance().getSettings().postProcessing);
+ postProcessing.allowEmptyValue();
+ postProcessing.fileProperty().addListener((obs, o, n) -> {
+ String path = n;
+ if(!Objects.equals(path, Config.getInstance().getSettings().postProcessing)) {
+ Config.getInstance().getSettings().postProcessing = path;
+ saveConfig();
+ }
+ });
+ GridPane.setFillWidth(postProcessing, true);
+ GridPane.setHgrow(postProcessing, Priority.ALWAYS);
+ GridPane.setMargin(postProcessing, new Insets(0, 0, 0, CHECKBOX_MARGIN));
+ layout.add(postProcessing, 1, row++);
+
+ Tooltip tt = new Tooltip("Check every x seconds, if a model came online");
+ l = new Label("Check online state every (seconds)");
+ l.setTooltip(tt);
+ layout.add(l, 0, row);
+ onlineCheckIntervalInSecs = new TextField(Integer.toString(Config.getInstance().getSettings().onlineCheckIntervalInSecs));
+ onlineCheckIntervalInSecs.setTooltip(tt);
+ onlineCheckIntervalInSecs.textProperty().addListener((observable, oldValue, newValue) -> {
+ if (!newValue.matches("\\d*")) {
+ onlineCheckIntervalInSecs.setText(newValue.replaceAll("[^\\d]", ""));
+ }
+ if(!onlineCheckIntervalInSecs.getText().isEmpty()) {
+ Config.getInstance().getSettings().onlineCheckIntervalInSecs = Integer.parseInt(onlineCheckIntervalInSecs.getText());
+ saveConfig();
+ }
+ });
+ GridPane.setMargin(onlineCheckIntervalInSecs, new Insets(0, 0, 0, CHECKBOX_MARGIN));
+ layout.add(onlineCheckIntervalInSecs, 1, row++);
+
+ tt = new Tooltip("Stop recording, if the free space on the device gets below this threshold");
+ l = new Label("Leave space on device (GiB)");
+ l.setTooltip(tt);
+ layout.add(l, 0, row);
+ long minimumSpaceLeftInBytes = Config.getInstance().getSettings().minimumSpaceLeftInBytes;
+ int minimumSpaceLeftInGiB = (int) (minimumSpaceLeftInBytes / ONE_GiB_IN_BYTES);
+ leaveSpaceOnDevice = new TextField(Integer.toString(minimumSpaceLeftInGiB));
+ leaveSpaceOnDevice.setTooltip(tt);
+ leaveSpaceOnDevice.textProperty().addListener((observable, oldValue, newValue) -> {
+ if (!newValue.matches("\\d*")) {
+ leaveSpaceOnDevice.setText(newValue.replaceAll("[^\\d]", ""));
+ }
+ if(!leaveSpaceOnDevice.getText().isEmpty()) {
+ long spaceLeftInGiB = Long.parseLong(leaveSpaceOnDevice.getText());
+ Config.getInstance().getSettings().minimumSpaceLeftInBytes = spaceLeftInGiB * ONE_GiB_IN_BYTES;
+ saveConfig();
+ }
+ });
+ GridPane.setMargin(leaveSpaceOnDevice, new Insets(0, 0, 0, CHECKBOX_MARGIN));
+ layout.add(leaveSpaceOnDevice, 1, row++);
+
+ tt = new Tooltip("Delete recordings, which are shorter than x seconds. 0 to disable.");
+ l = new Label("Delete recordings shorter than (secs)");
+ l.setTooltip(tt);
+ layout.add(l, 0, row);
+ int minimumLengthInSeconds = Config.getInstance().getSettings().minimumLengthInSeconds;
+ minimumLengthInSecs = new TextField(Integer.toString(minimumLengthInSeconds));
+ minimumLengthInSecs.setTooltip(tt);
+ minimumLengthInSecs.textProperty().addListener((observable, oldValue, newValue) -> {
+ if (!newValue.matches("\\d*")) {
+ minimumLengthInSecs.setText(newValue.replaceAll("[^\\d]", ""));
+ }
+ if(!minimumLengthInSecs.getText().isEmpty()) {
+ int minimumLength = Integer.parseInt(minimumLengthInSecs.getText());
+ Config.getInstance().getSettings().minimumLengthInSeconds = minimumLength;
+ saveConfig();
+ }
+ });
+ GridPane.setMargin(minimumLengthInSecs, new Insets(0, 0, 0, CHECKBOX_MARGIN));
+ layout.add(minimumLengthInSecs, 1, row++);
+
+ TitledPane locations = new TitledPane("Recorder", layout);
+ locations.setCollapsible(false);
+ return locations;
+ }
+
+ private Node createGeneralPanel() {
+ GridPane layout = createGridLayout();
+ int row = 0;
+
+ layout.add(new Label("Player"), 0, row);
+ mediaPlayer = new ProgramSelectionBox(Config.getInstance().getSettings().mediaPlayer);
+ mediaPlayer.fileProperty().addListener((obs, o, n) -> {
+ String path = n;
+ if (!Objects.equals(path, Config.getInstance().getSettings().mediaPlayer)) {
+ Config.getInstance().getSettings().mediaPlayer = path;
+ saveConfig();
+ }
+ });
+ GridPane.setFillWidth(mediaPlayer, true);
+ GridPane.setHgrow(mediaPlayer, Priority.ALWAYS);
+ GridPane.setMargin(mediaPlayer, new Insets(0, 0, 0, CHECKBOX_MARGIN));
+ layout.add(mediaPlayer, 1, row++);
+
+ Label l = new Label("Allow multiple players");
+ layout.add(l, 0, row);
+ multiplePlayers.setSelected(!Config.getInstance().getSettings().singlePlayer);
+ multiplePlayers.setOnAction((e) -> {
+ Config.getInstance().getSettings().singlePlayer = !multiplePlayers.isSelected();
+ saveConfig();
+ });
+ GridPane.setMargin(l, new Insets(3, 0, 0, 0));
+ GridPane.setMargin(multiplePlayers, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN));
+ layout.add(multiplePlayers, 1, row++);
+
+ l = new Label("Show \"Player Starting\" Message");
+ layout.add(l, 0, row);
+ showPlayerStarting.setSelected(Config.getInstance().getSettings().showPlayerStarting);
+ showPlayerStarting.setOnAction((e) -> {
+ Config.getInstance().getSettings().showPlayerStarting = showPlayerStarting.isSelected();
+ saveConfig();
+ });
+ GridPane.setMargin(l, new Insets(3, 0, 0, 0));
+ GridPane.setMargin(showPlayerStarting, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN));
+ layout.add(showPlayerStarting, 1, row++);
+
+ l = new Label("Display stream resolution in overview");
+ layout.add(l, 0, row);
+ loadResolution = new CheckBox();
+ loadResolution.setSelected(Config.getInstance().getSettings().determineResolution);
+ loadResolution.setOnAction((e) -> {
+ Config.getInstance().getSettings().determineResolution = loadResolution.isSelected();
+ saveConfig();
+ });
+ GridPane.setMargin(l, new Insets(3, 0, 0, 0));
+ GridPane.setMargin(loadResolution, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN));
+ layout.add(loadResolution, 1, row++);
+
+ l = new Label("Manually select stream quality");
+ layout.add(l, 0, row);
+ chooseStreamQuality.setSelected(Config.getInstance().getSettings().chooseStreamQuality);
+ chooseStreamQuality.setOnAction((e) -> {
+ Config.getInstance().getSettings().chooseStreamQuality = chooseStreamQuality.isSelected();
+ saveConfig();
+ });
+ GridPane.setMargin(l, new Insets(3, 0, 0, 0));
+ GridPane.setMargin(chooseStreamQuality, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN));
+ layout.add(chooseStreamQuality, 1, row++);
+
+ l = new Label("Update thumbnails");
+ layout.add(l, 0, row);
+ updateThumbnails.setSelected(Config.getInstance().getSettings().updateThumbnails);
+ updateThumbnails.setOnAction((e) -> {
+ Config.getInstance().getSettings().updateThumbnails = updateThumbnails.isSelected();
+ saveConfig();
+ });
+ GridPane.setMargin(l, new Insets(3, 0, 0, 0));
+ GridPane.setMargin(updateThumbnails, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN));
+ layout.add(updateThumbnails, 1, row++);
+
+ l = new Label("Preview in thumbnails");
+ layout.add(l, 0, row);
+ previewInThumbnails.setSelected(Config.getInstance().getSettings().previewInThumbnails);
+ previewInThumbnails.setOnAction((e) -> {
+ Config.getInstance().getSettings().previewInThumbnails = previewInThumbnails.isSelected();
+ saveConfig();
+ });
+ GridPane.setMargin(l, new Insets(3, 0, 0, 0));
+ GridPane.setMargin(previewInThumbnails, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN));
+ layout.add(previewInThumbnails, 1, row++);
+
+ l = new Label("Start Tab");
+ layout.add(l, 0, row);
+ startTab = new ComboBox<>();
+ startTab.setOnAction((e) -> {
+ Config.getInstance().getSettings().startTab = startTab.getSelectionModel().getSelectedItem();
+ saveConfig();
+ });
+ layout.add(startTab, 1, row++);
+ GridPane.setMargin(l, new Insets(3, 0, 0, 0));
+ GridPane.setMargin(startTab, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN));
+
+ l = new Label("Colors (Base / Accent)");
+ layout.add(l, 0, row);
+ ColorSettingsPane colorSettingsPane = new ColorSettingsPane(this);
+ layout.add(colorSettingsPane, 1, row++);
+ GridPane.setMargin(l, new Insets(0, 0, 0, 0));
+ GridPane.setMargin(colorSettingsPane, new Insets(CHECKBOX_MARGIN, 0, 0, CHECKBOX_MARGIN));
+
+ TitledPane general = new TitledPane("General", layout);
+ general.setCollapsible(false);
+ return general;
+ }
+
+ private void setSplitAfterValue() {
+ int value = Config.getInstance().getSettings().splitRecordings;
+ for (SplitAfterOption option : splitAfter.getItems()) {
+ if(option.getValue() == value) {
+ splitAfter.getSelectionModel().select(option);
+ }
+ }
+ }
+
+ void showRestartRequired() {
+ restartLabel.setVisible(true);
+ }
+
+ public static GridPane createGridLayout() {
+ GridPane layout = new GridPane();
+ layout.setPadding(new Insets(10));
+ layout.setHgap(5);
+ layout.setVgap(5);
+ return layout;
+ }
+
+ private void setRecordingMode(boolean local) {
+ server.setDisable(local);
+ port.setDisable(local);
+ secureCommunication.setDisable(local);
+ recordingsDirectory.setDisable(!local);
+ splitAfter.setDisable(!local);
+ maxResolution.setDisable(!local);
+ directoryStructure.setDisable(!local);
+ onlineCheckIntervalInSecs.setDisable(!local);
+ leaveSpaceOnDevice.setDisable(!local);
+ postProcessing.setDisable(!local);
+ }
+
+ @Override
+ public void selected() {
+ if(startTab.getItems().isEmpty()) {
+ for(Tab tab : getTabPane().getTabs()) {
+ startTab.getItems().add(tab.getText());
+ }
+ }
+ String startTabName = Config.getInstance().getSettings().startTab;
+ if(StringUtil.isNotBlank(startTabName)) {
+ startTab.getSelectionModel().select(startTabName);
+ }
+ }
+
+ @Override
+ public void deselected() {
+ saveConfig();
+ }
+
+ public void saveConfig() {
+ if(proxySettingsPane != null) {
+ proxySettingsPane.saveConfig();
+ }
+ try {
+ Config.getInstance().save();
+ } catch (IOException e) {
+ LOG.error("Couldn't save config", e);
+ }
+ }
+
+ public static class SplitAfterOption {
+ private String label;
+ private int value;
+
+ public SplitAfterOption(String label, int value) {
+ super();
+ this.label = label;
+ this.value = value;
+ }
+
+ public int getValue() {
+ return value;
+ }
+
+ @Override
+ public String toString() {
+ return label;
+ }
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/sites/AbstractConfigUI.java b/client/src/main/java/ctbrec/ui/sites/AbstractConfigUI.java
new file mode 100644
index 00000000..69e2fd9a
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/sites/AbstractConfigUI.java
@@ -0,0 +1,22 @@
+package ctbrec.ui.sites;
+
+import java.io.IOException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.Config;
+import ctbrec.sites.ConfigUI;
+
+public abstract class AbstractConfigUI implements ConfigUI {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(AbstractConfigUI.class);
+
+ protected void save() {
+ try {
+ Config.getInstance().save();
+ } catch (IOException e) {
+ LOG.error("Couldn't save config");
+ }
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsConfigUI.java b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsConfigUI.java
index 4c74d258..0214dad3 100644
--- a/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsConfigUI.java
+++ b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsConfigUI.java
@@ -1,21 +1,22 @@
package ctbrec.ui.sites.bonga;
import ctbrec.Config;
-import ctbrec.sites.ConfigUI;
+import ctbrec.Settings;
import ctbrec.sites.bonga.BongaCams;
import ctbrec.ui.DesktopIntegration;
-import ctbrec.ui.SettingsTab;
+import ctbrec.ui.settings.SettingsTab;
+import ctbrec.ui.sites.AbstractConfigUI;
import javafx.geometry.Insets;
import javafx.scene.Parent;
import javafx.scene.control.Button;
+import javafx.scene.control.CheckBox;
import javafx.scene.control.Label;
import javafx.scene.control.PasswordField;
import javafx.scene.control.TextField;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.Priority;
-public class BongaCamsConfigUI implements ConfigUI {
-
+public class BongaCamsConfigUI extends AbstractConfigUI {
private BongaCams bongaCams;
public BongaCamsConfigUI(BongaCams bongaCams) {
@@ -25,26 +26,56 @@ public class BongaCamsConfigUI implements ConfigUI {
@Override
public Parent createConfigPanel() {
GridPane layout = SettingsTab.createGridLayout();
- layout.add(new Label("BongaCams User"), 0, 0);
- TextField username = new TextField(Config.getInstance().getSettings().bongaUsername);
- username.focusedProperty().addListener((e) -> Config.getInstance().getSettings().bongaUsername = username.getText());
+ Settings settings = Config.getInstance().getSettings();
+
+ int row = 0;
+ Label l = new Label("Active");
+ layout.add(l, 0, row);
+ CheckBox enabled = new CheckBox();
+ enabled.setSelected(!settings.disabledSites.contains(bongaCams.getName()));
+ enabled.setOnAction((e) -> {
+ if(enabled.isSelected()) {
+ settings.disabledSites.remove(bongaCams.getName());
+ } else {
+ settings.disabledSites.add(bongaCams.getName());
+ }
+ save();
+ });
+ GridPane.setMargin(enabled, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
+ layout.add(enabled, 1, row++);
+
+ layout.add(new Label("BongaCams User"), 0, row);
+ TextField username = new TextField(settings.bongaUsername);
+ username.textProperty().addListener((ob, o, n) -> {
+ if(!n.equals(Config.getInstance().getSettings().bongaUsername)) {
+ Config.getInstance().getSettings().bongaUsername = username.getText();
+ bongaCams.getHttpClient().logout();
+ save();
+ }
+ });
GridPane.setFillWidth(username, true);
GridPane.setHgrow(username, Priority.ALWAYS);
GridPane.setColumnSpan(username, 2);
- layout.add(username, 1, 0);
+ layout.add(username, 1, row++);
- layout.add(new Label("BongaCams Password"), 0, 1);
+ layout.add(new Label("BongaCams Password"), 0, row);
PasswordField password = new PasswordField();
- password.setText(Config.getInstance().getSettings().bongaPassword);
- password.focusedProperty().addListener((e) -> Config.getInstance().getSettings().bongaPassword = password.getText());
+ password.setText(settings.bongaPassword);
+ password.textProperty().addListener((ob, o, n) -> {
+ if(!n.equals(Config.getInstance().getSettings().bongaPassword)) {
+ Config.getInstance().getSettings().bongaPassword = password.getText();
+ bongaCams.getHttpClient().logout();
+ save();
+ }
+ });
GridPane.setFillWidth(password, true);
GridPane.setHgrow(password, Priority.ALWAYS);
GridPane.setColumnSpan(password, 2);
- layout.add(password, 1, 1);
+ layout.add(password, 1, row++);
Button createAccount = new Button("Create new Account");
createAccount.setOnAction((e) -> DesktopIntegration.open(bongaCams.getAffiliateLink()));
- layout.add(createAccount, 1, 2);
+ layout.add(createAccount, 1, row++);
GridPane.setColumnSpan(createAccount, 2);
GridPane.setMargin(username, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
GridPane.setMargin(password, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
diff --git a/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsSiteUi.java b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsSiteUi.java
index 328528bc..8e123696 100644
--- a/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsSiteUi.java
+++ b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsSiteUi.java
@@ -44,7 +44,7 @@ public class BongaCamsSiteUi implements SiteUI {
}
@Override
- public boolean login() throws IOException {
+ public synchronized boolean login() throws IOException {
boolean automaticLogin = bongaCams.login();
if(automaticLogin) {
return true;
diff --git a/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsUpdateService.java b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsUpdateService.java
index 80f979fe..beb2f07c 100644
--- a/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsUpdateService.java
+++ b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsUpdateService.java
@@ -1,5 +1,7 @@
package ctbrec.ui.sites.bonga;
+import static ctbrec.Model.State.*;
+
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
@@ -54,22 +56,41 @@ public class BongaCamsUpdateService extends PaginatedScheduledService {
JSONArray _models = json.getJSONArray("models");
for (int i = 0; i < _models.length(); i++) {
JSONObject m = _models.getJSONObject(i);
- String name = m.getString("username");
+ String name = m.optString("username");
+ if(name.isEmpty()) {
+ continue;
+ }
BongaCamsModel model = (BongaCamsModel) bongaCams.createModel(name);
- model.setUserId(m.getInt("user_id"));
boolean away = m.optBoolean("is_away");
- boolean online = m.optBoolean("online") && !away;
+ boolean online = m.optBoolean("online");
model.setOnline(online);
+
if(online) {
+ model.setOnlineState(ONLINE);
if(away) {
- model.setOnlineState("away");
+ model.setOnlineState(AWAY);
} else {
- model.setOnlineState(m.getString("room"));
+ switch(m.optString("room")) {
+ case "private":
+ case "fullprivate":
+ model.setOnlineState(PRIVATE);
+ break;
+ case "group":
+ case "public":
+ model.setOnlineState(ONLINE);
+ break;
+ default:
+ LOG.debug(m.optString("room"));
+ model.setOnlineState(ONLINE);
+ }
}
} else {
- model.setOnlineState("offline");
+ model.setOnlineState(OFFLINE);
}
model.setPreview("https:" + m.getString("thumb_image"));
+ if(m.has("display_name")) {
+ model.setDisplayName(m.getString("display_name"));
+ }
models.add(model);
}
}
diff --git a/client/src/main/java/ctbrec/ui/sites/cam4/Cam4ConfigUI.java b/client/src/main/java/ctbrec/ui/sites/cam4/Cam4ConfigUI.java
index 4429c729..e9062360 100644
--- a/client/src/main/java/ctbrec/ui/sites/cam4/Cam4ConfigUI.java
+++ b/client/src/main/java/ctbrec/ui/sites/cam4/Cam4ConfigUI.java
@@ -1,44 +1,81 @@
package ctbrec.ui.sites.cam4;
import ctbrec.Config;
-import ctbrec.sites.ConfigUI;
+import ctbrec.Settings;
import ctbrec.sites.cam4.Cam4;
import ctbrec.ui.DesktopIntegration;
-import ctbrec.ui.SettingsTab;
+import ctbrec.ui.settings.SettingsTab;
+import ctbrec.ui.sites.AbstractConfigUI;
import javafx.geometry.Insets;
import javafx.scene.Parent;
import javafx.scene.control.Button;
+import javafx.scene.control.CheckBox;
import javafx.scene.control.Label;
import javafx.scene.control.PasswordField;
import javafx.scene.control.TextField;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.Priority;
-public class Cam4ConfigUI implements ConfigUI {
+public class Cam4ConfigUI extends AbstractConfigUI {
+ private Cam4 cam4;
+
+ public Cam4ConfigUI(Cam4 cam4) {
+ this.cam4 = cam4;
+ }
@Override
public Parent createConfigPanel() {
GridPane layout = SettingsTab.createGridLayout();
- layout.add(new Label("Cam4 User"), 0, 0);
+ Settings settings = Config.getInstance().getSettings();
+
+ int row = 0;
+ Label l = new Label("Active");
+ layout.add(l, 0, row);
+ CheckBox enabled = new CheckBox();
+ enabled.setSelected(!settings.disabledSites.contains(cam4.getName()));
+ enabled.setOnAction((e) -> {
+ if(enabled.isSelected()) {
+ settings.disabledSites.remove(cam4.getName());
+ } else {
+ settings.disabledSites.add(cam4.getName());
+ }
+ save();
+ });
+ GridPane.setMargin(enabled, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
+ layout.add(enabled, 1, row++);
+
+ layout.add(new Label("Cam4 User"), 0, row);
TextField username = new TextField(Config.getInstance().getSettings().cam4Username);
- username.focusedProperty().addListener((e) -> Config.getInstance().getSettings().cam4Username = username.getText());
+ username.textProperty().addListener((ob, o, n) -> {
+ if(!n.equals(Config.getInstance().getSettings().cam4Username)) {
+ Config.getInstance().getSettings().cam4Username = username.getText();
+ cam4.getHttpClient().logout();
+ save();
+ }
+ });
GridPane.setFillWidth(username, true);
GridPane.setHgrow(username, Priority.ALWAYS);
GridPane.setColumnSpan(username, 2);
- layout.add(username, 1, 0);
+ layout.add(username, 1, row++);
- layout.add(new Label("Cam4 Password"), 0, 1);
+ layout.add(new Label("Cam4 Password"), 0, row);
PasswordField password = new PasswordField();
password.setText(Config.getInstance().getSettings().cam4Password);
- password.focusedProperty().addListener((e) -> Config.getInstance().getSettings().cam4Password = password.getText());
+ password.textProperty().addListener((ob, o, n) -> {
+ if(!n.equals(Config.getInstance().getSettings().cam4Password)) {
+ Config.getInstance().getSettings().cam4Password = password.getText();
+ cam4.getHttpClient().logout();
+ save();
+ }
+ });
GridPane.setFillWidth(password, true);
GridPane.setHgrow(password, Priority.ALWAYS);
GridPane.setColumnSpan(password, 2);
- layout.add(password, 1, 1);
+ layout.add(password, 1, row++);
Button createAccount = new Button("Create new Account");
createAccount.setOnAction((e) -> DesktopIntegration.open(Cam4.AFFILIATE_LINK));
- layout.add(createAccount, 1, 2);
+ layout.add(createAccount, 1, row++);
GridPane.setColumnSpan(createAccount, 2);
GridPane.setMargin(username, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
GridPane.setMargin(password, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
diff --git a/client/src/main/java/ctbrec/ui/sites/cam4/Cam4FollowedUpdateService.java b/client/src/main/java/ctbrec/ui/sites/cam4/Cam4FollowedUpdateService.java
index 60047998..4b68232b 100644
--- a/client/src/main/java/ctbrec/ui/sites/cam4/Cam4FollowedUpdateService.java
+++ b/client/src/main/java/ctbrec/ui/sites/cam4/Cam4FollowedUpdateService.java
@@ -68,7 +68,7 @@ public class Cam4FollowedUpdateService extends PaginatedScheduledService {
String modelName = path.substring(1);
Cam4Model model = (Cam4Model) site.createModel(modelName);
model.setPreview("https://snapshots.xcdnpro.com/thumbnails/"+model.getName()+"?s=" + System.currentTimeMillis());
- model.setOnlineState(parseOnlineState(cellHtml));
+ model.setOnlineStateByShowType(parseOnlineState(cellHtml));
models.add(model);
}
return models.stream()
diff --git a/client/src/main/java/ctbrec/ui/sites/cam4/Cam4SiteUi.java b/client/src/main/java/ctbrec/ui/sites/cam4/Cam4SiteUi.java
index d0c5d2b5..8e61b411 100644
--- a/client/src/main/java/ctbrec/ui/sites/cam4/Cam4SiteUi.java
+++ b/client/src/main/java/ctbrec/ui/sites/cam4/Cam4SiteUi.java
@@ -30,7 +30,7 @@ public class Cam4SiteUi implements SiteUI {
public Cam4SiteUi(Cam4 cam4) {
this.cam4 = cam4;
tabProvider = new Cam4TabProvider(cam4);
- configUI = new Cam4ConfigUI();
+ configUI = new Cam4ConfigUI(cam4);
}
@Override
@@ -44,7 +44,7 @@ public class Cam4SiteUi implements SiteUI {
}
@Override
- public boolean login() throws IOException {
+ public synchronized boolean login() throws IOException {
boolean automaticLogin = cam4.login();
if(automaticLogin) {
return true;
diff --git a/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaConfigUI.java b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaConfigUI.java
index c8468a82..8cdc5932 100644
--- a/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaConfigUI.java
+++ b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaConfigUI.java
@@ -1,21 +1,22 @@
package ctbrec.ui.sites.camsoda;
import ctbrec.Config;
-import ctbrec.sites.ConfigUI;
+import ctbrec.Settings;
import ctbrec.sites.camsoda.Camsoda;
import ctbrec.ui.DesktopIntegration;
-import ctbrec.ui.SettingsTab;
+import ctbrec.ui.settings.SettingsTab;
+import ctbrec.ui.sites.AbstractConfigUI;
import javafx.geometry.Insets;
import javafx.scene.Parent;
import javafx.scene.control.Button;
+import javafx.scene.control.CheckBox;
import javafx.scene.control.Label;
import javafx.scene.control.PasswordField;
import javafx.scene.control.TextField;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.Priority;
-public class CamsodaConfigUI implements ConfigUI {
-
+public class CamsodaConfigUI extends AbstractConfigUI {
private Camsoda camsoda;
public CamsodaConfigUI(Camsoda camsoda) {
@@ -25,26 +26,56 @@ public class CamsodaConfigUI implements ConfigUI {
@Override
public Parent createConfigPanel() {
GridPane layout = SettingsTab.createGridLayout();
- layout.add(new Label("CamSoda User"), 0, 0);
+ Settings settings = Config.getInstance().getSettings();
+
+ int row = 0;
+ Label l = new Label("Active");
+ layout.add(l, 0, row);
+ CheckBox enabled = new CheckBox();
+ enabled.setSelected(!settings.disabledSites.contains(camsoda.getName()));
+ enabled.setOnAction((e) -> {
+ if(enabled.isSelected()) {
+ settings.disabledSites.remove(camsoda.getName());
+ } else {
+ settings.disabledSites.add(camsoda.getName());
+ }
+ save();
+ });
+ GridPane.setMargin(enabled, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
+ layout.add(enabled, 1, row++);
+
+ layout.add(new Label("CamSoda User"), 0, row);
TextField username = new TextField(Config.getInstance().getSettings().camsodaUsername);
- username.focusedProperty().addListener((e) -> Config.getInstance().getSettings().camsodaUsername = username.getText());
+ username.textProperty().addListener((ob, o, n) -> {
+ if(!n.equals(Config.getInstance().getSettings().camsodaUsername)) {
+ Config.getInstance().getSettings().camsodaUsername = username.getText();
+ camsoda.getHttpClient().logout();
+ save();
+ }
+ });
GridPane.setFillWidth(username, true);
GridPane.setHgrow(username, Priority.ALWAYS);
GridPane.setColumnSpan(username, 2);
- layout.add(username, 1, 0);
+ layout.add(username, 1, row++);
- layout.add(new Label("CamSoda Password"), 0, 1);
+ layout.add(new Label("CamSoda Password"), 0, row);
PasswordField password = new PasswordField();
password.setText(Config.getInstance().getSettings().camsodaPassword);
- password.focusedProperty().addListener((e) -> Config.getInstance().getSettings().camsodaPassword = password.getText());
+ password.textProperty().addListener((ob, o, n) -> {
+ if(!n.equals(Config.getInstance().getSettings().camsodaPassword)) {
+ Config.getInstance().getSettings().camsodaPassword = password.getText();
+ camsoda.getHttpClient().logout();
+ save();
+ }
+ });
GridPane.setFillWidth(password, true);
GridPane.setHgrow(password, Priority.ALWAYS);
GridPane.setColumnSpan(password, 2);
- layout.add(password, 1, 1);
+ layout.add(password, 1, row++);
Button createAccount = new Button("Create new Account");
createAccount.setOnAction((e) -> DesktopIntegration.open(camsoda.getAffiliateLink()));
- layout.add(createAccount, 1, 2);
+ layout.add(createAccount, 1, row++);
GridPane.setColumnSpan(createAccount, 2);
GridPane.setMargin(username, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
GridPane.setMargin(password, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
diff --git a/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaFollowedUpdateService.java b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaFollowedUpdateService.java
index 4e92d25c..2d225780 100644
--- a/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaFollowedUpdateService.java
+++ b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaFollowedUpdateService.java
@@ -1,5 +1,7 @@
package ctbrec.ui.sites.camsoda;
+import static ctbrec.Model.State.*;
+
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
@@ -47,7 +49,7 @@ public class CamsodaFollowedUpdateService extends PaginatedScheduledService {
JSONObject m = following.getJSONObject(i);
CamsodaModel model = (CamsodaModel) camsoda.createModel(m.getString("followname"));
boolean online = m.getInt("online") == 1;
- model.setOnlineState(online ? "online" : "offline");
+ model.setOnlineState(online ? ONLINE : OFFLINE);
model.setPreview("https://md.camsoda.com/thumbs/" + model.getName() + ".jpg");
models.add(model);
}
diff --git a/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaSiteUi.java b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaSiteUi.java
index a86f72e3..af1be6ab 100644
--- a/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaSiteUi.java
+++ b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaSiteUi.java
@@ -45,7 +45,7 @@ public class CamsodaSiteUi implements SiteUI {
}
@Override
- public boolean login() throws IOException {
+ public synchronized boolean login() throws IOException {
boolean automaticLogin = camsoda.login();
return automaticLogin;
}
diff --git a/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaUpdateService.java b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaUpdateService.java
index f7b6e321..75f827a6 100644
--- a/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaUpdateService.java
+++ b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaUpdateService.java
@@ -4,6 +4,8 @@ import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.Objects;
import java.util.stream.Collectors;
import org.json.JSONArray;
@@ -56,39 +58,43 @@ public class CamsodaUpdateService extends PaginatedScheduledService {
if (response.isSuccessful()) {
JSONObject json = new JSONObject(response.body().string());
if(json.has("status") && json.getBoolean("status")) {
+ JSONArray template = json.getJSONArray("template");
JSONArray results = json.getJSONArray("results");
for (int i = 0; i < results.length(); i++) {
JSONObject result = results.getJSONObject(i);
if(result.has("tpl")) {
JSONArray tpl = result.getJSONArray("tpl");
- String name = tpl.getString(0);
+ String name = tpl.getString(getTemplateIndex(template, "username"));
+ String displayName = tpl.getString(getTemplateIndex(template, "display_name"));
// int connections = tpl.getInt(2);
- String streamName = tpl.getString(5);
- String tsize = tpl.getString(6);
- String serverPrefix = tpl.getString(7);
+ String streamName = tpl.getString(getTemplateIndex(template, "stream_name"));
+ String tsize = tpl.getString(getTemplateIndex(template, "tsize"));
+ String serverPrefix = tpl.getString(getTemplateIndex(template, "server_prefix"));
CamsodaModel model = (CamsodaModel) camsoda.createModel(name);
- model.setDescription(tpl.getString(4));
- model.setSortOrder(tpl.getFloat(3));
+ model.setDescription(tpl.getString(getTemplateIndex(template, "subject_html")));
+ model.setSortOrder(tpl.getFloat(getTemplateIndex(template, "sort_value")));
long unixtime = System.currentTimeMillis() / 1000;
String preview = "https://thumbs-orig.camsoda.com/thumbs/"
+ streamName + '/' + serverPrefix + '/' + tsize + '/' + unixtime + '/' + name + ".jpg?cb=" + unixtime;
model.setPreview(preview);
- if(result.has("edge_servers")) {
- JSONArray edgeServers = result.getJSONArray("edge_servers");
- model.setStreamUrl("https://" + edgeServers.getString(0) + "/cam/mp4:" + streamName + "_h264_aac_480p/playlist.m3u8");
- }
+ JSONArray edgeServers = tpl.getJSONArray(getTemplateIndex(template, "edge_servers"));
+ model.setStreamUrl("https://" + edgeServers.getString(0) + "/cam/mp4:" + streamName + "_h264_aac_480p/playlist.m3u8");
+ model.setDisplayName(displayName);
models.add(model);
} else {
String name = result.getString("username");
CamsodaModel model = (CamsodaModel) camsoda.createModel(name);
-
if(result.has("server_prefix")) {
String serverPrefix = result.getString("server_prefix");
String streamName = result.getString("stream_name");
model.setSortOrder(result.getFloat("sort_value"));
models.add(model);
if(result.has("status")) {
- model.setOnlineState(result.getString("status"));
+ model.setOnlineStateByStatus(result.getString("status"));
+ }
+
+ if(result.has("display_name")) {
+ model.setDisplayName(result.getString("display_name"));
}
if(result.has("edge_servers")) {
@@ -120,6 +126,16 @@ public class CamsodaUpdateService extends PaginatedScheduledService {
}
}
}
+
+ private int getTemplateIndex(JSONArray template, String string) {
+ for (int i = 0; i < template.length(); i++) {
+ String s = template.getString(i);
+ if(Objects.equals(s, string)) {
+ return i;
+ }
+ }
+ throw new NoSuchElementException(string + " not found in template: " + template.toString());
+ }
};
}
diff --git a/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateConfigUi.java b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateConfigUi.java
index 1a824c0c..791b1fd1 100644
--- a/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateConfigUi.java
+++ b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateConfigUi.java
@@ -1,49 +1,101 @@
package ctbrec.ui.sites.chaturbate;
import ctbrec.Config;
-import ctbrec.sites.ConfigUI;
+import ctbrec.Settings;
import ctbrec.sites.chaturbate.Chaturbate;
import ctbrec.ui.DesktopIntegration;
-import ctbrec.ui.SettingsTab;
+import ctbrec.ui.settings.SettingsTab;
+import ctbrec.ui.sites.AbstractConfigUI;
import javafx.geometry.Insets;
import javafx.scene.Parent;
import javafx.scene.control.Button;
+import javafx.scene.control.CheckBox;
import javafx.scene.control.Label;
import javafx.scene.control.PasswordField;
import javafx.scene.control.TextField;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.Priority;
-public class ChaturbateConfigUi implements ConfigUI {
+public class ChaturbateConfigUi extends AbstractConfigUI {
+ private Chaturbate chaturbate;
+
+ public ChaturbateConfigUi(Chaturbate chaturbate) {
+ this.chaturbate = chaturbate;
+ }
+
@Override
public Parent createConfigPanel() {
+ Settings settings = Config.getInstance().getSettings();
GridPane layout = SettingsTab.createGridLayout();
- layout.add(new Label("Chaturbate User"), 0, 0);
+ int row = 0;
+ Label l = new Label("Active");
+ layout.add(l, 0, row);
+ CheckBox enabled = new CheckBox();
+ enabled.setSelected(!settings.disabledSites.contains(chaturbate.getName()));
+ enabled.setOnAction((e) -> {
+ if(enabled.isSelected()) {
+ settings.disabledSites.remove(chaturbate.getName());
+ } else {
+ settings.disabledSites.add(chaturbate.getName());
+ }
+ save();
+ });
+ GridPane.setMargin(enabled, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
+ layout.add(enabled, 1, row++);
+
+ layout.add(new Label("Chaturbate User"), 0, row);
TextField username = new TextField(Config.getInstance().getSettings().username);
- username.focusedProperty().addListener((e) -> Config.getInstance().getSettings().username = username.getText());
+ username.textProperty().addListener((ob, o, n) -> {
+ if(!n.equals(Config.getInstance().getSettings().username)) {
+ Config.getInstance().getSettings().username = n;
+ chaturbate.getHttpClient().logout();
+ save();
+ }
+ });
GridPane.setFillWidth(username, true);
GridPane.setHgrow(username, Priority.ALWAYS);
GridPane.setColumnSpan(username, 2);
- layout.add(username, 1, 0);
+ layout.add(username, 1, row++);
- layout.add(new Label("Chaturbate Password"), 0, 1);
+ layout.add(new Label("Chaturbate Password"), 0, row);
PasswordField password = new PasswordField();
password.setText(Config.getInstance().getSettings().password);
- password.focusedProperty().addListener((e) -> Config.getInstance().getSettings().password = password.getText());
+ password.textProperty().addListener((ob, o, n) -> {
+ if(!n.equals(Config.getInstance().getSettings().password)) {
+ Config.getInstance().getSettings().password = n;
+ chaturbate.getHttpClient().logout();
+ save();
+ }
+ });
GridPane.setFillWidth(password, true);
GridPane.setHgrow(password, Priority.ALWAYS);
GridPane.setColumnSpan(password, 2);
- layout.add(password, 1, 1);
+ layout.add(password, 1, row++);
+
+ layout.add(new Label("Chaturbate Base URL"), 0, row);
+ TextField baseUrl = new TextField();
+ baseUrl.setText(Config.getInstance().getSettings().chaturbateBaseUrl);
+ baseUrl.textProperty().addListener((ob, o, n) -> {
+ Config.getInstance().getSettings().chaturbateBaseUrl = baseUrl.getText();
+ save();
+ });
+ GridPane.setFillWidth(baseUrl, true);
+ GridPane.setHgrow(baseUrl, Priority.ALWAYS);
+ GridPane.setColumnSpan(baseUrl, 2);
+ layout.add(baseUrl, 1, row++);
Button createAccount = new Button("Create new Account");
createAccount.setOnAction((e) -> DesktopIntegration.open(Chaturbate.REGISTRATION_LINK));
- layout.add(createAccount, 1, 2);
+ layout.add(createAccount, 1, row++);
GridPane.setColumnSpan(createAccount, 2);
GridPane.setMargin(username, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
GridPane.setMargin(password, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
+ GridPane.setMargin(baseUrl, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
GridPane.setMargin(createAccount, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
+ username.setPrefWidth(300);
+
return layout;
}
}
diff --git a/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateSiteUi.java b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateSiteUi.java
index 7765e717..575ca10f 100644
--- a/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateSiteUi.java
+++ b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateSiteUi.java
@@ -16,7 +16,7 @@ public class ChaturbateSiteUi implements SiteUI {
public ChaturbateSiteUi(Chaturbate chaturbate) {
this.chaturbate = chaturbate;
tabProvider = new ChaturbateTabProvider(chaturbate);
- configUi = new ChaturbateConfigUi();
+ configUi = new ChaturbateConfigUi(chaturbate);
}
@Override
@@ -30,7 +30,7 @@ public class ChaturbateSiteUi implements SiteUI {
}
@Override
- public boolean login() throws IOException {
+ public synchronized boolean login() throws IOException {
return chaturbate.login();
}
diff --git a/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateTabProvider.java b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateTabProvider.java
index b9864907..f425a23b 100644
--- a/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateTabProvider.java
+++ b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateTabProvider.java
@@ -1,7 +1,5 @@
package ctbrec.ui.sites.chaturbate;
-import static ctbrec.sites.chaturbate.Chaturbate.*;
-
import java.util.ArrayList;
import java.util.List;
@@ -21,17 +19,17 @@ public class ChaturbateTabProvider extends TabProvider {
public ChaturbateTabProvider(Chaturbate chaturbate) {
this.chaturbate = chaturbate;
this.recorder = chaturbate.getRecorder();
- this.followedTab = new ChaturbateFollowedTab("Followed", BASE_URI + "/followed-cams/", chaturbate);
+ this.followedTab = new ChaturbateFollowedTab("Followed", chaturbate.getBaseUrl() + "/followed-cams/", chaturbate);
}
@Override
public List getTabs(Scene scene) {
List tabs = new ArrayList<>();
- tabs.add(createTab("Featured", BASE_URI + "/"));
- tabs.add(createTab("Female", BASE_URI + "/female-cams/"));
- tabs.add(createTab("Male", BASE_URI + "/male-cams/"));
- tabs.add(createTab("Couples", BASE_URI + "/couple-cams/"));
- tabs.add(createTab("Trans", BASE_URI + "/trans-cams/"));
+ tabs.add(createTab("Featured", chaturbate.getBaseUrl() + "/"));
+ tabs.add(createTab("Female", chaturbate.getBaseUrl() + "/female-cams/"));
+ tabs.add(createTab("Male", chaturbate.getBaseUrl() + "/male-cams/"));
+ tabs.add(createTab("Couples", chaturbate.getBaseUrl() + "/couple-cams/"));
+ tabs.add(createTab("Trans", chaturbate.getBaseUrl() + "/trans-cams/"));
followedTab.setScene(scene);
followedTab.setRecorder(recorder);
tabs.add(followedTab);
diff --git a/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsConfigUI.java b/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsConfigUI.java
index e74f63d8..2c65a382 100644
--- a/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsConfigUI.java
+++ b/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsConfigUI.java
@@ -1,21 +1,22 @@
package ctbrec.ui.sites.myfreecams;
import ctbrec.Config;
-import ctbrec.sites.ConfigUI;
+import ctbrec.Settings;
import ctbrec.sites.mfc.MyFreeCams;
import ctbrec.ui.DesktopIntegration;
-import ctbrec.ui.SettingsTab;
+import ctbrec.ui.settings.SettingsTab;
+import ctbrec.ui.sites.AbstractConfigUI;
import javafx.geometry.Insets;
import javafx.scene.Parent;
import javafx.scene.control.Button;
+import javafx.scene.control.CheckBox;
import javafx.scene.control.Label;
import javafx.scene.control.PasswordField;
import javafx.scene.control.TextField;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.Priority;
-public class MyFreeCamsConfigUI implements ConfigUI {
-
+public class MyFreeCamsConfigUI extends AbstractConfigUI {
private MyFreeCams myFreeCams;
public MyFreeCamsConfigUI(MyFreeCams myFreeCams) {
@@ -24,32 +25,85 @@ public class MyFreeCamsConfigUI implements ConfigUI {
@Override
public Parent createConfigPanel() {
+ int row = 0;
GridPane layout = SettingsTab.createGridLayout();
- layout.add(new Label("MyFreeCams User"), 0, 0);
+ Settings settings = Config.getInstance().getSettings();
+
+ Label l = new Label("Active");
+ layout.add(l, 0, row);
+ CheckBox enabled = new CheckBox();
+ enabled.setSelected(!settings.disabledSites.contains(myFreeCams.getName()));
+ enabled.setOnAction((e) -> {
+ if(enabled.isSelected()) {
+ settings.disabledSites.remove(myFreeCams.getName());
+ } else {
+ settings.disabledSites.add(myFreeCams.getName());
+ }
+ save();
+ });
+ GridPane.setMargin(enabled, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
+ layout.add(enabled, 1, row++);
+
+ layout.add(new Label("MyFreeCams User"), 0, row);
TextField username = new TextField(Config.getInstance().getSettings().mfcUsername);
- username.focusedProperty().addListener((e) -> Config.getInstance().getSettings().mfcUsername = username.getText());
+ username.setPrefWidth(300);
+ username.textProperty().addListener((ob, o, n) -> {
+ if(!n.equals(Config.getInstance().getSettings().mfcUsername)) {
+ Config.getInstance().getSettings().mfcUsername = username.getText();
+ myFreeCams.getHttpClient().logout();
+ save();
+ }
+ });
GridPane.setFillWidth(username, true);
GridPane.setHgrow(username, Priority.ALWAYS);
GridPane.setColumnSpan(username, 2);
- layout.add(username, 1, 0);
+ layout.add(username, 1, row++);
- layout.add(new Label("MyFreeCams Password"), 0, 1);
+ layout.add(new Label("MyFreeCams Password"), 0, row);
PasswordField password = new PasswordField();
password.setText(Config.getInstance().getSettings().mfcPassword);
- password.focusedProperty().addListener((e) -> Config.getInstance().getSettings().mfcPassword = password.getText());
+ password.textProperty().addListener((ob, o, n) -> {
+ if(!n.equals(Config.getInstance().getSettings().mfcPassword)) {
+ Config.getInstance().getSettings().mfcPassword = password.getText();
+ myFreeCams.getHttpClient().logout();
+ save();
+ }
+ });
GridPane.setFillWidth(password, true);
GridPane.setHgrow(password, Priority.ALWAYS);
GridPane.setColumnSpan(password, 2);
- layout.add(password, 1, 1);
+ layout.add(password, 1, row++);
+
+ layout.add(new Label("MyFreeCams Base URL"), 0, row);
+ TextField baseUrl = new TextField();
+ baseUrl.setText(Config.getInstance().getSettings().mfcBaseUrl);
+ baseUrl.textProperty().addListener((ob, o, n) -> {
+ Config.getInstance().getSettings().mfcBaseUrl = baseUrl.getText();
+ save();
+ });
+ GridPane.setFillWidth(baseUrl, true);
+ GridPane.setHgrow(baseUrl, Priority.ALWAYS);
+ GridPane.setColumnSpan(baseUrl, 2);
+ layout.add(baseUrl, 1, row++);
+
+ layout.add(new Label("Ignore upscaled stream (960p)"), 0, row);
+ CheckBox ignoreUpscaled = new CheckBox();
+ ignoreUpscaled.setSelected(Config.getInstance().getSettings().mfcIgnoreUpscaled);
+ ignoreUpscaled.selectedProperty().addListener((obs, oldV, newV) -> {
+ Config.getInstance().getSettings().mfcIgnoreUpscaled = newV;
+ });
+ layout.add(ignoreUpscaled, 1, row++);
Button createAccount = new Button("Create new Account");
createAccount.setOnAction((e) -> DesktopIntegration.open(myFreeCams.getAffiliateLink()));
- layout.add(createAccount, 1, 2);
+ layout.add(createAccount, 1, row);
GridPane.setColumnSpan(createAccount, 2);
GridPane.setMargin(username, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
GridPane.setMargin(password, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
+ GridPane.setMargin(baseUrl, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
+ GridPane.setMargin(ignoreUpscaled, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
GridPane.setMargin(createAccount, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
+
return layout;
}
-
}
diff --git a/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsSiteUi.java b/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsSiteUi.java
index f98528ed..59bb5829 100644
--- a/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsSiteUi.java
+++ b/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsSiteUi.java
@@ -30,7 +30,7 @@ public class MyFreeCamsSiteUi implements SiteUI {
}
@Override
- public boolean login() throws IOException {
+ public synchronized boolean login() throws IOException {
return myFreeCams.login();
}
diff --git a/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsTabProvider.java b/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsTabProvider.java
index ad5eb961..68847ed1 100644
--- a/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsTabProvider.java
+++ b/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsTabProvider.java
@@ -49,6 +49,8 @@ public class MyFreeCamsTabProvider extends TabProvider {
updateService.setPeriod(new Duration(TimeUnit.SECONDS.toMillis(10)));
tabs.add(pop);
+ MyFreeCamsTableTab table = new MyFreeCamsTableTab(myFreeCams);
+ tabs.add(table);
return tabs;
}
diff --git a/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsTableTab.java b/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsTableTab.java
new file mode 100644
index 00000000..2c5fda0f
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsTableTab.java
@@ -0,0 +1,568 @@
+package ctbrec.ui.sites.myfreecams;
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.ReentrantLock;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.Config;
+import ctbrec.Model;
+import ctbrec.StringUtil;
+import ctbrec.sites.mfc.MyFreeCams;
+import ctbrec.sites.mfc.MyFreeCamsModel;
+import ctbrec.sites.mfc.SessionState;
+import ctbrec.ui.DesktopIntegration;
+import ctbrec.ui.TabSelectionListener;
+import ctbrec.ui.action.FollowAction;
+import ctbrec.ui.action.PlayAction;
+import ctbrec.ui.action.StartRecordingAction;
+import ctbrec.ui.controls.SearchBox;
+import javafx.beans.property.DoubleProperty;
+import javafx.beans.property.SimpleDoubleProperty;
+import javafx.beans.property.SimpleStringProperty;
+import javafx.beans.property.StringProperty;
+import javafx.collections.FXCollections;
+import javafx.collections.ListChangeListener;
+import javafx.collections.ObservableList;
+import javafx.concurrent.Worker.State;
+import javafx.concurrent.WorkerStateEvent;
+import javafx.event.ActionEvent;
+import javafx.geometry.Insets;
+import javafx.geometry.Point2D;
+import javafx.geometry.Pos;
+import javafx.scene.control.Button;
+import javafx.scene.control.CheckMenuItem;
+import javafx.scene.control.ContextMenu;
+import javafx.scene.control.Label;
+import javafx.scene.control.MenuItem;
+import javafx.scene.control.ScrollPane;
+import javafx.scene.control.SelectionMode;
+import javafx.scene.control.Tab;
+import javafx.scene.control.TableColumn;
+import javafx.scene.control.TableColumn.SortType;
+import javafx.scene.control.TableView;
+import javafx.scene.input.Clipboard;
+import javafx.scene.input.ClipboardContent;
+import javafx.scene.input.ContextMenuEvent;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
+import javafx.util.Duration;
+
+public class MyFreeCamsTableTab extends Tab implements TabSelectionListener {
+ private static final transient Logger LOG = LoggerFactory.getLogger(MyFreeCamsTableTab.class);
+ private ScrollPane scrollPane = new ScrollPane();
+ private TableView table = new TableView();
+ private ObservableList filteredModels = FXCollections.observableArrayList();
+ private ObservableList observableModels = FXCollections.observableArrayList();
+ private TableUpdateService updateService;
+ private MyFreeCams mfc;
+ private ReentrantLock lock = new ReentrantLock();
+ private SearchBox filterInput;
+ private Label count = new Label("models");
+ private List> columns = new ArrayList<>();
+ private ContextMenu popup;
+
+ public MyFreeCamsTableTab(MyFreeCams mfc) {
+ this.mfc = mfc;
+ setText("Tabular");
+ setClosable(false);
+ initUpdateService();
+ createGui();
+ restoreState();
+ filter(filterInput.getText());
+ }
+
+ private void initUpdateService() {
+ updateService = new TableUpdateService(mfc);
+ updateService.setPeriod(new Duration(TimeUnit.SECONDS.toMillis(1)));
+ updateService.setOnSucceeded(this::onSuccess);
+ updateService.setOnFailed((event) -> {
+ LOG.info("Couldn't update MyFreeCams model table", event.getSource().getException());
+ });
+ }
+
+ private void onSuccess(WorkerStateEvent evt) {
+ Collection sessionStates = updateService.getValue();
+ if (sessionStates == null) {
+ return;
+ }
+
+ lock.lock();
+ try {
+ for (SessionState updatedModel : sessionStates) {
+ ModelTableRow row = new ModelTableRow(updatedModel);
+ int index = observableModels.indexOf(row);
+ if (index == -1) {
+ observableModels.add(row);
+ } else {
+ observableModels.get(index).update(updatedModel);
+ }
+ }
+
+ for (Iterator iterator = observableModels.iterator(); iterator.hasNext();) {
+ ModelTableRow model = iterator.next();
+ boolean found = false;
+ for (SessionState sessionState : sessionStates) {
+ if(Objects.equals(sessionState.getUid(), model.uid)) {
+ found = true;
+ break;
+ }
+ }
+ if(!found) {
+ iterator.remove();
+ }
+ }
+ } finally {
+ lock.unlock();
+ }
+
+ filteredModels.clear();
+ filter(filterInput.getText());
+ table.sort();
+ }
+
+ private void createGui() {
+ BorderPane layout = new BorderPane();
+ layout.setPadding(new Insets(5, 10, 10, 10));
+
+ filterInput = new SearchBox(false);
+ filterInput.setPromptText("Filter");
+ filterInput.textProperty().addListener( (observableValue, oldValue, newValue) -> {
+ String filter = filterInput.getText();
+ Config.getInstance().getSettings().mfcModelsTableFilter = filter;
+ lock.lock();
+ try {
+ filter(filter);
+ } finally {
+ lock.unlock();
+ }
+ });
+ filterInput.getStyleClass().remove("search-box-icon");
+ HBox.setHgrow(filterInput, Priority.ALWAYS);
+ Button columnSelection = new Button("âš™");
+ //Button columnSelection = new Button("â©©");
+ columnSelection.setOnAction(this::showColumnSelection);
+ HBox topBar = new HBox(5);
+ topBar.getChildren().addAll(filterInput, count, columnSelection);
+ count.prefHeightProperty().bind(filterInput.heightProperty());
+ count.setAlignment(Pos.CENTER);
+ layout.setTop(topBar);
+ BorderPane.setMargin(topBar, new Insets(0, 0, 5, 0));
+
+ table.setItems(observableModels);
+ table.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
+ table.getSortOrder().addListener(createSortOrderChangedListener());
+ table.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, event -> {
+ popup = createContextMenu();
+ if (popup != null) {
+ popup.show(table, event.getScreenX(), event.getScreenY());
+ }
+ event.consume();
+ });
+ table.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
+ if (popup != null) {
+ popup.hide();
+ }
+ });
+
+ int idx = 0;
+ TableColumn name = createTableColumn("Name", 200, idx++);
+ name.setCellValueFactory(cdf -> cdf.getValue().nameProperty());
+ addTableColumnIfEnabled(name);
+
+ TableColumn state = createTableColumn("State", 130, idx++);
+ state.setCellValueFactory(cdf -> cdf.getValue().stateProperty());
+ addTableColumnIfEnabled(state);
+
+ TableColumn camscore = createTableColumn("Score", 75, idx++);
+ camscore.setCellValueFactory(cdf -> cdf.getValue().camScoreProperty());
+ addTableColumnIfEnabled(camscore);
+
+ // this is always 0, use https://api.myfreecams.com/missmfc and https://api.myfreecams.com/missmfc/online
+ // TableColumn missMfc = createTableColumn("Miss MFC", 75, idx++);
+ // missMfc.setCellValueFactory(cdf -> {
+ // Integer mmfc = Optional.ofNullable(cdf.getValue().getM()).map(m -> m.getMissmfc()).orElse(-1);
+ // return new SimpleIntegerProperty(mmfc);
+ // });
+ // addTableColumnIfEnabled(missMfc);
+
+ TableColumn newModel = createTableColumn("New", 60, idx++);
+ newModel.setCellValueFactory(cdf -> cdf.getValue().newModelProperty());
+ addTableColumnIfEnabled(newModel);
+
+ TableColumn ethnic = createTableColumn("Ethnicity", 130, idx++);
+ ethnic.setCellValueFactory(cdf -> cdf.getValue().ethnicityProperty());
+ addTableColumnIfEnabled(ethnic);
+
+ TableColumn country = createTableColumn("Country", 160, idx++);
+ country.setCellValueFactory(cdf -> cdf.getValue().countryProperty());
+ addTableColumnIfEnabled(country);
+
+ TableColumn continent = createTableColumn("Continent", 100, idx++);
+ continent.setCellValueFactory(cdf -> cdf.getValue().continentProperty());
+ addTableColumnIfEnabled(continent);
+
+ TableColumn occupation = createTableColumn("Occupation", 160, idx++);
+ occupation.setCellValueFactory(cdf -> cdf.getValue().occupationProperty());
+ addTableColumnIfEnabled(occupation);
+
+ TableColumn tags = createTableColumn("Tags", 300, idx++);
+ tags.setCellValueFactory(cdf -> cdf.getValue().tagsProperty());
+ addTableColumnIfEnabled(tags);
+
+ TableColumn blurp = createTableColumn("Blurp", 300, idx++);
+ blurp.setCellValueFactory(cdf -> cdf.getValue().blurpProperty());
+ addTableColumnIfEnabled(blurp);
+
+ TableColumn topic = createTableColumn("Topic", 600, idx++);
+ topic.setCellValueFactory(cdf -> cdf.getValue().topicProperty());
+ addTableColumnIfEnabled(topic);
+
+ scrollPane.setFitToHeight(true);
+ scrollPane.setFitToWidth(true);
+ scrollPane.setContent(table);
+ scrollPane.setStyle("-fx-background-color: -fx-background");
+ layout.setCenter(scrollPane);
+ setContent(layout);
+ }
+
+ private ContextMenu createContextMenu() {
+ ObservableList selectedStates = table.getSelectionModel().getSelectedItems();
+ if (selectedStates.isEmpty()) {
+ return null;
+ }
+
+ List selectedModels = new ArrayList<>();
+ for (ModelTableRow sessionState : selectedStates) {
+ if(sessionState.name.get() != null) {
+ MyFreeCamsModel model = mfc.createModel(sessionState.name.get());
+ mfc.getClient().update(model);
+ selectedModels.add(model);
+ }
+ }
+
+ MenuItem copyUrl = new MenuItem("Copy URL");
+ copyUrl.setOnAction((e) -> {
+ Model selected = selectedModels.get(0);
+ final Clipboard clipboard = Clipboard.getSystemClipboard();
+ final ClipboardContent content = new ClipboardContent();
+ content.putString(selected.getUrl());
+ clipboard.setContent(content);
+ });
+
+ MenuItem startRecording = new MenuItem("Start Recording");
+ startRecording.setOnAction((e) -> startRecording(selectedModels));
+ MenuItem openInBrowser = new MenuItem("Open in Browser");
+ openInBrowser.setOnAction((e) -> DesktopIntegration.open(selectedModels.get(0).getUrl()));
+ MenuItem openInPlayer = new MenuItem("Open in Player");
+ openInPlayer.setOnAction((e) -> openInPlayer(selectedModels.get(0)));
+ MenuItem follow = new MenuItem("Follow");
+ follow.setOnAction((e) -> new FollowAction(getTabPane(), selectedModels).execute());
+
+ ContextMenu menu = new ContextMenu();
+ menu.getItems().addAll(startRecording, copyUrl, openInPlayer, openInBrowser, follow);
+
+ if (selectedModels.size() > 1) {
+ copyUrl.setDisable(true);
+ openInPlayer.setDisable(true);
+ openInBrowser.setDisable(true);
+ }
+
+ return menu;
+ }
+
+ private void startRecording(List selectedModels) {
+ new StartRecordingAction(getTabPane(), selectedModels, mfc.getRecorder()).execute();
+ }
+
+ private void openInPlayer(Model selectedModel) {
+ new PlayAction(getTabPane(), selectedModel).execute();
+ }
+
+ private void addTableColumnIfEnabled(TableColumn tc) {
+ if(isColumnEnabled(tc)) {
+ table.getColumns().add(tc);
+ }
+ }
+
+ private void filter(String filter) {
+ lock.lock();
+ try {
+ if (StringUtil.isBlank(filter)) {
+ observableModels.addAll(filteredModels);
+ filteredModels.clear();
+ return;
+ }
+
+ String[] tokens = filter.split(" ");
+ observableModels.addAll(filteredModels);
+ filteredModels.clear();
+ for (int i = 0; i < table.getItems().size(); i++) {
+ StringBuilder sb = new StringBuilder();
+ for (TableColumn tc : table.getColumns()) {
+ String cellData = tc.getCellData(i).toString();
+ sb.append(cellData).append(' ');
+ }
+ String searchText = sb.toString();
+
+ boolean tokensMissing = false;
+ for (String token : tokens) {
+ if(!searchText.toLowerCase().contains(token.toLowerCase())) {
+ tokensMissing = true;
+ break;
+ }
+ }
+ if(tokensMissing) {
+ ModelTableRow sessionState = table.getItems().get(i);
+ filteredModels.add(sessionState);
+ }
+ }
+ observableModels.removeAll(filteredModels);
+ } finally {
+ lock.unlock();
+ int filtered = filteredModels.size();
+ int showing = observableModels.size();
+ int total = showing + filtered;
+ count.setText(showing + "/" + total);
+ }
+ }
+
+ private void showColumnSelection(ActionEvent evt) {
+ ContextMenu menu = new ContextMenu();
+ for (TableColumn tc : columns) {
+ CheckMenuItem item = new CheckMenuItem(tc.getText());
+ item.setSelected(isColumnEnabled(tc));
+ menu.getItems().add(item);
+ item.setOnAction(e -> {
+ if(item.isSelected()) {
+ Config.getInstance().getSettings().mfcDisabledModelsTableColumns.remove(tc.getText());
+ for (int i = table.getColumns().size()-1; i>=0; i--) {
+ TableColumn other = table.getColumns().get(i);
+ int idx = (int) tc.getUserData();
+ int otherIdx = (int) other.getUserData();
+ if(otherIdx < idx) {
+ table.getColumns().add(i+1, tc);
+ break;
+ }
+ }
+ } else {
+ Config.getInstance().getSettings().mfcDisabledModelsTableColumns.add(tc.getText());
+ table.getColumns().remove(tc);
+ }
+ });
+ }
+ Button src = (Button) evt.getSource();
+ Point2D location = src.localToScreen(src.getTranslateX(), src.getTranslateY());
+ menu.show(getTabPane().getScene().getWindow(), location.getX(), location.getY() + src.getHeight() + 5);
+ }
+
+ private boolean isColumnEnabled(TableColumn tc) {
+ return !Config.getInstance().getSettings().mfcDisabledModelsTableColumns.contains(tc.getText());
+ }
+
+ private TableColumn createTableColumn(String text, int width, int idx) {
+ TableColumn tc = new TableColumn<>(text);
+ tc.setPrefWidth(width);
+ tc.sortTypeProperty().addListener((obs, o, n) -> saveState());
+ tc.widthProperty().addListener((obs, o, n) -> saveState());
+ tc.setUserData(idx);
+ columns.add(tc);
+ return tc;
+ }
+
+ @Override
+ public void selected() {
+ if(updateService != null) {
+ State s = updateService.getState();
+ if (s != State.SCHEDULED && s != State.RUNNING) {
+ updateService.reset();
+ updateService.restart();
+ }
+ }
+ }
+
+ @Override
+ public void deselected() {
+ if(updateService != null) {
+ updateService.cancel();
+ }
+ }
+
+ private void saveState() {
+ if (!table.getSortOrder().isEmpty()) {
+ TableColumn col = table.getSortOrder().get(0);
+ Config.getInstance().getSettings().mfcModelsTableSortColumn = col.getText();
+ Config.getInstance().getSettings().mfcModelsTableSortType = col.getSortType().toString();
+ }
+ double[] columnWidths = new double[table.getColumns().size()];
+ for (int i = 0; i < columnWidths.length; i++) {
+ columnWidths[i] = table.getColumns().get(i).getWidth();
+ }
+ Config.getInstance().getSettings().mfcModelsTableColumnWidths = columnWidths;
+ };
+
+ private void restoreState() {
+ String sortCol = Config.getInstance().getSettings().mfcModelsTableSortColumn;
+ if (StringUtil.isNotBlank(sortCol)) {
+ for (TableColumn col : table.getColumns()) {
+ if (Objects.equals(sortCol, col.getText())) {
+ col.setSortType(SortType.valueOf(Config.getInstance().getSettings().mfcModelsTableSortType));
+ table.getSortOrder().clear();
+ table.getSortOrder().add(col);
+ break;
+ }
+ }
+ }
+
+ double[] columnWidths = Config.getInstance().getSettings().mfcModelsTableColumnWidths;
+ if (columnWidths != null && columnWidths.length == table.getColumns().size()) {
+ for (int i = 0; i < columnWidths.length; i++) {
+ table.getColumns().get(i).setPrefWidth(columnWidths[i]);
+ }
+ }
+
+ filterInput.setText(Config.getInstance().getSettings().mfcModelsTableFilter);
+ }
+
+ private ListChangeListener> createSortOrderChangedListener() {
+ return new ListChangeListener>() {
+ @Override
+ public void onChanged(Change extends TableColumn> c) {
+ saveState();
+ }
+ };
+ }
+
+ private static class ModelTableRow {
+ private Integer uid;
+ private StringProperty name = new SimpleStringProperty();
+ private StringProperty state = new SimpleStringProperty();
+ private DoubleProperty camScore = new SimpleDoubleProperty();
+ private StringProperty newModel = new SimpleStringProperty();
+ private StringProperty ethnic = new SimpleStringProperty();
+ private StringProperty country = new SimpleStringProperty();
+ private StringProperty continent = new SimpleStringProperty();
+ private StringProperty occupation = new SimpleStringProperty();
+ private StringProperty tags = new SimpleStringProperty();
+ private StringProperty blurp = new SimpleStringProperty();
+ private StringProperty topic = new SimpleStringProperty();
+
+ public ModelTableRow(SessionState st) {
+ update(st);
+ }
+
+ public void update(SessionState st) {
+ uid = st.getUid();
+ name.set(Optional.ofNullable(st.getNm()).orElse("n/a"));
+ state.set(Optional.ofNullable(st.getVs()).map(vs -> ctbrec.sites.mfc.State.of(vs).toString()).orElse("n/a"));
+ camScore.set(Optional.ofNullable(st.getM()).map(m -> m.getCamscore()).orElse(0d));
+ Integer nu = Optional.ofNullable(st.getM()).map(m -> m.getNewModel()).orElse(0);
+ newModel.set(nu == 1 ? "new" : "");
+ ethnic.set(Optional.ofNullable(st.getU()).map(u -> u.getEthnic()).orElse("n/a"));
+ country.set(Optional.ofNullable(st.getU()).map(u -> u.getCountry()).orElse("n/a"));
+ continent.set(Optional.ofNullable(st.getM()).map(m -> m.getContinent()).orElse("n/a"));
+ occupation.set(Optional.ofNullable(st.getU()).map(u -> u.getOccupation()).orElse("n/a"));
+ Set tagSet = Optional.ofNullable(st.getM()).map(m -> m.getTags()).orElse(Collections.emptySet());
+ if(tagSet.isEmpty()) {
+ tags.set("");
+ } else {
+ StringBuilder sb = new StringBuilder();
+ for (String t : tagSet) {
+ sb.append(t).append(',').append(' ');
+ }
+ tags.set(sb.substring(0, sb.length()-2));
+ }
+ blurp.set(Optional.ofNullable(st.getU()).map(u -> u.getBlurb()).orElse("n/a"));
+ String tpc = Optional.ofNullable(st.getM()).map(m -> m.getTopic()).orElse("n/a");
+ try {
+ tpc = URLDecoder.decode(tpc, "utf-8");
+ } catch (UnsupportedEncodingException e) {
+ LOG.warn("Couldn't url decode topic", e);
+ }
+ topic.set(tpc);
+ }
+
+ public StringProperty nameProperty() {
+ return name;
+ };
+
+ public StringProperty stateProperty() {
+ return state;
+ };
+
+ public DoubleProperty camScoreProperty() {
+ return camScore;
+ };
+
+ public StringProperty newModelProperty() {
+ return newModel;
+ };
+
+ public StringProperty ethnicityProperty() {
+ return ethnic;
+ };
+
+ public StringProperty countryProperty() {
+ return country;
+ };
+
+ public StringProperty continentProperty() {
+ return continent;
+ };
+
+ public StringProperty occupationProperty() {
+ return occupation;
+ };
+
+ public StringProperty tagsProperty() {
+ return tags;
+ };
+
+ public StringProperty blurpProperty() {
+ return blurp;
+ };
+
+ public StringProperty topicProperty() {
+ return topic;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((uid == null) ? 0 : uid.hashCode());
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj)
+ return true;
+ if (obj == null)
+ return false;
+ if (getClass() != obj.getClass())
+ return false;
+ ModelTableRow other = (ModelTableRow) obj;
+ if (uid == null) {
+ if (other.uid != null)
+ return false;
+ } else if (!uid.equals(other.uid))
+ return false;
+ return true;
+ };
+
+
+ }
+}
\ No newline at end of file
diff --git a/client/src/main/java/ctbrec/ui/sites/myfreecams/TableUpdateService.java b/client/src/main/java/ctbrec/ui/sites/myfreecams/TableUpdateService.java
new file mode 100644
index 00000000..0ee25c04
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/sites/myfreecams/TableUpdateService.java
@@ -0,0 +1,31 @@
+package ctbrec.ui.sites.myfreecams;
+
+import java.io.IOException;
+import java.util.Collection;
+
+import ctbrec.sites.mfc.MyFreeCams;
+import ctbrec.sites.mfc.MyFreeCamsClient;
+import ctbrec.sites.mfc.SessionState;
+import javafx.concurrent.ScheduledService;
+import javafx.concurrent.Task;
+
+public class TableUpdateService extends ScheduledService> {
+
+ private MyFreeCams mfc;
+
+ public TableUpdateService(MyFreeCams mfc) {
+ this.mfc = mfc;
+ }
+
+ @Override
+ protected Task> createTask() {
+ return new Task>() {
+ @Override
+ public Collection call() throws IOException {
+ MyFreeCamsClient client = mfc.getClient();
+ return client.getSessionStates();
+ }
+ };
+ }
+
+}
diff --git a/client/src/main/java/ctbrec/ui/sites/streamate/StreamateConfigUI.java b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateConfigUI.java
new file mode 100644
index 00000000..d338cab1
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateConfigUI.java
@@ -0,0 +1,86 @@
+package ctbrec.ui.sites.streamate;
+
+import ctbrec.Config;
+import ctbrec.Settings;
+import ctbrec.sites.streamate.Streamate;
+import ctbrec.ui.DesktopIntegration;
+import ctbrec.ui.settings.SettingsTab;
+import ctbrec.ui.sites.AbstractConfigUI;
+import javafx.geometry.Insets;
+import javafx.scene.Parent;
+import javafx.scene.control.Button;
+import javafx.scene.control.CheckBox;
+import javafx.scene.control.Label;
+import javafx.scene.control.PasswordField;
+import javafx.scene.control.TextField;
+import javafx.scene.layout.GridPane;
+import javafx.scene.layout.Priority;
+
+public class StreamateConfigUI extends AbstractConfigUI {
+ private Streamate streamate;
+
+ public StreamateConfigUI(Streamate streamate) {
+ this.streamate = streamate;
+ }
+
+ @Override
+ public Parent createConfigPanel() {
+ GridPane layout = SettingsTab.createGridLayout();
+ Settings settings = Config.getInstance().getSettings();
+
+ int row = 0;
+ Label l = new Label("Active");
+ layout.add(l, 0, row);
+ CheckBox enabled = new CheckBox();
+ enabled.setSelected(!settings.disabledSites.contains(streamate.getName()));
+ enabled.setOnAction((e) -> {
+ if(enabled.isSelected()) {
+ settings.disabledSites.remove(streamate.getName());
+ } else {
+ settings.disabledSites.add(streamate.getName());
+ }
+ save();
+ });
+ GridPane.setMargin(enabled, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
+ layout.add(enabled, 1, row++);
+
+ layout.add(new Label("Streamate User"), 0, row);
+ TextField username = new TextField(settings.streamateUsername);
+ username.textProperty().addListener((ob, o, n) -> {
+ if(!n.equals(Config.getInstance().getSettings().streamateUsername)) {
+ Config.getInstance().getSettings().streamateUsername = username.getText();
+ streamate.getHttpClient().logout();
+ save();
+ }
+ });
+ GridPane.setFillWidth(username, true);
+ GridPane.setHgrow(username, Priority.ALWAYS);
+ GridPane.setColumnSpan(username, 2);
+ layout.add(username, 1, row++);
+
+ layout.add(new Label("Streamate Password"), 0, row);
+ PasswordField password = new PasswordField();
+ password.setText(settings.streamatePassword);
+ password.textProperty().addListener((ob, o, n) -> {
+ if(!n.equals(Config.getInstance().getSettings().streamatePassword)) {
+ Config.getInstance().getSettings().streamatePassword = password.getText();
+ streamate.getHttpClient().logout();
+ save();
+ }
+ });
+ GridPane.setFillWidth(password, true);
+ GridPane.setHgrow(password, Priority.ALWAYS);
+ GridPane.setColumnSpan(password, 2);
+ layout.add(password, 1, row++);
+
+ Button createAccount = new Button("Create new Account");
+ createAccount.setOnAction((e) -> DesktopIntegration.open(streamate.getAffiliateLink()));
+ layout.add(createAccount, 1, row++);
+ GridPane.setColumnSpan(createAccount, 2);
+ GridPane.setMargin(username, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
+ GridPane.setMargin(password, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
+ GridPane.setMargin(createAccount, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN));
+ return layout;
+ }
+
+}
diff --git a/client/src/main/java/ctbrec/ui/sites/streamate/StreamateFollowedService.java b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateFollowedService.java
new file mode 100644
index 00000000..d78b04c1
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateFollowedService.java
@@ -0,0 +1,93 @@
+package ctbrec.ui.sites.streamate;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.xpath.XPathExpressionException;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.xml.sax.SAXException;
+
+import ctbrec.Config;
+import ctbrec.Model;
+import ctbrec.io.HttpException;
+import ctbrec.sites.streamate.Streamate;
+import ctbrec.sites.streamate.StreamateHttpClient;
+import ctbrec.sites.streamate.StreamateModel;
+import ctbrec.ui.PaginatedScheduledService;
+import javafx.concurrent.Task;
+import okhttp3.Request;
+import okhttp3.Response;
+
+public class StreamateFollowedService extends PaginatedScheduledService {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(StreamateFollowedService.class);
+
+ private static final int MODELS_PER_PAGE = 48;
+ private Streamate streamate;
+ private StreamateHttpClient httpClient;
+ private String url;
+ private boolean showOnline = true;
+
+ public StreamateFollowedService(Streamate streamate) {
+ this.streamate = streamate;
+ this.httpClient = (StreamateHttpClient) streamate.getHttpClient();
+ this.url = streamate.getBaseUrl() + "/api/search/v1/favorites?host=streamate.com&domain=streamate.com";
+ }
+
+ @Override
+ protected Task> createTask() {
+ return new Task>() {
+ @Override
+ public List call() throws IOException, SAXException, ParserConfigurationException, XPathExpressionException {
+ httpClient.login();
+ String saKey = httpClient.getSaKey();
+ String _url = url + "&page_number=" + page + "&results_per_page=" + MODELS_PER_PAGE + "&sakey=" + saKey;
+ LOG.debug("Fetching page {}", _url);
+ Request request = new Request.Builder()
+ .url(_url)
+ .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent)
+ .addHeader("Accept", "application/json, */*")
+ .addHeader("Accept-Language", "en")
+ .addHeader("Referer", streamate.getBaseUrl())
+ .build();
+ try(Response response = streamate.getHttpClient().execute(request)) {
+ if (response.isSuccessful()) {
+ List models = new ArrayList<>();
+ String content = response.body().string();
+ JSONObject json = new JSONObject(content);
+ if(json.optString("status").equals("SM_OK")) {
+ JSONArray performers = json.getJSONArray("Results");
+ for (int i = 0; i < performers.length(); i++) {
+ JSONObject p = performers.getJSONObject(i);
+ String nickname = p.getString("Nickname");
+ StreamateModel model = (StreamateModel) streamate.createModel(nickname);
+ model.setId(p.getLong("PerformerId"));
+ model.setPreview("https://m1.nsimg.net/biopic/320x240/" + model.getId());
+ boolean online = p.optString("LiveStatus").equals("live");
+ model.setOnline(online);
+ if(online == showOnline) {
+ models.add(model);
+ }
+ }
+ } else {
+ throw new IOException("Status: " + json.optString("status"));
+ }
+ return models;
+ } else {
+ throw new HttpException(response.code(), response.message());
+ }
+ }
+ }
+ };
+ }
+
+ public void setOnline(boolean online) {
+ this.showOnline = online;
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/sites/streamate/StreamateFollowedTab.java b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateFollowedTab.java
new file mode 100644
index 00000000..f79cc30f
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateFollowedTab.java
@@ -0,0 +1,77 @@
+package ctbrec.ui.sites.streamate;
+
+import ctbrec.sites.streamate.Streamate;
+import ctbrec.ui.FollowedTab;
+import ctbrec.ui.ThumbOverviewTab;
+import javafx.concurrent.WorkerStateEvent;
+import javafx.geometry.Insets;
+import javafx.scene.Scene;
+import javafx.scene.control.Label;
+import javafx.scene.control.RadioButton;
+import javafx.scene.control.ToggleGroup;
+import javafx.scene.input.KeyCode;
+import javafx.scene.input.KeyEvent;
+import javafx.scene.layout.HBox;
+
+public class StreamateFollowedTab extends ThumbOverviewTab implements FollowedTab {
+ private Label status;
+
+ public StreamateFollowedTab(Streamate streamate) {
+ super("Favorites", new StreamateFollowedService(streamate), streamate);
+
+ status = new Label("Logging in...");
+ grid.getChildren().add(status);
+ }
+
+ @Override
+ protected void createGui() {
+ super.createGui();
+ addOnlineOfflineSelector();
+ }
+
+ private void addOnlineOfflineSelector() {
+ ToggleGroup group = new ToggleGroup();
+ RadioButton online = new RadioButton("online");
+ online.setToggleGroup(group);
+ RadioButton offline = new RadioButton("offline");
+ offline.setToggleGroup(group);
+ pagination.getChildren().add(online);
+ pagination.getChildren().add(offline);
+ HBox.setMargin(online, new Insets(5,5,5,40));
+ HBox.setMargin(offline, new Insets(5,5,5,5));
+ online.setSelected(true);
+ group.selectedToggleProperty().addListener((e) -> {
+ ((StreamateFollowedService)updateService).setOnline(online.isSelected());
+ queue.clear();
+ updateService.restart();
+ });
+ }
+
+ @Override
+ protected void onSuccess() {
+ grid.getChildren().remove(status);
+ super.onSuccess();
+ }
+
+ @Override
+ protected void onFail(WorkerStateEvent event) {
+ status.setText("Login failed");
+ super.onFail(event);
+ }
+
+ @Override
+ public void selected() {
+ status.setText("Logging in...");
+ super.selected();
+ }
+
+ public void setScene(Scene scene) {
+ scene.addEventFilter(KeyEvent.KEY_PRESSED, event -> {
+ if(this.isSelected()) {
+ if(event.getCode() == KeyCode.DELETE) {
+ follow(selectedThumbCells, false);
+ }
+ }
+ });
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/sites/streamate/StreamateSiteUi.java b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateSiteUi.java
new file mode 100644
index 00000000..c7348a1f
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateSiteUi.java
@@ -0,0 +1,37 @@
+package ctbrec.ui.sites.streamate;
+
+import java.io.IOException;
+
+import ctbrec.sites.ConfigUI;
+import ctbrec.sites.streamate.Streamate;
+import ctbrec.ui.SiteUI;
+import ctbrec.ui.TabProvider;
+
+public class StreamateSiteUi implements SiteUI {
+
+ private StreamateTabProvider tabProvider;
+ private StreamateConfigUI configUi;
+ private Streamate streamate;
+
+ public StreamateSiteUi(Streamate streamate) {
+ this.streamate = streamate;
+ tabProvider = new StreamateTabProvider(streamate);
+ configUi = new StreamateConfigUI(streamate);
+ }
+
+ @Override
+ public TabProvider getTabProvider() {
+ return tabProvider;
+ }
+
+ @Override
+ public ConfigUI getConfigUI() {
+ return configUi;
+ }
+
+ @Override
+ public boolean login() throws IOException {
+ return streamate.login();
+ }
+
+}
diff --git a/client/src/main/java/ctbrec/ui/sites/streamate/StreamateTabProvider.java b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateTabProvider.java
new file mode 100644
index 00000000..d43ec25f
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateTabProvider.java
@@ -0,0 +1,62 @@
+package ctbrec.ui.sites.streamate;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.recorder.Recorder;
+import ctbrec.sites.streamate.Streamate;
+import ctbrec.ui.TabProvider;
+import ctbrec.ui.ThumbOverviewTab;
+import javafx.scene.Scene;
+import javafx.scene.control.Tab;
+
+public class StreamateTabProvider extends TabProvider {
+ private static final transient Logger LOG = LoggerFactory.getLogger(StreamateTabProvider.class);
+ private Streamate streamate;
+ private Recorder recorder;
+ private ThumbOverviewTab followedTab;
+
+ public StreamateTabProvider(Streamate streamate) {
+ this.streamate = streamate;
+ this.recorder = streamate.getRecorder();
+ }
+
+ @Override
+ public List getTabs(Scene scene) {
+ List tabs = new ArrayList<>();
+ try {
+ tabs.add(createTab("Girls", Streamate.BASE_URL + "/api/search/list?domain=streamate.com&index=availperf&filters=gender:f"));
+ tabs.add(createTab("Guys", Streamate.BASE_URL + "/api/search/list?domain=streamate.com&index=availperf&filters=gender:m"));
+ tabs.add(createTab("Couples", Streamate.BASE_URL + "/api/search/list?domain=streamate.com&index=availperf&filters=gender:mf"));
+ tabs.add(createTab("Lesbian", Streamate.BASE_URL + "/api/search/list?domain=streamate.com&index=availperf&filters=gender:ff"));
+ tabs.add(createTab("Gay", Streamate.BASE_URL + "/api/search/list?domain=streamate.com&index=availperf&filters=gender:mm"));
+ tabs.add(createTab("Groups", Streamate.BASE_URL + "/api/search/list?domain=streamate.com&index=availperf&filters=gender:g"));
+ tabs.add(createTab("Trans female", Streamate.BASE_URL + "/api/search/list?domain=streamate.com&index=availperf&filters=gender:tm2f"));
+ tabs.add(createTab("Trans male", Streamate.BASE_URL + "/api/search/list?domain=streamate.com&index=availperf&filters=gender:tf2m"));
+ tabs.add(createTab("New", Streamate.BASE_URL + "/api/search/list?domain=streamate.com&index=availperf&filters=new:true"));
+
+ followedTab = new StreamateFollowedTab(streamate);
+ followedTab.setRecorder(recorder);
+ tabs.add(followedTab);
+ } catch (IOException e) {
+ LOG.error("Couldn't create streamate tab", e);
+ }
+ return tabs;
+ }
+
+ @Override
+ public Tab getFollowedTab() {
+ return followedTab;
+ }
+
+ private Tab createTab(String title, String url) throws IOException {
+ StreamateUpdateService updateService = new StreamateUpdateService(streamate, url);
+ ThumbOverviewTab tab = new ThumbOverviewTab(title, updateService, streamate);
+ tab.setRecorder(recorder);
+ return tab;
+ }
+}
diff --git a/client/src/main/java/ctbrec/ui/sites/streamate/StreamateUpdateService.java b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateUpdateService.java
new file mode 100644
index 00000000..083a2008
--- /dev/null
+++ b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateUpdateService.java
@@ -0,0 +1,86 @@
+package ctbrec.ui.sites.streamate;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.xpath.XPathExpressionException;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.xml.sax.SAXException;
+
+import ctbrec.Config;
+import ctbrec.Model;
+import ctbrec.io.HttpException;
+import ctbrec.sites.streamate.Streamate;
+import ctbrec.sites.streamate.StreamateModel;
+import ctbrec.ui.PaginatedScheduledService;
+import javafx.concurrent.Task;
+import okhttp3.Request;
+import okhttp3.Response;
+
+public class StreamateUpdateService extends PaginatedScheduledService {
+
+ private static final transient Logger LOG = LoggerFactory.getLogger(StreamateUpdateService.class);
+
+ private static final int MODELS_PER_PAGE = 48;
+ private Streamate streamate;
+ private String url;
+
+ public StreamateUpdateService(Streamate streamate, String url) {
+ this.streamate = streamate;
+ this.url = url;
+ }
+
+ @Override
+ protected Task> createTask() {
+ return new Task>() {
+ @Override
+ public List call() throws IOException, SAXException, ParserConfigurationException, XPathExpressionException {
+ int from = (page - 1) * MODELS_PER_PAGE;
+ String _url = url + "&from=" + from + "&size=" + MODELS_PER_PAGE;
+ LOG.debug("Fetching page {}", _url);
+ Request request = new Request.Builder()
+ .url(_url)
+ .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent)
+ .addHeader("Accept", "application/json, */*")
+ .addHeader("Accept-Language", "en")
+ .addHeader("Referer", streamate.getBaseUrl())
+ .build();
+ try(Response response = streamate.getHttpClient().execute(request)) {
+ if (response.isSuccessful()) {
+ List models = new ArrayList<>();
+ String content = response.body().string();
+ JSONObject json = new JSONObject(content);
+ JSONArray performers = json.getJSONArray("performers");
+ for (int i = 0; i < performers.length(); i++) {
+ JSONObject p = performers.getJSONObject(i);
+ String nickname = p.getString("nickname");
+ StreamateModel model = (StreamateModel) streamate.createModel(nickname);
+ model.setId(p.getLong("id"));
+ model.setPreview(p.getString("thumbnail"));
+ model.setOnline(p.optBoolean("online"));
+ // TODO figure out, what all the states mean
+ // liveState {…}
+ // exclusiveShow false
+ // goldShow true
+ // onBreak false
+ // partyChat true
+ // preGoldShow true
+ // privateChat false
+ // specialShow false
+ models.add(model);
+ }
+ return models;
+ } else {
+ throw new HttpException(response.code(), response.message());
+ }
+ }
+ }
+ };
+ }
+}
diff --git a/client/src/main/resources/Oxygen-Im-Highlight-Msg.mp3 b/client/src/main/resources/Oxygen-Im-Highlight-Msg.mp3
new file mode 100644
index 00000000..ad3a8a54
Binary files /dev/null and b/client/src/main/resources/Oxygen-Im-Highlight-Msg.mp3 differ
diff --git a/client/src/main/resources/anonymous.png b/client/src/main/resources/anonymous.png
new file mode 100644
index 00000000..b0294b26
Binary files /dev/null and b/client/src/main/resources/anonymous.png differ
diff --git a/client/src/main/resources/buymeacoffee-round.png b/client/src/main/resources/buymeacoffee-round.png
new file mode 100644
index 00000000..341a0f8e
Binary files /dev/null and b/client/src/main/resources/buymeacoffee-round.png differ
diff --git a/client/src/main/resources/dark.css b/client/src/main/resources/dark.css
new file mode 100644
index 00000000..a7af0985
--- /dev/null
+++ b/client/src/main/resources/dark.css
@@ -0,0 +1,7 @@
+.root {
+ -fx-base: #4d4d4d;
+ -fx-accent: #0096c9;
+ -fx-default-button: -fx-accent;
+ -fx-focus-color: -fx-accent;
+ -fx-control-inner-background-alt: derive(-fx-base, 95%);
+}
\ No newline at end of file
diff --git a/client/src/main/resources/patreon-round.png b/client/src/main/resources/patreon-round.png
new file mode 100644
index 00000000..22c9edac
Binary files /dev/null and b/client/src/main/resources/patreon-round.png differ
diff --git a/client/src/main/resources/paypal-round.png b/client/src/main/resources/paypal-round.png
new file mode 100644
index 00000000..481490ea
Binary files /dev/null and b/client/src/main/resources/paypal-round.png differ
diff --git a/common/pom.xml b/common/pom.xml
index 9152962b..4f20e4fa 100644
--- a/common/pom.xml
+++ b/common/pom.xml
@@ -8,7 +8,7 @@
ctbrec
master
- 1.10.0
+ 1.15.0
../master
diff --git a/common/src/main/java/ctbrec/AbstractModel.java b/common/src/main/java/ctbrec/AbstractModel.java
index ce8b7ef1..53198b05 100644
--- a/common/src/main/java/ctbrec/AbstractModel.java
+++ b/common/src/main/java/ctbrec/AbstractModel.java
@@ -3,6 +3,7 @@ package ctbrec;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
+import java.util.Optional;
import java.util.concurrent.ExecutionException;
import com.squareup.moshi.JsonReader;
@@ -14,12 +15,14 @@ public abstract class AbstractModel implements Model {
private String url;
private String name;
+ private String displayName;
private String preview;
private String description;
private List tags = new ArrayList<>();
private int streamUrlIndex = -1;
private boolean suspended = false;
protected Site site;
+ protected State onlineState = State.UNKNOWN;
@Override
public boolean isOnline() throws IOException, ExecutionException, InterruptedException {
@@ -46,6 +49,20 @@ public abstract class AbstractModel implements Model {
this.name = name;
}
+ @Override
+ public String getDisplayName() {
+ if(displayName != null) {
+ return displayName;
+ } else {
+ return getName();
+ }
+ }
+
+ @Override
+ public void setDisplayName(String name) {
+ this.displayName = name;
+ }
+
@Override
public String getPreview() {
return preview;
@@ -106,6 +123,15 @@ public abstract class AbstractModel implements Model {
this.suspended = suspended;
}
+ @Override
+ public State getOnlineState(boolean failFast) throws IOException, ExecutionException {
+ return onlineState;
+ }
+
+ public void setOnlineState(State status) {
+ this.onlineState = status;
+ }
+
@Override
public int hashCode() {
final int prime = 31;
@@ -137,6 +163,13 @@ public abstract class AbstractModel implements Model {
return true;
}
+ @Override
+ public int compareTo(Model o) {
+ String thisName = Optional.ofNullable(getDisplayName()).orElse("").toLowerCase();
+ String otherName = Optional.ofNullable(o).map(m -> m.getDisplayName()).orElse("").toLowerCase();
+ return thisName.compareTo(otherName);
+ }
+
@Override
public String toString() {
return getName();
diff --git a/common/src/main/java/ctbrec/Config.java b/common/src/main/java/ctbrec/Config.java
index 865f6bc1..9170a5f1 100644
--- a/common/src/main/java/ctbrec/Config.java
+++ b/common/src/main/java/ctbrec/Config.java
@@ -7,6 +7,7 @@ import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.Files;
+import java.nio.file.StandardCopyOption;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
@@ -46,7 +47,6 @@ public class Config {
} else {
filename = "settings.json";
}
- load();
}
private void load() throws FileNotFoundException, IOException {
@@ -61,6 +61,13 @@ public class Config {
BufferedSource source = buffer.readFrom(fin);
settings = adapter.fromJson(source);
settings.httpTimeout = Math.max(settings.httpTimeout, 10_000);
+ } catch(Throwable e) {
+ settings = OS.getDefaultSettings();
+ for (Site site : sites) {
+ site.setEnabled(!settings.disabledSites.contains(site.getName()));
+ }
+ makeBackup(configFile);
+ throw e;
}
} else {
LOG.error("Config file does not exist. Falling back to default values.");
@@ -71,9 +78,22 @@ public class Config {
}
}
+ private void makeBackup(File source) {
+ try {
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss");
+ String timestamp = sdf.format(new Date());
+ String backup = source.getName() + '.' + timestamp;
+ File target = new File(source.getParentFile(), backup);
+ Files.copy(source.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING);
+ } catch(Throwable e) {
+ LOG.error("Couldn't create backup of settings file", e);
+ }
+ }
+
public static synchronized void init(List sites) throws FileNotFoundException, IOException {
if(instance == null) {
instance = new Config(sites);
+ instance.load();
}
}
@@ -100,10 +120,14 @@ public class Config {
Files.write(configFile.toPath(), json.getBytes("utf-8"), CREATE, WRITE, TRUNCATE_EXISTING);
}
- public boolean isServerMode() {
+ public static boolean isServerMode() {
return Objects.equals(System.getProperty("ctbrec.server.mode"), "1");
}
+ public static boolean isDevMode() {
+ return Objects.equals(System.getenv("CTBREC_DEV"), "1");
+ }
+
public File getConfigDir() {
return configDir;
}
@@ -113,10 +137,6 @@ public class Config {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd_HH-mm");
String startTime = sdf.format(new Date());
File targetFile = new File(dirForRecording, model.getName() + '_' + startTime + ".ts");
- if(getSettings().splitRecordings > 0) {
- LOG.debug("Splitting recordings every {} seconds", getSettings().splitRecordings);
- targetFile = new File(targetFile.getAbsolutePath().replaceAll("\\.ts", "-00000.ts"));
- }
return targetFile;
}
diff --git a/common/src/main/java/ctbrec/Model.java b/common/src/main/java/ctbrec/Model.java
index 3144f777..feb73817 100644
--- a/common/src/main/java/ctbrec/Model.java
+++ b/common/src/main/java/ctbrec/Model.java
@@ -12,33 +12,95 @@ import com.squareup.moshi.JsonWriter;
import ctbrec.recorder.download.StreamSource;
import ctbrec.sites.Site;
-public interface Model {
+public interface Model extends Comparable {
+
+ public static enum State {
+ ONLINE("online"),
+ OFFLINE("offline"),
+ AWAY("away"),
+ PRIVATE("private"),
+ GROUP("group"),
+ UNKNOWN("unknown");
+
+ String display;
+ State(String display) {
+ this.display = display;
+ }
+
+ @Override
+ public String toString() {
+ return display;
+ }
+ }
+
public String getUrl();
+
public void setUrl(String url);
+
+ public String getDisplayName();
+
+ public void setDisplayName(String name);
+
public String getName();
+
public void setName(String name);
+
public String getPreview();
+
public void setPreview(String preview);
+
public List getTags();
+
public void setTags(List tags);
+
public String getDescription();
+
public void setDescription(String description);
+
public int getStreamUrlIndex();
+
public void setStreamUrlIndex(int streamUrlIndex);
+
public boolean isOnline() throws IOException, ExecutionException, InterruptedException;
+
public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException;
- public String getOnlineState(boolean failFast) throws IOException, ExecutionException;
+
+ public State getOnlineState(boolean failFast) throws IOException, ExecutionException;
+
public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException;
+
public void invalidateCacheEntries();
+
public void receiveTip(int tokens) throws IOException;
+
+ /**
+ * Determines the stream resolution for this model
+ *
+ * @param failFast
+ * If set to true, the method returns emmediately, even if the resolution is unknown. If
+ * the resolution is unknown, the array contains 0,0
+ *
+ * @return a tupel of width and height represented by an int[2]
+ * @throws ExecutionException
+ */
public int[] getStreamResolution(boolean failFast) throws ExecutionException;
+
public boolean follow() throws IOException;
+
public boolean unfollow() throws IOException;
+
public void setSite(Site site);
+
public Site getSite();
+
public void writeSiteSpecificData(JsonWriter writer) throws IOException;
+
public void readSiteSpecificData(JsonReader reader) throws IOException;
+
public boolean isSuspended();
+
public void setSuspended(boolean suspended);
+
+
}
\ No newline at end of file
diff --git a/common/src/main/java/ctbrec/MpegUtil.java b/common/src/main/java/ctbrec/MpegUtil.java
new file mode 100644
index 00000000..4ef37a53
--- /dev/null
+++ b/common/src/main/java/ctbrec/MpegUtil.java
@@ -0,0 +1,73 @@
+package ctbrec;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.channels.ReadableByteChannel;
+import java.time.Duration;
+import java.util.Set;
+
+import org.jcodec.common.Demuxer;
+import org.jcodec.common.DemuxerTrack;
+import org.jcodec.common.TrackType;
+import org.jcodec.common.Tuple;
+import org.jcodec.common.Tuple._2;
+import org.jcodec.common.io.FileChannelWrapper;
+import org.jcodec.common.io.NIOUtils;
+import org.jcodec.common.model.Packet;
+import org.jcodec.containers.mps.MPSDemuxer;
+import org.jcodec.containers.mps.MTSDemuxer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class MpegUtil {
+ private static final transient Logger LOG = LoggerFactory.getLogger(MpegUtil.class);
+
+ public static void main(String[] args) throws IOException {
+ readFile(new File("../../test-recs/ff.ts"));
+ }
+
+ public static void readFile(File file) throws IOException {
+ System.out.println(file.getCanonicalPath());
+ double duration = MpegUtil.getFileDuration(file);
+ System.out.println(Duration.ofSeconds((long) duration));
+ }
+
+ public static double getFileDuration(File file) throws IOException {
+ try(FileChannelWrapper ch = NIOUtils.readableChannel(file)) {
+ _2 m2tsDemuxer = createM2TSDemuxer(ch, TrackType.VIDEO);
+ Demuxer demuxer = m2tsDemuxer.v1;
+ DemuxerTrack videoDemux = demuxer.getTracks().get(0);
+ Packet videoFrame = null;
+ double totalDuration = 0;
+ while( (videoFrame = videoDemux.nextFrame()) != null) {
+ totalDuration += videoFrame.getDurationD();
+ }
+ return totalDuration;
+ }
+ }
+
+ public static _2 createM2TSDemuxer(FileChannelWrapper ch, TrackType targetTrack) throws IOException {
+ MTSDemuxer mts = new MTSDemuxer(ch);
+ Set programs = mts.getPrograms();
+ if (programs.size() == 0) {
+ LOG.error("The MPEG TS stream contains no programs");
+ return null;
+ }
+ Tuple._2 found = null;
+ for (Integer pid : programs) {
+ ReadableByteChannel program = mts.getProgram(pid);
+ if (found != null) {
+ program.close();
+ continue;
+ }
+ MPSDemuxer demuxer = new MPSDemuxer(program);
+ if (targetTrack == TrackType.AUDIO && demuxer.getAudioTracks().size() > 0
+ || targetTrack == TrackType.VIDEO && demuxer.getVideoTracks().size() > 0) {
+ found = org.jcodec.common.Tuple._2(pid, (Demuxer) demuxer);
+ } else {
+ program.close();
+ }
+ }
+ return found;
+ }
+}
diff --git a/common/src/main/java/ctbrec/OS.java b/common/src/main/java/ctbrec/OS.java
index cc9fbb45..e86842e4 100644
--- a/common/src/main/java/ctbrec/OS.java
+++ b/common/src/main/java/ctbrec/OS.java
@@ -1,12 +1,26 @@
package ctbrec;
+import java.awt.AWTException;
+import java.awt.Image;
+import java.awt.SystemTray;
+import java.awt.Toolkit;
+import java.awt.TrayIcon;
+import java.awt.TrayIcon.MessageType;
import java.io.File;
+import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Map.Entry;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ctbrec.io.StreamRedirectThread;
+
public class OS {
+ private static final transient Logger LOG = LoggerFactory.getLogger(OS.class);
+
public static enum TYPE {
LINUX,
MAC,
@@ -72,4 +86,61 @@ public class OS {
}
return env;
}
+
+ public static void notification(String title, String header, String msg) {
+ if(OS.getOsType() == OS.TYPE.LINUX) {
+ notifyLinux(title, header, msg);
+ } else if(OS.getOsType() == OS.TYPE.WINDOWS) {
+ notifyWindows(title, header, msg);
+ } else if(OS.getOsType() == OS.TYPE.MAC) {
+ notifyMac(title, header, msg);
+ } else {
+ // unknown system, try systemtray notification anyways
+ notifySystemTray(title, header, msg);
+ }
+ }
+
+ private static void notifyLinux(String title, String header, String msg) {
+ try {
+ Process p = Runtime.getRuntime().exec(new String[] {
+ "notify-send",
+ "-u", "normal",
+ "-t", "5000",
+ "-a", title,
+ header,
+ msg.replaceAll("-", "\\\\-").replaceAll("\\s", "\\\\ "),
+ "--icon=dialog-information"
+ });
+ new Thread(new StreamRedirectThread(p.getInputStream(), System.out)).start();
+ new Thread(new StreamRedirectThread(p.getErrorStream(), System.err)).start();
+ } catch (IOException e1) {
+ LOG.error("Notification failed", e1);
+ }
+ }
+
+ private static void notifyWindows(String title, String header, String msg) {
+ notifySystemTray(title, header, msg);
+ }
+
+ private static void notifyMac(String title, String header, String msg) {
+ notifySystemTray(title, header, msg);
+ }
+
+ private static void notifySystemTray(String title, String header, String msg) {
+ if(SystemTray.isSupported()) {
+ SystemTray tray = SystemTray.getSystemTray();
+ Image image = Toolkit.getDefaultToolkit().createImage(OS.class.getResource("/icon64.png"));
+ TrayIcon trayIcon = new TrayIcon(image, title);
+ trayIcon.setImageAutoSize(true);
+ trayIcon.setToolTip(title);
+ try {
+ tray.add(trayIcon);
+ } catch (AWTException e) {
+ LOG.error("Coulnd't add tray icon", e);
+ }
+ trayIcon.displayMessage(header, msg, MessageType.INFO);
+ } else {
+ LOG.error("SystemTray notifications not supported by this OS");
+ }
+ }
}
diff --git a/common/src/main/java/ctbrec/Recording.java b/common/src/main/java/ctbrec/Recording.java
index 1c9d43b6..dadbe3c7 100644
--- a/common/src/main/java/ctbrec/Recording.java
+++ b/common/src/main/java/ctbrec/Recording.java
@@ -10,16 +10,29 @@ public class Recording {
private Instant startDate;
private String path;
private boolean hasPlaylist;
- private STATUS status;
+ private State status = State.UNKNOWN;
private int progress = -1;
private long sizeInByte;
- public static enum STATUS {
- RECORDING,
- GENERATING_PLAYLIST,
- FINISHED,
- DOWNLOADING,
- MERGING
+ public static enum State {
+ RECORDING("recording"),
+ STOPPED("stopped"),
+ GENERATING_PLAYLIST("generating playlist"),
+ POST_PROCESSING("post-processing"),
+ FINISHED("finished"),
+ DOWNLOADING("downloading"),
+ UNKNOWN("unknown");
+
+ private String desc;
+
+ State(String desc) {
+ this.desc = desc;
+ }
+
+ @Override
+ public String toString() {
+ return desc;
+ }
}
public Recording() {}
@@ -48,11 +61,11 @@ public class Recording {
this.startDate = startDate;
}
- public STATUS getStatus() {
+ public State getStatus() {
return status;
}
- public void setStatus(STATUS status) {
+ public void setStatus(State status) {
this.status = status;
}
diff --git a/common/src/main/java/ctbrec/Settings.java b/common/src/main/java/ctbrec/Settings.java
index 7e4a7136..eb480855 100644
--- a/common/src/main/java/ctbrec/Settings.java
+++ b/common/src/main/java/ctbrec/Settings.java
@@ -4,6 +4,8 @@ import java.io.File;
import java.util.ArrayList;
import java.util.List;
+import ctbrec.event.EventHandlerConfiguration;
+
public class Settings {
public enum ProxyType {
@@ -30,6 +32,7 @@ public class Settings {
}
public boolean singlePlayer = true;
+ public boolean showPlayerStarting = false;
public boolean localRecording = true;
public int httpPort = 8080;
public int httpTimeout = 10000;
@@ -37,22 +40,36 @@ public class Settings {
public String httpServer = "localhost";
public String recordingsDir = System.getProperty("user.home") + File.separator + "ctbrec";
public DirectoryStructure recordingsDirStructure = DirectoryStructure.FLAT;
+ public long minimumSpaceLeftInBytes = 0;
+ public int minimumLengthInSeconds = 0;
public String mediaPlayer = "/usr/bin/mpv";
public String postProcessing = "";
public String username = ""; // chaturbate username TODO maybe rename this onetime
public String password = ""; // chaturbate password TODO maybe rename this onetime
+ public String chaturbateBaseUrl = "https://chaturbate.com";
public String bongaUsername = "";
public String bongaPassword = "";
public String mfcUsername = "";
public String mfcPassword = "";
+ public String mfcBaseUrl = "https://www.myfreecams.com";
+ public String mfcModelsTableSortColumn = "";
+ public String mfcModelsTableSortType = "";
+ public double[] mfcModelsTableColumnWidths = new double[0];
+ public String mfcModelsTableFilter = "";
+ public List mfcDisabledModelsTableColumns = new ArrayList<>();
+ public boolean mfcIgnoreUpscaled = false;
public String camsodaUsername = "";
public String camsodaPassword = "";
- public String cam4Username;
- public String cam4Password;
+ public String cam4Username = "";
+ public String cam4Password = "";
+ public String streamateUsername = "";
+ public String streamatePassword = "";
public String lastDownloadDir = "";
- public List models = new ArrayList();
+ public List models = new ArrayList<>();
+ public List eventHandlers = new ArrayList<>();
public boolean determineResolution = false;
+ public boolean previewInThumbnails = true;
public boolean requireAuthentication = false;
public boolean chooseStreamQuality = false;
public int maximumResolution = 0;
@@ -62,7 +79,9 @@ public class Settings {
public String proxyPort;
public String proxyUser;
public String proxyPassword;
+ public String startTab = "Settings";
public int thumbWidth = 180;
+ public boolean updateThumbnails = true;
public int windowWidth = 1340;
public int windowHeight = 800;
public boolean windowMaximized = false;
@@ -70,4 +89,13 @@ public class Settings {
public int windowY;
public int splitRecordings = 0;
public List disabledSites = new ArrayList<>();
+ public String colorBase = "#FFFFFF";
+ public String colorAccent = "#FFFFFF";
+ public int onlineCheckIntervalInSecs = 60;
+ public String recordedModelsSortColumn = "";
+ public String recordedModelsSortType = "";
+ public double[] recordedModelsColumnWidths = new double[0];
+ public String recordingsSortColumn = "";
+ public String recordingsSortType = "";
+ public double[] recordingsColumnWidths = new double[0];
}
diff --git a/common/src/main/java/ctbrec/StringUtil.java b/common/src/main/java/ctbrec/StringUtil.java
index 229e44b1..d9ae9796 100644
--- a/common/src/main/java/ctbrec/StringUtil.java
+++ b/common/src/main/java/ctbrec/StringUtil.java
@@ -1,5 +1,7 @@
package ctbrec;
+import java.text.DecimalFormat;
+
public class StringUtil {
public static boolean isBlank(String s) {
return s == null || s.trim().isEmpty();
@@ -8,4 +10,21 @@ public class StringUtil {
public static boolean isNotBlank(String s) {
return !isBlank(s);
}
+
+ public static String formatSize(Number sizeInByte) {
+ DecimalFormat df = new DecimalFormat("0.00");
+ String unit = "Bytes";
+ double size = sizeInByte.doubleValue();
+ if(size > 1024.0 * 1024 * 1024) {
+ size = size / 1024.0 / 1024 / 1024;
+ unit = "GiB";
+ } else if(size > 1024.0 * 1024) {
+ size = size / 1024.0 / 1024;
+ unit = "MiB";
+ } else if(size > 1024.0) {
+ size = size / 1024.0;
+ unit = "KiB";
+ }
+ return df.format(size) + ' ' + unit;
+ }
}
diff --git a/common/src/main/java/ctbrec/event/AbstractModelEvent.java b/common/src/main/java/ctbrec/event/AbstractModelEvent.java
new file mode 100644
index 00000000..51da54e7
--- /dev/null
+++ b/common/src/main/java/ctbrec/event/AbstractModelEvent.java
@@ -0,0 +1,16 @@
+package ctbrec.event;
+
+import ctbrec.Model;
+
+public abstract class AbstractModelEvent extends Event {
+
+ protected Model model;
+
+ public Model getModel() {
+ return model;
+ }
+
+ public void setModel(Model model) {
+ this.model = model;
+ }
+}
diff --git a/common/src/main/java/ctbrec/event/Action.java b/common/src/main/java/ctbrec/event/Action.java
new file mode 100644
index 00000000..67ccf08e
--- /dev/null
+++ b/common/src/main/java/ctbrec/event/Action.java
@@ -0,0 +1,25 @@
+package ctbrec.event;
+
+import java.util.function.Consumer;
+
+import ctbrec.event.EventHandlerConfiguration.ActionConfiguration;
+
+public abstract class Action implements Consumer {
+
+ protected String name;
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public abstract void configure(ActionConfiguration config) throws Exception;
+
+ @Override
+ public String toString() {
+ return name;
+ }
+}
diff --git a/common/src/main/java/ctbrec/event/Event.java b/common/src/main/java/ctbrec/event/Event.java
new file mode 100644
index 00000000..9e67d032
--- /dev/null
+++ b/common/src/main/java/ctbrec/event/Event.java
@@ -0,0 +1,39 @@
+package ctbrec.event;
+
+public abstract class Event {
+
+ public static enum Type {
+ /**
+ * This event is fired every time the OnlineMonitor sees a model online
+ * It is also fired, if the model was online before. You can see it as a "still online ping".
+ */
+ MODEL_ONLINE("model is online"),
+
+ /**
+ * This event is fired whenever the model's online state (Model.STATUS) changes.
+ */
+ MODEL_STATUS_CHANGED("model status changed"),
+
+
+ /**
+ * This event is fired whenever the state of a recording changes.
+ */
+ RECORDING_STATUS_CHANGED("recording status changed");
+
+ private String desc;
+
+ Type(String desc) {
+ this.desc = desc;
+ }
+
+ @Override
+ public String toString() {
+ return desc;
+ }
+ }
+
+ public abstract Type getType();
+ public abstract String getName();
+ public abstract String getDescription();
+ public abstract String[] getExecutionParams();
+}
diff --git a/common/src/main/java/ctbrec/event/EventBusHolder.java b/common/src/main/java/ctbrec/event/EventBusHolder.java
new file mode 100644
index 00000000..6be9ce37
--- /dev/null
+++ b/common/src/main/java/ctbrec/event/EventBusHolder.java
@@ -0,0 +1,37 @@
+package ctbrec.event;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.Executors;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.common.eventbus.AsyncEventBus;
+import com.google.common.eventbus.EventBus;
+
+public class EventBusHolder {
+ private static final transient Logger LOG = LoggerFactory.getLogger(EventBusHolder.class);
+ private static Map handlers = new HashMap<>();
+
+ public static final EventBus BUS = new AsyncEventBus(Executors.newFixedThreadPool(10));
+
+ public static void register(EventHandler handler) {
+ if(handlers.containsKey(handler.getId())) {
+ LOG.warn("EventHandler with ID {} is already registered", handler.getId());
+ } else {
+ BUS.register(handler);
+ handlers.put(handler.getId(), handler);
+ LOG.debug("EventHandler with ID {} has been added", handler.getId());
+ }
+ }
+
+ public static void unregister(String id) {
+ EventHandler handler = handlers.get(id);
+ if(handler != null) {
+ BUS.unregister(handler);
+ handlers.remove(id);
+ LOG.debug("EventHandler with ID {} has been removed", id);
+ }
+ }
+}
diff --git a/common/src/main/java/ctbrec/event/EventHandler.java b/common/src/main/java/ctbrec/event/EventHandler.java
new file mode 100644
index 00000000..4afd09a2
--- /dev/null
+++ b/common/src/main/java/ctbrec/event/EventHandler.java
@@ -0,0 +1,137 @@
+package ctbrec.event;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.common.eventbus.Subscribe;
+
+import ctbrec.event.Event.Type;
+import ctbrec.event.EventHandlerConfiguration.ActionConfiguration;
+import ctbrec.event.EventHandlerConfiguration.PredicateConfiguration;
+
+public class EventHandler {
+ private static final transient Logger LOG = LoggerFactory.getLogger(EventHandler.class);
+
+ private List predicates = new ArrayList<>();
+ private List actions;
+ private Type event;
+ private String id;
+
+ public EventHandler(EventHandlerConfiguration config) {
+ id = config.getId();
+ event = config.getEvent();
+ actions = createActions(config);
+ predicates = createPredicates(config);
+ predicates.add(new EventTypePredicate(event));
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ @SafeVarargs
+ public EventHandler(Action action, EventPredicate... predicates) {
+ this(Collections.singletonList(action), predicates);
+ }
+
+ @SafeVarargs
+ public EventHandler(List actions, EventPredicate... predicates) {
+ this.actions = actions;
+ for (EventPredicate predicate : predicates) {
+ this.predicates.add(predicate);
+ }
+ }
+
+ @Subscribe
+ public void reactToEvent(Event evt) {
+ try {
+ boolean matches = true;
+ for (Predicate predicate : predicates) {
+ if(!predicate.test(evt)) {
+ matches = false;
+ }
+ }
+ if(matches) {
+ for (Consumer action : actions) {
+ action.accept(evt);
+ }
+ }
+ } catch(Exception e) {
+ LOG.error("Error while processing event", e);
+ }
+ }
+
+ private List createPredicates(EventHandlerConfiguration config) {
+ List predicates = new ArrayList<>(config.getPredicates().size());
+ for (PredicateConfiguration pc : config.getPredicates()) {
+
+ try {
+ @SuppressWarnings("unchecked")
+ Class cls = (Class) Class.forName(pc.getType());
+ if(cls == null) {
+ LOG.warn("Ignoring unknown action {}", cls);
+ continue;
+ }
+ EventPredicate predicate = cls.newInstance();
+ predicate.configure(pc);
+ predicates.add(predicate);
+ } catch (InstantiationException | IllegalAccessException | ClassNotFoundException e) {
+ LOG.warn("Error while creating action {} {}", pc.getType(), pc.getConfiguration(), e);
+ }
+ }
+ return predicates;
+ }
+
+ private List createActions(EventHandlerConfiguration config) {
+ List actions = new ArrayList<>(config.getActions().size());
+ for (ActionConfiguration ac : config.getActions()) {
+ try {
+ @SuppressWarnings("unchecked")
+ Class cls = (Class) Class.forName(ac.getType());
+ if(cls == null) {
+ LOG.warn("Ignoring unknown action {}", cls);
+ continue;
+ }
+ Action action = cls.newInstance();
+ action.configure(ac);
+ actions.add(action);
+ } catch (Exception e) {
+ LOG.warn("Error while creating action {} {}", ac.getType(), ac.getConfiguration(), e);
+ }
+ }
+ return actions;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + ((id == null) ? 0 : id.hashCode());
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj)
+ return true;
+ if (obj == null)
+ return false;
+ if (getClass() != obj.getClass())
+ return false;
+ EventHandler other = (EventHandler) obj;
+ if (id == null) {
+ if (other.id != null)
+ return false;
+ } else if (!id.equals(other.id))
+ return false;
+ return true;
+ }
+
+
+}
diff --git a/common/src/main/java/ctbrec/event/EventHandlerConfiguration.java b/common/src/main/java/ctbrec/event/EventHandlerConfiguration.java
new file mode 100644
index 00000000..f3d7df81
--- /dev/null
+++ b/common/src/main/java/ctbrec/event/EventHandlerConfiguration.java
@@ -0,0 +1,163 @@
+package ctbrec.event;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import ctbrec.Model;
+
+public class EventHandlerConfiguration {
+
+ private String id;
+ private String name;
+ private Event.Type event;
+ private List predicates = new ArrayList<>();
+ private List actions = new ArrayList<>();
+
+ public EventHandlerConfiguration() {
+ id = UUID.randomUUID().toString();
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ public Event.Type getEvent() {
+ return event;
+ }
+
+ public void setEvent(Event.Type event) {
+ this.event = event;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public List getPredicates() {
+ return predicates;
+ }
+
+ public void setPredicates(List predicates) {
+ this.predicates = predicates;
+ }
+
+ public List