diff --git a/client/src/main/java/ctbrec/ui/CamrecApplication.java b/client/src/main/java/ctbrec/ui/CamrecApplication.java index 72c0259d..08f07836 100644 --- a/client/src/main/java/ctbrec/ui/CamrecApplication.java +++ b/client/src/main/java/ctbrec/ui/CamrecApplication.java @@ -10,19 +10,21 @@ import java.lang.reflect.Type; import java.util.ArrayList; import java.util.Iterator; import java.util.List; +import java.util.Map; 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.google.common.eventbus.Subscribe; import com.squareup.moshi.JsonAdapter; import com.squareup.moshi.Moshi; import com.squareup.moshi.Types; import ctbrec.Config; +import ctbrec.EventBusHolder; +import ctbrec.Model; import ctbrec.StringUtil; import ctbrec.Version; import ctbrec.io.HttpClient; @@ -35,6 +37,7 @@ import ctbrec.sites.cam4.Cam4; import ctbrec.sites.camsoda.Camsoda; import ctbrec.sites.chaturbate.Chaturbate; import ctbrec.sites.mfc.MyFreeCams; +import eu.hansolo.enzo.notification.Notification; import javafx.application.Application; import javafx.application.HostServices; import javafx.application.Platform; @@ -45,6 +48,7 @@ import javafx.scene.control.Alert; import javafx.scene.control.Tab; import javafx.scene.control.TabPane; import javafx.scene.image.Image; +import javafx.scene.media.AudioClip; import javafx.scene.paint.Color; import javafx.stage.Stage; import okhttp3.Request; @@ -54,17 +58,19 @@ public class CamrecApplication extends Application { static final transient Logger LOG = LoggerFactory.getLogger(CamrecApplication.class); + private Stage primaryStage; private Config config; private Recorder recorder; static HostServices hostServices; private SettingsTab settingsTab; private TabPane rootPane = new TabPane(); - static EventBus bus; private List sites = new ArrayList<>(); public static HttpClient httpClient; + private Notification.Notifier notifier; @Override public void start(Stage primaryStage) throws Exception { + this.primaryStage = primaryStage; logEnvironment(); sites.add(new BongaCams()); sites.add(new Cam4()); @@ -73,7 +79,6 @@ public class CamrecApplication extends Application { sites.add(new MyFreeCams()); loadConfig(); createHttpClient(); - bus = new AsyncEventBus(Executors.newSingleThreadExecutor()); hostServices = getHostServices(); createRecorder(); for (Site site : sites) { @@ -88,6 +93,14 @@ public class CamrecApplication extends Application { } createGui(primaryStage); checkForUpdates(); + new Thread(() -> { + try { + Thread.sleep(TimeUnit.SECONDS.toMillis(10)); + } catch (InterruptedException e) { + e.printStackTrace(); + } + Platform.runLater(() -> registerAlertSystem()); + }).start(); } private void logEnvironment() { @@ -197,6 +210,33 @@ public class CamrecApplication extends Application { }); } + private void registerAlertSystem() { + Notification.Notifier.setNotificationOwner(primaryStage); + notifier = Notification.Notifier.INSTANCE; + EventBusHolder.BUS.register(new Object() { + @Subscribe + public void modelEvent(Map e) { + try { + if (Objects.equals("model.status", e.get("event"))) { + String status = (String) e.get("status"); + Model model = (Model) e.get("model"); + LOG.debug("Alert: {} is {}", model.getName(), status); + if (Objects.equals("online", status)) { + Platform.runLater(() -> { + AudioClip clip = new AudioClip("file:///tmp/Oxygen-Im-Highlight-Msg.mp3"); + clip.play(); + Notification notification = new Notification("Model Online", model.getName() + " is now online"); + notifier.notify(notification); + }); + } + } + } catch (Exception e1) { + e1.printStackTrace(); + } + } + }); + } + private void writeColorSchemeStyleSheet(Stage primaryStage) { File colorCss = new File(Config.getInstance().getConfigDir(), "color.css"); try(FileOutputStream fos = new FileOutputStream(colorCss)) { diff --git a/client/src/main/java/ctbrec/ui/ThumbOverviewTab.java b/client/src/main/java/ctbrec/ui/ThumbOverviewTab.java index 618b0bcd..4c6fb8f9 100644 --- a/client/src/main/java/ctbrec/ui/ThumbOverviewTab.java +++ b/client/src/main/java/ctbrec/ui/ThumbOverviewTab.java @@ -25,6 +25,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import ctbrec.Config; +import ctbrec.EventBusHolder; import ctbrec.Model; import ctbrec.recorder.Recorder; import ctbrec.sites.Site; @@ -468,7 +469,7 @@ 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) { showError("Couldn't send tip", "An error occured while sending tip:", e1); } diff --git a/client/src/main/java/ctbrec/ui/TokenLabel.java b/client/src/main/java/ctbrec/ui/TokenLabel.java index d19b16d0..c24c40a9 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.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/eu/hansolo/enzo/notification/Notification.java b/client/src/main/java/eu/hansolo/enzo/notification/Notification.java new file mode 100644 index 00000000..074fc295 --- /dev/null +++ b/client/src/main/java/eu/hansolo/enzo/notification/Notification.java @@ -0,0 +1,369 @@ +/* + * Copyright (c) 2013 by Gerrit Grunwald + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package eu.hansolo.enzo.notification; + +import javafx.animation.KeyFrame; +import javafx.animation.KeyValue; +import javafx.animation.Timeline; +import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Label; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.stage.Popup; +import javafx.stage.Screen; +import javafx.stage.Stage; +import javafx.stage.StageStyle; +import javafx.util.Duration; + + +/** + * Created by + * User: hansolo + * Date: 01.07.13 + * Time: 07:10 + */ +public class Notification { + public static final Image INFO_ICON = new Image(Notifier.class.getResourceAsStream("info.png")); + public static final Image WARNING_ICON = new Image(Notifier.class.getResourceAsStream("warning.png")); + public static final Image SUCCESS_ICON = new Image(Notifier.class.getResourceAsStream("success.png")); + public static final Image ERROR_ICON = new Image(Notifier.class.getResourceAsStream("error.png")); + public final String TITLE; + public final String MESSAGE; + public final Image IMAGE; + + + // ******************** Constructors ************************************** + public Notification(final String TITLE, final String MESSAGE) { + this(TITLE, MESSAGE, null); + } + public Notification(final String MESSAGE, final Image IMAGE) { + this("", MESSAGE, IMAGE); + } + public Notification(final String TITLE, final String MESSAGE, final Image IMAGE) { + this.TITLE = TITLE; + this.MESSAGE = MESSAGE; + this.IMAGE = IMAGE; + } + + + // ******************** Inner Classes ************************************* + public enum Notifier { + INSTANCE; + + private static final double ICON_WIDTH = 24; + private static final double ICON_HEIGHT = 24; + private static double width = 300; + private static double height = 80; + private static double offsetX = 0; + private static double offsetY = 25; + private static double spacingY = 5; + private static Pos popupLocation = Pos.TOP_RIGHT; + private static Stage stageRef = null; + private Duration popupLifetime; + private Stage stage; + private Scene scene; + private ObservableList popups; + + + // ******************** Constructor *************************************** + private Notifier() { + init(); + initGraphics(); + } + + + // ******************** Initialization ************************************ + private void init() { + popupLifetime = Duration.millis(5000); + popups = FXCollections.observableArrayList(); + } + + private void initGraphics() { + scene = new Scene(new Region()); + scene.setFill(null); + scene.getStylesheets().add(getClass().getResource("notifier.css").toExternalForm()); + + stage = new Stage(); + stage.initStyle(StageStyle.TRANSPARENT); + stage.setScene(scene); + } + + + // ******************** Methods ******************************************* + /** + * @param STAGE_REF The Notification will be positioned relative to the given Stage.
+ * If null then the Notification will be positioned relative to the primary Screen. + * @param POPUP_LOCATION The default is TOP_RIGHT of primary Screen. + */ + public static void setPopupLocation(final Stage STAGE_REF, final Pos POPUP_LOCATION) { + if (null != STAGE_REF) { + INSTANCE.stage.initOwner(STAGE_REF); + Notifier.stageRef = STAGE_REF; + } + Notifier.popupLocation = POPUP_LOCATION; + } + + /** + * Sets the Notification's owner stage so that when the owner + * stage is closed Notifications will be shut down as well.
+ * This is only needed if setPopupLocation is called + * without a stage reference. + * @param OWNER + */ + public static void setNotificationOwner(final Stage OWNER) { + INSTANCE.stage.initOwner(OWNER); + } + + /** + * @param OFFSET_X The horizontal shift required. + *
The default is 0 px. + */ + public static void setOffsetX(final double OFFSET_X) { + Notifier.offsetX = OFFSET_X; + } + + /** + * @param OFFSET_Y The vertical shift required. + *
The default is 25 px. + */ + public static void setOffsetY(final double OFFSET_Y) { + Notifier.offsetY = OFFSET_Y; + } + + /** + * @param WIDTH The default is 300 px. + */ + public static void setWidth(final double WIDTH) { + Notifier.width = WIDTH; + } + + /** + * @param HEIGHT The default is 80 px. + */ + public static void setHeight(final double HEIGHT) { + Notifier.height = HEIGHT; + } + + /** + * @param SPACING_Y The spacing between multiple Notifications. + *
The default is 5 px. + */ + public static void setSpacingY(final double SPACING_Y) { + Notifier.spacingY = SPACING_Y; + } + + public void stop() { + popups.clear(); + stage.close(); + } + + /** + * Returns the Duration that the notification will stay on screen before it + * will fade out. + * @return the Duration the popup notification will stay on screen + */ + public Duration getPopupLifetime() { + return popupLifetime; + } + + /** + * Defines the Duration that the popup notification will stay on screen before it + * will fade out. The parameter is limited to values between 2 and 20 seconds. + * @param POPUP_LIFETIME + */ + public void setPopupLifetime(final Duration POPUP_LIFETIME) { + popupLifetime = Duration.millis(clamp(2000, 20000, POPUP_LIFETIME.toMillis())); + } + + /** + * Show the given Notification on the screen + * @param NOTIFICATION + */ + public void notify(final Notification NOTIFICATION) { + preOrder(); + showPopup(NOTIFICATION); + } + + /** + * Show a Notification with the given parameters on the screen + * @param TITLE + * @param MESSAGE + * @param IMAGE + */ + public void notify(final String TITLE, final String MESSAGE, final Image IMAGE) { + notify(new Notification(TITLE, MESSAGE, IMAGE)); + } + + /** + * Show a Notification with the given title and message and an Info icon + * @param TITLE + * @param MESSAGE + */ + public void notifyInfo(final String TITLE, final String MESSAGE) { + notify(new Notification(TITLE, MESSAGE, Notification.INFO_ICON)); + } + + /** + * Show a Notification with the given title and message and a Warning icon + * @param TITLE + * @param MESSAGE + */ + public void notifyWarning(final String TITLE, final String MESSAGE) { + notify(new Notification(TITLE, MESSAGE, Notification.WARNING_ICON)); + } + + /** + * Show a Notification with the given title and message and a Checkmark icon + * @param TITLE + * @param MESSAGE + */ + public void notifySuccess(final String TITLE, final String MESSAGE) { + notify(new Notification(TITLE, MESSAGE, Notification.SUCCESS_ICON)); + } + + /** + * Show a Notification with the given title and message and an Error icon + * @param TITLE + * @param MESSAGE + */ + public void notifyError(final String TITLE, final String MESSAGE) { + notify(new Notification(TITLE, MESSAGE, Notification.ERROR_ICON)); + } + + /** + * Makes sure that the given VALUE is within the range of MIN to MAX + * @param MIN + * @param MAX + * @param VALUE + * @return + */ + private double clamp(final double MIN, final double MAX, final double VALUE) { + if (VALUE < MIN) return MIN; + if (VALUE > MAX) return MAX; + return VALUE; + } + + /** + * Reorder the popup Notifications on screen so that the latest Notification will stay on top + */ + private void preOrder() { + if (popups.isEmpty()) return; + for (int i = 0 ; i < popups.size() ; i++) { + switch (popupLocation) { + case TOP_LEFT: case TOP_CENTER: case TOP_RIGHT: popups.get(i).setY(popups.get(i).getY() + height + spacingY); break; + default: popups.get( i ).setY( popups.get( i ).getY() - height - spacingY); + } + } + } + + /** + * Creates and shows a popup with the data from the given Notification object + * @param NOTIFICATION + */ + private void showPopup(final Notification NOTIFICATION) { + Label title = new Label(NOTIFICATION.TITLE); + title.getStyleClass().add("title"); + + ImageView icon = new ImageView(NOTIFICATION.IMAGE); + icon.setFitWidth(ICON_WIDTH); + icon.setFitHeight(ICON_HEIGHT); + + Label message = new Label(NOTIFICATION.MESSAGE, icon); + message.getStyleClass().add("message"); + + VBox popupLayout = new VBox(); + popupLayout.setSpacing(10); + popupLayout.setPadding(new Insets(10, 10, 10, 10)); + popupLayout.getChildren().addAll(title, message); + + StackPane popupContent = new StackPane(); + popupContent.setPrefSize(width, height); + popupContent.getStyleClass().add("notification"); + popupContent.getChildren().addAll(popupLayout); + + final Popup POPUP = new Popup(); + POPUP.setX( getX() ); + POPUP.setY( getY() ); + System.out.println(POPUP.getX() + "," + POPUP.getY()); + POPUP.getContent().add(popupContent); + + popups.add(POPUP); + + // Add a timeline for popup fade out + KeyValue fadeOutBegin = new KeyValue(POPUP.opacityProperty(), 1.0); + KeyValue fadeOutEnd = new KeyValue(POPUP.opacityProperty(), 0.0); + + KeyFrame kfBegin = new KeyFrame(Duration.ZERO, fadeOutBegin); + KeyFrame kfEnd = new KeyFrame(Duration.millis(500), fadeOutEnd); + + Timeline timeline = new Timeline(kfBegin, kfEnd); + timeline.setDelay(popupLifetime); + timeline.setOnFinished(actionEvent -> Platform.runLater(() -> { + POPUP.hide(); + popups.remove(POPUP); + })); + + // Move popup to the right during fade out + //POPUP.opacityProperty().addListener((observableValue, oldOpacity, opacity) -> popup.setX(popup.getX() + (1.0 - opacity.doubleValue()) * popup.getWidth()) ); + + if (stage.isShowing()) { + stage.toFront(); + } else { + stage.show(); + } + + POPUP.show(stage); + timeline.play(); + } + + private double getX() { + if (null == stageRef) return calcX( 0.0, Screen.getPrimary().getBounds().getWidth() ); + + return calcX(stageRef.getX(), stageRef.getWidth()); + } + private double getY() { + if (null == stageRef) return calcY( 0.0, Screen.getPrimary().getBounds().getHeight() ); + + return calcY(stageRef.getY(), stageRef.getHeight()); + } + + private double calcX(final double LEFT, final double TOTAL_WIDTH) { + switch (popupLocation) { + case TOP_LEFT : case CENTER_LEFT : case BOTTOM_LEFT : return LEFT + offsetX; + case TOP_CENTER: case CENTER : case BOTTOM_CENTER: return LEFT + (TOTAL_WIDTH - width) * 0.5 - offsetX; + case TOP_RIGHT : case CENTER_RIGHT: case BOTTOM_RIGHT : return LEFT + TOTAL_WIDTH - width - offsetX; + default: return 0.0; + } + } + private double calcY(final double TOP, final double TOTAL_HEIGHT ) { + switch (popupLocation) { + case TOP_LEFT : case TOP_CENTER : case TOP_RIGHT : return TOP + offsetY; + case CENTER_LEFT: case CENTER : case CENTER_RIGHT: return TOP + (TOTAL_HEIGHT- height)/2 - offsetY; + case BOTTOM_LEFT: case BOTTOM_CENTER: case BOTTOM_RIGHT: return TOP + TOTAL_HEIGHT - height - offsetY; + default: return 0.0; + } + } + } +} diff --git a/client/src/main/resources/eu/hansolo/enzo/notification/README.md b/client/src/main/resources/eu/hansolo/enzo/notification/README.md new file mode 100644 index 00000000..849c6e92 --- /dev/null +++ b/client/src/main/resources/eu/hansolo/enzo/notification/README.md @@ -0,0 +1,4 @@ +Enzo +==== + +A repo that contains custom controls for JavaFX 8 (current version is hosted on bitbucket) diff --git a/client/src/main/resources/eu/hansolo/enzo/notification/error.png b/client/src/main/resources/eu/hansolo/enzo/notification/error.png new file mode 100644 index 00000000..f0651ec3 Binary files /dev/null and b/client/src/main/resources/eu/hansolo/enzo/notification/error.png differ diff --git a/client/src/main/resources/eu/hansolo/enzo/notification/info.png b/client/src/main/resources/eu/hansolo/enzo/notification/info.png new file mode 100644 index 00000000..87037497 Binary files /dev/null and b/client/src/main/resources/eu/hansolo/enzo/notification/info.png differ diff --git a/client/src/main/resources/eu/hansolo/enzo/notification/license.txt b/client/src/main/resources/eu/hansolo/enzo/notification/license.txt new file mode 100644 index 00000000..7a4a3ea2 --- /dev/null +++ b/client/src/main/resources/eu/hansolo/enzo/notification/license.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/client/src/main/resources/eu/hansolo/enzo/notification/notifier.css b/client/src/main/resources/eu/hansolo/enzo/notification/notifier.css new file mode 100644 index 00000000..723117bc --- /dev/null +++ b/client/src/main/resources/eu/hansolo/enzo/notification/notifier.css @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2013 by Gerrit Grunwald + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.root { + -fx-background-color: transparent; + -fx-fill : transparent; +} +.notification { + -fx-background-color : -fx-background; + -fx-background-radius: 5; + -fx-effect : innershadow(two-pass-box, rgba(255, 255, 255, 0.6), 5.0, 0.25, 0, 0); + -foreground-color : -fx-base; + -icon-color : -fx-base; +} + +.notification .title { + -fx-font-size : 1.083333em; + -fx-font-weight: bold; + -fx-text-fill : -foreground-color; +} +.notification .message { + -fx-font-size : 1.0em; + -fx-content-display : left; + -fx-graphic-text-gap: 10; + -fx-text-fill : -foreground-color; +} + diff --git a/client/src/main/resources/eu/hansolo/enzo/notification/success.png b/client/src/main/resources/eu/hansolo/enzo/notification/success.png new file mode 100644 index 00000000..472804af Binary files /dev/null and b/client/src/main/resources/eu/hansolo/enzo/notification/success.png differ diff --git a/client/src/main/resources/eu/hansolo/enzo/notification/warning.png b/client/src/main/resources/eu/hansolo/enzo/notification/warning.png new file mode 100644 index 00000000..0a89e132 Binary files /dev/null and b/client/src/main/resources/eu/hansolo/enzo/notification/warning.png differ diff --git a/client/src/test/java/AudioTest.java b/client/src/test/java/AudioTest.java new file mode 100644 index 00000000..78a9ab5e --- /dev/null +++ b/client/src/test/java/AudioTest.java @@ -0,0 +1,19 @@ +import javafx.application.Application; +import javafx.application.Platform; +import javafx.scene.media.AudioClip; +import javafx.stage.Stage; + +public class AudioTest extends Application { + + @Override + public void start(Stage primaryStage) throws Exception { + AudioClip clip = new AudioClip("file:///tmp/Oxygen-Im-Highlight-Msg.mp3"); + clip.cycleCountProperty().set(3); + clip.play(); + Platform.exit(); + } + + public static void main(String[] args) { + launch(args); + } +} diff --git a/client/src/test/java/HlsTest.java b/client/src/test/java/HlsTest.java new file mode 100644 index 00000000..d9c06e87 --- /dev/null +++ b/client/src/test/java/HlsTest.java @@ -0,0 +1,44 @@ +import javafx.application.Application; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.media.Media; +import javafx.scene.media.MediaPlayer; +import javafx.stage.Stage; + +public class HlsTest extends Application { + // media = new Media("http://localhost:3202/hls/sun_shine_baby/2018-11-28_20-43/playlist.m3u8"); + + private static final String MEDIA_URL = "http://localhost:3202/hls/sun_shine_baby/2018-11-28_20-43/playlist.m3u8"; + + private Media media; + private MediaPlayer mediaPlayer; + private MediaControl mediaControl; + + public Parent createContent() { + media = new Media(MEDIA_URL); + mediaPlayer = new MediaPlayer(media); + mediaPlayer.setOnError(()-> { + mediaPlayer.getError().printStackTrace(System.err); + }); + mediaControl = new MediaControl(mediaPlayer); + mediaControl.setMinSize(480, 280); + mediaControl.setPrefSize(480, 280); + mediaControl.setMaxSize(480, 280); + return mediaControl; + } + + @Override + public void start(Stage primaryStage) throws Exception { + primaryStage.setScene(new Scene(createContent())); + primaryStage.show(); + } + + @Override + public void stop() { + mediaPlayer.stop(); + } + + public static void main(String[] args) { + launch(args); + } +} diff --git a/client/src/test/java/MediaControl.java b/client/src/test/java/MediaControl.java new file mode 100644 index 00000000..55c0e0bb --- /dev/null +++ b/client/src/test/java/MediaControl.java @@ -0,0 +1,355 @@ +import javafx.application.Platform; +import javafx.beans.Observable; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.value.ObservableValue; +import javafx.event.ActionEvent; +import javafx.event.EventHandler; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Control; +import javafx.scene.control.Label; +import javafx.scene.control.Slider; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.media.MediaPlayer; +import javafx.scene.media.MediaView; +import javafx.stage.Stage; +import javafx.util.Duration; + +public class MediaControl extends BorderPane { + + private MediaPlayer mp; + private MediaView mediaView; + private final boolean repeat = false; + private boolean stopRequested = false; + private boolean atEndOfMedia = false; + private Duration duration; + private Slider timeSlider; + private Label playTime; + private Slider volumeSlider; + private HBox mediaBar; + private Pane mvPane; + private Stage newStage; + private boolean fullScreen = false; + + @Override + protected void layoutChildren() { + if (mediaView != null && getBottom() != null) { + mediaView.setFitWidth(getWidth()); + mediaView.setFitHeight(getHeight() - getBottom().prefHeight(-1)); + } + super.layoutChildren(); + if (mediaView != null && getCenter() != null) { + mediaView.setTranslateX((((Pane) getCenter()).getWidth() - mediaView.prefWidth(-1)) / 2); + mediaView.setTranslateY((((Pane) getCenter()).getHeight() - mediaView.prefHeight(-1)) / 2); + } + } + + @Override + protected double computeMinWidth(double height) { + return mediaBar.prefWidth(-1); + } + + @Override + protected double computeMinHeight(double width) { + return 200; + } + + @Override + protected double computePrefWidth(double height) { + return Math.max(mp.getMedia().getWidth(), mediaBar.prefWidth(height)); + } + + @Override + protected double computePrefHeight(double width) { + return mp.getMedia().getHeight() + mediaBar.prefHeight(width); + } + + @Override + protected double computeMaxWidth(double height) { + return Double.MAX_VALUE; + } + + @Override + protected double computeMaxHeight(double width) { + return Double.MAX_VALUE; + } + + public MediaControl(final MediaPlayer mp) { + this.mp = mp; + setStyle("-fx-background-color: #bfc2c7;"); // TODO: Use css file + mediaView = new MediaView(mp); + mvPane = new Pane(); + mvPane.getChildren().add(mediaView); + mvPane.setStyle("-fx-background-color: black;"); // TODO: Use css file + setCenter(mvPane); + mediaBar = new HBox(5.0); + mediaBar.setPadding(new Insets(5, 10, 5, 10)); + mediaBar.setAlignment(Pos.CENTER_LEFT); + BorderPane.setAlignment(mediaBar, Pos.CENTER); + + final Button playButton = new Button(); + playButton.setMinWidth(Control.USE_PREF_SIZE); + + String PLAY = "playbutton.png"; + String PAUSE = "pausebutton.png"; + Image PlayButton = new Image(getClass().getResourceAsStream(PLAY)); + Image PauseButton = new Image(getClass().getResourceAsStream(PAUSE)); + ImageView imageViewPlay = new ImageView(PlayButton); + ImageView imageViewPause = new ImageView(PauseButton); + playButton.setGraphic(imageViewPlay); + playButton.setOnAction((ActionEvent e) -> { + updateValues(); + MediaPlayer.Status status = mp.getStatus(); + if (status == MediaPlayer.Status.UNKNOWN || status == MediaPlayer.Status.HALTED) { + // don't do anything in these states + return; + } + + if (status == MediaPlayer.Status.PAUSED || status == MediaPlayer.Status.READY || status == MediaPlayer.Status.STOPPED) { + // rewind the movie if we're sitting at the end + if (atEndOfMedia) { + mp.seek(mp.getStartTime()); + atEndOfMedia = false; + playButton.setGraphic(imageViewPlay); + // playButton.setText(">"); + updateValues(); + } + mp.play(); + playButton.setGraphic(imageViewPause); + // playButton.setText("||"); + } else { + mp.pause(); + } + }); + ReadOnlyObjectProperty time = mp.currentTimeProperty(); + time.addListener((ObservableValue observable, Duration oldValue, Duration newValue) -> { + //updateValues(); + }); + mp.setOnPlaying(() -> { + if (stopRequested) { + mp.pause(); + stopRequested = false; + } else { + playButton.setGraphic(imageViewPause); + // playButton.setText("||"); + } + }); + mp.setOnPaused(() -> { + playButton.setGraphic(imageViewPlay); + // playButton.setText("||"); + }); + mp.setOnReady(() -> { + duration = mp.getMedia().getDuration(); + updateValues(); + }); + + mp.setCycleCount(repeat ? MediaPlayer.INDEFINITE : 1); + mp.setOnEndOfMedia(() -> { + if (!repeat) { + playButton.setGraphic(imageViewPlay); + // playButton.setText(">"); + stopRequested = true; + atEndOfMedia = true; + } + }); + mediaBar.getChildren().add(playButton); + + // Time label + Label timeLabel = new Label("Time"); + timeLabel.setMinWidth(Control.USE_PREF_SIZE); + mediaBar.getChildren().add(timeLabel); + + // Time slider + timeSlider = new Slider(); + timeSlider.setMinWidth(30); + timeSlider.setMaxWidth(Double.MAX_VALUE); + HBox.setHgrow(timeSlider, Priority.ALWAYS); + + DoubleProperty timeValue = timeSlider.valueProperty(); + timeValue.addListener((ObservableValue observable, Number old, Number now) -> { + if (timeSlider.isValueChanging()) { + // multiply duration by percentage calculated by slider position + if (duration != null) { + System.out.println(timeSlider.getValue() + "%"); + mp.seek(duration.multiply(timeSlider.getValue() / 100.0)); + } + updateValues(); + } else if (Math.abs(now.doubleValue() - old.doubleValue()) > 1.5) { + // multiply duration by percentage calculated by slider position + System.out.println(timeSlider.getValue() + "%"); + if (duration != null) { + mp.seek(duration.multiply(timeSlider.getValue() / 100.0)); + } + } + }); + mediaBar.getChildren().add(timeSlider); + + // Play label + playTime = new Label(); + playTime.setMinWidth(Control.USE_PREF_SIZE); + + mediaBar.getChildren().add(playTime); + + // Fullscreen button + Button buttonFullScreen = new Button("Full Screen"); + buttonFullScreen.setMinWidth(Control.USE_PREF_SIZE); + + buttonFullScreen.setOnAction(new EventHandler() { + @Override + public void handle(ActionEvent event) { + if (!fullScreen) { + newStage = new Stage(); + ReadOnlyBooleanProperty full = newStage.fullScreenProperty(); + full.addListener((ObservableValue ov, Boolean old, Boolean now) -> { + onFullScreen(); + }); + final BorderPane borderPane = new BorderPane() { + @Override + protected void layoutChildren() { + if (mediaView != null && getBottom() != null) { + mediaView.setFitWidth(getWidth()); + double height = getHeight() - getBottom().prefHeight(-1); + mediaView.setFitHeight(height); + } + super.layoutChildren(); + if (mediaView != null) { + final Pane center = (Pane) getCenter(); + if (center != null) { // if smaller pane has content + double width = center.getWidth() - mediaView.prefWidth(-1); + double height = center.getHeight() - mediaView.prefHeight(-1); + double xval = width / 2.0; + double yval = height / 2.0; + + mediaView.setTranslateX(xval); + mediaView.setTranslateY(yval); + } + } + } + }; + + setCenter(null); + setBottom(null); + borderPane.setCenter(mvPane); + borderPane.setBottom(mediaBar); + + Scene newScene = new Scene(borderPane); + newStage.setScene(newScene); + // Workaround for disposing stage when exit fullscreen + newStage.setX(-100000); + newStage.setY(-100000); + + newStage.setFullScreen(true); + fullScreen = true; + newStage.show(); + + } else { + // toggle FullScreen + fullScreen = false; + newStage.setFullScreen(false); + + } + } + }); + mediaBar.getChildren().add(buttonFullScreen); + + // Volume label + Label volumeLabel = new Label("Vol"); + volumeLabel.setMinWidth(Control.USE_PREF_SIZE); + mediaBar.getChildren().add(volumeLabel); + + // Volume slider + volumeSlider = new Slider(); + volumeSlider.setPrefWidth(70); + volumeSlider.setMinWidth(30); + volumeSlider.setMaxWidth(Region.USE_PREF_SIZE); + volumeSlider.valueProperty().addListener((Observable ov) -> { + }); + + final DoubleProperty volume = volumeSlider.valueProperty(); + volume.addListener((ObservableValue observable, Number old, Number now) -> { + mp.setVolume(volumeSlider.getValue() / 100.0); + }); + mediaBar.getChildren().add(volumeSlider); + + setBottom(mediaBar); + + } + + protected void onFullScreen() { + if (!newStage.isFullScreen()) { + + fullScreen = false; + BorderPane smallBP = (BorderPane) newStage.getScene().getRoot(); + smallBP.setCenter(null); + setCenter(mvPane); + + smallBP.setBottom(null); + setBottom(mediaBar); + Platform.runLater(() -> { + newStage.close(); + }); + + } + } + + protected void updateValues() { + if (playTime != null && timeSlider != null && volumeSlider != null && duration != null) { + Platform.runLater(() -> { + Duration now = mp.getCurrentTime(); + playTime.setText(formatTime(now, duration)); + timeSlider.setDisable(duration.isUnknown()); + if (!timeSlider.isDisabled() && duration.greaterThan(Duration.ZERO) && !timeSlider.isValueChanging()) { + final double value = now.divide(duration).toMillis() * 100.0; + timeSlider.setValue(value); + } + if (!volumeSlider.isValueChanging()) { + final int value = (int) Math.round(mp.getVolume() * 100); + volumeSlider.setValue(value); + } + }); + } + } + + private String formatTime(Duration elapsed, Duration duration) { + int intElapsed = (int) Math.floor(elapsed.toSeconds()); + int elapsedHours = intElapsed / (60 * 60); + if (elapsedHours > 0) { + intElapsed -= elapsedHours * 60 * 60; + } + int elapsedMinutes = intElapsed / 60; + int elapsedSeconds = intElapsed - elapsedHours * 60 * 60 - elapsedMinutes * 60; + + if (duration.greaterThan(Duration.ZERO)) { + int intDuration = (int) Math.floor(duration.toSeconds()); + int durationHours = intDuration / (60 * 60); + if (durationHours > 0) { + intDuration -= durationHours * 60 * 60; + } + int durationMinutes = intDuration / 60; + int durationSeconds = intDuration - durationHours * 60 * 60 - durationMinutes * 60; + + if (durationHours > 0) { + return String.format("%d:%02d:%02d/%d:%02d:%02d", elapsedHours, elapsedMinutes, elapsedSeconds, durationHours, durationMinutes, + durationSeconds); + } else { + return String.format("%02d:%02d/%02d:%02d", elapsedMinutes, elapsedSeconds, durationMinutes, durationSeconds); + } + } else { + if (elapsedHours > 0) { + return String.format("%d:%02d:%02d", elapsedHours, elapsedMinutes, elapsedSeconds); + } else { + return String.format("%02d:%02d", elapsedMinutes, elapsedSeconds); + } + } + } +} \ No newline at end of file diff --git a/client/src/test/java/pausebutton.png b/client/src/test/java/pausebutton.png new file mode 100644 index 00000000..4e429238 Binary files /dev/null and b/client/src/test/java/pausebutton.png differ diff --git a/client/src/test/java/playbutton.png b/client/src/test/java/playbutton.png new file mode 100644 index 00000000..198984de Binary files /dev/null and b/client/src/test/java/playbutton.png differ diff --git a/common/src/main/java/ctbrec/EventBusHolder.java b/common/src/main/java/ctbrec/EventBusHolder.java new file mode 100644 index 00000000..aefe499b --- /dev/null +++ b/common/src/main/java/ctbrec/EventBusHolder.java @@ -0,0 +1,10 @@ +package ctbrec; + +import java.util.concurrent.Executors; + +import com.google.common.eventbus.AsyncEventBus; +import com.google.common.eventbus.EventBus; + +public class EventBusHolder { + public static final EventBus BUS = new AsyncEventBus(Executors.newSingleThreadExecutor()); +} diff --git a/common/src/main/java/ctbrec/recorder/RemoteRecorder.java b/common/src/main/java/ctbrec/recorder/RemoteRecorder.java index 7c3ced0a..8844d5c8 100644 --- a/common/src/main/java/ctbrec/recorder/RemoteRecorder.java +++ b/common/src/main/java/ctbrec/recorder/RemoteRecorder.java @@ -6,7 +6,9 @@ import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.time.Instant; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.json.JSONObject; import org.slf4j.Logger; @@ -16,6 +18,7 @@ import com.squareup.moshi.JsonAdapter; import com.squareup.moshi.Moshi; import ctbrec.Config; +import ctbrec.EventBusHolder; import ctbrec.Hmac; import ctbrec.Model; import ctbrec.Recording; @@ -95,6 +98,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()); @@ -231,6 +235,7 @@ public class RemoteRecorder implements Recorder { if (response.isSuccessful()) { ModelListResponse resp = modelListResponseAdapter.fromJson(json); if (resp.status.equals("success")) { + List previouslyOnline = onlineModels; onlineModels = resp.models; for (Model model : models) { for (Site site : sites) { @@ -239,6 +244,25 @@ public class RemoteRecorder implements Recorder { } } } + + for (Model prev : previouslyOnline) { + if(!onlineModels.contains(prev)) { + Map evt = new HashMap<>(); + evt.put("event", "model.status"); + evt.put("status", "offline"); + evt.put("model", prev); + EventBusHolder.BUS.post(evt); + } + } + for (Model model : onlineModels) { + if(!previouslyOnline.contains(model)) { + Map evt = new HashMap<>(); + evt.put("event", "model.status"); + evt.put("status", "online"); + evt.put("model", model); + EventBusHolder.BUS.post(evt); + } + } } else { LOG.error("Server returned error: {} - {}", resp.status, resp.msg); }