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 createRecordingsDirectoryFocusListener() { - return new ChangeListener() { - @Override - public void changed(ObservableValue 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 createMpvFocusListener() { - return new ChangeListener() { - @Override - public void changed(ObservableValue 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 createPostProcessingFocusListener() { - return new ChangeListener() { - @Override - public void changed(ObservableValue 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 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 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 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 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 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 models; + protected Consumer action; + protected Node source; + + protected ModelMassEditAction(Node source, List models) { + this.source = source; + this.models = models; + } + + public ModelMassEditAction(Node source, List 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 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 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 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 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 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 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 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> 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 getActions() { + return actions; + } + + public void setActions(List actions) { + this.actions = actions; + } + + public static class PredicateConfiguration { + private String name; + private String type; + private List models; + private Map configuration = new HashMap<>(); + + public void setName(String name) { + this.name = name; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public Map getConfiguration() { + return configuration; + } + + public void setConfiguration(Map configuration) { + this.configuration = configuration; + } + + public List getModels() { + return models; + } + + public void setModels(List models) { + this.models = models; + } + + @Override + public String toString() { + return name; + } + } + + public static class ActionConfiguration { + private String name; + private String type; + private Map configuration = new HashMap<>(); + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public Map getConfiguration() { + return configuration; + } + + public void setConfiguration(Map configuration) { + this.configuration = configuration; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public String toString() { + return name; + } + } + + @Override + public String toString() { + return name + ", when:" + predicates + " do:" + 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; + EventHandlerConfiguration other = (EventHandlerConfiguration) 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/EventPredicate.java b/common/src/main/java/ctbrec/event/EventPredicate.java new file mode 100644 index 00000000..298ffb48 --- /dev/null +++ b/common/src/main/java/ctbrec/event/EventPredicate.java @@ -0,0 +1,10 @@ +package ctbrec.event; + +import java.util.function.Predicate; + +import ctbrec.event.EventHandlerConfiguration.PredicateConfiguration; + +public abstract class EventPredicate implements Predicate { + + public abstract void configure(PredicateConfiguration pc); +} diff --git a/common/src/main/java/ctbrec/event/EventTypePredicate.java b/common/src/main/java/ctbrec/event/EventTypePredicate.java new file mode 100644 index 00000000..35135122 --- /dev/null +++ b/common/src/main/java/ctbrec/event/EventTypePredicate.java @@ -0,0 +1,26 @@ +package ctbrec.event; + +import ctbrec.event.Event.Type; +import ctbrec.event.EventHandlerConfiguration.PredicateConfiguration; + +public class EventTypePredicate extends EventPredicate { + + private Type type; + + public EventTypePredicate() { + } + + public EventTypePredicate(Type type) { + this.type = type; + } + + @Override + public boolean test(Event evt) { + return evt.getType() == type; + } + + @Override + public void configure(PredicateConfiguration pc) { + type = Type.valueOf((String) pc.getConfiguration().get("type")); + } +} diff --git a/common/src/main/java/ctbrec/event/ExecuteProgram.java b/common/src/main/java/ctbrec/event/ExecuteProgram.java new file mode 100644 index 00000000..5bb2b321 --- /dev/null +++ b/common/src/main/java/ctbrec/event/ExecuteProgram.java @@ -0,0 +1,66 @@ +package ctbrec.event; + +import java.util.Arrays; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.OS; +import ctbrec.event.EventHandlerConfiguration.ActionConfiguration; +import ctbrec.io.StreamRedirectThread; + +public class ExecuteProgram extends Action { + + private static final transient Logger LOG = LoggerFactory.getLogger(ExecuteProgram.class); + + private String executable; + + public ExecuteProgram() { + name = "execute program"; + } + + public ExecuteProgram(String executable) { + this(); + this.executable = executable; + } + + @Override + public void accept(Event evt) { + Runtime rt = Runtime.getRuntime(); + Process process = null; + try { + String[] args = evt.getExecutionParams(); + String[] cmd = new String[args.length+1]; + cmd[0] = executable; + System.arraycopy(args, 0, cmd, 1, args.length); + LOG.debug("Executing {}", Arrays.toString(cmd)); + process = rt.exec(cmd, OS.getEnvironment()); + + // 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(process.getInputStream(), System.out)); + std.setName("Player stdout pipe"); + std.setDaemon(true); + std.start(); + Thread err = new Thread(new StreamRedirectThread(process.getErrorStream(), System.err)); + err.setName("Player stderr pipe"); + err.setDaemon(true); + err.start(); + + process.waitFor(); + LOG.debug("Executing {} finished", executable); + } catch (Exception e) { + LOG.error("Error while executing {}", executable, e); + } + } + + @Override + public void configure(ActionConfiguration config) { + executable = (String) config.getConfiguration().get("file"); + } + + @Override + public String toString() { + return "execute " + executable; + } +} diff --git a/common/src/main/java/ctbrec/event/ModelIsOnlineEvent.java b/common/src/main/java/ctbrec/event/ModelIsOnlineEvent.java new file mode 100644 index 00000000..d93e7d5d --- /dev/null +++ b/common/src/main/java/ctbrec/event/ModelIsOnlineEvent.java @@ -0,0 +1,36 @@ +package ctbrec.event; + +import ctbrec.Model; + +public class ModelIsOnlineEvent extends AbstractModelEvent { + + public ModelIsOnlineEvent(Model model) { + super.model = model; + } + + @Override + public Type getType() { + return Event.Type.MODEL_ONLINE; + } + + @Override + public String getName() { + return "Model is online"; + } + + @Override + public String getDescription() { + return "Repeatedly fired when a model is online"; + } + + @Override + public String[] getExecutionParams() { + return new String[] { + getType().toString(), + model.getDisplayName(), + model.getUrl(), + model.getSite().getName() + }; + } + +} diff --git a/common/src/main/java/ctbrec/event/ModelPredicate.java b/common/src/main/java/ctbrec/event/ModelPredicate.java new file mode 100644 index 00000000..d573ab6f --- /dev/null +++ b/common/src/main/java/ctbrec/event/ModelPredicate.java @@ -0,0 +1,57 @@ +package ctbrec.event; + +import java.util.List; +import java.util.Objects; +import java.util.function.Predicate; + +import ctbrec.Model; +import ctbrec.event.EventHandlerConfiguration.PredicateConfiguration; + +public class ModelPredicate extends EventPredicate { + + private Predicate internal; + + public ModelPredicate() {} + + public ModelPredicate(Model model) { + internal = createFor(model); + } + + public ModelPredicate(List models) { + configure(models); + } + + private void configure(List models) { + if(models.isEmpty()) { + throw new IllegalArgumentException("List has to contain at least one model"); + } + + Predicate predicate = createFor(models.get(0)); + for (int i = 1; i < models.size(); i++) { + predicate = predicate.or(createFor(models.get(i))); + } + internal = predicate; + } + + @Override + public boolean test(Event evt) { + return internal.test(evt); + } + + private Predicate createFor(Model model) { + return evt -> { + if(evt instanceof AbstractModelEvent) { + AbstractModelEvent modelEvent = (AbstractModelEvent) evt; + Model other = modelEvent.getModel(); + return Objects.equals(model, other); + } else { + return false; + } + }; + } + + @Override + public void configure(PredicateConfiguration pc) { + configure(pc.getModels()); + } +} diff --git a/common/src/main/java/ctbrec/event/ModelStateChangedEvent.java b/common/src/main/java/ctbrec/event/ModelStateChangedEvent.java new file mode 100644 index 00000000..1f79556b --- /dev/null +++ b/common/src/main/java/ctbrec/event/ModelStateChangedEvent.java @@ -0,0 +1,59 @@ +package ctbrec.event; + +import ctbrec.Model; +import ctbrec.Model.State; + +public class ModelStateChangedEvent extends AbstractModelEvent { + + private State oldState; + private State newState; + + public ModelStateChangedEvent(Model model, Model.State oldState, Model.State newState) { + super.model = model; + this.oldState = oldState; + this.newState = newState; + } + + @Override + public Type getType() { + return Event.Type.MODEL_STATUS_CHANGED; + } + + @Override + public String getName() { + return "Model state changed"; + } + + @Override + public String getDescription() { + return "Fired when a model state changed. E.g. from OFFLINE to ONLINE"; + } + + @Override + public String[] getExecutionParams() { + return new String[] { + getType().name(), + model.getDisplayName(), + model.getUrl(), + model.getSite().getName(), + oldState.name(), + newState.name() + }; + } + + public State getOldState() { + return oldState; + } + + public void setOldState(State oldState) { + this.oldState = oldState; + } + + public State getNewState() { + return newState; + } + + public void setNewState(State newState) { + this.newState = newState; + } +} diff --git a/common/src/main/java/ctbrec/event/ModelStatePredicate.java b/common/src/main/java/ctbrec/event/ModelStatePredicate.java new file mode 100644 index 00000000..9df7b97a --- /dev/null +++ b/common/src/main/java/ctbrec/event/ModelStatePredicate.java @@ -0,0 +1,32 @@ +package ctbrec.event; + +import ctbrec.Model; +import ctbrec.Model.State; +import ctbrec.event.EventHandlerConfiguration.PredicateConfiguration; + +public class ModelStatePredicate extends EventPredicate { + + private Model.State state; + + public ModelStatePredicate() {} + + public ModelStatePredicate(Model.State state) { + this.state = state; + } + + @Override + public boolean test(Event evt) { + if(evt instanceof ModelStateChangedEvent) { + ModelStateChangedEvent modelEvent = (ModelStateChangedEvent) evt; + Model.State newState = modelEvent.getNewState(); + return newState == state; + } else { + return false; + } + } + + @Override + public void configure(PredicateConfiguration pc) { + state = State.valueOf((String) pc.getConfiguration().get("state")); + } +} diff --git a/common/src/main/java/ctbrec/event/RecordingStateChangedEvent.java b/common/src/main/java/ctbrec/event/RecordingStateChangedEvent.java new file mode 100644 index 00000000..ad8deb00 --- /dev/null +++ b/common/src/main/java/ctbrec/event/RecordingStateChangedEvent.java @@ -0,0 +1,59 @@ +package ctbrec.event; + +import java.io.File; +import java.time.Instant; + +import ctbrec.Model; +import ctbrec.Recording.State; + +public class RecordingStateChangedEvent extends AbstractModelEvent { + + private File path; + private State newState; + private Instant startTime; + + public RecordingStateChangedEvent(File recording, State newState, Model model, Instant startTime) { + super.model = model; + this.path = recording; + this.newState = newState; + this.startTime = startTime; + } + + @Override + public Type getType() { + return Event.Type.RECORDING_STATUS_CHANGED; + } + + @Override + public String getName() { + return "Recording state changed"; + } + + @Override + public String getDescription() { + return "Fired when a recording state changed. E.g. from RECORDING to STOPPED"; + } + + @Override + public String[] getExecutionParams() { + return new String[] { + getType().name(), + path.getAbsolutePath(), + newState.name(), + model.getDisplayName(), + model.getSite().getName(), + model.getUrl(), + Long.toString(startTime.getEpochSecond()) + }; + } + + public State getState() { + return newState; + } + + @Override + public String toString() { + return "RecordingStateChanged[" + newState.name() + "," + model.getDisplayName() + "," + path + "]"; + } + +} diff --git a/common/src/main/java/ctbrec/event/RecordingStatePredicate.java b/common/src/main/java/ctbrec/event/RecordingStatePredicate.java new file mode 100644 index 00000000..bad65223 --- /dev/null +++ b/common/src/main/java/ctbrec/event/RecordingStatePredicate.java @@ -0,0 +1,31 @@ +package ctbrec.event; + +import ctbrec.Recording; +import ctbrec.event.EventHandlerConfiguration.PredicateConfiguration; + +public class RecordingStatePredicate extends EventPredicate { + + private Recording.State state; + + public RecordingStatePredicate() {} + + public RecordingStatePredicate(Recording.State state) { + this.state = state; + } + + @Override + public boolean test(Event evt) { + if(evt instanceof RecordingStateChangedEvent) { + RecordingStateChangedEvent event = (RecordingStateChangedEvent) evt; + Recording.State newState = event.getState(); + return newState == state; + } else { + return false; + } + } + + @Override + public void configure(PredicateConfiguration pc) { + state = Recording.State.valueOf((String) pc.getConfiguration().get("state")); + } +} diff --git a/common/src/main/java/ctbrec/io/CookieJarImpl.java b/common/src/main/java/ctbrec/io/CookieJarImpl.java index deaaab2c..69192ecb 100644 --- a/common/src/main/java/ctbrec/io/CookieJarImpl.java +++ b/common/src/main/java/ctbrec/io/CookieJarImpl.java @@ -92,4 +92,8 @@ public class CookieJarImpl implements CookieJar { public Map> getCookies() { return cookieStore; } + + public void clear() { + cookieStore.clear(); + } } diff --git a/common/src/main/java/ctbrec/io/HttpClient.java b/common/src/main/java/ctbrec/io/HttpClient.java index 2cc40372..5b2d8d9c 100644 --- a/common/src/main/java/ctbrec/io/HttpClient.java +++ b/common/src/main/java/ctbrec/io/HttpClient.java @@ -23,7 +23,6 @@ import ctbrec.Config; import ctbrec.Settings.ProxyType; import okhttp3.ConnectionPool; import okhttp3.Cookie; -import okhttp3.CookieJar; import okhttp3.Credentials; import okhttp3.OkHttpClient; import okhttp3.OkHttpClient.Builder; @@ -214,10 +213,15 @@ public abstract class HttpClient { } } - public CookieJar getCookieJar() { + public CookieJarImpl getCookieJar() { return cookieJar; } + public void logout() { + getCookieJar().clear(); + loggedIn = false; + } + public WebSocket newWebSocket(String url, WebSocketListener l) { Request request = new Request.Builder().url(url).build(); return client.newWebSocket(request, l); diff --git a/common/src/main/java/ctbrec/io/StreamRedirectThread.java b/common/src/main/java/ctbrec/io/StreamRedirectThread.java index 07d88485..2ff52c9c 100644 --- a/common/src/main/java/ctbrec/io/StreamRedirectThread.java +++ b/common/src/main/java/ctbrec/io/StreamRedirectThread.java @@ -26,7 +26,7 @@ public class StreamRedirectThread implements Runnable { while(in != null && (length = in.read(buffer)) >= 0) { out.write(buffer, 0, length); } - LOG.debug("Stream redirect thread ended"); + LOG.trace("Stream redirect thread ended"); } catch(Exception e) { LOG.error("Couldn't redirect stream: {}", e.getLocalizedMessage()); } diff --git a/common/src/main/java/ctbrec/io/XmlParserUtils.java b/common/src/main/java/ctbrec/io/XmlParserUtils.java new file mode 100644 index 00000000..a1ac9cf6 --- /dev/null +++ b/common/src/main/java/ctbrec/io/XmlParserUtils.java @@ -0,0 +1,117 @@ +package ctbrec.io; + +import java.io.IOException; +import java.io.StringReader; +import java.util.List; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.xpath.XPath; +import javax.xml.xpath.XPathConstants; +import javax.xml.xpath.XPathExpressionException; +import javax.xml.xpath.XPathFactory; + +import org.w3c.dom.Document; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; + +public class XmlParserUtils { + + public static Document parse(String xml) throws ParserConfigurationException, SAXException, IOException { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + DocumentBuilder builder = factory.newDocumentBuilder(); + return builder.parse(new InputSource(new StringReader(xml))); + } + + public static Node getFirstElementByTagName(Document doc, String tagName) { + NodeList list = doc.getElementsByTagName(tagName); + if (list.getLength() > 0) { + return list.item(0); + } else { + return null; + } + } + + public static String getTextContent(Document doc, String tagName) { + Node node = getFirstElementByTagName(doc, tagName); + if (node != null) { + return node.getTextContent(); + } else { + return null; + } + } + + public static String getTextContent(Node parent, String tagName) { + Node node = findChildWithTagName(parent, tagName); + if (node != null) { + return node.getTextContent(); + } else { + return null; + } + } + + public static Node findChildWithTagName(Node parent, String tagName) { + if (parent == null) { + return null; + } + + NodeList childs = parent.getChildNodes(); + for (int i = 0; i < childs.getLength(); i++) { + Node child = childs.item(i); + if (child.getNodeName().equals(tagName)) { + return child; + } else if (child.hasChildNodes()) { + Node result = findChildWithTagName(child, tagName); + if (result != null) { + return result; + } + } + } + + return null; + } + + public static void getElementsByTagName(Node parent, String tagName, List result) { + if (parent == null) { + return; + } + + NodeList childs = parent.getChildNodes(); + for (int i = 0; i < childs.getLength(); i++) { + Node child = childs.item(i); + if (child.getNodeName().equals(tagName)) { + result.add(child); + } else if (child.hasChildNodes()) { + getElementsByTagName(child, tagName, result); + } + } + } + + public static String getStringWithXpath(String xml, String xpath) throws XPathExpressionException { + XPath xp = XPathFactory.newInstance().newXPath(); + return xp.evaluate(xpath, new InputSource(new StringReader(xml))); + } + + public static String getStringWithXpath(Node node, String xpath) throws XPathExpressionException { + XPath xp = XPathFactory.newInstance().newXPath(); + return xp.evaluate(xpath, node); + } + + public static Node getNodeWithXpath(String xml, String xpath) throws XPathExpressionException { + XPath xp = XPathFactory.newInstance().newXPath(); + return (Node) xp.evaluate(xpath, new InputSource(new StringReader(xml)), XPathConstants.NODE); + } + + public static Node getNodeWithXpath(Node node, String xpath) throws XPathExpressionException { + XPath xp = XPathFactory.newInstance().newXPath(); + return (Node) xp.evaluate(xpath, node, XPathConstants.NODE); + } + + public static NodeList getNodeListWithXpath(String xml, String xpath) throws XPathExpressionException { + XPath xp = XPathFactory.newInstance().newXPath(); + return (NodeList) xp.evaluate(xpath, new InputSource(new StringReader(xml)), XPathConstants.NODESET); + } +} diff --git a/common/src/main/java/ctbrec/recorder/LocalRecorder.java b/common/src/main/java/ctbrec/recorder/LocalRecorder.java index 381a276a..5dafee65 100644 --- a/common/src/main/java/ctbrec/recorder/LocalRecorder.java +++ b/common/src/main/java/ctbrec/recorder/LocalRecorder.java @@ -1,14 +1,19 @@ package ctbrec.recorder; -import static ctbrec.Recording.STATUS.*; +import static ctbrec.Recording.State.*; +import static ctbrec.event.Event.Type.*; import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.FilenameFilter; import java.io.IOException; +import java.nio.file.FileStore; import java.nio.file.Files; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.text.SimpleDateFormat; +import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; @@ -22,22 +27,37 @@ import java.util.Map; import java.util.Map.Entry; import java.util.NoSuchElementException; import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.common.eventbus.Subscribe; +import com.iheartradio.m3u8.Encoding; +import com.iheartradio.m3u8.Format; import com.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.ParsingMode; import com.iheartradio.m3u8.PlaylistException; +import com.iheartradio.m3u8.PlaylistParser; +import com.iheartradio.m3u8.data.MediaPlaylist; +import com.iheartradio.m3u8.data.Playlist; +import com.iheartradio.m3u8.data.TrackData; import ctbrec.Config; import ctbrec.Model; +import ctbrec.MpegUtil; import ctbrec.OS; import ctbrec.Recording; -import ctbrec.Recording.STATUS; +import ctbrec.Recording.State; +import ctbrec.event.Event; +import ctbrec.event.EventBusHolder; +import ctbrec.event.ModelIsOnlineEvent; +import ctbrec.event.RecordingStateChangedEvent; import ctbrec.io.HttpClient; -import ctbrec.io.HttpException; import ctbrec.io.StreamRedirectThread; import ctbrec.recorder.PlaylistGenerator.InvalidPlaylistException; import ctbrec.recorder.download.Download; @@ -55,12 +75,13 @@ public class LocalRecorder implements Recorder { private Map playlistGenerators = new HashMap<>(); private Config config; private ProcessMonitor processMonitor; - private OnlineMonitor onlineMonitor; - private PostProcessingTrigger postProcessingTrigger; private volatile boolean recording = true; private List deleteInProgress = Collections.synchronizedList(new ArrayList<>()); private RecorderHttpClient client = new RecorderHttpClient(); private ReentrantLock lock = new ReentrantLock(); + private long lastSpaceMessage = 0; + + private ExecutorService ppThreadPool = Executors.newFixedThreadPool(2); public LocalRecorder(Config config) { this.config = config; @@ -75,12 +96,10 @@ public class LocalRecorder implements Recorder { recording = true; processMonitor = new ProcessMonitor(); processMonitor.start(); - onlineMonitor = new OnlineMonitor(); - onlineMonitor.start(); - postProcessingTrigger = new PostProcessingTrigger(); - if(Config.getInstance().isServerMode()) { - postProcessingTrigger.start(); + registerEventBusListener(); + if(Config.isServerMode()) { + processUnfinishedRecordings(); } LOG.debug("Recorder initialized"); @@ -88,6 +107,25 @@ public class LocalRecorder implements Recorder { LOG.info("Saving recordings in {}", config.getSettings().recordingsDir); } + private void registerEventBusListener() { + EventBusHolder.BUS.register(new Object() { + @Subscribe + public void modelEvent(Event e) { + try { + if (e.getType() == MODEL_ONLINE) { + ModelIsOnlineEvent evt = (ModelIsOnlineEvent) e; + Model model = evt.getModel(); + if(!isSuspended(model) && !recordingProcesses.containsKey(model)) { + startRecordingProcess(model); + } + } + } catch (Exception e1) { + LOG.error("Error while handling model state changed event", e); + } + } + }); + } + @Override public void startRecording(Model model) { if (!models.contains(model)) { @@ -96,6 +134,9 @@ public class LocalRecorder implements Recorder { try { models.add(model); config.getSettings().models.add(model); + config.save(); + } catch (IOException e) { + LOG.error("Couldn't save config", e); } finally { lock.unlock(); } @@ -113,6 +154,7 @@ public class LocalRecorder implements Recorder { stopRecordingProcess(model); } LOG.info("Model {} removed", model); + config.save(); } else { throw new NoSuchElementException("Model " + model.getName() + " ["+model.getUrl()+"] not found in list of recorded models"); } @@ -127,7 +169,6 @@ public class LocalRecorder implements Recorder { return; } - LOG.debug("Starting recording for model {}", model.getName()); if (recordingProcesses.containsKey(model)) { LOG.error("A recording for model {} is already running", model); return; @@ -143,8 +184,18 @@ public class LocalRecorder implements Recorder { lock.unlock(); } + if(!enoughSpaceForRecording()) { + long now = System.currentTimeMillis(); + if( (now - lastSpaceMessage) > TimeUnit.MINUTES.toMillis(1)) { + LOG.info("Not enough space for recording, not starting recording for {}", model); + lastSpaceMessage = now; + } + return; + } + + LOG.debug("Starting recording for model {}", model.getName()); Download download; - if (Config.getInstance().isServerMode()) { + if (Config.isServerMode()) { download = new HlsDownload(client); } else { download = new MergedHlsDownload(client); @@ -165,48 +216,46 @@ public class LocalRecorder implements Recorder { private void stopRecordingProcess(Model model) { Download download = recordingProcesses.get(model); - download.stop(); recordingProcesses.remove(model); - if(!Config.getInstance().isServerMode()) { - postprocess(download); - } + fireRecordingStateChanged(download.getTarget(), STOPPED, model, download.getStartTime()); + + Runnable stopAndThePostProcess = () -> { + download.stop(); + createPostProcessor(download).run(); + }; + ppThreadPool.submit(stopAndThePostProcess); } private void postprocess(Download download) { - if(!(download instanceof MergedHlsDownload)) { - throw new IllegalArgumentException("Download should be of type MergedHlsDownload"); - } String postProcessing = Config.getInstance().getSettings().postProcessing; if (postProcessing != null && !postProcessing.isEmpty()) { - new Thread(() -> { - Runtime rt = Runtime.getRuntime(); - try { - MergedHlsDownload d = (MergedHlsDownload) download; - String[] args = new String[] { - postProcessing, - d.getTarget().getParentFile().getAbsolutePath(), - d.getTarget().getAbsolutePath(), - d.getModel().getName(), - d.getModel().getSite().getName(), - Long.toString(download.getStartTime().getEpochSecond()) - }; - LOG.debug("Running {}", Arrays.toString(args)); - Process process = rt.exec(args, OS.getEnvironment()); - Thread std = new Thread(new StreamRedirectThread(process.getInputStream(), System.out)); - std.setName("Process stdout pipe"); - std.setDaemon(true); - std.start(); - Thread err = new Thread(new StreamRedirectThread(process.getErrorStream(), System.err)); - err.setName("Process stderr pipe"); - err.setDaemon(true); - err.start(); + Runtime rt = Runtime.getRuntime(); + try { + String[] args = new String[] { + postProcessing, + download.getTarget().getParentFile().getAbsolutePath(), + download.getTarget().getAbsolutePath(), + download.getModel().getName(), + download.getModel().getSite().getName(), + Long.toString(download.getStartTime().getEpochSecond()) + }; + LOG.debug("Running {}", Arrays.toString(args)); + Process process = rt.exec(args, OS.getEnvironment()); + // TODO maybe write these to a separate log file, e.g. recname.ts.pp.log + Thread std = new Thread(new StreamRedirectThread(process.getInputStream(), System.out)); + std.setName("Process stdout pipe"); + std.setDaemon(true); + std.start(); + Thread err = new Thread(new StreamRedirectThread(process.getErrorStream(), System.err)); + err.setName("Process stderr pipe"); + err.setDaemon(true); + err.start(); - process.waitFor(); - LOG.debug("Process finished."); - } catch (Exception e) { - LOG.error("Error in process thread", e); - } - }).start(); + process.waitFor(); + LOG.debug("Process finished."); + } catch (Exception e) { + LOG.error("Error in process thread", e); + } } } @@ -265,11 +314,10 @@ public class LocalRecorder implements Recorder { LOG.info("Shutting down"); recording = false; LOG.debug("Stopping monitor threads"); - onlineMonitor.running = false; processMonitor.running = false; - postProcessingTrigger.running = false; LOG.debug("Stopping all recording processes"); stopRecordingProcesses(); + ppThreadPool.shutdown(); client.shutdown(); } @@ -279,12 +327,7 @@ public class LocalRecorder implements Recorder { for (Model model : models) { Download recordingProcess = recordingProcesses.get(model); if (recordingProcess != null) { - try { - recordingProcess.stop(); - LOG.debug("Stopped recording for {}", model); - } catch (Exception e) { - LOG.error("Couldn't stop recording for model {}", model, e); - } + stopRecordingProcess(model); } } } finally { @@ -323,24 +366,27 @@ public class LocalRecorder implements Recorder { public void run() { running = true; while (running) { + try { + if(!enoughSpaceForRecording() && !recordingProcesses.isEmpty()) { + LOG.info("No space left -> Stopping all recordings"); + stopRecordingProcesses(); + } + } catch (IOException e1) { + LOG.warn("Couldn't check free space left", e1); + } + List restart = new ArrayList<>(); for (Iterator> iterator = recordingProcesses.entrySet().iterator(); iterator.hasNext();) { Entry entry = iterator.next(); Model m = entry.getKey(); - Download d = entry.getValue(); - if (!d.isAlive()) { + Download download = entry.getValue(); + if (!download.isAlive()) { LOG.debug("Recording terminated for model {}", m.getName()); iterator.remove(); restart.add(m); - if(config.isServerMode()) { - try { - finishRecording(d.getTarget()); - } catch(Exception e) { - LOG.error("Error while finishing recording for model {}", m.getName(), e); - } - } else { - postprocess(d); - } + fireRecordingStateChanged(download.getTarget(), STOPPED, m, download.getStartTime()); + Runnable pp = createPostProcessor(download); + ppThreadPool.submit(pp); } } for (Model m : restart) { @@ -358,20 +404,6 @@ public class LocalRecorder implements Recorder { } } - private void finishRecording(File directory) { - if(Config.getInstance().isServerMode()) { - Thread t = new Thread() { - @Override - public void run() { - generatePlaylist(directory); - } - }; - t.setDaemon(true); - t.setName("Post-Processing " + directory.toString()); - t.start(); - } - } - private void generatePlaylist(File recDir) { PlaylistGenerator playlistGenerator = new PlaylistGenerator(); playlistGenerators.put(recDir, playlistGenerator); @@ -391,96 +423,43 @@ public class LocalRecorder implements Recorder { } } - private class OnlineMonitor extends Thread { - private volatile boolean running = false; - - public OnlineMonitor() { - setName("OnlineMonitor"); - setDaemon(true); - } - - @Override - public void run() { - running = true; - while (running) { - for (Model model : getModelsRecording()) { - try { - if (!model.isSuspended() && !recordingProcesses.containsKey(model)) { - boolean isOnline = model.isOnline(IGNORE_CACHE); - LOG.trace("Checking online state for {}: {}", model, (isOnline ? "online" : "offline")); - if (isOnline) { - LOG.info("Model {}'s room back to public. Starting recording", model); - startRecordingProcess(model); - } - } - } catch (HttpException e) { - LOG.error("Couldn't check if model {} is online. HTTP Response: {} - {}", - model.getName(), e.getResponseCode(), e.getResponseMessage()); - } catch (Exception e) { - LOG.error("Couldn't check if model {} is online", model.getName(), e); - } - } - - try { - if (running) - Thread.sleep(10000); - } catch (InterruptedException e) { - LOG.trace("Sleep interrupted"); - } - } - LOG.debug(getName() + " terminated"); - } + private void fireRecordingStateChanged(File path, Recording.State newState, Model model, Instant startTime) { + RecordingStateChangedEvent evt = new RecordingStateChangedEvent(path, newState, model, startTime); + EventBusHolder.BUS.post(evt); } - private class PostProcessingTrigger extends Thread { - private volatile boolean running = false; - - public PostProcessingTrigger() { - setName("PostProcessingTrigger"); - setDaemon(true); - } - - @Override - public void run() { - running = true; - while (running) { - try { - List recs = getRecordings(); - for (Recording rec : recs) { - if (rec.getStatus() == RECORDING) { - boolean recordingProcessFound = false; - File recordingsDir = new File(config.getSettings().recordingsDir); - File recDir = new File(recordingsDir, rec.getPath()); - for (Entry download : recordingProcesses.entrySet()) { - if (download.getValue().getTarget().equals(recDir)) { - recordingProcessFound = true; - } - } - if (!recordingProcessFound) { - if (deleteInProgress.contains(recDir)) { - LOG.debug("{} is being deleted. Not going to start post-processing", recDir); - } else { - finishRecording(recDir); - } - } + /** + * This is called once at start for server mode. When the server is killed, recordings are + * left without playlist. This method creates playlists for them. + */ + private void processUnfinishedRecordings() { + try { + List recs = getRecordings(); + for (Recording rec : recs) { + if (rec.getStatus() == RECORDING) { + boolean recordingProcessFound = false; + File recordingsDir = new File(config.getSettings().recordingsDir); + File recDir = new File(recordingsDir, rec.getPath()); + for (Entry download : recordingProcesses.entrySet()) { + if (download.getValue().getTarget().equals(recDir)) { + recordingProcessFound = true; } } - - if (running) - Thread.sleep(10000); - } catch (InterruptedException e) { - LOG.error("Couldn't sleep", e); - } catch (Exception e) { - LOG.error("Unexpected error in playlist trigger thread", e); + if (!recordingProcessFound) { + ppThreadPool.submit(() -> { + generatePlaylist(recDir); + }); + } } } - LOG.debug(getName() + " terminated"); + } catch (Exception e) { + LOG.error("Unexpected error in playlist trigger", e); } } @Override public List getRecordings() { - if(Config.getInstance().isServerMode()) { + if(Config.isServerMode()) { return listSegmentedRecordings(); } else { return listMergedRecordings(); @@ -516,7 +495,7 @@ public class LocalRecorder implements Recorder { return recordings; } - private STATUS getStatus(Recording recording) { + private State getStatus(Recording recording) { File absolutePath = new File(Config.getInstance().getSettings().recordingsDir, recording.getPath()); PlaylistGenerator playlistGenerator = playlistGenerators.get(absolutePath); @@ -525,7 +504,7 @@ public class LocalRecorder implements Recorder { return GENERATING_PLAYLIST; } - if (config.isServerMode()) { + if (Config.isServerMode()) { if (recording.hasPlaylist()) { return FINISHED; } else { @@ -576,6 +555,8 @@ public class LocalRecorder implements Recorder { continue; } + // TODO don't list recordings, which currently get deleted + Date startDate = sdf.parse(rec.getName()); Recording recording = new Recording(); recording.setModelName(subdir.getName()); @@ -674,12 +655,20 @@ public class LocalRecorder implements Recorder { @Override public void switchStreamSource(Model model) throws IOException, InvalidKeyException, NoSuchAlgorithmException, IllegalStateException { - LOG.debug("Switching stream source to index {} for model {}", model.getStreamUrlIndex(), model.getName()); - Download download = recordingProcesses.get(model); - if(download != null) { - stopRecordingProcess(model); + if (models.contains(model)) { + int index = models.indexOf(model); + models.get(index).setStreamUrlIndex(model.getStreamUrlIndex()); + config.save(); + LOG.debug("Switching stream source to index {} for model {}", model.getStreamUrlIndex(), model.getName()); + Download download = recordingProcesses.get(model); + if(download != null) { + stopRecordingProcess(model); + } + tryRestartRecording(model); + } else { + LOG.warn("Couldn't switch stream source for model {}. Not found in list", model.getName()); + return; } - tryRestartRecording(model); } @Override @@ -690,10 +679,13 @@ public class LocalRecorder implements Recorder { int index = models.indexOf(model); models.get(index).setSuspended(true); model.setSuspended(true); + config.save(); } else { LOG.warn("Couldn't suspend model {}. Not found in list", model.getName()); return; } + } catch (IOException e) { + LOG.error("Couldn't save config", e); } finally { lock.unlock(); } @@ -712,12 +704,17 @@ public class LocalRecorder implements Recorder { int index = models.indexOf(model); Model m = models.get(index); m.setSuspended(false); - startRecordingProcess(m); + if(m.isOnline()) { + startRecordingProcess(m); + } model.setSuspended(false); + config.save(); } else { LOG.warn("Couldn't resume model {}. Not found in list", model.getName()); return; } + } catch (ExecutionException | InterruptedException e) { + LOG.error("Couldn't check, if model {} is online", model.getName()); } finally { lock.unlock(); } @@ -727,4 +724,104 @@ public class LocalRecorder implements Recorder { public HttpClient getHttpClient() { return client; } + + @Override + public long getTotalSpaceBytes() throws IOException { + return getRecordingsFileStore().getTotalSpace(); + } + + @Override + public long getFreeSpaceBytes() throws IOException { + return getRecordingsFileStore().getUsableSpace(); + } + + private FileStore getRecordingsFileStore() throws IOException { + File recordingsDir = new File(config.getSettings().recordingsDir); + FileStore store = Files.getFileStore(recordingsDir.toPath()); + return store; + } + + private boolean enoughSpaceForRecording() throws IOException { + long minimum = config.getSettings().minimumSpaceLeftInBytes; + if(minimum == 0) { // 0 means don't check + return true; + } else { + return getFreeSpaceBytes() > minimum; + } + } + + private Runnable createPostProcessor(Download download) { + return () -> { + LOG.debug("Starting post-processing for {}", download.getTarget()); + if(Config.isServerMode()) { + fireRecordingStateChanged(download.getTarget(), GENERATING_PLAYLIST, download.getModel(), download.getStartTime()); + generatePlaylist(download.getTarget()); + } + boolean deleted = deleteIfTooShort(download); + if(deleted) { + // recording was too short. stop here and don't do post-processing + return; + } + fireRecordingStateChanged(download.getTarget(), POST_PROCESSING, download.getModel(), download.getStartTime()); + postprocess(download); + fireRecordingStateChanged(download.getTarget(), FINISHED, download.getModel(), download.getStartTime()); + }; + } + + + // TODO maybe get file size and bitrate and check, if the values are plausible + // we could also compare the length with the time elapsed since starting the recording + private boolean deleteIfTooShort(Download download) { + long minimumLengthInSeconds = Config.getInstance().getSettings().minimumLengthInSeconds; + if(minimumLengthInSeconds <= 0) { + return false; + } + + try { + LOG.debug("Determining video length for {}", download.getTarget()); + File target = download.getTarget(); + double duration = 0; + if(target.isDirectory()) { + File playlist = new File(target, "playlist.m3u8"); + duration = getPlaylistLength(playlist); + } else { + duration = MpegUtil.getFileDuration(target); + } + Duration minLength = Duration.ofSeconds(minimumLengthInSeconds); + Duration videoLength = Duration.ofSeconds((long) duration); + LOG.debug("Recording started at:{}. Video length is {}", download.getStartTime(), videoLength); + if(videoLength.minus(minLength).isNegative()) { + LOG.debug("Video too short {} {}", videoLength, download.getTarget()); + LOG.debug("Deleting {}", target); + if(target.isDirectory()) { + deleteDirectory(target); + deleteEmptyParents(target); + } else { + Files.delete(target.toPath()); + deleteEmptyParents(target.getParentFile()); + } + return true; + } else { + return false; + } + } catch (Exception e) { + LOG.error("Couldn't check video length", e); + return false; + } + } + + private double getPlaylistLength(File playlist) throws IOException, ParseException, PlaylistException { + if(playlist.exists()) { + PlaylistParser playlistParser = new PlaylistParser(new FileInputStream(playlist), Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT); + Playlist m3u = playlistParser.parse(); + MediaPlaylist mediaPlaylist = m3u.getMediaPlaylist(); + double length = 0; + for (TrackData trackData : mediaPlaylist.getTracks()) { + length += trackData.getTrackInfo().duration; + } + return length; + } else { + throw new FileNotFoundException(playlist.getAbsolutePath() + " does not exist"); + } + } } diff --git a/common/src/main/java/ctbrec/recorder/OnlineMonitor.java b/common/src/main/java/ctbrec/recorder/OnlineMonitor.java new file mode 100644 index 00000000..15370194 --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/OnlineMonitor.java @@ -0,0 +1,104 @@ +package ctbrec.recorder; + +import static ctbrec.Model.State.*; + +import java.io.InterruptedIOException; +import java.net.SocketTimeoutException; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.event.EventBusHolder; +import ctbrec.event.ModelIsOnlineEvent; +import ctbrec.event.ModelStateChangedEvent; +import ctbrec.io.HttpException; + +public class OnlineMonitor extends Thread { + private static final transient Logger LOG = LoggerFactory.getLogger(OnlineMonitor.class); + private static final boolean IGNORE_CACHE = true; + + private volatile boolean running = false; + private Recorder recorder; + + private Map states = new HashMap<>(); + + public OnlineMonitor(Recorder recorder) { + this.recorder = recorder; + setName("OnlineMonitor"); + setDaemon(true); + } + + @Override + public void run() { + running = true; + while (running) { + Instant begin = Instant.now(); + List models = recorder.getModelsRecording(); + + // remove models, which are not recorded anymore + for (Iterator iterator = states.keySet().iterator(); iterator.hasNext();) { + Model model = iterator.next(); + if(!models.contains(model)) { + iterator.remove(); + } + } + + // update the currently recorded models + for (Model model : models) { + try { + if(model.isOnline(IGNORE_CACHE)) { + EventBusHolder.BUS.post(new ModelIsOnlineEvent(model)); + } + Model.State state = model.getOnlineState(false); + Model.State oldState = states.getOrDefault(model, UNKNOWN); + states.put(model, state); + if(state != oldState) { + EventBusHolder.BUS.post(new ModelStateChangedEvent(model, oldState, state)); + } + } catch (HttpException e) { + LOG.error("Couldn't check if model {} is online. HTTP Response: {} - {}", + model.getName(), e.getResponseCode(), e.getResponseMessage()); + } catch (SocketTimeoutException e) { + LOG.error("Couldn't check if model {} is online. Request timed out", model.getName()); + } catch (InterruptedException | InterruptedIOException e) { + if(running) { + LOG.error("Couldn't check if model {} is online", model.getName(), e); + } + } catch (Exception e) { + LOG.error("Couldn't check if model {} is online", model.getName(), e); + } + } + Instant end = Instant.now(); + Duration timeCheckTook = Duration.between(begin, end); + LOG.trace("Online check for {} models took {} seconds", models.size(), timeCheckTook.getSeconds()); + + long sleepTime = Config.getInstance().getSettings().onlineCheckIntervalInSecs; + if(timeCheckTook.getSeconds() < sleepTime) { + try { + if (running) { + long millis = TimeUnit.SECONDS.toMillis(sleepTime - timeCheckTook.getSeconds()); + LOG.trace("Sleeping {}ms", millis); + Thread.sleep(millis); + } + } catch (InterruptedException e) { + LOG.trace("Sleep interrupted"); + } + } + } + LOG.debug(getName() + " terminated"); + } + + public void shutdown() { + running = false; + interrupt(); + } +} \ No newline at end of file diff --git a/common/src/main/java/ctbrec/recorder/PlaylistGenerator.java b/common/src/main/java/ctbrec/recorder/PlaylistGenerator.java index e615f63f..1c02a614 100644 --- a/common/src/main/java/ctbrec/recorder/PlaylistGenerator.java +++ b/common/src/main/java/ctbrec/recorder/PlaylistGenerator.java @@ -6,30 +6,17 @@ import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.FilenameFilter; import java.io.IOException; -import java.nio.channels.ReadableByteChannel; import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -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; import com.iheartradio.m3u8.Encoding; import com.iheartradio.m3u8.Format; import com.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.ParsingMode; import com.iheartradio.m3u8.PlaylistException; import com.iheartradio.m3u8.PlaylistParser; import com.iheartradio.m3u8.PlaylistWriter; @@ -39,6 +26,8 @@ import com.iheartradio.m3u8.data.PlaylistType; import com.iheartradio.m3u8.data.TrackData; import com.iheartradio.m3u8.data.TrackInfo; +import ctbrec.MpegUtil; + public class PlaylistGenerator { private static final transient Logger LOG = LoggerFactory.getLogger(PlaylistGenerator.class); @@ -57,10 +46,8 @@ public class PlaylistGenerator { Arrays.sort(files, (f1, f2) -> { String n1 = f1.getName(); - int seq1 = getSequence(n1); String n2 = f2.getName(); - int seq2 = getSequence(n2); - return seq1 - seq2; + return n1.compareTo(n2); }); // create a track containing all files @@ -71,7 +58,7 @@ public class PlaylistGenerator { try { track.add(new TrackData.Builder() .withUri(file.getName()) - .withTrackInfo(new TrackInfo((float) getFileDuration(file), file.getName())) + .withTrackInfo(new TrackInfo((float) MpegUtil.getFileDuration(file), file.getName())) .build()); } catch(Exception e) { LOG.warn("Couldn't determine duration for {}. Skipping this file.", file.getName()); @@ -111,16 +98,6 @@ public class PlaylistGenerator { return output; } - private int getSequence(String filename) { - filename = filename.substring(0, filename.lastIndexOf('.')); // cut off file suffix - Matcher matcher = Pattern.compile(".*?(\\d+)").matcher(filename); - if(matcher.matches()) { - return Integer.parseInt(matcher.group(1)); - } else { - return -1; - } - } - private void updateProgressListeners(double percentage) { int p = (int) (percentage*100); if(p > lastPercentage) { @@ -140,45 +117,6 @@ public class PlaylistGenerator { return targetDuration; } - private 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; - } - public void addProgressListener(ProgressListener l) { listeners.add(l); } @@ -190,7 +128,7 @@ public class PlaylistGenerator { public void validate(File recDir) throws IOException, ParseException, PlaylistException { File playlist = new File(recDir, "playlist.m3u8"); if(playlist.exists()) { - PlaylistParser playlistParser = new PlaylistParser(new FileInputStream(playlist), Format.EXT_M3U, Encoding.UTF_8); + PlaylistParser playlistParser = new PlaylistParser(new FileInputStream(playlist), Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT); Playlist m3u = playlistParser.parse(); MediaPlaylist mediaPlaylist = m3u.getMediaPlaylist(); int playlistSize = mediaPlaylist.getTracks().size(); diff --git a/common/src/main/java/ctbrec/recorder/Recorder.java b/common/src/main/java/ctbrec/recorder/Recorder.java index bc4e60cf..1a9bf682 100644 --- a/common/src/main/java/ctbrec/recorder/Recorder.java +++ b/common/src/main/java/ctbrec/recorder/Recorder.java @@ -42,4 +42,18 @@ public interface Recorder { public List getOnlineModels(); public HttpClient getHttpClient(); + + /** + * Get the total size of the filesystem we are recording to + * @return the total size in bytes + * @throws IOException + */ + public long getTotalSpaceBytes() throws IOException; + + /** + * Get the free space left on the filesystem we are recording to + * @return the free space in bytes + * @throws IOException + */ + public long getFreeSpaceBytes() throws IOException; } diff --git a/common/src/main/java/ctbrec/recorder/RemoteRecorder.java b/common/src/main/java/ctbrec/recorder/RemoteRecorder.java index dd648301..38d27d99 100644 --- a/common/src/main/java/ctbrec/recorder/RemoteRecorder.java +++ b/common/src/main/java/ctbrec/recorder/RemoteRecorder.java @@ -1,27 +1,38 @@ package ctbrec.recorder; +import java.io.File; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.time.Instant; +import java.util.ArrayList; import java.util.Collections; +import java.util.Iterator; import java.util.List; +import java.util.concurrent.ExecutionException; +import org.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.PlaylistException; import com.squareup.moshi.JsonAdapter; import com.squareup.moshi.Moshi; +import ctbrec.AbstractModel; import ctbrec.Config; import ctbrec.Hmac; import ctbrec.Model; import ctbrec.Recording; +import ctbrec.event.EventBusHolder; +import ctbrec.event.RecordingStateChangedEvent; import ctbrec.io.HttpClient; import ctbrec.io.HttpException; import ctbrec.io.InstantJsonAdapter; import ctbrec.io.ModelJsonAdapter; +import ctbrec.recorder.download.StreamSource; import ctbrec.sites.Site; import okhttp3.MediaType; import okhttp3.Request; @@ -32,7 +43,6 @@ import okhttp3.Response; public class RemoteRecorder implements Recorder { private static final transient Logger LOG = LoggerFactory.getLogger(RemoteRecorder.class); - public static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); private Moshi moshi = new Moshi.Builder() .add(Instant.class, new InstantJsonAdapter()) @@ -44,7 +54,10 @@ public class RemoteRecorder implements Recorder { private List models = Collections.emptyList(); private List onlineModels = Collections.emptyList(); + private List recordings = Collections.emptyList(); private List sites; + private long spaceTotal = -1; + private long spaceFree = -1; private Config config; private HttpClient client; @@ -92,6 +105,7 @@ public class RemoteRecorder implements Recorder { models.add(model); } else if ("stop".equals(action)) { models.remove(model); + onlineModels.remove(model); } } else { throw new HttpException(response.code(), response.message()); @@ -125,10 +139,10 @@ public class RemoteRecorder implements Recorder { @Override public List getModelsRecording() { - if(lastSync.isBefore(Instant.now().minusSeconds(60))) { + if(!lastSync.equals(Instant.EPOCH) && lastSync.isBefore(Instant.now().minusSeconds(60))) { throw new RuntimeException("Last sync was over a minute ago"); } - return models; + return new ArrayList(models); } @Override @@ -150,10 +164,36 @@ public class RemoteRecorder implements Recorder { while(running) { syncModels(); syncOnlineModels(); + syncSpace(); + syncRecordings(); sleep(); } } + private void syncSpace() { + try { + String msg = "{\"action\": \"space\"}"; + RequestBody body = RequestBody.create(JSON, msg); + Request.Builder builder = new Request.Builder() + .url("http://" + config.getSettings().httpServer + ":" + config.getSettings().httpPort + "/rec") + .post(body); + addHmacIfNeeded(msg, builder); + Request request = builder.build(); + try(Response response = client.execute(request)) { + String json = response.body().string(); + if(response.isSuccessful()) { + JSONObject resp = new JSONObject(json); + spaceTotal = resp.getLong("spaceTotal"); + spaceFree = resp.getLong("spaceFree"); + } else { + LOG.error("Couldn't synchronize with server. HTTP status: {} - {}", response.code(), json); + } + } + } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e) { + LOG.error("Couldn't synchronize with server", e); + } + } + private void syncModels() { try { String msg = "{\"action\": \"list\"}"; @@ -223,9 +263,52 @@ public class RemoteRecorder implements Recorder { } } + private void syncRecordings() { + try { + String msg = "{\"action\": \"recordings\"}"; + RequestBody body = RequestBody.create(JSON, msg); + Request.Builder builder = new Request.Builder() + .url("http://" + config.getSettings().httpServer + ":" + config.getSettings().httpPort + "/rec") + .post(body); + addHmacIfNeeded(msg, builder); + Request request = builder.build(); + try (Response response = client.execute(request)) { + String json = response.body().string(); + if (response.isSuccessful()) { + RecordingListResponse resp = recordingListResponseAdapter.fromJson(json); + if (resp.status.equals("success")) { + List newRecordings = resp.recordings; + // fire changed events + for (Iterator iterator = recordings.iterator(); iterator.hasNext();) { + Recording recording = iterator.next(); + if(newRecordings.contains(recording)) { + int idx = newRecordings.indexOf(recording); + Recording newRecording = newRecordings.get(idx); + if(newRecording.getStatus() != recording.getStatus()) { + File file = new File(recording.getPath()); + Model m = new UnknownModel(); + m.setName(newRecording.getModelName()); + RecordingStateChangedEvent evt = new RecordingStateChangedEvent(file, newRecording.getStatus(), m, recording.getStartDate()); + EventBusHolder.BUS.post(evt); + } + } + } + recordings = newRecordings; + } else { + LOG.error("Server returned error: {} - {}", resp.status, resp.msg); + } + } else { + LOG.error("Couldn't synchronize with server. HTTP status: {} - {}", response.code(), json); + } + } + } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e) { + LOG.error("Couldn't synchronize with server", e); + } + } + private void sleep() { try { - Thread.sleep(10000); + Thread.sleep(2000); } catch (InterruptedException e) { // interrupted, probably by stopThread } @@ -251,28 +334,7 @@ public class RemoteRecorder implements Recorder { @Override public List getRecordings() throws IOException, InvalidKeyException, NoSuchAlgorithmException, IllegalStateException { - String msg = "{\"action\": \"recordings\"}"; - RequestBody body = RequestBody.create(JSON, msg); - Request.Builder builder = new Request.Builder() - .url("http://" + config.getSettings().httpServer + ":" + config.getSettings().httpPort + "/rec") - .post(body); - addHmacIfNeeded(msg, builder); - Request request = builder.build(); - try(Response response = client.execute(request)) { - String json = response.body().string(); - if(response.isSuccessful()) { - RecordingListResponse resp = recordingListResponseAdapter.fromJson(json); - if(resp.status.equals("success")) { - List recordings = resp.recordings; - return recordings; - } else { - LOG.error("Server returned error: {} - {}", resp.status, resp.msg); - } - } else { - LOG.error("Couldn't synchronize with server. HTTP status: {} - {}", response.code(), json); - } - } - return Collections.emptyList(); + return recordings; } @Override @@ -290,6 +352,8 @@ public class RemoteRecorder implements Recorder { if(response.isSuccessful()) { if(!resp.status.equals("success")) { throw new IOException("Couldn't delete recording: " + resp.msg); + } else { + recordings.remove(recording); } } else { throw new IOException("Couldn't delete recording: " + resp.msg); @@ -362,4 +426,161 @@ public class RemoteRecorder implements Recorder { public HttpClient getHttpClient() { return client; } + + @Override + public long getTotalSpaceBytes() throws IOException { + return spaceTotal; + } + + @Override + public long getFreeSpaceBytes() { + return spaceFree; + } + + private static class UnknownModel extends AbstractModel { + @Override + public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { + return false; + } + + @Override + public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException { + return Collections.emptyList(); + } + + @Override + public void invalidateCacheEntries() { + } + + @Override + public void receiveTip(int tokens) throws IOException { + } + + @Override + public int[] getStreamResolution(boolean failFast) throws ExecutionException { + return new int[2]; + } + + @Override + public boolean follow() throws IOException { + return false; + } + + @Override + public boolean unfollow() throws IOException { + return false; + } + + @Override + public Site getSite() { + return new Site() { + + @Override + public boolean supportsTips() { + return false; + } + + @Override + public boolean supportsSearch() { + return false; + } + + @Override + public boolean supportsFollow() { + return false; + } + + @Override + public void shutdown() { + } + + @Override + public void setRecorder(Recorder recorder) { + } + + @Override + public void setEnabled(boolean enabled) { + } + + @Override + public boolean searchRequiresLogin() { + return false; + } + + @Override + public List search(String q) throws IOException, InterruptedException { + return Collections.emptyList(); + } + + @Override + public boolean login() throws IOException { + return false; + } + + @Override + public boolean isSiteForModel(Model m) { + return false; + } + + @Override + public boolean isEnabled() { + return false; + } + + @Override + public void init() throws IOException { + } + + @Override + public Integer getTokenBalance() throws IOException { + return 0; + } + + @Override + public Recorder getRecorder() { + return null; + } + + @Override + public String getName() { + return "unknown"; + } + + @Override + public HttpClient getHttpClient() { + return null; + } + + @Override + public String getBuyTokensLink() { + return ""; + } + + @Override + public String getBaseUrl() { + return ""; + } + + @Override + public String getAffiliateLink() { + return ""; + } + + @Override + public boolean credentialsAvailable() { + return false; + } + + @Override + public Model createModelFromUrl(String url) { + return null; + } + + @Override + public Model createModel(String name) { + return null; + } + }; + } + } } diff --git a/common/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java b/common/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java index 04b11402..1fb6333d 100644 --- a/common/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/AbstractHlsDownload.java @@ -83,12 +83,18 @@ public abstract class AbstractHlsDownload implements Download { String getSegmentPlaylistUrl(Model model) throws IOException, ExecutionException, ParseException, PlaylistException { + LOG.debug("{} stream idx: {}", model.getName(), model.getStreamUrlIndex()); List streamSources = model.getStreamSources(); + Collections.sort(streamSources); + for (StreamSource streamSource : streamSources) { + LOG.debug("{} src {}", model.getName(), streamSource); + } String url = null; if(model.getStreamUrlIndex() >= 0 && model.getStreamUrlIndex() < streamSources.size()) { + // TODO don't use the index, but the bandwidth. if the bandwidth does not match, take the closest one + LOG.debug("{} selected {}", model.getName(), streamSources.get(model.getStreamUrlIndex())); url = streamSources.get(model.getStreamUrlIndex()).getMediaPlaylistUrl(); } else { - Collections.sort(streamSources); // filter out stream resolutions, which are too high int maxRes = Config.getInstance().getSettings().maximumResolution; if(maxRes > 0) { @@ -103,9 +109,11 @@ public abstract class AbstractHlsDownload implements Download { if(streamSources.isEmpty()) { throw new ExecutionException(new RuntimeException("No stream left in playlist")); } else { + LOG.debug("{} selected {}", model.getName(), streamSources.get(streamSources.size()-1)); url = streamSources.get(streamSources.size()-1).getMediaPlaylistUrl(); } } + LOG.debug("Segment playlist url {}", url); return url; } diff --git a/common/src/main/java/ctbrec/recorder/download/HlsDownload.java b/common/src/main/java/ctbrec/recorder/download/HlsDownload.java index c9f4f174..f682acda 100644 --- a/common/src/main/java/ctbrec/recorder/download/HlsDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/HlsDownload.java @@ -1,5 +1,7 @@ package ctbrec.recorder.download; +import static ctbrec.Recording.State.*; + import java.io.EOFException; import java.io.File; import java.io.FileNotFoundException; @@ -11,10 +13,13 @@ import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; +import java.text.DecimalFormat; +import java.text.NumberFormat; import java.text.SimpleDateFormat; import java.time.Instant; import java.util.Date; import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,6 +29,8 @@ import com.iheartradio.m3u8.PlaylistException; import ctbrec.Config; import ctbrec.Model; +import ctbrec.event.EventBusHolder; +import ctbrec.event.RecordingStateChangedEvent; import ctbrec.io.HttpClient; import ctbrec.io.HttpException; import okhttp3.Request; @@ -35,6 +42,10 @@ public class HlsDownload extends AbstractHlsDownload { protected Path downloadDir; + private int segmentCounter = 1; + private NumberFormat nf = new DecimalFormat("000000"); + private Object downloadFinished = new Object(); + public HlsDownload(HttpClient client) { super(client); } @@ -54,6 +65,10 @@ public class HlsDownload extends AbstractHlsDownload { throw new IOException(model.getName() +"'s room is not public"); } + // let the world know, that we are recording now + RecordingStateChangedEvent evt = new RecordingStateChangedEvent(getTarget(), RECORDING, model, getStartTime()); + EventBusHolder.BUS.post(evt); + String segments = getSegmentPlaylistUrl(model); if(segments != null) { if (!Files.exists(downloadDir, LinkOption.NOFOLLOW_LINKS)) { @@ -61,18 +76,13 @@ public class HlsDownload extends AbstractHlsDownload { } int lastSegment = 0; int nextSegment = 0; + boolean sleep = true; // this enables sleeping between playlist requests. once we miss a segment, this is set to false, so that no sleeping happens anymore while(running) { SegmentPlaylist lsp = getNextSegments(segments); if(nextSegment > 0 && lsp.seq > nextSegment) { - LOG.warn("Missed segments {} < {} in download for {}", nextSegment, lsp.seq, model); - String first = lsp.segments.get(0); - int seq = lsp.seq; - for (int i = nextSegment; i < lsp.seq; i++) { - URL segmentUrl = new URL(first.replaceAll(Integer.toString(seq), Integer.toString(i))); - LOG.debug("Reloading segment {} for model {}", i, model.getName()); - downloadThreadPool.submit(new SegmentDownload(segmentUrl, downloadDir, client)); - } // TODO switch to a lower bitrate/resolution ?!? + LOG.warn("Missed segments {} < {} in download for {}", nextSegment, lsp.seq, model); + sleep = false; } int skip = nextSegment - lsp.seq; for (String segment : lsp.segments) { @@ -80,13 +90,14 @@ public class HlsDownload extends AbstractHlsDownload { skip--; } else { URL segmentUrl = new URL(segment); - downloadThreadPool.submit(new SegmentDownload(segmentUrl, downloadDir, client)); + String prefix = nf.format(segmentCounter++); + downloadThreadPool.submit(new SegmentDownload(segmentUrl, downloadDir, client, prefix)); //new SegmentDownload(segment, downloadDir).call(); } } long wait = 0; - if(lastSegment == lsp.seq) { + if(sleep && lastSegment == lsp.seq) { // playlist didn't change -> wait for at least half the target duration wait = (long) lsp.targetDuration * 1000 / 2; LOG.trace("Playlist didn't change... waiting for {}ms", wait); @@ -126,7 +137,15 @@ public class HlsDownload extends AbstractHlsDownload { } catch(Exception e) { throw new IOException("Couldn't download segment", e); } finally { + downloadThreadPool.shutdown(); + try { + LOG.debug("Waiting for last segments for {}", model); + downloadThreadPool.awaitTermination(60, TimeUnit.SECONDS); + } catch (InterruptedException e) {} alive = false; + synchronized (downloadFinished) { + downloadFinished.notifyAll(); + } LOG.debug("Download for {} terminated", model); } } @@ -134,7 +153,13 @@ public class HlsDownload extends AbstractHlsDownload { @Override public void stop() { running = false; - alive = false; + try { + synchronized (downloadFinished) { + downloadFinished.wait(); + } + } catch (InterruptedException e) { + LOG.error("Couldn't wait for download to finish", e); + } } private static class SegmentDownload implements Callable { @@ -142,11 +167,11 @@ public class HlsDownload extends AbstractHlsDownload { private Path file; private HttpClient client; - public SegmentDownload(URL url, Path dir, HttpClient client) { + public SegmentDownload(URL url, Path dir, HttpClient client, String prefix) { this.url = url; this.client = client; File path = new File(url.getPath()); - file = FileSystems.getDefault().getPath(dir.toString(), path.getName()); + file = FileSystems.getDefault().getPath(dir.toString(), prefix + '_' + path.getName()); } @Override @@ -171,7 +196,7 @@ public class HlsDownload extends AbstractHlsDownload { break; } catch(Exception e) { if (i == maxTries) { - LOG.warn("Error while downloading segment. Segment finally {} failed", file.toFile().getName()); + LOG.warn("Error while downloading segment. Segment {} finally failed", file.toFile().getName()); } else { LOG.warn("Error while downloading segment on try {}", i); } diff --git a/common/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java b/common/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java index 84c4c7d6..958fae17 100644 --- a/common/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/MergedHlsDownload.java @@ -1,5 +1,6 @@ package ctbrec.recorder.download; +import static ctbrec.Recording.State.*; import static java.nio.file.StandardOpenOption.*; import java.io.ByteArrayInputStream; @@ -15,11 +16,20 @@ import java.nio.file.LinkOption; import java.nio.file.Path; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; -import java.text.DecimalFormat; import java.time.Duration; import java.time.Instant; import java.time.ZonedDateTime; +import java.util.LinkedList; +import java.util.Optional; +import java.util.Queue; +import java.util.concurrent.BlockingQueue; import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -35,6 +45,8 @@ import com.iheartradio.m3u8.PlaylistException; import ctbrec.Config; import ctbrec.Hmac; import ctbrec.Model; +import ctbrec.event.EventBusHolder; +import ctbrec.event.RecordingStateChangedEvent; import ctbrec.io.HttpClient; import ctbrec.io.HttpException; import ctbrec.recorder.ProgressListener; @@ -48,11 +60,13 @@ public class MergedHlsDownload extends AbstractHlsDownload { private BlockingMultiMTSSource multiSource; private Thread mergeThread; private Streamer streamer; - private ZonedDateTime startTime; + private ZonedDateTime splitRecStartTime; private Config config; private File targetFile; - private DecimalFormat df = new DecimalFormat("00000"); - private int splitCounter = 0; + private BlockingQueue downloadQueue = new LinkedBlockingQueue<>(50); + private ExecutorService downloadThreadPool = new ThreadPoolExecutor(5, 5, 2, TimeUnit.MINUTES, downloadQueue); + private FileChannel fileChannel = null; + private Object downloadFinished = new Object(); public MergedHlsDownload(HttpClient client) { super(client); @@ -67,6 +81,7 @@ public class MergedHlsDownload extends AbstractHlsDownload { try { running = true; super.startTime = Instant.now(); + splitRecStartTime = ZonedDateTime.now(); mergeThread = createMergeThread(targetFile, progressListener, false); LOG.debug("Merge thread started"); mergeThread.start(); @@ -81,7 +96,7 @@ public class MergedHlsDownload extends AbstractHlsDownload { downloadSegments(segmentPlaylistUri, false); LOG.debug("Waiting for merge thread to finish"); mergeThread.join(); - LOG.debug("Merge thread to finished"); + LOG.debug("Merge thread finished"); } catch(ParseException e) { throw new IOException("Couldn't parse stream information", e); } catch(PlaylistException e) { @@ -91,8 +106,20 @@ public class MergedHlsDownload extends AbstractHlsDownload { } catch (InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e) { throw new IOException("Couldn't add HMAC to playlist url", e); } finally { + try { + streamer.stop(); + } catch(Exception e) { + LOG.error("Couldn't stop streamer", e); + } + downloadThreadPool.shutdown(); + try { + LOG.debug("Waiting for last segments for {}", model); + downloadThreadPool.awaitTermination(60, TimeUnit.SECONDS); + } catch (InterruptedException e) {} alive = false; - streamer.stop(); + synchronized (downloadFinished) { + downloadFinished.notifyAll(); + } LOG.debug("Download terminated for {}", segmentPlaylistUri); } } @@ -107,13 +134,22 @@ public class MergedHlsDownload extends AbstractHlsDownload { running = true; super.startTime = Instant.now(); + splitRecStartTime = ZonedDateTime.now(); super.model = model; targetFile = Config.getInstance().getFileForRecording(model); + + // let the world know, that we are recording now + RecordingStateChangedEvent evt = new RecordingStateChangedEvent(getTarget(), RECORDING, model, getStartTime()); + EventBusHolder.BUS.post(evt); + String segments = getSegmentPlaylistUrl(model); mergeThread = createMergeThread(targetFile, null, true); mergeThread.start(); if(segments != null) { downloadSegments(segments, true); + if(config.getSettings().splitRecordings > 0) { + LOG.debug("Splitting recordings every {} seconds", config.getSettings().splitRecordings); + } } else { throw new IOException("Couldn't determine segments uri"); } @@ -127,9 +163,21 @@ public class MergedHlsDownload extends AbstractHlsDownload { } catch(Exception e) { throw new IOException("Couldn't download segment", e); } finally { - alive = false; if(streamer != null) { - streamer.stop(); + try { + streamer.stop(); + } catch(Exception e) { + LOG.error("Couldn't stop streamer", e); + } + } + downloadThreadPool.shutdown(); + try { + LOG.debug("Waiting for last segments for {}", model); + downloadThreadPool.awaitTermination(60, TimeUnit.SECONDS); + } catch (InterruptedException e) {} + alive = false; + synchronized (downloadFinished) { + downloadFinished.notifyAll(); } LOG.debug("Download for {} terminated", model); } @@ -138,36 +186,127 @@ public class MergedHlsDownload extends AbstractHlsDownload { private void downloadSegments(String segmentPlaylistUri, boolean livestreamDownload) throws IOException, ParseException, PlaylistException { int lastSegment = 0; int nextSegment = 0; + long playlistNotFoundFirstEncounter = -1; while(running) { try { + if(playlistNotFoundFirstEncounter != -1) { + LOG.debug("Downloading playlist {}", segmentPlaylistUri); + } SegmentPlaylist lsp = getNextSegments(segmentPlaylistUri); + playlistNotFoundFirstEncounter = -1; if(!livestreamDownload) { multiSource.setTotalSegments(lsp.segments.size()); } - // download segments, which might have been skipped - downloadMissedSegments(lsp, nextSegment); - // download new segments - downloadNewSegments(lsp, nextSegment); + long downloadStart = System.currentTimeMillis(); + if(livestreamDownload) { + downloadNewSegments(lsp, nextSegment); + } else { + downloadRecording(lsp); + } + long downloadTookMillis = System.currentTimeMillis() - downloadStart; + + // download segments, which might have been skipped + //downloadMissedSegments(lsp, nextSegment); + if(nextSegment > 0 && lsp.seq > nextSegment) { + LOG.warn("Missed segments {} < {} in download for {}. Download took {}ms. Playlist is {}sec", nextSegment, lsp.seq, lsp.url, downloadTookMillis, lsp.totalDuration); + } if(livestreamDownload) { // split up the recording, if configured splitRecording(); // wait some time until requesting the segment playlist again to not hammer the server - waitForNewSegments(lsp, lastSegment); + waitForNewSegments(lsp, lastSegment, downloadTookMillis); lastSegment = lsp.seq; nextSegment = lastSegment + lsp.segments.size(); } else { break; } - } catch(HttpException e) { - if(e.getResponseCode() == 404) { - // playlist is gone -> model probably logged out - LOG.debug("Playlist not found. Assuming model went offline"); - running = false; + } catch(Exception e) { + if(model != null) { + LOG.info("Unexpected error while downloading {}", model.getName(), e); + } else { + LOG.info("Unexpected error while downloading", e); + } + running = false; + } + } + } + + private void downloadRecording(SegmentPlaylist lsp) throws IOException, InterruptedException { + for (String segment : lsp.segments) { + URL segmentUrl = new URL(segment); + SegmentDownload segmentDownload = new SegmentDownload(segmentUrl, client); + byte[] segmentData = segmentDownload.call(); + writeSegment(segmentData); + } + } + + private void downloadNewSegments(SegmentPlaylist lsp, int nextSegment) throws MalformedURLException, MissingSegmentException, ExecutionException, HttpException { + int skip = nextSegment - lsp.seq; + if(lsp.segments.isEmpty()) { + LOG.debug("Empty playlist: {}", lsp.url); + } + + // add segments to download threadpool + Queue> downloads = new LinkedList<>(); + if(downloadQueue.remainingCapacity() == 0) { + LOG.warn("Download to slow for this stream. Download queue is full. Skipping segment"); + } else { + for (String segment : lsp.segments) { + if(!running) { + break; + } + if(skip > 0) { + skip--; + } else { + URL segmentUrl = new URL(segment); + Future download = downloadThreadPool.submit(new SegmentDownload(segmentUrl, client)); + downloads.add(download); + } + } + } + + // get completed downloads and write them to the file + // TODO it might be a good idea to do this in a separate thread, so that the main download loop isn't blocked + writeFinishedSegments(downloads); + } + + private void writeFinishedSegments(Queue> downloads) throws ExecutionException, HttpException { + for (Future downloadFuture : downloads) { + try { + byte[] segmentData = downloadFuture.get(); + writeSegment(segmentData); + } catch (InterruptedException e) { + LOG.error("Error while downloading segment", e); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if(cause instanceof MissingSegmentException) { + if(model != null && !isModelOnline()) { + LOG.debug("Error while downloading segment, because model {} is offline. Stopping now", model.getName()); + running = false; + } else { + LOG.debug("Segment not available, but model {} still online. Going on", model.getName()); + } + } else if(cause instanceof HttpException) { + HttpException he = (HttpException) cause; + if(model != null && !isModelOnline()) { + LOG.debug("Error {} while downloading segment, because model {} is offline. Stopping now", he.getResponseCode(), model.getName()); + running = false; + } else { + if(he.getResponseCode() == 404) { + LOG.info("Playlist for {} not found [HTTP 404]. Stopping now", model.getName()); + running = false; + } else if(he.getResponseCode() == 403) { + LOG.info("Playlist for {} not accessible [HTTP 403]. Stopping now", model.getName()); + running = false; + } else { + throw he; + } + } } else { throw e; } @@ -175,43 +314,6 @@ public class MergedHlsDownload extends AbstractHlsDownload { } } - private void downloadMissedSegments(SegmentPlaylist lsp, int nextSegment) throws MalformedURLException { - if(nextSegment > 0 && lsp.seq > nextSegment) { - LOG.warn("Missed segments {} < {} in download for {}", nextSegment, lsp.seq, lsp.url); - String first = lsp.segments.get(0); - int seq = lsp.seq; - for (int i = nextSegment; i < lsp.seq; i++) { - URL segmentUrl = new URL(first.replaceAll(Integer.toString(seq), Integer.toString(i))); - LOG.debug("Loading missed segment {} for model {}", i, lsp.url); - byte[] segmentData; - try { - segmentData = new SegmentDownload(segmentUrl, client).call(); - writeSegment(segmentData); - } catch (Exception e) { - LOG.error("Error while downloading segment {}", segmentUrl, e); - } - } - // TODO switch to a lower bitrate/resolution ?!? - } - } - - private void downloadNewSegments(SegmentPlaylist lsp, int nextSegment) throws MalformedURLException { - int skip = nextSegment - lsp.seq; - for (String segment : lsp.segments) { - if(skip > 0) { - skip--; - } else { - URL segmentUrl = new URL(segment); - try { - byte[] segmentData = new SegmentDownload(segmentUrl, client).call(); - writeSegment(segmentData); - } catch (Exception e) { - LOG.error("Error while downloading segment {}", segmentUrl, e); - } - } - } - } - private void writeSegment(byte[] segmentData) throws InterruptedException { InputStream in = new ByteArrayInputStream(segmentData); InputStreamMTSSource source = InputStreamMTSSource.builder().setInputStream(in).build(); @@ -220,24 +322,36 @@ public class MergedHlsDownload extends AbstractHlsDownload { private void splitRecording() { if(config.getSettings().splitRecordings > 0) { - Duration recordingDuration = Duration.between(startTime, ZonedDateTime.now()); + Duration recordingDuration = Duration.between(splitRecStartTime, ZonedDateTime.now()); long seconds = recordingDuration.getSeconds(); if(seconds >= config.getSettings().splitRecordings) { - streamer.stop(); - File target = new File(targetFile.getAbsolutePath().replaceAll("\\.ts", "-"+df.format(++splitCounter)+".ts")); - mergeThread = createMergeThread(target, null, true); - mergeThread.start(); - startTime = ZonedDateTime.now(); + try { + targetFile = Config.getInstance().getFileForRecording(model); + LOG.debug("Switching to file {}", targetFile.getAbsolutePath()); + fileChannel = FileChannel.open(targetFile.toPath(), CREATE, WRITE); + MTSSink sink = ByteChannelSink.builder().setByteChannel(fileChannel).build(); + streamer.switchSink(sink); + splitRecStartTime = ZonedDateTime.now(); + } catch (IOException e) { + LOG.error("Error while splitting recording", e); + running = false; + } } } } - private void waitForNewSegments(SegmentPlaylist lsp, int lastSegment) { + private void waitForNewSegments(SegmentPlaylist lsp, int lastSegment, long downloadTookMillis) { try { long wait = 0; if (lastSegment == lsp.seq) { - // playlist didn't change -> wait for at least half the target duration - wait = (long) lsp.targetDuration * 1000 / 2; + int timeLeftMillis = (int)(lsp.totalDuration * 1000 - downloadTookMillis); + if(timeLeftMillis < 3000) { // we have less than 3 seconds to get the new playlist and start downloading it + wait = 1; + } else { + // wait a second to be nice to the server (don't hammer it with requests) + // 1 second seems to be a good compromise. every other calculation resulted in more missing segments + wait = 1000; + } LOG.trace("Playlist didn't change... waiting for {}ms", wait); } else { // playlist did change -> wait for at least last segment duration @@ -255,8 +369,16 @@ public class MergedHlsDownload extends AbstractHlsDownload { @Override public void stop() { running = false; - alive = false; - streamer.stop(); + if(streamer != null) { + streamer.stop(); + } + try { + synchronized (downloadFinished) { + downloadFinished.wait(); + } + } catch (InterruptedException e) { + LOG.error("Couldn't wait for download to finish", e); + } LOG.debug("Download stopped"); } @@ -267,20 +389,20 @@ public class MergedHlsDownload extends AbstractHlsDownload { .setProgressListener(listener) .build(); - FileChannel channel = null; try { Path downloadDir = targetFile.getParentFile().toPath(); if (!Files.exists(downloadDir, LinkOption.NOFOLLOW_LINKS)) { Files.createDirectories(downloadDir); } - channel = FileChannel.open(targetFile.toPath(), CREATE, WRITE); - MTSSink sink = ByteChannelSink.builder().setByteChannel(channel).build(); + fileChannel = FileChannel.open(targetFile.toPath(), CREATE, WRITE); + MTSSink sink = ByteChannelSink.builder().setByteChannel(fileChannel).build(); streamer = Streamer.builder() .setSource(multiSource) .setSink(sink) .setSleepingEnabled(liveStream) .setBufferSize(10) + .setName(Optional.ofNullable(model).map(m -> m.getName()).orElse("")) .build(); // Start streaming @@ -293,11 +415,16 @@ public class MergedHlsDownload extends AbstractHlsDownload { } catch(Exception e) { LOG.error("Error while saving stream to file", e); } finally { - closeFile(channel); deleteEmptyRecording(targetFile); + running = false; + closeFile(fileChannel); } }); - t.setName("Segment Merger Thread"); + if(model != null) { + t.setName("Segment Merger Thread [" + model.getName() + "]"); + } else { + t.setName("Segment Merger Thread"); + } t.setDaemon(true); return t; } @@ -308,22 +435,22 @@ public class MergedHlsDownload extends AbstractHlsDownload { Files.delete(targetFile.toPath()); Files.delete(targetFile.getParentFile().toPath()); } - } catch (IOException e) { + } catch (Exception e) { LOG.error("Error while deleting empty recording {}", targetFile); } } private void closeFile(FileChannel channel) { try { - if (channel != null) { + if (channel != null && channel.isOpen()) { channel.close(); } - } catch (IOException e) { + } catch (Exception e) { LOG.error("Error while closing file channel", e); } } - private static class SegmentDownload implements Callable { + private class SegmentDownload implements Callable { private URL url; private HttpClient client; @@ -333,24 +460,38 @@ public class MergedHlsDownload extends AbstractHlsDownload { } @Override - public byte[] call() throws Exception { + public byte[] call() throws IOException { LOG.trace("Downloading segment " + url.getFile()); int maxTries = 3; - for (int i = 1; i <= maxTries; i++) { - try { - Request request = new Request.Builder().url(url).addHeader("connection", "keep-alive").build(); - Response response = client.execute(request); - byte[] segment = response.body().bytes(); - return segment; + for (int i = 1; i <= maxTries && running; i++) { + Request request = new Request.Builder().url(url).addHeader("connection", "keep-alive").build(); + try (Response response = client.execute(request)) { + if(response.isSuccessful()) { + byte[] segment = response.body().bytes(); + return segment; + } else { + throw new HttpException(response.code(), response.message()); + } } catch(Exception e) { if (i == maxTries) { LOG.warn("Error while downloading segment. Segment {} finally failed", url.getFile()); } else { - LOG.warn("Error while downloading segment {} on try {}", url.getFile(), i); + LOG.warn("Error while downloading segment {} on try {}", url.getFile(), i, e); + } + if(model != null && !isModelOnline()) { + break; } } } - throw new IOException("Unable to download segment " + url.getFile() + " after " + maxTries + " tries"); + throw new MissingSegmentException("Unable to download segment " + url.getFile() + " after " + maxTries + " tries"); + } + } + + public boolean isModelOnline() { + try { + return model.isOnline(IGNORE_CACHE); + } catch (IOException | ExecutionException | InterruptedException e) { + return false; } } } diff --git a/common/src/main/java/ctbrec/recorder/download/MissingSegmentException.java b/common/src/main/java/ctbrec/recorder/download/MissingSegmentException.java new file mode 100644 index 00000000..d6971aab --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/download/MissingSegmentException.java @@ -0,0 +1,11 @@ +package ctbrec.recorder.download; + +import java.io.IOException; + +public class MissingSegmentException extends IOException { + + public MissingSegmentException(String msg) { + super(msg); + } + +} diff --git a/common/src/main/java/ctbrec/recorder/download/StreamSource.java b/common/src/main/java/ctbrec/recorder/download/StreamSource.java index 1968df81..6044716b 100644 --- a/common/src/main/java/ctbrec/recorder/download/StreamSource.java +++ b/common/src/main/java/ctbrec/recorder/download/StreamSource.java @@ -44,7 +44,11 @@ public class StreamSource implements Comparable { public String toString() { DecimalFormat df = new DecimalFormat("0.00"); float mbit = bandwidth / 1024.0f / 1024.0f; - return height + "p (" + df.format(mbit) + " Mbit/s)"; + if(height == Integer.MAX_VALUE) { + return "unknown resolution (" + df.format(mbit) + " Mbit/s)"; + } else { + return height + "p (" + df.format(mbit) + " Mbit/s)"; + } } /** @@ -54,7 +58,7 @@ public class StreamSource implements Comparable { @Override public int compareTo(StreamSource o) { int heightDiff = height - o.height; - if(heightDiff != 0) { + if(heightDiff != 0 && height != Integer.MAX_VALUE && o.height != Integer.MAX_VALUE) { return heightDiff; } else { return bandwidth - o.bandwidth; diff --git a/common/src/main/java/ctbrec/sites/AbstractSite.java b/common/src/main/java/ctbrec/sites/AbstractSite.java index 1d50d186..4ecd6465 100644 --- a/common/src/main/java/ctbrec/sites/AbstractSite.java +++ b/common/src/main/java/ctbrec/sites/AbstractSite.java @@ -1,5 +1,10 @@ package ctbrec.sites; +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +import ctbrec.Model; import ctbrec.recorder.Recorder; public abstract class AbstractSite implements Site { @@ -26,4 +31,24 @@ public abstract class AbstractSite implements Site { public Recorder getRecorder() { return recorder; } + + @Override + public boolean supportsSearch() { + return false; + } + + @Override + public List search(String q) throws IOException, InterruptedException { + return Collections.emptyList(); + } + + @Override + public boolean searchRequiresLogin() { + return false; + } + + @Override + public Model createModelFromUrl(String url) { + return null; + } } diff --git a/common/src/main/java/ctbrec/sites/Site.java b/common/src/main/java/ctbrec/sites/Site.java index 08fef0f4..9225b52c 100644 --- a/common/src/main/java/ctbrec/sites/Site.java +++ b/common/src/main/java/ctbrec/sites/Site.java @@ -1,6 +1,7 @@ package ctbrec.sites; import java.io.IOException; +import java.util.List; import ctbrec.Model; import ctbrec.io.HttpClient; @@ -21,8 +22,12 @@ public interface Site { public void shutdown(); public boolean supportsTips(); public boolean supportsFollow(); + public boolean supportsSearch(); public boolean isSiteForModel(Model m); public boolean credentialsAvailable(); public void setEnabled(boolean enabled); public boolean isEnabled(); + public List search(String q) throws IOException, InterruptedException; + public boolean searchRequiresLogin(); + public Model createModelFromUrl(String url); } diff --git a/common/src/main/java/ctbrec/sites/bonga/BongaCams.java b/common/src/main/java/ctbrec/sites/bonga/BongaCams.java index f763bccc..573c4d67 100644 --- a/common/src/main/java/ctbrec/sites/bonga/BongaCams.java +++ b/common/src/main/java/ctbrec/sites/bonga/BongaCams.java @@ -1,8 +1,17 @@ package ctbrec.sites.bonga; import java.io.IOException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.json.JSONArray; import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import ctbrec.Config; import ctbrec.Model; @@ -16,6 +25,8 @@ import okhttp3.Response; public class BongaCams extends AbstractSite { + private static final transient Logger LOG = LoggerFactory.getLogger(BongaCams.class); + public static final String BASE_URL = "https://bongacams.com"; private BongaCamsHttpClient httpClient; @@ -83,7 +94,7 @@ public class BongaCams extends AbstractSite { } @Override - public boolean login() throws IOException { + public synchronized boolean login() throws IOException { return credentialsAvailable() && getHttpClient().login(); } @@ -116,6 +127,57 @@ public class BongaCams extends AbstractSite { return false; } + @Override + public boolean supportsSearch() { + return true; + } + + @Override + public boolean searchRequiresLogin() { + return true; + } + + @Override + public List search(String q) throws IOException, InterruptedException { + String url = BASE_URL + "/tools/listing_v3.php?offset=0&model_search[display_name][text]=" + URLEncoder.encode(q, "utf-8"); + Request req = new Request.Builder() + .url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .addHeader("Accept", "application/json, text/javascript, */*") + .addHeader("Accept-Language", "en") + .addHeader("Referer", BongaCams.BASE_URL) + .addHeader("X-Requested-With", "XMLHttpRequest") + .build(); + try(Response response = getHttpClient().execute(req)) { + if(response.isSuccessful()) { + String body = response.body().string(); + JSONObject json = new JSONObject(body); + if(json.optString("status").equals("success")) { + List models = new ArrayList<>(); + JSONArray results = json.getJSONArray("models"); + for (int i = 0; i < results.length(); i++) { + JSONObject result = results.getJSONObject(i); + Model model = createModel(result.getString("username")); + String thumb = result.getString("thumb_image"); + if(thumb != null) { + model.setPreview("https:" + thumb); + } + if(result.has("display_name")) { + model.setDisplayName(result.getString("display_name")); + } + models.add(model); + } + return models; + } else { + LOG.warn("Search result: " + json.toString(2)); + return Collections.emptyList(); + } + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + @Override public boolean isSiteForModel(Model m) { return m instanceof BongaCamsModel; @@ -127,4 +189,14 @@ public class BongaCams extends AbstractSite { return username != null && !username.trim().isEmpty(); } + @Override + public Model createModelFromUrl(String url) { + Matcher m = Pattern.compile("https?://.*?bongacams.com(?:/profile)?/([^/]*?)/?").matcher(url); + if(m.matches()) { + String modelName = m.group(1); + return createModel(modelName); + } else { + return super.createModelFromUrl(url); + } + } } diff --git a/common/src/main/java/ctbrec/sites/bonga/BongaCamsModel.java b/common/src/main/java/ctbrec/sites/bonga/BongaCamsModel.java index a6fd377f..bd891eba 100644 --- a/common/src/main/java/ctbrec/sites/bonga/BongaCamsModel.java +++ b/common/src/main/java/ctbrec/sites/bonga/BongaCamsModel.java @@ -1,5 +1,7 @@ package ctbrec.sites.bonga; +import static ctbrec.Model.State.*; + import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; @@ -14,6 +16,7 @@ import org.slf4j.LoggerFactory; import com.iheartradio.m3u8.Encoding; import com.iheartradio.m3u8.Format; import com.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.ParsingMode; import com.iheartradio.m3u8.PlaylistException; import com.iheartradio.m3u8.PlaylistParser; import com.iheartradio.m3u8.data.MasterPlaylist; @@ -35,7 +38,6 @@ public class BongaCamsModel extends AbstractModel { private static final transient Logger LOG = LoggerFactory.getLogger(BongaCamsModel.class); private int userId; - private String onlineState = "n/a"; private boolean online = false; private List streamSources = new ArrayList<>(); private int[] resolution; @@ -83,11 +85,19 @@ public class BongaCamsModel extends AbstractModel { } @Override - public String getOnlineState(boolean failFast) throws IOException, ExecutionException { - return onlineState; + public State getOnlineState(boolean failFast) throws IOException, ExecutionException { + if(failFast) { + return onlineState; + } else { + if(onlineState == UNKNOWN) { + return online ? ONLINE : OFFLINE; + } + return onlineState; + } } - public void setOnlineState(String onlineState) { + @Override + public void setOnlineState(State onlineState) { this.onlineState = onlineState; } @@ -101,7 +111,7 @@ public class BongaCamsModel extends AbstractModel { try(Response response = site.getHttpClient().execute(req)) { if(response.isSuccessful()) { InputStream inputStream = response.body().byteStream(); - PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8); + PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT); Playlist playlist = parser.parse(); MasterPlaylist master = playlist.getMasterPlaylist(); streamSources.clear(); diff --git a/common/src/main/java/ctbrec/sites/cam4/Cam4.java b/common/src/main/java/ctbrec/sites/cam4/Cam4.java index d63ef050..62a1cea2 100644 --- a/common/src/main/java/ctbrec/sites/cam4/Cam4.java +++ b/common/src/main/java/ctbrec/sites/cam4/Cam4.java @@ -1,16 +1,27 @@ package ctbrec.sites.cam4; import java.io.IOException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.json.JSONArray; +import org.json.JSONObject; import ctbrec.Config; import ctbrec.Model; +import ctbrec.StringUtil; import ctbrec.io.HttpClient; +import ctbrec.io.HttpException; import ctbrec.sites.AbstractSite; +import okhttp3.Request; +import okhttp3.Response; public class Cam4 extends AbstractSite { public static final String BASE_URI = "https://www.cam4.com"; - public static final String AFFILIATE_LINK = BASE_URI + "/?referrerId=1514a80d87b5effb456cca02f6743aa1"; private HttpClient httpClient; @@ -53,7 +64,7 @@ public class Cam4 extends AbstractSite { } @Override - public boolean login() throws IOException { + public synchronized boolean login() throws IOException { return credentialsAvailable() && getHttpClient().login(); } @@ -84,6 +95,57 @@ public class Cam4 extends AbstractSite { return true; } + @Override + public boolean supportsSearch() { + return true; + } + + @Override + public boolean searchRequiresLogin() { + return true; + } + + @Override + public List search(String q) throws IOException, InterruptedException { + List result = new ArrayList<>(); + search(q, false, result); + search(q, true, result); + return result; + } + + private void search(String q, boolean offline, List models) throws IOException { + String url = BASE_URI + "/usernameSearch?username=" + URLEncoder.encode(q, "utf-8"); + if(offline) { + url += "&offline=true"; + } + Request req = new Request.Builder() + .url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .build(); + try(Response response = getHttpClient().execute(req)) { + if(response.isSuccessful()) { + String body = response.body().string(); + JSONArray results = new JSONArray(body); + for (int i = 0; i < results.length(); i++) { + JSONObject result = results.getJSONObject(i); + Model model = createModel(result.getString("username")); + String thumb = null; + if(result.has("thumbnailId")) { + thumb = "https://snapshots.xcdnpro.com/thumbnails/" + model.getName() + "?s=" + result.getString("thumbnailId"); + } else { + thumb = result.getString("profileImageLink"); + } + if(StringUtil.isNotBlank(thumb)) { + model.setPreview(thumb); + } + models.add(model); + } + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + @Override public boolean isSiteForModel(Model m) { return m instanceof Cam4Model; @@ -94,4 +156,15 @@ public class Cam4 extends AbstractSite { String username = Config.getInstance().getSettings().cam4Username; return username != null && !username.trim().isEmpty(); } + + @Override + public Model createModelFromUrl(String url) { + Matcher m = Pattern.compile("https?://(?:www\\.)?cam4(?:.*?).com/([^/]*?)/?").matcher(url); + if(m.matches()) { + String modelName = m.group(1); + return createModel(modelName); + } else { + return super.createModelFromUrl(url); + } + } } diff --git a/common/src/main/java/ctbrec/sites/cam4/Cam4Model.java b/common/src/main/java/ctbrec/sites/cam4/Cam4Model.java index 30b87e9a..0c40a424 100644 --- a/common/src/main/java/ctbrec/sites/cam4/Cam4Model.java +++ b/common/src/main/java/ctbrec/sites/cam4/Cam4Model.java @@ -1,10 +1,13 @@ package ctbrec.sites.cam4; +import static ctbrec.Model.State.*; + import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.ExecutionException; import org.json.JSONArray; @@ -16,6 +19,7 @@ import org.slf4j.LoggerFactory; import com.iheartradio.m3u8.Encoding; import com.iheartradio.m3u8.Format; import com.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.ParsingMode; import com.iheartradio.m3u8.PlaylistException; import com.iheartradio.m3u8.PlaylistParser; import com.iheartradio.m3u8.data.MasterPlaylist; @@ -36,24 +40,19 @@ public class Cam4Model extends AbstractModel { private static final transient Logger LOG = LoggerFactory.getLogger(Cam4Model.class); private String playlistUrl; - private String onlineState = "offline"; private int[] resolution = null; - - @Override - public boolean isOnline() throws IOException, ExecutionException, InterruptedException { - return isOnline(false); - } + private boolean privateRoom = false; @Override public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { - if(ignoreCache || onlineState == null) { + if(ignoreCache || onlineState == UNKNOWN) { try { loadModelDetails(); } catch (ModelDetailsEmptyException e) { return false; } } - return Objects.equals("NORMAL", onlineState); + return onlineState == ONLINE && !privateRoom; } private void loadModelDetails() throws IOException, ModelDetailsEmptyException { @@ -64,11 +63,17 @@ public class Cam4Model extends AbstractModel { if(response.isSuccessful()) { JSONArray json = new JSONArray(response.body().string()); if(json.length() == 0) { + onlineState = OFFLINE; throw new ModelDetailsEmptyException("Model details are empty"); } JSONObject details = json.getJSONObject(0); - onlineState = details.getString("showType"); + String showType = details.getString("showType"); + setOnlineStateByShowType(showType); playlistUrl = details.getString("hlsPreviewUrl"); + privateRoom = details.getBoolean("privateRoom"); + if(privateRoom) { + onlineState = PRIVATE; + } if(details.has("resolution")) { String res = details.getString("resolution"); String[] tokens = res.split(":"); @@ -80,9 +85,42 @@ public class Cam4Model extends AbstractModel { } } + public void setOnlineStateByShowType(String showType) { + switch(showType) { + case "NORMAL": + case "GROUP_SHOW_SELLING_TICKETS": + onlineState = ONLINE; + break; + case "PRIVATE_SHOW": + onlineState = PRIVATE; + break; + case "GROUP_SHOW": + onlineState = GROUP; + break; + case "OFFLINE": + onlineState = OFFLINE; + break; + default: + LOG.debug("Unknown show type {}", showType); + onlineState = UNKNOWN; + } + + } + @Override - public String getOnlineState(boolean failFast) throws IOException, ExecutionException { - return onlineState; + public State getOnlineState(boolean failFast) throws IOException, ExecutionException { + if(failFast) { + return onlineState; + } else { + if(onlineState == UNKNOWN) { + try { + loadModelDetails(); + } catch (ModelDetailsEmptyException e) { + LOG.warn("Couldn't load model details", e.getMessage()); + } + } + return onlineState; + } } private String getPlaylistUrl() throws IOException { @@ -104,7 +142,7 @@ public class Cam4Model extends AbstractModel { if (playlist.hasStreamInfo()) { StreamSource src = new StreamSource(); src.bandwidth = playlist.getStreamInfo().getBandwidth(); - src.height = playlist.getStreamInfo().getResolution().height; + src.height = Optional.ofNullable(playlist.getStreamInfo()).map(si -> si.getResolution()).map(res -> res.height).orElse(0); String masterUrl = getPlaylistUrl(); String baseUrl = masterUrl.substring(0, masterUrl.lastIndexOf('/') + 1); String segmentUri = baseUrl + playlist.getUri(); @@ -122,7 +160,7 @@ public class Cam4Model extends AbstractModel { Response response = site.getHttpClient().execute(req); try { InputStream inputStream = response.body().byteStream(); - PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8); + PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT); Playlist playlist = parser.parse(); MasterPlaylist master = playlist.getMasterPlaylist(); return master; @@ -149,7 +187,11 @@ public class Cam4Model extends AbstractModel { return new int[2]; } else { try { - loadModelDetails(); + if(onlineState != OFFLINE) { + loadModelDetails(); + } else { + resolution = new int[2]; + } } catch (Exception e) { throw new ExecutionException(e); } @@ -223,10 +265,6 @@ public class Cam4Model extends AbstractModel { this.playlistUrl = playlistUrl; } - public void setOnlineState(String onlineState) { - this.onlineState = onlineState; - } - public class ModelDetailsEmptyException extends Exception { public ModelDetailsEmptyException(String msg) { super(msg); diff --git a/common/src/main/java/ctbrec/sites/camsoda/Camsoda.java b/common/src/main/java/ctbrec/sites/camsoda/Camsoda.java index c8750bc5..10a12117 100644 --- a/common/src/main/java/ctbrec/sites/camsoda/Camsoda.java +++ b/common/src/main/java/ctbrec/sites/camsoda/Camsoda.java @@ -1,8 +1,17 @@ package ctbrec.sites.camsoda; import java.io.IOException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.json.JSONArray; import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import ctbrec.Config; import ctbrec.Model; @@ -14,6 +23,7 @@ import okhttp3.Response; public class Camsoda extends AbstractSite { + private static final transient Logger LOG = LoggerFactory.getLogger(Camsoda.class); public static final String BASE_URI = "https://www.camsoda.com"; private HttpClient httpClient; @@ -72,7 +82,7 @@ public class Camsoda extends AbstractSite { } @Override - public boolean login() throws IOException { + public synchronized boolean login() throws IOException { return credentialsAvailable() && getHttpClient().login(); } @@ -105,6 +115,47 @@ public class Camsoda extends AbstractSite { return true; } + @Override + public boolean supportsSearch() { + return true; + } + + @Override + public List search(String q) throws IOException, InterruptedException { + String url = BASE_URI + "/api/v1/browse/autocomplete?s=" + URLEncoder.encode(q, "utf-8"); + Request req = new Request.Builder() + .url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .build(); + try(Response response = getHttpClient().execute(req)) { + if(response.isSuccessful()) { + JSONObject json = new JSONObject(response.body().string()); + if(json.optBoolean("status")) { + List models = new ArrayList<>(); + JSONArray results = json.getJSONArray("results"); + for (int i = 0; i < results.length(); i++) { + JSONObject result = results.getJSONObject(i); + CamsodaModel model = (CamsodaModel) createModel(result.getString("username")); + String thumb = result.getString("thumb"); + if(thumb != null) { + model.setPreview("https:" + thumb); + } + if(result.has("display_name")) { + model.setDisplayName(result.getString("display_name")); + } + models.add(model); + } + return models; + } else { + LOG.warn("Search result: " + json.toString(2)); + return Collections.emptyList(); + } + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + @Override public boolean isSiteForModel(Model m) { return m instanceof CamsodaModel; @@ -115,4 +166,15 @@ public class Camsoda extends AbstractSite { String username = Config.getInstance().getSettings().camsodaUsername; return username != null && !username.trim().isEmpty(); } + + @Override + public Model createModelFromUrl(String url) { + Matcher m = Pattern.compile("https?://(?:www\\.)?camsoda.com/([^/]*?)/?").matcher(url); + if(m.matches()) { + String modelName = m.group(1); + return createModel(modelName); + } else { + return super.createModelFromUrl(url); + } + } } diff --git a/common/src/main/java/ctbrec/sites/camsoda/CamsodaModel.java b/common/src/main/java/ctbrec/sites/camsoda/CamsodaModel.java index 53af0dad..36bd9019 100644 --- a/common/src/main/java/ctbrec/sites/camsoda/CamsodaModel.java +++ b/common/src/main/java/ctbrec/sites/camsoda/CamsodaModel.java @@ -1,22 +1,22 @@ package ctbrec.sites.camsoda; +import static ctbrec.Model.State.*; + import java.io.IOException; import java.io.InputStream; import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; import org.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.google.common.cache.Cache; -import com.google.common.cache.CacheBuilder; import com.iheartradio.m3u8.Encoding; import com.iheartradio.m3u8.Format; import com.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.ParsingMode; import com.iheartradio.m3u8.PlaylistException; import com.iheartradio.m3u8.PlaylistParser; import com.iheartradio.m3u8.data.MasterPlaylist; @@ -38,14 +38,8 @@ public class CamsodaModel extends AbstractModel { private static final transient Logger LOG = LoggerFactory.getLogger(CamsodaModel.class); private String streamUrl; private List streamSources = null; - private String status = "n/a"; private float sortOrder = 0; - - private static Cache streamResolutionCache = CacheBuilder.newBuilder() - .initialCapacity(10_000) - .maximumSize(10_000) - .expireAfterWrite(30, TimeUnit.MINUTES) - .build(); + int[] resolution = new int[2]; public String getStreamUrl() throws IOException { if(streamUrl == null) { @@ -63,7 +57,8 @@ public class CamsodaModel extends AbstractModel { JSONObject result = new JSONObject(response.body().string()); if(result.getBoolean("status")) { JSONObject chat = result.getJSONObject("user").getJSONObject("chat"); - status = chat.getString("status"); + String status = chat.getString("status"); + setOnlineStateByStatus(status); if(chat.has("edge_servers")) { String edgeServer = chat.getJSONArray("edge_servers").getString(0); String streamName = chat.getString("stream_name"); @@ -78,30 +73,46 @@ public class CamsodaModel extends AbstractModel { } } + public void setOnlineStateByStatus(String status) { + switch(status) { + case "online": + onlineState = ONLINE; + break; + case "offline": + onlineState = OFFLINE; + break; + case "private": + onlineState = PRIVATE; + break; + case "limited": + onlineState = GROUP; + break; + default: + LOG.debug("Unknown show type {}", status); + onlineState = UNKNOWN; + } + } + @Override public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { - if(ignoreCache) { + if(ignoreCache || onlineState == UNKNOWN) { loadModel(); } - return Objects.equals(status, "online"); + return onlineState == ONLINE; } @Override - public String getOnlineState(boolean failFast) throws IOException, ExecutionException { + public State getOnlineState(boolean failFast) throws IOException, ExecutionException { if(failFast) { - return status; + return onlineState; } else { - if(status.equals("n/a")) { + if(onlineState == UNKNOWN) { loadModel(); } - return status; + return onlineState; } } - public void setOnlineState(String state) { - this.status = state; - } - @Override public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException { String streamUrl = getStreamUrl(); @@ -112,7 +123,7 @@ public class CamsodaModel extends AbstractModel { Response response = site.getHttpClient().execute(req); try { InputStream inputStream = response.body().byteStream(); - PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8); + PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT); Playlist playlist = parser.parse(); MasterPlaylist master = playlist.getMasterPlaylist(); PlaylistData playlistData = master.getPlaylists().get(0); @@ -138,13 +149,11 @@ public class CamsodaModel extends AbstractModel { @Override public void invalidateCacheEntries() { streamSources = null; - streamResolutionCache.invalidate(getName()); } @Override public int[] getStreamResolution(boolean failFast) throws ExecutionException { - int[] resolution = streamResolutionCache.getIfPresent(getName()); - if(resolution != null) { + if(failFast) { return resolution; } else { if(failFast) { @@ -157,7 +166,6 @@ public class CamsodaModel extends AbstractModel { } else { StreamSource src = streamSources.get(0); resolution = new int[] {src.width, src.height}; - streamResolutionCache.put(getName(), resolution); return resolution; } } catch (IOException | ParseException | PlaylistException e) { diff --git a/common/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java b/common/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java index 105be013..d31b7983 100644 --- a/common/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java +++ b/common/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java @@ -3,9 +3,15 @@ package ctbrec.sites.chaturbate; import java.io.EOFException; import java.io.IOException; import java.io.InputStream; +import java.net.URLEncoder; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,6 +22,7 @@ import com.google.common.cache.LoadingCache; import com.iheartradio.m3u8.Encoding; import com.iheartradio.m3u8.Format; import com.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.ParsingMode; import com.iheartradio.m3u8.PlaylistException; import com.iheartradio.m3u8.PlaylistParser; import com.iheartradio.m3u8.data.MasterPlaylist; @@ -38,14 +45,14 @@ import okhttp3.Response; public class Chaturbate extends AbstractSite { private static final transient Logger LOG = LoggerFactory.getLogger(Chaturbate.class); - public static final String BASE_URI = "https://chaturbate.com"; - public static final String AFFILIATE_LINK = BASE_URI + "/in/?track=default&tour=grq0&campaign=55vTi"; - public static final String REGISTRATION_LINK = BASE_URI + "/in/?track=default&tour=g4pe&campaign=55vTi"; + static String baseUrl = "https://chaturbate.com"; + public static final String AFFILIATE_LINK = "https://chaturbate.com/in/?track=default&tour=grq0&campaign=55vTi"; + public static final String REGISTRATION_LINK = "https://chaturbate.com/in/?track=default&tour=g4pe&campaign=55vTi"; private ChaturbateHttpClient httpClient; @Override public void init() throws IOException { - + baseUrl = Config.getInstance().getSettings().chaturbateBaseUrl; } @Override @@ -55,7 +62,7 @@ public class Chaturbate extends AbstractSite { @Override public String getBaseUrl() { - return "https://chaturbate.com"; + return baseUrl; } @Override @@ -68,6 +75,7 @@ public class Chaturbate extends AbstractSite { ChaturbateModel m = new ChaturbateModel(this); m.setName(name); m.setUrl(getBaseUrl() + '/' + name + '/'); + m.setPreview("https://roomimg.stream.highwebmedia.com/ri/" + name + ".jpg?" + Instant.now().getEpochSecond()); return m; } @@ -97,7 +105,7 @@ public class Chaturbate extends AbstractSite { } @Override - public boolean login() throws IOException { + public synchronized boolean login() throws IOException { return credentialsAvailable() && getHttpClient().login(); } @@ -124,6 +132,44 @@ public class Chaturbate extends AbstractSite { return true; } + @Override + public boolean supportsSearch() { + return true; + } + + @Override + public List search(String q) throws IOException, InterruptedException { + String url = baseUrl + "?keywords=" + URLEncoder.encode(q, "utf-8"); + List result = new ArrayList<>(); + + // search online models + Request req = new Request.Builder() + .url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .build(); + try(Response resp = getHttpClient().execute(req)) { + if(resp.isSuccessful()) { + result.addAll(ChaturbateModelParser.parseModels(this, resp.body().string())); + } + } + + // since chaturbate does not return offline models, we at least try, if the profile page + // exists for the search string + url = baseUrl + '/' + q; + req = new Request.Builder() + .url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .build(); + try(Response resp = getHttpClient().execute(req)) { + if(resp.isSuccessful()) { + Model model = createModel(q); + result.add(model); + } + } + + return result; + } + @Override public boolean isSiteForModel(Model m) { return m instanceof ChaturbateModel; @@ -143,17 +189,6 @@ public class Chaturbate extends AbstractSite { } }); - LoadingCache streamResolutionCache = CacheBuilder.newBuilder() - .initialCapacity(10_000) - .maximumSize(10_000) - .expireAfterWrite(5, TimeUnit.MINUTES) - .build(new CacheLoader () { - @Override - public int[] load(String model) throws Exception { - return loadResolution(model); - } - }); - public void sendTip(String name, int tokens) throws IOException { if (!Objects.equals(System.getenv("CTBREC_DEV"), "1")) { RequestBody body = new FormBody.Builder() @@ -176,7 +211,15 @@ public class Chaturbate extends AbstractSite { } StreamInfo getStreamInfo(String modelName) throws IOException, ExecutionException { - return streamInfoCache.get(modelName); + return getStreamInfo(modelName, false); + } + + StreamInfo getStreamInfo(String modelName, boolean failFast) throws IOException, ExecutionException { + if(failFast) { + return streamInfoCache.getIfPresent(modelName); + } else { + return streamInfoCache.get(modelName); + } } StreamInfo loadStreamInfo(String modelName) throws HttpException, IOException, InterruptedException { @@ -186,7 +229,7 @@ public class Chaturbate extends AbstractSite { .add("bandwidth", "high") .build(); Request req = new Request.Builder() - .url("https://chaturbate.com/get_edge_hls_url_ajax/") + .url(getBaseUrl() + "/get_edge_hls_url_ajax/") .post(body) .addHeader("X-Requested-With", "XMLHttpRequest") .build(); @@ -210,11 +253,9 @@ public class Chaturbate extends AbstractSite { } } - public int[] getResolution(String modelName) throws ExecutionException { - return streamResolutionCache.get(modelName); - } + public int[] getResolution(String modelName) throws ExecutionException, IOException, ParseException, PlaylistException, InterruptedException { + throttleRequests(); - private int[] loadResolution(String modelName) throws IOException, ParseException, PlaylistException, ExecutionException, InterruptedException { int[] res = new int[2]; StreamInfo streamInfo = getStreamInfo(modelName); if(!streamInfo.url.startsWith("http")) { @@ -249,7 +290,6 @@ public class Chaturbate extends AbstractSite { throw ex; } - streamResolutionCache.put(modelName, res); return res; } @@ -273,7 +313,7 @@ public class Chaturbate extends AbstractSite { try (Response response = getHttpClient().execute(req)) { if(response.isSuccessful()) { InputStream inputStream = response.body().byteStream(); - PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8); + PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT); Playlist playlist = parser.parse(); MasterPlaylist master = playlist.getMasterPlaylist(); return master; @@ -288,4 +328,15 @@ public class Chaturbate extends AbstractSite { String username = Config.getInstance().getSettings().username; return username != null && !username.trim().isEmpty(); } + + @Override + public Model createModelFromUrl(String url) { + Matcher m = Pattern.compile("https?://.*?chaturbate.com(?:/p)?/([^/]*?)/?").matcher(url); + if(m.matches()) { + String modelName = m.group(1); + return createModel(modelName); + } else { + return super.createModelFromUrl(url); + } + } } diff --git a/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateHttpClient.java b/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateHttpClient.java index 9950bccd..fbfffa70 100644 --- a/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateHttpClient.java +++ b/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateHttpClient.java @@ -54,7 +54,7 @@ public class ChaturbateHttpClient extends HttpClient { try { Request login = new Request.Builder() - .url(Chaturbate.BASE_URI + "/auth/login/") + .url(Chaturbate.baseUrl + "/auth/login/") .build(); Response response = client.newCall(login).execute(); String content = response.body().string(); @@ -68,8 +68,8 @@ public class ChaturbateHttpClient extends HttpClient { .add("csrfmiddlewaretoken", token) .build(); login = new Request.Builder() - .url(Chaturbate.BASE_URI + "/auth/login/") - .header("Referer", Chaturbate.BASE_URI + "/auth/login/") + .url(Chaturbate.baseUrl + "/auth/login/") + .header("Referer", Chaturbate.baseUrl + "/auth/login/") .post(body) .build(); diff --git a/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateModel.java b/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateModel.java index 4a97dbe1..e8322b8c 100644 --- a/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateModel.java +++ b/common/src/main/java/ctbrec/sites/chaturbate/ChaturbateModel.java @@ -1,11 +1,12 @@ package ctbrec.sites.chaturbate; -import static ctbrec.sites.chaturbate.Chaturbate.*; +import static ctbrec.Model.State.*; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.ExecutionException; import org.slf4j.Logger; @@ -26,7 +27,7 @@ import okhttp3.Response; public class ChaturbateModel extends AbstractModel { private static final transient Logger LOG = LoggerFactory.getLogger(ChaturbateModel.class); - private Chaturbate chaturbate; + private int[] resolution = new int[2]; /** * This constructor exists only for deserialization. Please don't call it directly @@ -36,33 +37,34 @@ public class ChaturbateModel extends AbstractModel { ChaturbateModel(Chaturbate site) { this.site = site; - this.chaturbate = site; } @Override public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { - StreamInfo info; + String roomStatus; if(ignoreCache) { - info = chaturbate.loadStreamInfo(getName()); + StreamInfo info = getChaturbate().loadStreamInfo(getName()); + roomStatus = Optional.ofNullable(info).map(i -> i.room_status).orElse(""); LOG.trace("Model {} room status: {}", getName(), info.room_status); } else { - info = chaturbate.getStreamInfo(getName()); + StreamInfo info = getChaturbate().getStreamInfo(getName(), true); + roomStatus = Optional.ofNullable(info).map(i -> i.room_status).orElse(""); } - return Objects.equals("public", info.room_status); + return Objects.equals("public", roomStatus); } @Override public int[] getStreamResolution(boolean failFast) throws ExecutionException { - int[] resolution = chaturbate.streamResolutionCache.getIfPresent(getName()); - if(resolution != null) { - return chaturbate.getResolution(getName()); - } else { - if(failFast) { - return new int[2]; - } else { - return chaturbate.getResolution(getName()); - } + if(failFast) { + return resolution; } + + try { + resolution = getChaturbate().getResolution(getName()); + } catch(Exception e) { + throw new ExecutionException(e); + } + return resolution; } /** @@ -71,30 +73,62 @@ public class ChaturbateModel extends AbstractModel { */ @Override public void invalidateCacheEntries() { - chaturbate.streamInfoCache.invalidate(getName()); - chaturbate.streamResolutionCache.invalidate(getName()); + getChaturbate().streamInfoCache.invalidate(getName()); } - public String getOnlineState() throws IOException, ExecutionException { + public State getOnlineState() throws IOException, ExecutionException { return getOnlineState(false); } @Override - public String getOnlineState(boolean failFast) throws IOException, ExecutionException { - StreamInfo info = chaturbate.streamInfoCache.getIfPresent(getName()); - return info != null ? info.room_status : "n/a"; + public State getOnlineState(boolean failFast) throws IOException, ExecutionException { + if(failFast) { + StreamInfo info = getChaturbate().streamInfoCache.getIfPresent(getName()); + setOnlineStateByRoomStatus(info.room_status); + } else { + StreamInfo info = getChaturbate().streamInfoCache.get(getName()); + setOnlineStateByRoomStatus(info.room_status); + } + return onlineState; + } + + private void setOnlineStateByRoomStatus(String room_status) { + if(room_status != null) { + switch(room_status) { + case "public": + onlineState = ONLINE; + break; + case "offline": + onlineState = OFFLINE; + break; + case "private": + case "hidden": + case "password protected": + onlineState = PRIVATE; + break; + case "away": + onlineState = AWAY; + break; + case "group": + onlineState = State.GROUP; + break; + default: + LOG.debug("Unknown show type {}", room_status); + onlineState = State.UNKNOWN; + } + } } public StreamInfo getStreamInfo() throws IOException, ExecutionException { - return chaturbate.getStreamInfo(getName()); + return getChaturbate().getStreamInfo(getName()); } public MasterPlaylist getMasterPlaylist() throws IOException, ParseException, PlaylistException, ExecutionException { - return chaturbate.getMasterPlaylist(getName()); + return getChaturbate().getMasterPlaylist(getName()); } @Override public void receiveTip(int tokens) throws IOException { - chaturbate.sendTip(getName(), tokens); + getChaturbate().sendTip(getName(), tokens); } @Override @@ -112,6 +146,9 @@ public class ChaturbateModel extends AbstractModel { String baseUrl = masterUrl.substring(0, masterUrl.lastIndexOf('/') + 1); String segmentUri = baseUrl + playlist.getUri(); src.mediaPlaylistUrl = segmentUri; + if(src.mediaPlaylistUrl.contains("?")) { + src.mediaPlaylistUrl = src.mediaPlaylistUrl.substring(0, src.mediaPlaylistUrl.lastIndexOf('?')); + } LOG.trace("Media playlist {}", src.mediaPlaylistUrl); sources.add(src); } @@ -136,9 +173,9 @@ public class ChaturbateModel extends AbstractModel { String url = null; if(follow) { - url = BASE_URI + "/follow/follow/" + getName() + "/"; + url = getSite().getBaseUrl() + "/follow/follow/" + getName() + "/"; } else { - url = BASE_URI + "/follow/unfollow/" + getName() + "/"; + url = getSite().getBaseUrl() + "/follow/unfollow/" + getName() + "/"; } RequestBody body = RequestBody.create(null, new byte[0]); @@ -167,4 +204,8 @@ public class ChaturbateModel extends AbstractModel { throw new IOException("HTTP status " + resp.code() + " " + resp.message()); } } + + private Chaturbate getChaturbate() { + return (Chaturbate) site; + } } diff --git a/common/src/main/java/ctbrec/sites/fc2live/Fc2HttpClient.java b/common/src/main/java/ctbrec/sites/fc2live/Fc2HttpClient.java index db6139d1..5f5ab795 100644 --- a/common/src/main/java/ctbrec/sites/fc2live/Fc2HttpClient.java +++ b/common/src/main/java/ctbrec/sites/fc2live/Fc2HttpClient.java @@ -2,19 +2,11 @@ package ctbrec.sites.fc2live; import java.io.IOException; -import org.jsoup.select.Elements; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import ctbrec.Config; -import ctbrec.io.HtmlParser; import ctbrec.io.HttpClient; -import ctbrec.io.HttpException; -import ctbrec.sites.mfc.MyFreeCams; -import okhttp3.FormBody; import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; import okhttp3.WebSocket; import okhttp3.WebSocketListener; @@ -28,64 +20,66 @@ public class Fc2HttpClient extends HttpClient { @Override public boolean login() throws IOException { - if(loggedIn) { - return true; - } - - if(checkLogin()) { - loggedIn = true; - LOG.debug("Logged in with cookies"); - return true; - } - - String username = Config.getInstance().getSettings().mfcUsername; - String password = Config.getInstance().getSettings().mfcPassword; - RequestBody body = new FormBody.Builder() - .add("username", username) - .add("password", password) - .add("tz", "2") - .add("ss", "1920x1080") - .add("submit_login", "97") - .build(); - Request req = new Request.Builder() - .url(MyFreeCams.BASE_URI + "/php/login.php") - .header("Referer", MyFreeCams.BASE_URI) - .header("Content-Type", "application/x-www-form-urlencoded") - .post(body) - .build(); - Response resp = execute(req); - if(resp.isSuccessful()) { - String page = resp.body().string(); - if(page.contains("Your username or password are incorrect")) { - return false; - } else { - loggedIn = true; - return true; - } - } else { - resp.close(); - LOG.error("Login failed {} {}", resp.code(), resp.message()); - return false; - } + // if(loggedIn) { + // return true; + // } + // + // if(checkLogin()) { + // loggedIn = true; + // LOG.debug("Logged in with cookies"); + // return true; + // } + // + // String username = Config.getInstance().getSettings().mfcUsername; + // String password = Config.getInstance().getSettings().mfcPassword; + // RequestBody body = new FormBody.Builder() + // .add("username", username) + // .add("password", password) + // .add("tz", "2") + // .add("ss", "1920x1080") + // .add("submit_login", "97") + // .build(); + // Request req = new Request.Builder() + // .url(Fc2Live.BASE_URL + "/php/login.php") + // .header("Referer", Fc2Live.BASE_URL) + // .header("Content-Type", "application/x-www-form-urlencoded") + // .post(body) + // .build(); + // Response resp = execute(req); + // if(resp.isSuccessful()) { + // String page = resp.body().string(); + // if(page.contains("Your username or password are incorrect")) { + // return false; + // } else { + // loggedIn = true; + // return true; + // } + // } else { + // resp.close(); + // LOG.error("Login failed {} {}", resp.code(), resp.message()); + // return false; + // } + return false; } private boolean checkLogin() throws IOException { - Request req = new Request.Builder().url(MyFreeCams.BASE_URI + "/php/account.php?request=status").build(); - try(Response response = execute(req)) { - if(response.isSuccessful()) { - String content = response.body().string(); - try { - Elements tags = HtmlParser.getTags(content, "div.content > p > b"); - tags.get(2).text(); - return true; - } catch(Exception e) { - LOG.debug("Token tag not found. Login failed"); - return false; - } - } else { - throw new HttpException(response.code(), response.message()); - } - } + // Request req = new Request.Builder().url(Fc2Live.BASE_URL + "/php/account.php?request=status").build(); + // try(Response response = execute(req)) { + // if(response.isSuccessful()) { + // String content = response.body().string(); + // try { + // Elements tags = HtmlParser.getTags(content, "div.content > p > b"); + // tags.get(2).text(); + // return true; + // } catch(Exception e) { + // LOG.debug("Token tag not found. Login failed"); + // return false; + // } + // } else { + // throw new HttpException(response.code(), response.message()); + // } + // } + return false; } public WebSocket newWebSocket(Request req, WebSocketListener webSocketListener) { diff --git a/common/src/main/java/ctbrec/sites/fc2live/Fc2Model.java b/common/src/main/java/ctbrec/sites/fc2live/Fc2Model.java index fb263585..05afaeeb 100644 --- a/common/src/main/java/ctbrec/sites/fc2live/Fc2Model.java +++ b/common/src/main/java/ctbrec/sites/fc2live/Fc2Model.java @@ -36,7 +36,6 @@ public class Fc2Model extends AbstractModel { private String id; private int viewerCount; private boolean online; - private String onlineState = "n/a"; private String version; @Override @@ -71,7 +70,7 @@ public class Fc2Model extends AbstractModel { JSONObject data = json.getJSONObject("data"); JSONObject channelData = data.getJSONObject("channel_data"); online = channelData.optInt("is_publish") == 1; - onlineState = online ? "online" : "offline"; + onlineState = online ? State.ONLINE : State.OFFLINE; version = channelData.optString("version"); } else { resp.close(); @@ -81,7 +80,7 @@ public class Fc2Model extends AbstractModel { } @Override - public String getOnlineState(boolean failFast) throws IOException, ExecutionException { + public State getOnlineState(boolean failFast) throws IOException, ExecutionException { if(failFast) { return onlineState; } else if(Objects.equals(onlineState, "n/a")){ diff --git a/common/src/main/java/ctbrec/sites/mfc/MyFreeCams.java b/common/src/main/java/ctbrec/sites/mfc/MyFreeCams.java index bb0d4c13..787fac8e 100644 --- a/common/src/main/java/ctbrec/sites/mfc/MyFreeCams.java +++ b/common/src/main/java/ctbrec/sites/mfc/MyFreeCams.java @@ -1,6 +1,9 @@ package ctbrec.sites.mfc; import java.io.IOException; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.jsoup.select.Elements; @@ -14,20 +17,21 @@ import okhttp3.Response; public class MyFreeCams extends AbstractSite { - public static final String BASE_URI = "https://www.myfreecams.com"; + static String baseUrl = "https://www.myfreecams.com"; private MyFreeCamsClient client; private MyFreeCamsHttpClient httpClient; @Override public void init() throws IOException { + baseUrl = Config.getInstance().getSettings().mfcBaseUrl; client = MyFreeCamsClient.getInstance(); client.setSite(this); client.start(); } @Override - public boolean login() throws IOException { + public synchronized boolean login() throws IOException { return credentialsAvailable() && getHttpClient().login(); } @@ -38,12 +42,12 @@ public class MyFreeCams extends AbstractSite { @Override public String getBaseUrl() { - return BASE_URI; + return baseUrl; } @Override public String getAffiliateLink() { - return BASE_URI + "/?baf=8127165"; + return baseUrl + "/?baf=8127165"; } @Override @@ -56,7 +60,7 @@ public class MyFreeCams extends AbstractSite { @Override public Integer getTokenBalance() throws IOException { - Request req = new Request.Builder().url(BASE_URI + "/php/account.php?request=status").build(); + Request req = new Request.Builder().url(baseUrl + "/php/account.php?request=status").build(); try(Response response = getHttpClient().execute(req)) { if(response.isSuccessful()) { String content = response.body().string(); @@ -71,7 +75,7 @@ public class MyFreeCams extends AbstractSite { @Override public String getBuyTokensLink() { - return BASE_URI + "/php/purchase.php?request=tokens"; + return baseUrl + "/php/purchase.php?request=tokens"; } @Override @@ -97,6 +101,16 @@ public class MyFreeCams extends AbstractSite { return true; } + @Override + public boolean supportsSearch() { + return true; + } + + @Override + public List search(String q) throws IOException, InterruptedException { + return client.search(q); + } + @Override public boolean isSiteForModel(Model m) { return m instanceof MyFreeCamsModel; @@ -111,4 +125,20 @@ public class MyFreeCams extends AbstractSite { String username = Config.getInstance().getSettings().mfcUsername; return username != null && !username.trim().isEmpty(); } + + @Override + public Model createModelFromUrl(String url) { + String[] patterns = new String[] { + "https?://profiles.myfreecams.com/([^/]*?)", + "https?://(?:www.)?myfreecams.com/#(.*)" + }; + for (String pattern : patterns) { + Matcher m = Pattern.compile(pattern).matcher(url); + if(m.matches()) { + String modelName = m.group(1); + return createModel(modelName); + } + } + return super.createModelFromUrl(url); + } } diff --git a/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java b/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java index 7a5ef0c6..cc4a3e06 100644 --- a/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java +++ b/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java @@ -7,14 +7,18 @@ import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Map.Entry; import java.util.Objects; import java.util.Optional; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; import org.json.JSONArray; import org.json.JSONObject; @@ -49,7 +53,6 @@ public class MyFreeCamsClient { private Cache sessionStates = CacheBuilder.newBuilder().maximumSize(4000).build(); private Cache models = CacheBuilder.newBuilder().maximumSize(4000).build(); private Lock lock = new ReentrantLock(); - private ExecutorService executor = Executors.newSingleThreadExecutor(); private ServerConfig serverConfig; @SuppressWarnings("unused") private String tkx; @@ -59,6 +62,9 @@ public class MyFreeCamsClient { private String chatToken; private int sessionId; private long heartBeat; + private volatile boolean connecting = false; + private static int messageId = 31415; // starting with 31415 just for fun + private Map> responseHandlers = new HashMap<>(); private EvictingQueue receivedTextHistory = EvictingQueue.create(100); @@ -79,14 +85,20 @@ public class MyFreeCamsClient { public void start() throws IOException { running = true; - serverConfig = new ServerConfig(mfc.getHttpClient()); - List websocketServers = new ArrayList(serverConfig.wsServers.keySet()); - String server = websocketServers.get((int) (Math.random()*websocketServers.size())); + serverConfig = new ServerConfig(mfc); + List websocketServers = new ArrayList(serverConfig.wsServers.size()); + for (Entry entry : serverConfig.wsServers.entrySet()) { + if (entry.getValue().equals("rfc6455")) { + websocketServers.add(entry.getKey()); + } + } + String server = websocketServers.get((int) (Math.random() * websocketServers.size() - 1)); String wsUrl = "ws://" + server + ".myfreecams.com:8080/fcsl"; + LOG.debug("Connecting to random websocket server {}", wsUrl); Thread watchDog = new Thread(() -> { while(running) { - if (ws == null) { + if (ws == null && !connecting) { LOG.info("Websocket is null. Starting a new connection"); Request req = new Request.Builder() .url(wsUrl) @@ -126,11 +138,15 @@ public class MyFreeCamsClient { } private WebSocket createWebSocket(Request req) { + connecting = true; WebSocket ws = mfc.getHttpClient().newWebSocket(req, new WebSocketListener() { @Override public void onOpen(WebSocket webSocket, Response response) { super.onOpen(webSocket, response); try { + connecting = false; + sessionStates.invalidateAll(); + models.invalidateAll(); LOG.trace("open: [{}]", response.body().string()); webSocket.send("hello fcserver\n"); webSocket.send("fcsws_20180422\n"); @@ -147,6 +163,7 @@ public class MyFreeCamsClient { @Override public void onClosed(WebSocket webSocket, int code, String reason) { super.onClosed(webSocket, code, reason); + connecting = false; LOG.info("MFC websocket closed: {} {}", code, reason); MyFreeCamsClient.this.ws = null; if(!running) { @@ -157,11 +174,16 @@ public class MyFreeCamsClient { @Override public void onFailure(WebSocket webSocket, Throwable t, Response response) { super.onFailure(webSocket, t, response); - int code = response.code(); - String message = response.message(); - response.close(); + connecting = false; + if(response != null) { + int code = response.code(); + String message = response.message(); + LOG.error("MFC websocket failure: {} {}", code, message, t); + response.close(); + } else { + LOG.error("MFC websocket failure", t); + } MyFreeCamsClient.this.ws = null; - LOG.error("MFC websocket failure: {} {}", code, message, t); } private StringBuilder msgBuffer = new StringBuilder(); @@ -182,6 +204,7 @@ public class MyFreeCamsClient { case LOGIN: LOG.debug("LOGIN: {}", message); sessionId = message.getReceiver(); + LOG.debug("Session ID {}", sessionId); break; case DETAILS: case ROOMHELPER: @@ -190,7 +213,6 @@ public class MyFreeCamsClient { case CMESG: case PMESG: case TXPROFILE: - case USERNAMELOOKUP: case MYCAMSTATE: case MYWEBCAM: case JOINCHAN: @@ -206,6 +228,18 @@ public class MyFreeCamsClient { } } break; + case USERNAMELOOKUP: + // LOG.debug("{}", message.getType()); + // LOG.debug("{}", message.getSender()); + // LOG.debug("{}", message.getReceiver()); + // LOG.debug("{}", message.getArg1()); + // LOG.debug("{}", message.getArg2()); + // LOG.debug("{}", message.getMessage()); + Consumer responseHandler = responseHandlers.remove(message.getArg1()); + if(responseHandler != null) { + responseHandler.accept(message); + } + break; case TAGS: JSONObject json = new JSONObject(message.getMessage()); String[] names = JSONObject.getNames(json); @@ -243,7 +277,7 @@ public class MyFreeCamsClient { case ROOMDATA: LOG.debug("ROOMDATA: {}", message); case UEOPT: - LOG.debug("UEOPT: {}", message); + LOG.trace("UEOPT: {}", message); break; case SLAVEVSHARE: // LOG.debug("SLAVEVSHARE {}", message); @@ -261,7 +295,7 @@ public class MyFreeCamsClient { } break; default: - LOG.debug("Unknown message {}", message); + LOG.trace("Unknown message {}", message); break; } } @@ -279,7 +313,7 @@ public class MyFreeCamsClient { long opts = json.getInt("opts"); long serv = json.getInt("serv"); long type = json.getInt("type"); - String base = "http://www.myfreecams.com/php/FcwExtResp.php"; + String base = mfc.getBaseUrl() + "/php/FcwExtResp.php"; String url = base + "?respkey="+respkey+"&opts="+opts+"&serv="+serv+"&type="+type; Request req = new Request.Builder().url(url).build(); LOG.trace("Requesting EXTDATA {}", url); @@ -386,6 +420,11 @@ public class MyFreeCamsClient { return; } + // uid not set, we can't identify this model + if(state.getUid() == null || state.getUid() <= 0) { + return; + } + MyFreeCamsModel model = models.getIfPresent(state.getUid()); if(model == null) { model = mfc.createModel(state.getNm()); @@ -499,7 +538,7 @@ public class MyFreeCamsClient { for (SessionState state : sessionStates.asMap().values()) { String nm = Optional.ofNullable(state.getNm()).orElse(""); String name = Optional.ofNullable(model.getName()).orElse(""); - if(Objects.equals(nm.toLowerCase(), name.toLowerCase()) || Objects.equals(model.getUid(), state.getUid())) { + if(Objects.equals(nm.toLowerCase(), name.toLowerCase()) || Objects.equals(model.getUid(), state.getUid()) && state.getUid() > 0) { model.update(state, getStreamUrl(state)); return; } @@ -510,8 +549,8 @@ public class MyFreeCamsClient { } public String getStreamUrl(SessionState state) { - Integer camserv = state.getU().getCamserv(); - if(camserv != null) { + Integer camserv = Optional.ofNullable(state.getU()).map(u -> u.getCamserv()).orElse(-1); + if(camserv != null && camserv != -1) { int userChannel = 100000000 + state.getUid(); String streamUrl = ""; String phase = state.getU().getPhase() != null ? state.getU().getPhase() : "z"; @@ -539,10 +578,6 @@ public class MyFreeCamsClient { return models.getIfPresent(uid); } - public void execute(Runnable r) { - executor.execute(r); - } - public void getSessionState(ctbrec.Model model) { for (SessionState state : sessionStates.asMap().values()) { if(Objects.equals(state.getNm(), model.getName())) { @@ -560,4 +595,47 @@ public class MyFreeCamsClient { public ServerConfig getServerConfig() { return serverConfig; } + + public List search(String q) throws InterruptedException { + LOG.debug("Sending USERNAMELOOKUP for {}", q); + int msgId = messageId++; + Object monitor = new Object(); + List result = new ArrayList<>(); + responseHandlers.put(msgId, msg -> { + LOG.debug("Search result: " + msg); + if(StringUtil.isNotBlank(msg.getMessage()) && !Objects.equals(msg.getMessage(), q)) { + JSONObject json = new JSONObject(msg.getMessage()); + String name = json.getString("nm"); + MyFreeCamsModel model = mfc.createModel(name); + model.setUid(json.getInt("uid")); + model.setMfcState(State.of(json.getInt("vs"))); + String uid = Integer.toString(model.getUid()); + String uidStart = uid.substring(0, 3); + String previewUrl = "https://img.mfcimg.com/photos2/"+uidStart+'/'+uid+"/avatar.90x90.jpg"; + model.setPreview(previewUrl); + result.add(model); + } + synchronized (monitor) { + monitor.notify(); + } + }); + ws.send("10 " + sessionId + " 0 " + msgId + " 0 " + q + "\n"); + synchronized (monitor) { + monitor.wait(); + } + + for (MyFreeCamsModel model : models.asMap().values()) { + if(StringUtil.isNotBlank(model.getName())) { + if(model.getName().toLowerCase().contains(q.toLowerCase())) { + result.add(model); + } + } + } + + return result; + } + + public Collection getSessionStates() { + return Collections.unmodifiableCollection(sessionStates.asMap().values()); + } } diff --git a/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsHttpClient.java b/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsHttpClient.java index 1225ee45..8100b7b8 100644 --- a/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsHttpClient.java +++ b/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsHttpClient.java @@ -53,8 +53,8 @@ public class MyFreeCamsHttpClient extends HttpClient { .add("submit_login", "97") .build(); Request req = new Request.Builder() - .url(MyFreeCams.BASE_URI + "/php/login.php") - .header("Referer", MyFreeCams.BASE_URI) + .url(MyFreeCams.baseUrl + "/php/login.php") + .header("Referer", MyFreeCams.baseUrl) .header("Content-Type", "application/x-www-form-urlencoded") .post(body) .build(); @@ -75,7 +75,7 @@ public class MyFreeCamsHttpClient extends HttpClient { } private boolean checkLogin() throws IOException { - Request req = new Request.Builder().url(MyFreeCams.BASE_URI + "/php/account.php?request=status").build(); + Request req = new Request.Builder().url(MyFreeCams.baseUrl + "/php/account.php?request=status").build(); try(Response response = execute(req)) { if(response.isSuccessful()) { String content = response.body().string(); @@ -99,7 +99,7 @@ public class MyFreeCamsHttpClient extends HttpClient { public Cookie getCookie(String name) { CookieJar jar = client.cookieJar(); - HttpUrl url = HttpUrl.parse(MyFreeCams.BASE_URI); + HttpUrl url = HttpUrl.parse(MyFreeCams.baseUrl); List cookies = jar.loadForRequest(url); for (Cookie cookie : cookies) { if(Objects.equals(cookie.name(), name)) { diff --git a/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java b/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java index f4e0f046..768ef5df 100644 --- a/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java +++ b/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; import org.jsoup.nodes.Element; import org.slf4j.Logger; @@ -18,6 +19,7 @@ import org.slf4j.LoggerFactory; import com.iheartradio.m3u8.Encoding; import com.iheartradio.m3u8.Format; import com.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.ParsingMode; import com.iheartradio.m3u8.PlaylistException; import com.iheartradio.m3u8.PlaylistParser; import com.iheartradio.m3u8.data.MasterPlaylist; @@ -27,6 +29,7 @@ import com.squareup.moshi.JsonReader; import com.squareup.moshi.JsonWriter; import ctbrec.AbstractModel; +import ctbrec.Config; import ctbrec.io.HtmlParser; import ctbrec.io.HttpException; import ctbrec.recorder.download.StreamSource; @@ -43,8 +46,8 @@ public class MyFreeCamsModel extends AbstractModel { private String hlsUrl; private double camScore; private int viewerCount; - private State state; - private int resolution[]; + private ctbrec.sites.mfc.State state; + private int resolution[] = new int[2]; /** * This constructor exists only for deserialization. Please don't call it directly @@ -58,7 +61,7 @@ public class MyFreeCamsModel extends AbstractModel { @Override public boolean isOnline() throws IOException, ExecutionException, InterruptedException { MyFreeCamsClient.getInstance().update(this); - return state == State.ONLINE; + return state == ctbrec.sites.mfc.State.ONLINE; } @Override @@ -67,8 +70,29 @@ public class MyFreeCamsModel extends AbstractModel { } @Override - public String getOnlineState(boolean failFast) throws IOException, ExecutionException { - return state != null ? state.toString() : "offline"; + public State getOnlineState(boolean failFast) throws IOException, ExecutionException { + if(state == null) { + return State.UNKNOWN; + } + + switch(state) { + case ONLINE: + case RECORDING: + return ctbrec.Model.State.ONLINE; + case AWAY: + return ctbrec.Model.State.AWAY; + case PRIVATE: + return ctbrec.Model.State.PRIVATE; + case GROUP_SHOW: + return ctbrec.Model.State.GROUP; + case OFFLINE: + case CAMOFF: + case UNKNOWN: + return ctbrec.Model.State.OFFLINE; + default: + LOG.debug("State {} is not mapped", this.state); + return ctbrec.Model.State.UNKNOWN; + } } @Override @@ -94,11 +118,17 @@ public class MyFreeCamsModel extends AbstractModel { sources.add(src); } } - return sources; + if(Config.getInstance().getSettings().mfcIgnoreUpscaled) { + return sources.stream() + .filter(src -> src.height != 960) + .collect(Collectors.toList()); + } else { + return sources; + } } private MasterPlaylist getMasterPlaylist() throws IOException, ParseException, PlaylistException { - if(hlsUrl == null) { + if(getHlsUrl() == null) { throw new IllegalStateException("Stream url unknown"); } LOG.trace("Loading master playlist {}", hlsUrl); @@ -106,7 +136,7 @@ public class MyFreeCamsModel extends AbstractModel { try(Response response = site.getHttpClient().execute(req)) { if(response.isSuccessful()) { InputStream inputStream = response.body().byteStream(); - PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8); + PlaylistParser parser = new PlaylistParser(inputStream, Format.EXT_M3U, Encoding.UTF_8, ParsingMode.LENIENT); Playlist playlist = parser.parse(); MasterPlaylist master = playlist.getMasterPlaylist(); return master; @@ -116,6 +146,14 @@ public class MyFreeCamsModel extends AbstractModel { } } + private String getHlsUrl() { + if(hlsUrl == null) { + MyFreeCams mfc = (MyFreeCams) getSite(); + mfc.getClient().update(this); + } + return hlsUrl; + } + @Override public void invalidateCacheEntries() { resolution = null; @@ -123,7 +161,7 @@ public class MyFreeCamsModel extends AbstractModel { @Override public void receiveTip(int tokens) throws IOException { - String tipUrl = MyFreeCams.BASE_URI + "/php/tip.php"; + String tipUrl = MyFreeCams.baseUrl + "/php/tip.php"; String initUrl = tipUrl + "?request=tip&username="+getName()+"&broadcaster_id="+getUid(); Request req = new Request.Builder().url(initUrl).build(); try(Response resp = site.getHttpClient().execute(req)) { @@ -165,26 +203,19 @@ public class MyFreeCamsModel extends AbstractModel { @Override public int[] getStreamResolution(boolean failFast) throws ExecutionException { - if(resolution == null) { - if(failFast || hlsUrl == null) { - return new int[2]; + if (!failFast && hlsUrl != null) { + try { + List streamSources = getStreamSources(); + Collections.sort(streamSources); + StreamSource best = streamSources.get(streamSources.size() - 1); + resolution = new int[] { best.width, best.height }; + } catch (ParseException | PlaylistException e) { + LOG.warn("Couldn't determine stream resolution - {}", e.getMessage()); + } catch (ExecutionException | IOException e) { + LOG.error("Couldn't determine stream resolution", e); } - MyFreeCamsClient.getInstance().execute(()->{ - try { - List streamSources = getStreamSources(); - Collections.sort(streamSources); - StreamSource best = streamSources.get(streamSources.size()-1); - resolution = new int[] {best.width, best.height}; - } catch (ParseException | PlaylistException e) { - LOG.warn("Couldn't determine stream resolution - {}", e.getMessage()); - } catch (ExecutionException | IOException e) { - LOG.error("Couldn't determine stream resolution", e); - } - }); - return new int[2]; - } else { - return resolution; } + return resolution; } public void setStreamUrl(String hlsUrl) { @@ -203,7 +234,7 @@ public class MyFreeCamsModel extends AbstractModel { this.camScore = camScore; } - public void setState(State state) { + public void setMfcState(ctbrec.sites.mfc.State state) { this.state = state; } @@ -219,15 +250,16 @@ public class MyFreeCamsModel extends AbstractModel { public void update(SessionState state, String streamUrl) { uid = Integer.parseInt(state.getUid().toString()); setName(state.getNm()); - setCamScore(state.getM().getCamscore()); - setState(State.of(state.getVs())); + setMfcState(ctbrec.sites.mfc.State.of(state.getVs())); setStreamUrl(streamUrl); + Optional camScore = Optional.ofNullable(state.getM()).map(m -> m.getCamscore()); + setCamScore(camScore.orElse(0.0)); // preview String uid = state.getUid().toString(); String uidStart = uid.substring(0, 3); String previewUrl = "https://img.mfcimg.com/photos2/"+uidStart+'/'+uid+"/avatar.300x300.jpg"; - if(MyFreeCamsModel.this.state == State.ONLINE) { + if(MyFreeCamsModel.this.state == ctbrec.sites.mfc.State.ONLINE) { try { previewUrl = getLivePreviewUrl(state); } catch(Exception e) { diff --git a/common/src/main/java/ctbrec/sites/mfc/ServerConfig.java b/common/src/main/java/ctbrec/sites/mfc/ServerConfig.java index 6713212c..a880e633 100644 --- a/common/src/main/java/ctbrec/sites/mfc/ServerConfig.java +++ b/common/src/main/java/ctbrec/sites/mfc/ServerConfig.java @@ -9,13 +9,16 @@ import java.util.Objects; import org.json.JSONArray; import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -import ctbrec.io.HttpClient; import okhttp3.Request; import okhttp3.Response; public class ServerConfig { + private static final transient Logger LOG = LoggerFactory.getLogger(ServerConfig.class); + List ajaxServers; List videoServers; List chatServers; @@ -24,9 +27,11 @@ public class ServerConfig { Map wzobsServers; Map ngVideoServers; - public ServerConfig(HttpClient client) throws IOException { - Request req = new Request.Builder().url("http://www.myfreecams.com/_js/serverconfig.js").build(); - Response resp = client.execute(req); + public ServerConfig(MyFreeCams mfc) throws IOException { + String url = mfc.getBaseUrl() + "/_js/serverconfig.js"; + LOG.debug("Loading server config from {}", url); + Request req = new Request.Builder().url(url).build(); + Response resp = mfc.getHttpClient().execute(req); String json = resp.body().string(); JSONObject serverConfig = new JSONObject(json); diff --git a/common/src/main/java/ctbrec/sites/streamate/Streamate.java b/common/src/main/java/ctbrec/sites/streamate/Streamate.java new file mode 100644 index 00000000..a86eb91b --- /dev/null +++ b/common/src/main/java/ctbrec/sites/streamate/Streamate.java @@ -0,0 +1,201 @@ +package ctbrec.sites.streamate; + +import java.io.IOException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.StringUtil; +import ctbrec.io.HttpClient; +import ctbrec.io.HttpException; +import ctbrec.sites.AbstractSite; +import okhttp3.Request; +import okhttp3.Response; + +public class Streamate extends AbstractSite { + + private static final transient Logger LOG = LoggerFactory.getLogger(Streamate.class); + + public static final String BASE_URL = "https://www.streamate.com"; + + private StreamateHttpClient httpClient; + + @Override + public String getName() { + return "Streamate"; + } + + @Override + public String getBaseUrl() { + return BASE_URL; + } + + @Override + public String getAffiliateLink() { + return BASE_URL + "/landing/click/?AFNO=2-11329.1"; + // return BASE_URL + "/landing/click/?AFNO=2-11330.2"; + } + + @Override + public Model createModel(String name) { + StreamateModel model = new StreamateModel(); + model.setName(name); + model.setUrl(BASE_URL + "/cam/" + name); + model.setDescription(""); + model.setSite(this); + return model; + } + + @Override + public Integer getTokenBalance() throws IOException { + // int userId = ((StreamateHttpClient)getHttpClient()).getUserId(); + // String url = Streamate.BASE_URL + "/tools/amf.php"; + // RequestBody body = new FormBody.Builder() + // .add("method", "ping") + // .add("args[]", Integer.toString(userId)) + // .build(); + // Request request = new Request.Builder() + // .url(url) + // .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + // .addHeader("Accept", "application/json, text/javascript, */*") + // .addHeader("Accept-Language", "en") + // .addHeader("Referer", Streamate.BASE_URL) + // .addHeader("X-Requested-With", "XMLHttpRequest") + // .post(body) + // .build(); + // try(Response response = getHttpClient().execute(request)) { + // if(response.isSuccessful()) { + // JSONObject json = new JSONObject(response.body().string()); + // if(json.optString("status").equals("online")) { + // JSONObject userData = json.getJSONObject("userData"); + // return userData.getInt("balance"); + // } else { + // throw new IOException("Request was not successful: " + json.toString(2)); + // } + // } else { + // throw new HttpException(response.code(), response.message()); + // } + // } + return 0; + } + + @Override + public String getBuyTokensLink() { + return getAffiliateLink(); + } + + @Override + public synchronized boolean login() throws IOException { + return credentialsAvailable() && getHttpClient().login(); + } + + @Override + public HttpClient getHttpClient() { + if (httpClient == null) { + httpClient = new StreamateHttpClient(); + } + return httpClient; + } + + @Override + public void init() throws IOException { + } + + @Override + public void shutdown() { + if (httpClient != null) { + httpClient.shutdown(); + } + } + + @Override + public boolean supportsTips() { + return false; + } + + @Override + public boolean supportsFollow() { + return true; + } + + @Override + public boolean supportsSearch() { + return true; + } + + @Override + public boolean searchRequiresLogin() { + return false; + } + + @Override + public List search(String q) throws IOException, InterruptedException { + String url = BASE_URL + "/api/search/autocomplete?exact=false&skin_search_kids=0&results_per_page=10&query=" + URLEncoder.encode(q, "utf-8"); + Request req = new Request.Builder().url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .addHeader("Accept", "application/json, text/javascript, */*") + .addHeader("Accept-Language", "en") + .addHeader("Referer", Streamate.BASE_URL) + .addHeader("X-Requested-With", "XMLHttpRequest").build(); + try (Response response = getHttpClient().execute(req)) { + if (response.isSuccessful()) { + String body = response.body().string(); + JSONObject json = new JSONObject(body); + if (json.optString("status").equals("SM_OK")) { + List models = new ArrayList<>(); + JSONObject results = json.getJSONObject("results"); + JSONArray nickname = results.getJSONArray("nickname"); + for (int i = 0; i < nickname.length(); i++) { + JSONObject result = nickname.getJSONObject(i); + StreamateModel model = (StreamateModel) createModel(result.getString("nickname")); + model.setId(Long.parseLong(result.getString("performerId"))); + String thumb = result.getString("thumbnail"); + if (thumb != null) { + model.setPreview(thumb); + } + model.setOnline(result.optString("liveStatus").equals("live")); + models.add(model); + } + return models; + } else { + LOG.warn("Search result: " + json.toString(2)); + return Collections.emptyList(); + } + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + @Override + public boolean isSiteForModel(Model m) { + return m instanceof StreamateModel; + } + + @Override + public boolean credentialsAvailable() { + String username = Config.getInstance().getSettings().username; + return StringUtil.isNotBlank(username); + } + + @Override + public Model createModelFromUrl(String url) { + Matcher m = Pattern.compile("https?://.*?streamate.com/cam/([^/]*?)/?").matcher(url); + if (m.matches()) { + String modelName = m.group(1); + return createModel(modelName); + } else { + return super.createModelFromUrl(url); + } + } +} diff --git a/common/src/main/java/ctbrec/sites/streamate/StreamateHttpClient.java b/common/src/main/java/ctbrec/sites/streamate/StreamateHttpClient.java new file mode 100644 index 00000000..3f056e73 --- /dev/null +++ b/common/src/main/java/ctbrec/sites/streamate/StreamateHttpClient.java @@ -0,0 +1,144 @@ +package ctbrec.sites.streamate; + +import java.io.IOException; +import java.util.Collections; +import java.util.NoSuchElementException; + +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.io.HttpClient; +import okhttp3.Cookie; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +public class StreamateHttpClient extends HttpClient { + + private static final transient Logger LOG = LoggerFactory.getLogger(StreamateHttpClient.class); + + private Long userId; + private String saKey = ""; + private String userNickname = ""; + + public StreamateHttpClient() { + super("streamate"); + + // this cookie is needed for the search + Cookie searchCookie = new Cookie.Builder() + .domain("streamate.com") + .name("Xld_rct") + .value("1") + .build(); + getCookieJar().saveFromResponse(HttpUrl.parse(Streamate.BASE_URL), Collections.singletonList(searchCookie)); + + // try to load sakey from cookie + try { + Cookie cookie = getCookieJar().getCookie(HttpUrl.parse("https://www.streamate.com"), "sakey"); + saKey = cookie.value(); + } catch (NoSuchElementException e) { + // ignore + } + } + + @Override + public synchronized boolean login() throws IOException { + if(loggedIn) { + return true; + } + + boolean cookiesWorked = checkLoginSuccess(); + if(cookiesWorked) { + loggedIn = true; + LOG.debug("Logged in with cookies"); + return true; + } + + loggedIn = loginWithoutCookies(); + return loggedIn; + } + + private synchronized boolean loginWithoutCookies() throws IOException { + JSONObject loginRequest = new JSONObject(); + loginRequest.put("email", Config.getInstance().getSettings().streamateUsername); + loginRequest.put("password", Config.getInstance().getSettings().streamatePassword); + loginRequest.put("referrerId", 0); + loginRequest.put("siteId", 1); + RequestBody body = RequestBody.create(MediaType.parse("application/json"), loginRequest.toString()); + Request login = new Request.Builder() + .url(Streamate.BASE_URL + "/api/member/login") + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .addHeader("Accept", "application/json, text/javascript, */*") + .addHeader("Accept-Language", "en") + .addHeader("Referer", Streamate.BASE_URL) + .addHeader("X-Requested-With", "XMLHttpRequest") + .post(body) + .build(); + try (Response response = client.newCall(login).execute()) { + String content = response.body().string(); + if(response.isSuccessful()) { + JSONObject json = new JSONObject(content); + LOG.debug(json.toString(2)); + loggedIn = json.has("sakey"); + saKey = json.optString("sakey"); + JSONObject account = json.getJSONObject("account"); + userId = account.getLong("userid"); + userNickname = account.getString("nickname"); + } else { + throw new IOException("Login failed: " + response.code() + " " + response.message()); + } + response.close(); + } + + return loggedIn; + } + + /** + * Check, if the login worked by loading the favorites + */ + public boolean checkLoginSuccess() { + String url = Streamate.BASE_URL + "/api/search/v1/favorites?host=streamate.com&domain=streamate.com"; + url = url + "&page_number=1&results_per_page=48&sakey=" + saKey + "&userid=" + userId; + 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.BASE_URL) + .build(); + try(Response response = execute(request)) { + if (response.isSuccessful()) { + String content = response.body().string(); + JSONObject json = new JSONObject(content); + if(json.optString("status").equals("SM_OK")) { + return true; + } else { + return false; + } + } else { + return false; + } + } catch(Exception e) { + return false; + } + } + + public String getSaKey() { + return saKey; + } + + public Long getUserId() throws IOException { + if(userId == null) { + loginWithoutCookies(); + } + return userId; + } + + public String getUserNickname() { + return userNickname; + } +} diff --git a/common/src/main/java/ctbrec/sites/streamate/StreamateModel.java b/common/src/main/java/ctbrec/sites/streamate/StreamateModel.java new file mode 100644 index 00000000..55497e8a --- /dev/null +++ b/common/src/main/java/ctbrec/sites/streamate/StreamateModel.java @@ -0,0 +1,327 @@ +package ctbrec.sites.streamate; + +import static ctbrec.Model.State.*; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutionException; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.PlaylistException; +import com.squareup.moshi.JsonReader; +import com.squareup.moshi.JsonWriter; + +import ctbrec.AbstractModel; +import ctbrec.Config; +import ctbrec.io.HttpException; +import ctbrec.recorder.download.StreamSource; +import okhttp3.FormBody; +import okhttp3.MediaType; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okio.Buffer; + +public class StreamateModel extends AbstractModel { + + private static final transient Logger LOG = LoggerFactory.getLogger(StreamateModel.class); + + private boolean online = false; + private List streamSources = new ArrayList<>(); + private int[] resolution; + private Long id; + + @Override + public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { + if(ignoreCache) { + String url = "https://sea1c-ls.naiadsystems.com/sea1c-edge-ls/80/live/s:" + getName() + ".json"; + Request req = new Request.Builder().url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .addHeader("Accept", "*/*") + .addHeader("Accept-Language", "en") + .addHeader("Referer", Streamate.BASE_URL + '/' + getName()) + .addHeader("X-Requested-With", "XMLHttpRequest") + .build(); + try(Response response = site.getHttpClient().execute(req)) { + online = response.isSuccessful(); + } + } + return online; + } + + public void setOnline(boolean online) { + this.online = online; + } + + @Override + public State getOnlineState(boolean failFast) throws IOException, ExecutionException { + if(failFast) { + return onlineState; + } else { + if(onlineState == UNKNOWN) { + return online ? ONLINE : OFFLINE; + } + return onlineState; + } + } + + @Override + public void setOnlineState(State onlineState) { + this.onlineState = onlineState; + } + + @Override + public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException { + String url = "https://sea1c-ls.naiadsystems.com/sea1c-edge-ls/80/live/s:" + getName() + ".json"; + Request req = new Request.Builder().url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .addHeader("Accept", "*/*") + .addHeader("Accept-Language", "en") + .addHeader("Referer", Streamate.BASE_URL + '/' + getName()) + .addHeader("X-Requested-With", "XMLHttpRequest") + .build(); + try(Response response = site.getHttpClient().execute(req)) { + if(response.isSuccessful()) { + JSONObject json = new JSONObject(response.body().string()); + JSONObject formats = json.getJSONObject("formats"); + JSONObject hls = formats.getJSONObject("mp4-hls"); + + // add encodings + JSONArray encodings = hls.getJSONArray("encodings"); + streamSources.clear(); + for (int i = 0; i < encodings.length(); i++) { + JSONObject encoding = encodings.getJSONObject(i); + StreamSource src = new StreamSource(); + src.mediaPlaylistUrl = encoding.getString("location"); + src.width = encoding.optInt("videoWidth"); + src.height = encoding.optInt("videoHeight"); + src.bandwidth = (encoding.optInt("videoKbps") + encoding.optInt("audioKbps")) * 1024; + streamSources.add(src); + } + + // add raw source stream + if(formats.has("mp4-ws")) { + JSONObject ws = formats.getJSONObject("mp4-ws"); + JSONObject origin = hls.getJSONObject("origin"); + StreamSource src = new StreamSource(); + src.mediaPlaylistUrl = origin.getString("location"); + origin = ws.getJSONObject("origin"); // switch to web socket origin, because it has width, height and bitrates + src.width = origin.optInt("videoWidth"); + src.height = origin.optInt("videoHeight"); + src.bandwidth = (origin.optInt("videoKbps") + origin.optInt("audioKbps")) * 1024; + streamSources.add(src); + } + } else { + throw new HttpException(response.code(), response.message()); + } + } + return streamSources; + } + + @Override + public void invalidateCacheEntries() { + resolution = null; + } + + @Override + public void receiveTip(int tokens) throws IOException { + /* + Mt._giveGoldAjax = function(e, t) { + var n = _t.getState(), + a = n.nickname, + o = n.id, + i = Ds.getState(), + r = i.userStreamId, + s = i.sakey, + l = i.userId, + c = i.nickname, + u = ""; + switch (Ot.getState().streamType) { + case z.STREAM_TYPE_PRIVATE: + case z.STREAM_TYPE_BLOCK: + u = "premium"; + break; + case z.STREAM_TYPE_EXCLUSIVE: + case z.STREAM_TYPE_BLOCK_EXCLUSIVE: + u = "exclusive" + } + if (!l) return ae.a.reject("no userId!"); + var d = { + amt: e, + isprepopulated: t, + modelname: a, + nickname: c, + performernickname: a, + sakey: s, + session: u, + smid: o, + streamid: r, + userid: l, + username: c + }, + p = de.a.getBaseUrl() + "/api/v1/givegold/"; + return de.a.postPromise(p, d, "json") + }, + */ + + StreamateHttpClient client = (StreamateHttpClient) getSite().getHttpClient(); + client.login(); + String saKey = client.getSaKey(); + Long userId = client.getUserId(); + String nickname = client.getUserNickname(); + + String url = "https://hybridclient.naiadsystems.com/api/v1/givegold/"; // this returns 404 at the moment. not sure if it's the wrong server, or if this is not used anymore + RequestBody body = new FormBody.Builder() + .add("amt", Integer.toString(tokens)) // amount + .add("isprepopulated", "1") // ? + .add("modelname", getName()) // model's name + .add("nickname", nickname) // user's nickname + .add("performernickname", getName()) // model's name + .add("sakey", saKey) // sakey from login + .add("session", "") // is related to gold an private shows, for normal tips keep it empty + .add("smid", Long.toString(getId())) // model id + .add("streamid", getStreamId()) // id of the current stream + .add("userid", Long.toString(userId)) // user's id + .add("username", nickname) // user's nickname + .build(); + Buffer b = new Buffer(); + body.writeTo(b); + LOG.debug("tip params {}", b.readUtf8()); + Request request = new Request.Builder() + .url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .addHeader("Accept", "application/json, text/javascript, */*") + .addHeader("Accept-Language", "en") + .addHeader("Referer", Streamate.BASE_URL + '/' + getName()) + .addHeader("X-Requested-With", "XMLHttpRequest") + .post(body) + .build(); + try(Response response = site.getHttpClient().execute(request)) { + if(response.isSuccessful()) { + JSONObject json = new JSONObject(response.body().string()); + LOG.debug(json.toString(2)); + if(!json.optString("status").equals("success")) { + LOG.error("Sending tip failed {}", json.toString(2)); + throw new IOException("Sending tip failed"); + } + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + private String getStreamId() throws IOException { + String url = "https://hybridclient.naiadsystems.com/api/v1/config/?name=" + getName() + + "&sabasic=&sakey=&sk=www.streamate.com&userid=0&version=6.3.17&ajax=1"; + Request request = new Request.Builder() + .url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .addHeader("Accept", "application/json, text/javascript, */*") + .addHeader("Accept-Language", "en") + .addHeader("Referer", Streamate.BASE_URL + '/' + getName()) + .addHeader("X-Requested-With", "XMLHttpRequest") + .build(); + try(Response response = site.getHttpClient().execute(request)) { + if(response.isSuccessful()) { + JSONObject json = new JSONObject(response.body().string()); + JSONObject stream = json.getJSONObject("stream"); + return stream.getString("streamId"); + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + @Override + public int[] getStreamResolution(boolean failFast) throws ExecutionException { + if(resolution == null) { + if(failFast) { + return new int[2]; + } + try { + if(!isOnline()) { + return new int[2]; + } + List streamSources = getStreamSources(); + Collections.sort(streamSources); + StreamSource best = streamSources.get(streamSources.size()-1); + resolution = new int[] {best.width, best.height}; + } catch (ExecutionException | IOException | ParseException | PlaylistException | InterruptedException e) { + LOG.warn("Couldn't determine stream resolution for {} - {}", getName(), e.getMessage()); + } + return resolution; + } else { + return resolution; + } + } + + @Override + public boolean follow() throws IOException { + return follow(true); + } + + @Override + public boolean unfollow() throws IOException { + return follow(false); + } + + private boolean follow(boolean follow) throws IOException { + StreamateHttpClient client = (StreamateHttpClient) getSite().getHttpClient(); + client.login(); + String saKey = client.getSaKey(); + Long userId = client.getUserId(); + + JSONObject requestParams = new JSONObject(); + requestParams.put("sakey", saKey); + requestParams.put("userid", userId); + requestParams.put("pid", id); + requestParams.put("domain", "streamate.com"); + requestParams.put("fav", follow); + RequestBody body = RequestBody.create(MediaType.parse("application/json"), requestParams.toString()); + + String url = site.getBaseUrl() + "/ajax/fav-notify.php?userid="+userId+"&sakey="+saKey+"&pid="+id+"&fav="+follow+"&domain=streamate.com"; + Request request = new Request.Builder() + .url(url) + .addHeader("User-Agent", Config.getInstance().getSettings().httpUserAgent) + .addHeader("Accept", "application/json, */*") + .addHeader("Accept-Language", "en") + .addHeader("Referer", getSite().getBaseUrl()) + .post(body) + .build(); + try(Response response = getSite().getHttpClient().execute(request)) { + String content = response.body().string(); + if (response.isSuccessful()) { + JSONObject json = new JSONObject(content); + return json.optBoolean("success"); + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + @Override + public void readSiteSpecificData(JsonReader reader) throws IOException { + reader.nextName(); + id = reader.nextLong(); + } + + @Override + public void writeSiteSpecificData(JsonWriter writer) throws IOException { + writer.name("id").value(id); + } +} \ No newline at end of file diff --git a/common/src/main/java/ctbrec/sites/streamate/StreamateWebsocketClient.java b/common/src/main/java/ctbrec/sites/streamate/StreamateWebsocketClient.java new file mode 100644 index 00000000..d13cde56 --- /dev/null +++ b/common/src/main/java/ctbrec/sites/streamate/StreamateWebsocketClient.java @@ -0,0 +1,74 @@ +package ctbrec.sites.streamate; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.io.HttpClient; +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; +import okio.ByteString; + +public class StreamateWebsocketClient { + + private static final transient Logger LOG = LoggerFactory.getLogger(StreamateWebsocketClient.class); + private String url; + private HttpClient client; + + public StreamateWebsocketClient(String url, HttpClient client) { + this.url = url; + this.client = client; + } + + String roomId = ""; + public String getRoomId() throws InterruptedException { + LOG.debug("Connecting to {}", url); + Object monitor = new Object(); + client.newWebSocket(url, new WebSocketListener() { + @Override + public void onOpen(WebSocket webSocket, Response response) { + response.close(); + } + + @Override + public void onMessage(WebSocket webSocket, String text) { + if(text.contains("NaiadAuthorized")) { + Matcher m = Pattern.compile("\"roomid\":\"(.*?)\"").matcher(text); + if(m.find()) { + roomId = m.group(1); + webSocket.close(1000, ""); + } + } + } + + @Override + public void onMessage(WebSocket webSocket, ByteString bytes) { + LOG.debug("ws btxt {}", bytes.toString()); + } + + @Override + public void onClosed(WebSocket webSocket, int code, String reason) { + synchronized (monitor) { + monitor.notify(); + } + } + + @Override + public void onFailure(WebSocket webSocket, Throwable t, Response response) { + LOG.debug("ws failure", t); + response.close(); + synchronized (monitor) { + monitor.notify(); + } + } + }); + synchronized (monitor) { + monitor.wait(); + } + return roomId; + } +} + diff --git a/common/src/main/java/org/taktik/mpegts/Streamer.java b/common/src/main/java/org/taktik/mpegts/Streamer.java index d844da92..560bbfc8 100644 --- a/common/src/main/java/org/taktik/mpegts/Streamer.java +++ b/common/src/main/java/org/taktik/mpegts/Streamer.java @@ -30,12 +30,14 @@ public class Streamer { private Thread streamingThread; private boolean sleepingEnabled; + private String name; - private Streamer(MTSSource source, MTSSink sink, int bufferSize, boolean sleepingEnabled) { + private Streamer(MTSSource source, MTSSink sink, int bufferSize, boolean sleepingEnabled, String name) { this.source = source; this.sink = sink; this.bufferSize = bufferSize; this.sleepingEnabled = sleepingEnabled; + this.name = name; } public void stream() throws InterruptedException { @@ -48,20 +50,26 @@ public class Streamer { try { preBuffer(); } catch (Exception e) { - throw new IllegalStateException("Error while bufering", e); + throw new IllegalStateException("Error while buffering", e); } log.info("Done PreBuffering"); - bufferingThread = new Thread(this::fillBuffer, "buffering"); + bufferingThread = new Thread(this::fillBuffer, "Buffering ["+name+"]"); bufferingThread.setDaemon(true); bufferingThread.start(); - streamingThread = new Thread(this::internalStream, "streaming"); + streamingThread = new Thread(this::internalStream, "Streaming ["+name+"]"); streamingThread.setDaemon(true); streamingThread.start(); bufferingThread.join(); streamingThread.join(); + + try { + sink.close(); + } catch(Exception e) { + log.error("Couldn't close sink", e); + } } public void stop() { @@ -85,6 +93,16 @@ public class Streamer { } } + public void switchSink(MTSSink sink) { + MTSSink old = this.sink; + this.sink = sink; + try { + old.close(); + } catch (Exception e) { + log.error("Couldn't close old sink while switching sinks", e); + } + } + private void internalStream() { boolean resetState = false; MTSPacket packet = null; @@ -123,7 +141,7 @@ public class Streamer { } } } catch (InterruptedException e1) { - if(!endOfSourceReached) { + if(!endOfSourceReached && !streamingShouldStop) { log.error("Interrupted while waiting for packet"); continue; } else { @@ -240,7 +258,7 @@ public class Streamer { // Stream packet // System.out.println("Streaming packet #" + packetCount + ", PID=" + mtsPacket.getPid() + ", pcrCount=" + pcrCount + ", continuityCounter=" + mtsPacket.getContinuityCounter()); - if(!streamingShouldStop) { + if(!streamingShouldStop && !Thread.interrupted()) { try { sink.send(packet); } catch (Exception e) { @@ -275,7 +293,7 @@ public class Streamer { buffer.put(packet); put = true; } catch (InterruptedException ignored) { - + log.error("Error adding packet to buffer", ignored); } } } @@ -287,7 +305,11 @@ public class Streamer { log.error("Error reading from source", e); } finally { endOfSourceReached = true; - streamingThread.interrupt(); + try { + streamingThread.interrupt(); + } catch(Exception e) { + log.error("Couldn't interrupt streaming thread", e); + } } } @@ -308,6 +330,7 @@ public class Streamer { private MTSSource source; private int bufferSize = 1000; private boolean sleepingEnabled = false; + private String name; public StreamerBuilder setSink(MTSSink sink) { this.sink = sink; @@ -329,10 +352,16 @@ public class Streamer { return this; } + public StreamerBuilder setName(String name) { + this.name = name; + return this; + } + public Streamer build() { Preconditions.checkNotNull(sink); Preconditions.checkNotNull(source); - return new Streamer(source, sink, bufferSize, sleepingEnabled); + return new Streamer(source, sink, bufferSize, sleepingEnabled, name); } + } } \ No newline at end of file diff --git a/docs/index.html b/docs/index.html index 079938dd..d4d7df8f 100644 --- a/docs/index.html +++ b/docs/index.html @@ -118,19 +118,19 @@
- + Download for Linux! diff --git a/master/pom.xml b/master/pom.xml index cb6e8dca..78ea5606 100644 --- a/master/pom.xml +++ b/master/pom.xml @@ -6,7 +6,7 @@ ctbrec master pom - 1.10.0 + 1.15.0 ../common @@ -68,7 +68,7 @@ com.iheartradio.m3u8 open-m3u8 - 0.2.4 + 0.2.7-CTBREC org.jcodec diff --git a/server/.gitignore b/server/.gitignore index fc247909..2d132e83 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -2,7 +2,7 @@ /target/ *~ *.bak -/ctbrec.log +*.log /ctbrec-tunnel.sh /jre/ /server-local.sh diff --git a/server/pom.xml b/server/pom.xml index 721af621..24075158 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -8,7 +8,7 @@ ctbrec master - 1.10.0 + 1.15.0 ../master diff --git a/server/src/assembly/server-linux.sh b/server/src/assembly/server-linux.sh index 8c3658a7..498096bd 100755 --- a/server/src/assembly/server-linux.sh +++ b/server/src/assembly/server-linux.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/bin/bash pushd $(dirname $0) JAVA=java diff --git a/server/src/main/java/ctbrec/recorder/server/HttpServer.java b/server/src/main/java/ctbrec/recorder/server/HttpServer.java index 5767b72e..4262c6ff 100644 --- a/server/src/main/java/ctbrec/recorder/server/HttpServer.java +++ b/server/src/main/java/ctbrec/recorder/server/HttpServer.java @@ -1,6 +1,9 @@ package ctbrec.recorder.server; +import java.io.BufferedReader; import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; import java.net.BindException; import java.util.ArrayList; import java.util.List; @@ -17,7 +20,12 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import ctbrec.Config; +import ctbrec.Version; +import ctbrec.event.EventBusHolder; +import ctbrec.event.EventHandler; +import ctbrec.event.EventHandlerConfiguration; import ctbrec.recorder.LocalRecorder; +import ctbrec.recorder.OnlineMonitor; import ctbrec.recorder.Recorder; import ctbrec.sites.Site; import ctbrec.sites.bonga.BongaCams; @@ -25,16 +33,19 @@ import ctbrec.sites.cam4.Cam4; import ctbrec.sites.camsoda.Camsoda; import ctbrec.sites.chaturbate.Chaturbate; import ctbrec.sites.mfc.MyFreeCams; +import ctbrec.sites.streamate.Streamate; public class HttpServer { private static final transient Logger LOG = LoggerFactory.getLogger(HttpServer.class); private Recorder recorder; + private OnlineMonitor onlineMonitor; private Config config; private Server server = new Server(); private List sites = new ArrayList<>(); public HttpServer() throws Exception { + logEnvironment(); createSites(); System.setProperty("ctbrec.server.mode", "1"); if(System.getProperty("ctbrec.config") == null) { @@ -49,11 +60,15 @@ public class HttpServer { addShutdownHook(); // for graceful termination + registerAlertSystem(); + config = Config.getInstance(); if(config.getSettings().key != null) { LOG.info("HMAC authentication is enabled"); } recorder = new LocalRecorder(config); + OnlineMonitor monitor = new OnlineMonitor(recorder); + monitor.start(); for (Site site : sites) { if(site.isEnabled()) { site.init(); @@ -68,6 +83,7 @@ public class HttpServer { sites.add(new Camsoda()); sites.add(new Cam4()); sites.add(new BongaCams()); + sites.add(new Streamate()); } private void addShutdownHook() { @@ -75,6 +91,9 @@ public class HttpServer { @Override public void run() { LOG.info("Shutting down"); + if(onlineMonitor != null) { + onlineMonitor.shutdown(); + } if(recorder != null) { recorder.shutdown(); } @@ -126,6 +145,32 @@ public class HttpServer { } } + private void registerAlertSystem() { + 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"); + } + + private void logEnvironment() { + LOG.debug("OS:\t{} {}", System.getProperty("os.name"), System.getProperty("os.version")); + LOG.debug("Java:\t{} {} {}", System.getProperty("java.vendor"), System.getProperty("java.vm.name"), System.getProperty("java.version")); + try { + LOG.debug("ctbrec server {}", getVersion().toString()); + } catch (IOException e) {} + } + + private Version getVersion() throws IOException { + try (InputStream is = getClass().getClassLoader().getResourceAsStream("version")) { + BufferedReader reader = new BufferedReader(new InputStreamReader(is)); + String versionString = reader.readLine(); + Version version = Version.of(versionString); + return version; + } + } + public static void main(String[] args) throws Exception { new HttpServer(); } diff --git a/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java b/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java index ad6f81dd..c213ce2f 100644 --- a/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java +++ b/server/src/main/java/ctbrec/recorder/server/RecorderServlet.java @@ -137,9 +137,13 @@ public class RecorderServlet extends AbstractCtbrecServlet { response = "{\"status\": \"success\", \"msg\": \"Recording resumed\"}"; resp.getWriter().write(response); break; + case "space": + response = "{\"status\": \"success\", \"spaceTotal\": "+recorder.getTotalSpaceBytes()+", \"spaceFree\": "+recorder.getFreeSpaceBytes()+"}"; + resp.getWriter().write(response); + break; default: resp.setStatus(SC_BAD_REQUEST); - response = "{\"status\": \"error\", \"msg\": \"Unknown action\"}"; + response = "{\"status\": \"error\", \"msg\": \"Unknown action ["+request.action+"]\"}"; resp.getWriter().write(response); break; } diff --git a/server/src/main/resources/logback.xml b/server/src/main/resources/logback.xml index a2555eb9..4285a070 100644 --- a/server/src/main/resources/logback.xml +++ b/server/src/main/resources/logback.xml @@ -11,7 +11,7 @@ - ctbrec.log + server.log false DEBUG @@ -32,7 +32,6 @@ - diff --git a/server/src/main/resources/version b/server/src/main/resources/version new file mode 100644 index 00000000..f2ab45c3 --- /dev/null +++ b/server/src/main/resources/version @@ -0,0 +1 @@ +${project.version} \ No newline at end of file