From 8421f7056b25de37cd66aef0c7187f1233f72ce1 Mon Sep 17 00:00:00 2001 From: J62 Date: Wed, 12 Mar 2025 17:57:28 -0700 Subject: [PATCH] first commit --- .gitattributes | 10 + .github/ISSUE_TEMPLATE/bug_report.md | 23 + .github/ISSUE_TEMPLATE/feature_request.md | 17 + .github/ISSUE_TEMPLATE/other.md | 7 + .gitignore | 9 + CHANGELOG.md | 1257 ++ LICENSE.txt | 674 + README.md | 17 + build-all.sh | 6 + client/.gitignore | 16 + client/LICENSE.txt | 674 + client/README.md | 19 + client/build.sh | 5 + client/pom.xml | 279 + .../assembly/ctbrec-linux-jre-no-splash.sh | 8 + client/src/assembly/ctbrec-linux-jre.sh | 8 + .../assembly/ctbrec-macos-jre-no-splash.sh | 9 + client/src/assembly/ctbrec-macos-jre.sh | 9 + client/src/assembly/ctbrec.bat | 1 + client/src/assembly/linux-jre.xml | 70 + client/src/assembly/macos-jre.xml | 71 + client/src/assembly/win64-jre.xml | 67 + .../main/java/ctbrec/RecordingDownload.java | 89 + .../src/main/java/ctbrec/docs/DocServer.java | 78 + .../main/java/ctbrec/ui/AutosizeAlert.java | 43 + .../java/ctbrec/ui/CamrecApplication.java | 653 + .../java/ctbrec/ui/ClipboardListener.java | 65 + .../java/ctbrec/ui/DesktopIntegration.java | 229 + .../main/java/ctbrec/ui/ExternalBrowser.java | 238 + .../src/main/java/ctbrec/ui/FileDownload.java | 53 + client/src/main/java/ctbrec/ui/Icon.java | 23 + .../main/java/ctbrec/ui/InstantProperty.java | 18 + .../src/main/java/ctbrec/ui/JavaFxModel.java | 396 + .../main/java/ctbrec/ui/JavaFxRecording.java | 310 + client/src/main/java/ctbrec/ui/Launcher.java | 23 + client/src/main/java/ctbrec/ui/PauseIcon.java | 22 + client/src/main/java/ctbrec/ui/Player.java | 240 + .../java/ctbrec/ui/PreviewPopupHandler.java | 190 + .../java/ctbrec/ui/RecordUntilDialog.java | 105 + .../main/java/ctbrec/ui/ShutdownListener.java | 5 + client/src/main/java/ctbrec/ui/SiteUI.java | 18 + .../main/java/ctbrec/ui/SiteUiFactory.java | 168 + .../ui/StreamSourceSelectionDialog.java | 131 + .../java/ctbrec/ui/TimeTextFieldTest.java | 258 + client/src/main/java/ctbrec/ui/TipDialog.java | 102 + .../src/main/java/ctbrec/ui/TokenLabel.java | 97 + client/src/main/java/ctbrec/ui/TrayIcon.java | 182 + client/src/main/java/ctbrec/ui/UiUtils.java | 33 + .../src/main/java/ctbrec/ui/UnicodeEmoji.java | 8 + .../java/ctbrec/ui/action/AbstractAction.java | 56 + .../ctbrec/ui/action/AbstractModelAction.java | 91 + .../ui/action/AbstractPortraitAction.java | 80 + .../ctbrec/ui/action/AddToGroupAction.java | 206 + .../ui/action/CheckModelAccountAction.java | 85 + .../ctbrec/ui/action/EditGroupAction.java | 179 + .../ctbrec/ui/action/EditNotesAction.java | 57 + .../java/ctbrec/ui/action/FollowAction.java | 20 + .../ctbrec/ui/action/ForcePriorityAction.java | 20 + .../ctbrec/ui/action/IgnoreModelsAction.java | 66 + .../ctbrec/ui/action/LaterGroupAction.java | 51 + .../action/MarkForLaterRecordingAction.java | 24 + .../ctbrec/ui/action/ModelMassEditAction.java | 81 + .../ctbrec/ui/action/OpenRecordingsDir.java | 50 + .../java/ctbrec/ui/action/OpenUrlAction.java | 15 + .../java/ctbrec/ui/action/PauseAction.java | 20 + .../ctbrec/ui/action/PauseGroupAction.java | 49 + .../java/ctbrec/ui/action/PlayAction.java | 37 + .../ui/action/RemoveTimeLimitAction.java | 42 + .../java/ctbrec/ui/action/ResumeAction.java | 20 + .../ctbrec/ui/action/ResumeGroupAction.java | 49 + .../ui/action/ResumePriorityAction.java | 20 + .../ctbrec/ui/action/SetPortraitAction.java | 104 + .../ctbrec/ui/action/SetStopDateAction.java | 83 + .../ui/action/SetThumbAsPortraitAction.java | 49 + .../ui/action/StartRecordingAction.java | 81 + .../ctbrec/ui/action/StopGroupAction.java | 49 + .../ctbrec/ui/action/StopRecordingAction.java | 20 + .../action/SwitchStreamResolutionAction.java | 83 + .../main/java/ctbrec/ui/action/TipAction.java | 60 + .../ui/action/ToggleRecordingAction.java | 41 + .../java/ctbrec/ui/action/TriConsumer.java | 7 + .../java/ctbrec/ui/action/UnfollowAction.java | 20 + .../ui/controls/AbstractFileSelectionBox.java | 167 + .../CustomMouseBehaviorContextMenu.java | 17 + .../ui/controls/DateTimeCellFactory.java | 35 + .../ctbrec/ui/controls/DateTimePicker.java | 115 + .../main/java/ctbrec/ui/controls/Dialogs.java | 138 + .../ui/controls/DirectorySelectionBox.java | 43 + .../FasterVerticalScrollPaneSkin.java | 31 + .../ctbrec/ui/controls/FileSelectionBox.java | 28 + .../main/java/ctbrec/ui/controls/Popover.css | 28 + .../main/java/ctbrec/ui/controls/Popover.java | 456 + .../ctbrec/ui/controls/PopoverTreeList.java | 91 + .../ui/controls/ProgramSelectionBox.java | 28 + .../ui/controls/RecordingIndicator.java | 36 + .../ui/controls/SaveFileSelectionBox.java | 28 + .../java/ctbrec/ui/controls/SearchBox.css | 34 + .../java/ctbrec/ui/controls/SearchBox.java | 103 + .../ctbrec/ui/controls/SearchPopover.java | 9 + .../ui/controls/SearchPopoverTreeList.java | 346 + .../ctbrec/ui/controls/StreamPreview.java | 196 + .../java/ctbrec/ui/controls/TimePicker.java | 67 + .../main/java/ctbrec/ui/controls/Toast.java | 55 + .../main/java/ctbrec/ui/controls/Wizard.java | 102 + .../autocomplete/AutoFillTextField.java | 68 + .../autocomplete/ObservableListSuggester.java | 37 + .../ui/controls/autocomplete/Suggester.java | 9 + .../ui/controls/range/DiscreteRange.java | 46 + .../ui/controls/range/LabeledNumberAxis.java | 49 + .../java/ctbrec/ui/controls/range/Range.java | 10 + .../ctbrec/ui/controls/range/RangeSlider.java | 89 + .../controls/range/RangeSliderBehavior.java | 95 + .../ui/controls/range/RangeSliderSkin.java | 275 + .../ctbrec/ui/controls/range/rangeslider.css | 78 + .../table/SettingTableViewStateStore.java | 115 + .../table/StatePersistingTableView.java | 147 + .../controls/table/TableViewStateStore.java | 21 + .../table/TableViewStateStoreException.java | 9 + .../main/java/ctbrec/ui/event/PlaySound.java | 39 + .../ctbrec/ui/event/PlayerStartedEvent.java | 40 + .../ctbrec/ui/event/ShowNotification.java | 46 + .../ui/io/json/dto/PlayerStartedEventDto.java | 18 + .../json/mapper/PlayerStartedEventMapper.java | 14 + .../ctbrec/ui/menu/FollowUnfollowHandler.java | 49 + .../ctbrec/ui/menu/ForcePriorityHandler.java | 51 + .../ctbrec/ui/menu/ModelGroupMenuBuilder.java | 68 + .../ctbrec/ui/menu/ModelMenuContributor.java | 449 + .../ctbrec/ui/menu/PauseResumeHandler.java | 51 + .../src/main/java/ctbrec/ui/news/Account.java | 49 + .../src/main/java/ctbrec/ui/news/NewsTab.java | 105 + .../src/main/java/ctbrec/ui/news/Status.java | 75 + .../main/java/ctbrec/ui/news/StatusPane.java | 76 + .../AbstractPostProcessingPaneFactory.java | 142 + .../ui/settings/ActionSettingsPanel.java | 360 + .../ctbrec/ui/settings/CacheSettingsPane.java | 53 + .../ctbrec/ui/settings/ColorSettingsPane.css | 10 + .../ctbrec/ui/settings/ColorSettingsPane.java | 80 + .../CreateContactSheetPaneFactory.java | 67 + .../ui/settings/CtbrecPreferencesStorage.java | 390 + .../settings/DeleteTooShortPaneFactory.java | 24 + .../ctbrec/ui/settings/FontSettingsPane.java | 103 + .../java/ctbrec/ui/settings/IgnoreList.java | 135 + .../ctbrec/ui/settings/ListSelectionPane.java | 125 + .../ctbrec/ui/settings/MoverPaneFactory.java | 24 + .../settings/PostProcessingDialogFactory.java | 87 + .../ui/settings/PostProcessingStepPanel.java | 220 + .../ui/settings/RemuxerPaneFactory.java | 27 + .../ui/settings/RenamerPaneFactory.java | 24 + .../settings/RestrictResolutionSidePane.java | 44 + .../ctbrec/ui/settings/ScriptPaneFactory.java | 27 + .../java/ctbrec/ui/settings/SettingsTab.java | 718 + .../VariablePlayGroundDialogFactory.java | 100 + .../java/ctbrec/ui/settings/api/Category.java | 153 + .../api/ExclusiveSelectionProperty.java | 23 + .../ui/settings/api/GigabytesConverter.java | 19 + .../java/ctbrec/ui/settings/api/Group.java | 51 + .../ui/settings/api/HighlightingSupport.java | 89 + .../ui/settings/api/LocalTimeProperty.java | 13 + .../ctbrec/ui/settings/api/Preferences.css | 8 + .../ctbrec/ui/settings/api/Preferences.java | 260 + .../ui/settings/api/PreferencesStorage.java | 13 + .../java/ctbrec/ui/settings/api/Setting.java | 126 + .../settings/api/SimpleDirectoryProperty.java | 11 + .../ui/settings/api/SimpleFileProperty.java | 11 + .../api/SimpleJoinedStringListProperty.java | 48 + .../ui/settings/api/SimpleRangeProperty.java | 43 + .../ui/settings/api/ValueConverter.java | 7 + .../ctbrec/ui/sites/AbstractConfigUI.java | 21 + .../java/ctbrec/ui/sites/AbstractSiteUi.java | 12 + .../ctbrec/ui/sites/AbstractTabProvider.java | 39 + .../main/java/ctbrec/ui/sites/ConfigUI.java | 7 + .../ui/sites/amateurtv/AmateurTvConfigUI.java | 93 + .../AmateurTvElectronLoginDialog.java | 126 + .../sites/amateurtv/AmateurTvFollowedTab.java | 13 + .../ui/sites/amateurtv/AmateurTvSiteUi.java | 65 + .../sites/amateurtv/AmateurTvTabProvider.java | 73 + .../amateurtv/AmateurTvUpdateService.java | 132 + .../ui/sites/bonga/BongaCamsConfigUI.java | 106 + .../bonga/BongaCamsElectronLoginDialog.java | 124 + .../ui/sites/bonga/BongaCamsFriendsTab.java | 14 + .../ui/sites/bonga/BongaCamsSiteUi.java | 65 + .../ui/sites/bonga/BongaCamsTabProvider.java | 71 + .../sites/bonga/BongaCamsUpdateService.java | 108 + .../ctbrec/ui/sites/cam4/Cam4ConfigUI.java | 92 + .../sites/cam4/Cam4ElectronLoginDialog.java | 193 + .../ctbrec/ui/sites/cam4/Cam4FollowedTab.java | 78 + .../sites/cam4/Cam4FollowedUpdateService.java | 92 + .../java/ctbrec/ui/sites/cam4/Cam4SiteUi.java | 60 + .../ctbrec/ui/sites/cam4/Cam4TabProvider.java | 52 + .../ui/sites/cam4/Cam4UpdateService.java | 102 + .../ui/sites/camsoda/CamsodaConfigUI.java | 92 + .../ui/sites/camsoda/CamsodaFollowedTab.java | 97 + .../camsoda/CamsodaFollowedUpdateService.java | 32 + .../ui/sites/camsoda/CamsodaSiteUi.java | 41 + .../ui/sites/camsoda/CamsodaTabProvider.java | 55 + .../sites/camsoda/CamsodaUpdateService.java | 178 + .../ChaturbateApiUpdateService.java | 70 + .../sites/chaturbate/ChaturbateConfigUi.java | 124 + .../ChaturbateElectronLoginDialog.java | 123 + .../chaturbate/ChaturbateFollowedTab.java | 83 + .../ui/sites/chaturbate/ChaturbateSiteUi.java | 66 + .../chaturbate/ChaturbateTabProvider.java | 67 + .../chaturbate/ChaturbateUpdateService.java | 90 + .../ui/sites/cherrytv/CherryTvConfigUI.java | 88 + .../sites/cherrytv/CherryTvFollowedTab.java | 90 + .../CherryTvFollowedUpdateService.java | 55 + .../ui/sites/cherrytv/CherryTvSiteUi.java | 43 + .../sites/cherrytv/CherryTvTabProvider.java | 50 + .../sites/cherrytv/CherryTvUpdateService.java | 151 + .../ui/sites/dreamcam/DreamcamConfigUI.java | 66 + .../ui/sites/dreamcam/DreamcamSiteUi.java | 40 + .../sites/dreamcam/DreamcamTabProvider.java | 37 + .../sites/dreamcam/DreamcamUpdateService.java | 86 + .../ui/sites/fc2live/Fc2FollowedTab.java | 35 + .../fc2live/Fc2FollowedUpdateService.java | 90 + .../ui/sites/fc2live/Fc2LiveConfigUI.java | 92 + .../ui/sites/fc2live/Fc2LiveSiteUi.java | 79 + .../ui/sites/fc2live/Fc2TabProvider.java | 43 + .../ui/sites/fc2live/Fc2UpdateService.java | 87 + .../sites/flirt4free/Flirt4FreeConfigUI.java | 92 + .../flirt4free/Flirt4FreeFavoritesTab.java | 23 + .../Flirt4FreeFavoritesUpdateService.java | 82 + .../ui/sites/flirt4free/Flirt4FreeSiteUi.java | 40 + .../flirt4free/Flirt4FreeTabProvider.java | 49 + .../flirt4free/Flirt4FreeUpdateService.java | 122 + .../ui/sites/jasmin/LiveJasminConfigUi.java | 107 + .../jasmin/LiveJasminElectronLoginDialog.java | 98 + .../sites/jasmin/LiveJasminFollowedTab.java | 51 + .../LiveJasminFollowedUpdateService.java | 140 + .../ui/sites/jasmin/LiveJasminSiteUi.java | 78 + .../ctbrec/ui/sites/jasmin/LiveJasminTab.java | 80 + .../sites/jasmin/LiveJasminTabProvider.java | 51 + .../sites/jasmin/LiveJasminUpdateService.java | 189 + .../ui/sites/manyvids/MVLiveConfigUi.java | 50 + .../ui/sites/manyvids/MVLiveSiteUi.java | 41 + .../ui/sites/manyvids/MVLiveTabProvider.java | 32 + .../sites/manyvids/MVLiveUpdateService.java | 103 + .../myfreecams/FriendsUpdateService.java | 112 + .../sites/myfreecams/HDCamsUpdateService.java | 45 + .../MyFreeCamsAbstractUpdateService.java | 34 + .../sites/myfreecams/MyFreeCamsConfigUI.java | 114 + .../myfreecams/MyFreeCamsFriendsTab.java | 55 + .../ui/sites/myfreecams/MyFreeCamsSiteUi.java | 41 + .../myfreecams/MyFreeCamsTabProvider.java | 60 + .../sites/myfreecams/MyFreeCamsTableTab.java | 775 + .../ui/sites/myfreecams/NewModelService.java | 33 + .../myfreecams/OnlineCamsUpdateService.java | 39 + .../sites/myfreecams/PopularModelService.java | 36 + .../sites/myfreecams/TableUpdateService.java | 30 + .../secretfriends/SecretFriendsConfigUI.java | 89 + .../secretfriends/SecretFriendsSiteUi.java | 40 + .../SecretFriendsTabProvider.java | 34 + .../SecretFriendsUpdateService.java | 92 + .../ui/sites/showup/ShowupConfigUI.java | 92 + .../showup/ShowupElectronLoginDialog.java | 122 + .../ui/sites/showup/ShowupFollowedTab.java | 79 + .../showup/ShowupFollowedUpdateService.java | 96 + .../ctbrec/ui/sites/showup/ShowupSiteUi.java | 67 + .../ui/sites/showup/ShowupTabProvider.java | 41 + .../ui/sites/showup/ShowupUpdateService.java | 48 + .../ui/sites/streamate/StreamateConfigUI.java | 92 + .../streamate/StreamateFollowedService.java | 123 + .../sites/streamate/StreamateFollowedTab.java | 75 + .../ui/sites/streamate/StreamateSiteUi.java | 41 + .../ui/sites/streamate/StreamateTab.java | 39 + .../sites/streamate/StreamateTabProvider.java | 47 + .../streamate/StreamateUpdateService.java | 110 + .../AbstractStreamrayUpdateService.java | 96 + .../ui/sites/streamray/StreamrayConfigUI.java | 67 + .../StreamrayElectronLoginDialog.java | 71 + .../streamray/StreamrayFavoritesService.java | 159 + .../streamray/StreamrayFavoritesTab.java | 103 + .../ui/sites/streamray/StreamraySiteUi.java | 40 + .../sites/streamray/StreamrayTabProvider.java | 55 + .../streamray/StreamrayUpdateService.java | 124 + .../AbstractStripchatUpdateService.java | 65 + .../ui/sites/stripchat/StripchatConfigUI.java | 121 + .../StripchatElectronLoginDialog.java | 124 + .../sites/stripchat/StripchatFollowedTab.java | 82 + .../StripchatFollowedUpdateService.java | 124 + .../ui/sites/stripchat/StripchatSiteUi.java | 62 + .../sites/stripchat/StripchatTabProvider.java | 58 + .../stripchat/StripchatUpdateService.java | 102 + .../ui/sites/winktv/WinkTvConfigUI.java | 55 + .../ctbrec/ui/sites/winktv/WinkTvSiteUi.java | 40 + .../ui/sites/winktv/WinkTvTabProvider.java | 37 + .../ui/sites/winktv/WinkTvUpdateService.java | 108 + .../ui/sites/xlovecam/XloveCamConfigUI.java | 92 + .../ui/sites/xlovecam/XloveCamSiteUi.java | 43 + .../sites/xlovecam/XloveCamTabProvider.java | 76 + .../sites/xlovecam/XloveCamUpdateService.java | 42 + .../main/java/ctbrec/ui/tabs/DonateTabFx.java | 79 + .../ctbrec/ui/tabs/DownloadPostprocessor.java | 20 + .../ui/tabs/FollowTabBlinkTransition.java | 44 + .../main/java/ctbrec/ui/tabs/FollowedTab.java | 8 + .../src/main/java/ctbrec/ui/tabs/HelpTab.java | 49 + .../ui/tabs/PaginatedScheduledService.java | 15 + .../ctbrec/ui/tabs/RecentlyWatchedTab.java | 300 + .../java/ctbrec/ui/tabs/RecordingsTab.java | 875 + .../src/main/java/ctbrec/ui/tabs/SiteTab.java | 27 + .../main/java/ctbrec/ui/tabs/SiteTabPane.java | 46 + .../main/java/ctbrec/ui/tabs/TabProvider.java | 12 + .../ctbrec/ui/tabs/TabSelectionListener.java | 6 + .../main/java/ctbrec/ui/tabs/ThumbCell.css | 7 + .../main/java/ctbrec/ui/tabs/ThumbCell.java | 736 + .../java/ctbrec/ui/tabs/ThumbOverviewTab.java | 845 + .../ui/tabs/ThumbOverviewTabSearchTask.java | 72 + .../main/java/ctbrec/ui/tabs/UpdateTab.java | 73 + .../ui/tabs/logging/CtbrecAppender.java | 13 + .../ctbrec/ui/tabs/logging/LoggingTab.java | 231 + .../recorded/AbstractRecordedModelsTab.java | 593 + .../ui/tabs/recorded/ClickableTableCell.java | 21 + .../ui/tabs/recorded/IconTableCell.java | 49 + .../ui/tabs/recorded/ImageTableCell.java | 50 + .../ui/tabs/recorded/ModelExportDialog.java | 89 + .../ui/tabs/recorded/ModelImportExport.java | 215 + .../ctbrec/ui/tabs/recorded/ModelName.java | 29 + .../ui/tabs/recorded/ModelNameTableCell.java | 45 + .../ui/tabs/recorded/OnlineTableCell.java | 27 + .../ui/tabs/recorded/RecordLaterTab.java | 177 + .../recorded/RecordedModelsPerSiteTab.java | 64 + .../ui/tabs/recorded/RecordedModelsTab.java | 436 + .../ctbrec/ui/tabs/recorded/RecordedTab.java | 64 + .../ui/tabs/recorded/RecordingTableCell.java | 47 + .../ctbrec/ui/tasks/AbstractModelTask.java | 38 + .../main/java/ctbrec/ui/tasks/FollowTask.java | 22 + .../ctbrec/ui/tasks/ForcePriorityTask.java | 17 + .../ctbrec/ui/tasks/PauseRecordingTask.java | 17 + .../ctbrec/ui/tasks/ResumePriorityTask.java | 17 + .../ctbrec/ui/tasks/ResumeRecordingTask.java | 17 + .../ctbrec/ui/tasks/StartRecordingTask.java | 17 + .../ctbrec/ui/tasks/StopRecordingTask.java | 16 + .../ui/tasks/TaskExecutionException.java | 9 + .../java/ctbrec/ui/tasks/UnfollowTask.java | 22 + client/src/main/resources/16/blank.png | Bin 0 -> 533 bytes client/src/main/resources/16/bookmark-new.png | Bin 0 -> 1425 bytes client/src/main/resources/16/check-circle.png | Bin 0 -> 4962 bytes client/src/main/resources/16/check-small.png | Bin 0 -> 4792 bytes client/src/main/resources/16/check.png | Bin 0 -> 4791 bytes client/src/main/resources/16/clock.png | Bin 0 -> 4949 bytes client/src/main/resources/16/download.png | Bin 0 -> 4753 bytes .../main/resources/16/media-force-record.png | Bin 0 -> 3301 bytes .../resources/16/media-playback-pause.png | Bin 0 -> 1400 bytes client/src/main/resources/16/media-record.png | Bin 0 -> 1445 bytes client/src/main/resources/16/upload.png | Bin 0 -> 4761 bytes client/src/main/resources/16/users.png | Bin 0 -> 4966 bytes client/src/main/resources/32/blank.png | Bin 0 -> 541 bytes client/src/main/resources/32/check-circle.png | Bin 0 -> 822 bytes client/src/main/resources/32/check-small.png | Bin 0 -> 317 bytes client/src/main/resources/32/check-small.svg | 57 + client/src/main/resources/32/check.png | Bin 0 -> 291 bytes client/src/main/resources/32/clock.png | Bin 0 -> 757 bytes client/src/main/resources/32/download.png | Bin 0 -> 354 bytes client/src/main/resources/32/upload.png | Bin 0 -> 349 bytes client/src/main/resources/32/users.png | Bin 0 -> 603 bytes .../src/main/resources/META-INF/MANIFEST.MF | 3 + .../resources/Oxygen-Im-Highlight-Msg.mp3 | Bin 0 -> 17664 bytes client/src/main/resources/anonymous.png | Bin 0 -> 3344 bytes client/src/main/resources/bookmark-new.png | Bin 0 -> 785 bytes client/src/main/resources/ctb-logo.png | Bin 0 -> 13960 bytes client/src/main/resources/dark.css | 7 + .../main/resources/html/bitcoin-address.png | Bin 0 -> 10343 bytes client/src/main/resources/html/bitcoin.png | Bin 0 -> 1853 bytes .../resources/html/buymeacoffee-fancy.png | Bin 0 -> 5054 bytes .../main/resources/html/ethereum-address.png | Bin 0 -> 13177 bytes client/src/main/resources/html/ethereum.png | Bin 0 -> 1441 bytes .../main/resources/html/monero-address.png | Bin 0 -> 26567 bytes client/src/main/resources/html/monero.png | Bin 0 -> 1738 bytes .../src/main/resources/html/patreon-logo.png | Bin 0 -> 3451 bytes .../src/main/resources/html/patreon-logo.svg | 79 + client/src/main/resources/html/pp196.png | Bin 0 -> 6295 bytes .../main/resources/html/static/button-red.png | Bin 0 -> 6338 bytes .../main/resources/html/static/favicon.png | Bin 0 -> 1262 bytes .../main/resources/html/static/freelancer.css | 382 + .../resources/html/static/freelancer.min.js | 1 + .../src/main/resources/html/static/icon64.png | Bin 0 -> 2729 bytes .../vendor/bootstrap/css/bootstrap-grid.css | 1567 ++ .../bootstrap/css/bootstrap-grid.min.css | 7 + .../vendor/bootstrap/css/bootstrap-reboot.css | 342 + .../bootstrap/css/bootstrap-reboot.min.css | 8 + .../static/vendor/bootstrap/css/bootstrap.css | 8981 ++++++++ .../vendor/bootstrap/css/bootstrap.css.map | 1 + .../vendor/bootstrap/css/bootstrap.min.css | 7 + .../bootstrap/css/bootstrap.min.css.map | 1 + .../vendor/bootstrap/js/bootstrap.bundle.js | 6444 ++++++ .../bootstrap/js/bootstrap.bundle.js.map | 1 + .../bootstrap/js/bootstrap.bundle.min.js | 7 + .../bootstrap/js/bootstrap.bundle.min.js.map | 1 + .../static/vendor/bootstrap/js/bootstrap.js | 3927 ++++ .../vendor/bootstrap/js/bootstrap.js.map | 1 + .../vendor/bootstrap/js/bootstrap.min.js | 7 + .../vendor/bootstrap/js/bootstrap.min.js.map | 1 + .../vendor/font-awesome/css/font-awesome.css | 2337 ++ .../font-awesome/css/font-awesome.css.map | 7 + .../font-awesome/css/font-awesome.min.css | 4 + .../vendor/font-awesome/fonts/FontAwesome.otf | Bin 0 -> 134808 bytes .../fonts/fontawesome-webfont.eot | Bin 0 -> 165742 bytes .../fonts/fontawesome-webfont.svg | 2671 +++ .../fonts/fontawesome-webfont.ttf | Bin 0 -> 165548 bytes .../fonts/fontawesome-webfont.woff | Bin 0 -> 98024 bytes .../fonts/fontawesome-webfont.woff2 | Bin 0 -> 77160 bytes .../vendor/font-awesome/less/animated.less | 34 + .../font-awesome/less/bordered-pulled.less | 25 + .../static/vendor/font-awesome/less/core.less | 12 + .../vendor/font-awesome/less/fixed-width.less | 6 + .../font-awesome/less/font-awesome.less | 18 + .../vendor/font-awesome/less/icons.less | 789 + .../vendor/font-awesome/less/larger.less | 13 + .../static/vendor/font-awesome/less/list.less | 19 + .../vendor/font-awesome/less/mixins.less | 60 + .../static/vendor/font-awesome/less/path.less | 15 + .../font-awesome/less/rotated-flipped.less | 20 + .../font-awesome/less/screen-reader.less | 5 + .../vendor/font-awesome/less/stacked.less | 20 + .../vendor/font-awesome/less/variables.less | 800 + .../vendor/font-awesome/scss/_animated.scss | 34 + .../font-awesome/scss/_bordered-pulled.scss | 25 + .../vendor/font-awesome/scss/_core.scss | 12 + .../font-awesome/scss/_fixed-width.scss | 6 + .../vendor/font-awesome/scss/_icons.scss | 789 + .../vendor/font-awesome/scss/_larger.scss | 13 + .../vendor/font-awesome/scss/_list.scss | 19 + .../vendor/font-awesome/scss/_mixins.scss | 60 + .../vendor/font-awesome/scss/_path.scss | 15 + .../font-awesome/scss/_rotated-flipped.scss | 20 + .../font-awesome/scss/_screen-reader.scss | 5 + .../vendor/font-awesome/scss/_stacked.scss | 20 + .../vendor/font-awesome/scss/_variables.scss | 800 + .../font-awesome/scss/font-awesome.scss | 18 + .../jquery.easing.compatibility.js | 59 + .../vendor/jquery-easing/jquery.easing.js | 166 + .../vendor/jquery-easing/jquery.easing.min.js | 1 + .../vendor/jquery-ui/jquery-ui-1.12.1.css | 1311 ++ .../vendor/jquery-ui/jquery-ui-1.12.1.js | 18706 ++++++++++++++++ .../html/static/vendor/jquery/jquery.js | 10364 +++++++++ .../html/static/vendor/jquery/jquery.min.js | 2 + .../html/static/vendor/jquery/jquery.min.map | 1 + .../html/static/vendor/jquery/jquery.slim.js | 8269 +++++++ .../static/vendor/jquery/jquery.slim.min.js | 2 + .../static/vendor/jquery/jquery.slim.min.map | 1 + .../magnific-popup/jquery.magnific-popup.js | 1860 ++ .../jquery.magnific-popup.min.js | 4 + .../vendor/magnific-popup/magnific-popup.css | 351 + client/src/main/resources/html/token.png | Bin 0 -> 33081 bytes client/src/main/resources/html/token.xcf | Bin 0 -> 937155 bytes client/src/main/resources/icon.ico | Bin 0 -> 90022 bytes client/src/main/resources/icon.png | Bin 0 -> 27199 bytes client/src/main/resources/icon.svg | 103 + client/src/main/resources/icon128.png | Bin 0 -> 6192 bytes client/src/main/resources/icon16.png | Bin 0 -> 686 bytes client/src/main/resources/icon32.png | Bin 0 -> 1262 bytes client/src/main/resources/icon64.png | Bin 0 -> 2729 bytes client/src/main/resources/image_not_found.png | Bin 0 -> 6827 bytes client/src/main/resources/logback.xml | 58 + .../main/resources/media-playback-pause.png | Bin 0 -> 718 bytes client/src/main/resources/media-record.png | Bin 0 -> 1203 bytes client/src/main/resources/silhouette_256.png | Bin 0 -> 9373 bytes client/src/main/resources/splash.bmp | Bin 0 -> 518454 bytes client/src/main/resources/splash.png | Bin 0 -> 6115 bytes client/src/main/resources/splash.svg | 103 + client/src/main/resources/version | 1 + .../ui/tasks/StartRecordingTaskTest.java | 85 + client/src/test/resources/req-list.json | 1 + client/src/test/resources/req-start-pink.json | 7 + .../src/test/resources/req-start-queen.json | 7 + client/src/test/resources/req-start-uv.json | 7 + client/src/test/resources/req-stop-pink.json | 7 + client/src/test/resources/req-stop-queen.json | 7 + client/src/test/resources/req-stop-uv.json | 7 + common/.gitignore | 8 + common/pom.xml | 146 + .../variableexpansion/antlr/PostProcessing.g4 | 21 + .../src/main/java/ctbrec/AbstractModel.java | 337 + common/src/main/java/ctbrec/Config.java | 361 + .../src/main/java/ctbrec/ErrorMessages.java | 8 + .../java/ctbrec/ForkProcessException.java | 9 + .../main/java/ctbrec/GlobalThreadPool.java | 26 + common/src/main/java/ctbrec/Hmac.java | 70 + common/src/main/java/ctbrec/Java.java | 22 + .../main/java/ctbrec/LoggingInterceptor.java | 35 + .../main/java/ctbrec/MigrateModel5_1_2.java | 26 + common/src/main/java/ctbrec/Model.java | 165 + common/src/main/java/ctbrec/ModelGroup.java | 72 + .../java/ctbrec/ModelIsIgnoredException.java | 14 + .../java/ctbrec/ModelNotFoundException.java | 12 + .../java/ctbrec/NotImplementedExcetion.java | 13 + .../main/java/ctbrec/NotLoggedInExcetion.java | 13 + common/src/main/java/ctbrec/OS.java | 170 + common/src/main/java/ctbrec/Recording.java | 246 + .../src/main/java/ctbrec/RemoteService.java | 23 + common/src/main/java/ctbrec/Settings.java | 236 + .../java/ctbrec/StreamNotFoundException.java | 7 + .../src/main/java/ctbrec/StringConstants.java | 10 + common/src/main/java/ctbrec/StringUtil.java | 242 + .../main/java/ctbrec/SubsequentAction.java | 7 + .../ctbrec/UnexpectedResponseException.java | 9 + common/src/main/java/ctbrec/UnknownModel.java | 171 + .../UnsupportedOperatingSystemException.java | 9 + common/src/main/java/ctbrec/Version.java | 130 + .../java/ctbrec/event/AbstractModelEvent.java | 16 + common/src/main/java/ctbrec/event/Action.java | 25 + common/src/main/java/ctbrec/event/Event.java | 44 + .../java/ctbrec/event/EventBusHolder.java | 46 + .../main/java/ctbrec/event/EventHandler.java | 113 + .../event/EventHandlerConfiguration.java | 52 + .../java/ctbrec/event/EventPredicate.java | 10 + .../java/ctbrec/event/EventTypePredicate.java | 26 + .../java/ctbrec/event/ExecuteProgram.java | 66 + .../java/ctbrec/event/MatchAllPredicate.java | 17 + .../java/ctbrec/event/ModelIsOnlineEvent.java | 36 + .../java/ctbrec/event/ModelPredicate.java | 59 + .../ctbrec/event/ModelStateChangedEvent.java | 59 + .../ctbrec/event/ModelStatePredicate.java | 32 + .../java/ctbrec/event/NoSpaceLeftEvent.java | 25 + .../event/RecordingStateChangedEvent.java | 59 + .../ctbrec/event/RecordingStatePredicate.java | 31 + .../java/ctbrec/image/LocalPortraitStore.java | 60 + .../main/java/ctbrec/image/PortraitStore.java | 25 + .../ctbrec/image/RemotePortraitStore.java | 137 + .../main/java/ctbrec/io/BandwidthMeter.java | 89 + .../src/main/java/ctbrec/io/BoundField.java | 32 + .../java/ctbrec/io/ByteUnitFormatter.java | 42 + .../ctbrec/io/CompletableRequestFuture.java | 46 + .../main/java/ctbrec/io/CookieJarImpl.java | 137 + common/src/main/java/ctbrec/io/DevNull.java | 18 + .../java/ctbrec/io/FlaresolverrClient.java | 107 + .../java/ctbrec/io/FlaresolverrResponse.java | 23 + .../io/FlaresolverrSolutionResponse.java | 73 + .../src/main/java/ctbrec/io/HtmlParser.java | 50 + .../java/ctbrec/io/HtmlParserException.java | 9 + .../src/main/java/ctbrec/io/HttpClient.java | 460 + .../ctbrec/io/HttpClientCacheProvider.java | 46 + .../main/java/ctbrec/io/HttpConstants.java | 34 + .../main/java/ctbrec/io/HttpException.java | 36 + common/src/main/java/ctbrec/io/IoUtils.java | 95 + .../java/ctbrec/io/ProcessOutputLogger.java | 32 + .../ctbrec/io/ProcessStreamRedirector.java | 55 + .../main/java/ctbrec/io/StreamRedirector.java | 34 + common/src/main/java/ctbrec/io/UrlUtil.java | 29 + .../main/java/ctbrec/io/XmlParserUtils.java | 117 + .../ctbrec/io/json/ObjectMapperFactory.java | 25 + .../java/ctbrec/io/json/dto/CookieDto.java | 16 + .../java/ctbrec/io/json/dto/ModelDto.java | 43 + .../ctbrec/io/json/dto/PostProcessorDto.java | 12 + .../java/ctbrec/io/json/dto/RecordingDto.java | 33 + .../converter/InstantToMillisConverter.java | 25 + .../converter/MillisToInstantConverter.java | 24 + .../ctbrec/io/json/mapper/CookieMapper.java | 39 + .../io/json/mapper/MappingException.java | 7 + .../ctbrec/io/json/mapper/ModelFactory.java | 19 + .../ctbrec/io/json/mapper/ModelMapper.java | 32 + .../io/json/mapper/PostProcessorFactory.java | 19 + .../io/json/mapper/PostProcessorMapper.java | 14 + .../io/json/mapper/RecordingMapper.java | 13 + .../java/ctbrec/io/json/mapper/UriMapper.java | 17 + .../ctbrec/notes/LocalModelNotesService.java | 47 + .../java/ctbrec/notes/ModelNotesService.java | 16 + .../ctbrec/notes/RemoteModelNotesService.java | 169 + .../src/main/java/ctbrec/recorder/FFmpeg.java | 129 + .../recorder/InvalidPlaylistException.java | 7 + .../recorder/InvalidTrackLengthException.java | 7 + .../java/ctbrec/recorder/OnlineMonitor.java | 208 + .../recorder/PreconditionNotMetException.java | 15 + .../ctbrec/recorder/ProgressListener.java | 6 + .../recorder/RecordUntilExpiredException.java | 21 + .../main/java/ctbrec/recorder/Recorder.java | 205 + .../ctbrec/recorder/RecorderHttpClient.java | 18 + .../ctbrec/recorder/RecordingManager.java | 256 + .../recorder/RecordingPinnedException.java | 10 + .../recorder/RecordingPreconditions.java | 262 + .../java/ctbrec/recorder/RemoteRecorder.java | 727 + .../recorder/SimplifiedLocalRecorder.java | 1047 + .../main/java/ctbrec/recorder/Statistics.java | 23 + .../ctbrec/recorder/ThreadPoolScaler.java | 69 + .../recorder/download/AbstractDownload.java | 136 + .../recorder/download/HttpHeaderFactory.java | 10 + .../download/HttpHeaderFactoryImpl.java | 37 + .../ProcessExitedUncleanException.java | 7 + .../recorder/download/RecordingProcess.java | 69 + .../recorder/download/SplittingStrategy.java | 10 + .../recorder/download/StreamSource.java | 80 + .../download/VideoLengthDetector.java | 77 + .../recorder/download/dash/ActuateType.java | 58 + .../download/dash/AdaptationSetType.java | 904 + .../recorder/download/dash/BaseURLType.java | 202 + .../download/dash/ContentComponentType.java | 351 + .../recorder/download/dash/DashDownload.java | 425 + .../download/dash/DescriptorType.java | 187 + .../download/dash/EventStreamType.java | 283 + .../recorder/download/dash/EventType.java | 221 + .../recorder/download/dash/FfmpegMuxer.java | 116 + .../recorder/download/dash/MPDtype.java | 731 + .../recorder/download/dash/MetricsType.java | 198 + .../dash/MultipleSegmentBaseType.java | 163 + .../recorder/download/dash/ObjectFactory.java | 244 + .../recorder/download/dash/PeriodType.java | 553 + .../download/dash/PresentationType.java | 58 + .../download/dash/ProgramInformationType.java | 249 + .../recorder/download/dash/RangeType.java | 93 + .../download/dash/RepresentationBaseType.java | 723 + .../download/dash/RepresentationType.java | 337 + .../download/dash/SegmentBaseType.java | 335 + .../download/dash/SegmentListType.java | 138 + .../download/dash/SegmentTemplateType.java | 149 + .../download/dash/SegmentTimelineType.java | 312 + .../download/dash/SegmentURLType.java | 215 + .../download/dash/SubRepresentationType.java | 164 + .../recorder/download/dash/SubsetType.java | 124 + .../recorder/download/dash/SwitchingType.java | 111 + .../download/dash/SwitchingTypeType.java | 58 + .../recorder/download/dash/URLType.java | 160 + .../recorder/download/dash/VideoScanType.java | 61 + .../recorder/download/dash/package-info.java | 9 + .../download/hls/AbstractHlsDownload.java | 491 + .../hls/CombinedSplittingStrategy.java | 31 + .../ctbrec/recorder/download/hls/Crypto.java | 63 + .../download/hls/FfmpegHlsDownload.java | 319 + .../recorder/download/hls/HlsDownload.java | 225 + .../ctbrec/recorder/download/hls/Hlsdl.java | 236 + .../recorder/download/hls/HlsdlDownload.java | 215 + .../download/hls/MergedFfmpegHlsDownload.java | 260 + .../download/hls/MissingSegmentException.java | 11 + .../hls/MultiFileSegmentDownload.java | 24 + .../download/hls/NoStreamFoundException.java | 7 + .../download/hls/NoopSplittingStrategy.java | 19 + .../hls/PlaylistTimeoutException.java | 11 + .../download/hls/PostProcessingException.java | 13 + .../download/hls/SegmentDownload.java | 144 + .../hls/SegmentDownloadException.java | 9 + .../download/hls/SegmentPlaylist.java | 33 + .../download/hls/SizeSplittingStrategy.java | 22 + .../download/hls/TimeSplittingStrategy.java | 28 + ...AbstractPlaceholderAwarePostProcessor.java | 31 + .../postprocessing/AbstractPostProcessor.java | 21 + .../ctbrec/recorder/postprocessing/Copy.java | 49 + .../postprocessing/CreateContactSheet.java | 173 + .../postprocessing/CreateTimelineThumbs.java | 20 + .../postprocessing/DeleteOriginal.java | 31 + .../postprocessing/DeleteTooShort.java | 39 + .../ctbrec/recorder/postprocessing/Move.java | 66 + .../postprocessing/PostProcessingContext.java | 18 + .../postprocessing/PostProcessor.java | 27 + .../postprocessing/RemoveKeepFile.java | 18 + .../ctbrec/recorder/postprocessing/Remux.java | 100 + .../recorder/postprocessing/Rename.java | 54 + .../recorder/postprocessing/Script.java | 78 + .../recorder/postprocessing/Webhook.java | 30 + .../ctbrec/servlet/AbstractDocServlet.java | 121 + .../java/ctbrec/servlet/MarkdownServlet.java | 63 + .../java/ctbrec/servlet/SearchServlet.java | 64 + .../ctbrec/servlet/StaticFileServlet.java | 85 + .../main/java/ctbrec/sites/AbstractSite.java | 70 + .../ctbrec/sites/ModelOfflineException.java | 10 + .../sites/NeedsManualLoginException.java | 7 + common/src/main/java/ctbrec/sites/Site.java | 66 + .../src/main/java/ctbrec/sites/SiteUtil.java | 19 + .../ctbrec/sites/amateurtv/AmateurTv.java | 161 + .../sites/amateurtv/AmateurTvDownload.java | 171 + .../sites/amateurtv/AmateurTvHttpClient.java | 50 + .../sites/amateurtv/AmateurTvModel.java | 190 + .../java/ctbrec/sites/bonga/BongaCams.java | 209 + .../sites/bonga/BongaCamsHttpClient.java | 145 + .../ctbrec/sites/bonga/BongaCamsModel.java | 403 + .../src/main/java/ctbrec/sites/cam4/Cam4.java | 178 + .../ctbrec/sites/cam4/Cam4HttpClient.java | 103 + .../java/ctbrec/sites/cam4/Cam4Model.java | 300 + .../java/ctbrec/sites/cam4/Cam4WsClient.java | 215 + .../java/ctbrec/sites/camsoda/Camsoda.java | 186 + .../sites/camsoda/CamsodaHttpClient.java | 111 + .../ctbrec/sites/camsoda/CamsodaModel.java | 330 + .../ctbrec/sites/chaturbate/Chaturbate.java | 189 + .../chaturbate/ChaturbateHttpClient.java | 167 + .../sites/chaturbate/ChaturbateModel.java | 413 + .../ctbrec/sites/chaturbate/StreamInfo.java | 8 + .../java/ctbrec/sites/cherrytv/CherryTv.java | 178 + .../sites/cherrytv/CherryTvHttpClient.java | 128 + .../ctbrec/sites/cherrytv/CherryTvModel.java | 285 + .../java/ctbrec/sites/dreamcam/Dreamcam.java | 126 + .../sites/dreamcam/DreamcamDownload.java | 334 + .../sites/dreamcam/DreamcamHttpClient.java | 18 + .../ctbrec/sites/dreamcam/DreamcamModel.java | 195 + .../ctbrec/sites/fc2live/Fc2CookieJar.java | 25 + .../ctbrec/sites/fc2live/Fc2HlsDownload.java | 31 + .../sites/fc2live/Fc2HlsdlDownload.java | 37 + .../ctbrec/sites/fc2live/Fc2HttpClient.java | 110 + .../java/ctbrec/sites/fc2live/Fc2Live.java | 113 + .../sites/fc2live/Fc2MergedHlsDownload.java | 31 + .../java/ctbrec/sites/fc2live/Fc2Model.java | 376 + .../ctbrec/sites/flirt4free/Flirt4Free.java | 177 + .../flirt4free/Flirt4FreeHttpClient.java | 84 + .../sites/flirt4free/Flirt4FreeModel.java | 539 + .../java/ctbrec/sites/jasmin/LiveJasmin.java | 217 + .../sites/jasmin/LiveJasminHttpClient.java | 98 + .../ctbrec/sites/jasmin/LiveJasminModel.java | 255 + .../sites/jasmin/LiveJasminModelInfo.java | 18 + .../jasmin/LiveJasminStreamRegistration.java | 258 + .../sites/jasmin/LiveJasminStreamSource.java | 14 + .../jasmin/LiveJasminTippingWebSocket.java | 176 + .../jasmin/LiveJasminWebrtcDownload.java | 247 + .../java/ctbrec/sites/manyvids/MVLive.java | 212 + .../ctbrec/sites/manyvids/MVLiveClient.java | 219 + .../sites/manyvids/MVLiveHlsDownload.java | 49 + .../sites/manyvids/MVLiveHttpClient.java | 45 + .../manyvids/MVLiveMergedHlsDownload.java | 50 + .../ctbrec/sites/manyvids/MVLiveModel.java | 285 + .../ctbrec/sites/manyvids/StreamLocation.java | 14 + .../manyvids/wsmsg/GetBroadcastHealth.java | 32 + .../ctbrec/sites/manyvids/wsmsg/JoinChat.java | 34 + .../ctbrec/sites/manyvids/wsmsg/Message.java | 19 + .../ctbrec/sites/manyvids/wsmsg/Ping.java | 9 + .../sites/manyvids/wsmsg/RegisterMessage.java | 28 + .../ctbrec/sites/manyvids/wsmsg/Response.java | 7 + .../sites/manyvids/wsmsg/SendMessage.java | 33 + .../sites/mfc/DashStreamSourceProvider.java | 90 + .../src/main/java/ctbrec/sites/mfc/Fcext.java | 46 + .../sites/mfc/HlsStreamSourceProvider.java | 82 + .../main/java/ctbrec/sites/mfc/Message.java | 73 + .../java/ctbrec/sites/mfc/MessageTypes.java | 100 + .../src/main/java/ctbrec/sites/mfc/Model.java | 176 + .../java/ctbrec/sites/mfc/MyFreeCams.java | 161 + .../ctbrec/sites/mfc/MyFreeCamsClient.java | 773 + .../sites/mfc/MyFreeCamsHttpClient.java | 130 + .../ctbrec/sites/mfc/MyFreeCamsModel.java | 333 + .../java/ctbrec/sites/mfc/ServerConfig.java | 102 + .../java/ctbrec/sites/mfc/SessionState.java | 140 + .../src/main/java/ctbrec/sites/mfc/Share.java | 116 + .../src/main/java/ctbrec/sites/mfc/State.java | 45 + .../sites/mfc/StreamSourceProvider.java | 18 + .../src/main/java/ctbrec/sites/mfc/User.java | 164 + common/src/main/java/ctbrec/sites/mfc/X.java | 50 + .../sites/secretfriends/SecretFriends.java | 153 + .../SecretFriendsHttpClient.java | 174 + .../secretfriends/SecretFriendsModel.java | 198 + .../SecretFriendsModelParser.java | 75 + .../SecretFriendsWebrtcDownload.java | 227 + .../main/java/ctbrec/sites/showup/Showup.java | 181 + .../ctbrec/sites/showup/ShowupHttpClient.java | 137 + .../java/ctbrec/sites/showup/ShowupModel.java | 159 + .../sites/showup/ShowupWebrtcDownload.java | 216 + .../ctbrec/sites/streamate/Streamate.java | 177 + .../sites/streamate/StreamateHttpClient.java | 192 + .../sites/streamate/StreamateModel.java | 236 + .../streamate/StreamateWebsocketClient.java | 81 + .../ctbrec/sites/streamray/Streamray.java | 273 + .../sites/streamray/StreamrayHttpClient.java | 87 + .../sites/streamray/StreamrayModel.java | 247 + .../ctbrec/sites/stripchat/Stripchat.java | 202 + .../sites/stripchat/StripchatHttpClient.java | 244 + .../sites/stripchat/StripchatModel.java | 352 + .../main/java/ctbrec/sites/winktv/WinkTv.java | 183 + .../ctbrec/sites/winktv/WinkTvHttpClient.java | 18 + .../java/ctbrec/sites/winktv/WinkTvModel.java | 241 + .../java/ctbrec/sites/xlovecam/XloveCam.java | 129 + .../sites/xlovecam/XloveCamHttpClient.java | 201 + .../ctbrec/sites/xlovecam/XloveCamModel.java | 148 + .../sites/xlovecam/XloveCamModelLoader.java | 85 + .../AbstractVariableExpander.java | 52 + .../ConfigVariableExpander.java | 18 + .../ModelVariableExpander.java | 43 + .../variableexpansion/ParserVisitor.java | 61 + .../RecordingVariableExpander.java | 58 + .../variableexpansion/VarArgsFunction.java | 7 + .../VariableExpansionException.java | 7 + .../functions/AntlrSyntacErrorAdapter.java | 26 + .../functions/Capitalize.java | 16 + .../variableexpansion/functions/Format.java | 39 + .../variableexpansion/functions/Lower.java | 15 + .../variableexpansion/functions/OrElse.java | 37 + .../variableexpansion/functions/Sanitize.java | 16 + .../variableexpansion/functions/Trim.java | 15 + .../variableexpansion/functions/Upper.java | 15 + common/src/main/resources/DASH-MPD.xsd | 445 + common/src/main/resources/bindings.xjb | 19 + common/src/main/resources/docs/400.html | 3 + common/src/main/resources/docs/404.html | 2 + common/src/main/resources/docs/500.html | 3 + common/src/main/resources/docs/Avidemux.md | 9 + .../main/resources/docs/ConfigurationFile.md | 107 + common/src/main/resources/docs/FFmpeg.md | 30 + common/src/main/resources/docs/MKVToolNix.md | 9 + .../src/main/resources/docs/PostProcessing.md | 144 + .../resources/docs/QuestionsAndAnswers.md | 71 + .../main/resources/docs/RunningTheServer.md | 50 + .../src/main/resources/docs/VideoTutorials.md | 34 + common/src/main/resources/docs/footer.html | 41 + common/src/main/resources/docs/header.html | 80 + common/src/main/resources/generate-jaxb.sh | 3 + common/src/main/resources/xlink.xsd | 16 + .../src/test/java/ctbrec/ReflectionUtil.java | 21 + common/src/test/java/ctbrec/VersionTest.java | 53 + .../io/json/ObjectMapperRecordingTest.java | 73 + .../io/json/mapper/ModelMapperTest.java | 89 + .../io/json/mapper/RecordingMapperTest.java | 106 + .../recorder/RecordingPreconditionsTest.java | 395 + ...ractPlaceholderAwarePostProcessorTest.java | 178 + .../postprocessing/AbstractPpTest.java | 108 + .../recorder/postprocessing/CopyTest.java | 50 + .../postprocessing/DeleteOriginalTest.java | 57 + .../postprocessing/DeleteTooShortTest.java | 103 + .../postprocessing/MoveDirectoryTest.java | 56 + .../postprocessing/MoveSingleFileTest.java | 78 + .../postprocessing/RemoveKeepFileTest.java | 42 + .../postprocessing/RenameDirectoryTest.java | 51 + .../postprocessing/RenameSingleFileTest.java | 72 + .../sites/mfc/MyFreeCamsClientTest.java | 38 + .../ModelVariableExpanderTest.java | 72 + .../functions/CapitalizeTest.java | 31 + .../functions/FormatTest.java | 55 + .../functions/LowerTest.java | 31 + .../functions/OrElseTest.java | 29 + .../functions/SanitizeTest.java | 35 + .../variableexpansion/functions/TrimTest.java | 33 + .../functions/UpperTest.java | 31 + docs/.gitignore | 1 + docs/.travis.yml | 11 + docs/LICENSE | 21 + docs/TEMPLATE.md | 71 + docs/css/freelancer.css | 380 + docs/css/freelancer.min.css | 1 + docs/gulpfile.js | 121 + docs/img/buymeacoffee-round.png | Bin 0 -> 9832 bytes docs/img/buymeacoffee/.DS_Store | Bin 0 -> 8196 bytes docs/img/buymeacoffee/Button/.DS_Store | Bin 0 -> 6148 bytes docs/img/buymeacoffee/Button/Button-gif.gif | Bin 0 -> 110457 bytes .../img/buymeacoffee/Button/Button-orange.png | Bin 0 -> 5578 bytes .../img/buymeacoffee/Button/Button_yellow.png | Bin 0 -> 5341 bytes docs/img/buymeacoffee/Button/button-red.png | Bin 0 -> 6338 bytes docs/img/buymeacoffee/Button/button-red.svg | 204 + docs/img/buymeacoffee/Collection.eps | Bin 0 -> 5969810 bytes docs/img/buymeacoffee/Logo_Editable.ai | 2004 ++ docs/img/buymeacoffee/Logo_black-vector.eps | Bin 0 -> 2424874 bytes docs/img/buymeacoffee/Logo_orange-vector.eps | Bin 0 -> 2385434 bytes .../buymeacoffee/Logo_transparent_vector.eps | Bin 0 -> 2392178 bytes docs/img/buymeacoffee/Logo_yellow_vector.eps | Bin 0 -> 2385350 bytes .../buymeacoffee/Wordmark-yellow_vector.eps | Bin 0 -> 2533546 bytes .../buymeacoffee/Wordmark_black_vector.eps | Bin 0 -> 2491898 bytes .../buymeacoffee/Wordmark_orange_vector.eps | Bin 0 -> 2533610 bytes docs/img/buymeacoffee/Wordmark_vector.eps | Bin 0 -> 2516426 bytes docs/img/buymeacoffee/buymeacoffee-fancy.png | Bin 0 -> 5054 bytes docs/img/buymeacoffee/buymeacoffee-fancy.svg | 1119 + docs/img/buymeacoffee/buymeacoffee.png | Bin 0 -> 4114 bytes docs/img/buymeacoffee/buymeacoffee.svg | 48 + docs/img/favicon.png | Bin 0 -> 1262 bytes docs/img/featured-s.jpg | Bin 0 -> 34664 bytes docs/img/featured.jpg | Bin 0 -> 116288 bytes docs/img/featured.png | Bin 0 -> 711748 bytes docs/img/followed.jpg | Bin 0 -> 119068 bytes docs/img/followed.png | Bin 0 -> 705733 bytes docs/img/patreon-logo.png | Bin 0 -> 3190 bytes docs/img/patreon-round.png | Bin 0 -> 7770 bytes docs/img/paypal-round.png | Bin 0 -> 8842 bytes docs/img/portfolio/cabin.png | Bin 0 -> 36514 bytes docs/img/portfolio/cake.png | Bin 0 -> 17068 bytes docs/img/portfolio/circus.png | Bin 0 -> 27984 bytes docs/img/portfolio/game.png | Bin 0 -> 25896 bytes docs/img/portfolio/safe.png | Bin 0 -> 19240 bytes docs/img/portfolio/submarine.png | Bin 0 -> 24330 bytes docs/img/pp196.png | Bin 0 -> 6295 bytes docs/img/profile.png | Bin 0 -> 10284 bytes docs/img/recording.jpg | Bin 0 -> 61416 bytes docs/img/recording.png | Bin 0 -> 47796 bytes docs/img/recordings.jpg | Bin 0 -> 71605 bytes docs/img/recordings.png | Bin 0 -> 43513 bytes docs/img/server.png | Bin 0 -> 110453 bytes docs/img/settings.jpg | Bin 0 -> 42547 bytes docs/img/settings.png | Bin 0 -> 36540 bytes docs/img/token.png | Bin 0 -> 33081 bytes docs/index.html | 638 + docs/js/contact_me.js | 75 + docs/js/contact_me.min.js | 1 + docs/js/freelancer.js | 75 + docs/js/freelancer.min.js | 1 + docs/js/jqBootstrapValidation.js | 912 + docs/js/jqBootstrapValidation.min.js | 1 + docs/mail/contact_me.php | 26 + docs/package-lock.json | 6346 ++++++ docs/package.json | 45 + docs/scss/_bootstrap-overrides.scss | 51 + docs/scss/_contact.scss | 54 + docs/scss/_footer.scss | 10 + docs/scss/_global.scss | 95 + docs/scss/_masthead.scss | 26 + docs/scss/_mixins.scss | 8 + docs/scss/_navbar.scss | 67 + docs/scss/_portfolio.scss | 63 + docs/scss/_variables.scss | 16 + docs/scss/freelancer.scss | 9 + docs/vendor/bootstrap/css/bootstrap-grid.css | 1567 ++ .../bootstrap/css/bootstrap-grid.min.css | 7 + .../vendor/bootstrap/css/bootstrap-reboot.css | 342 + .../bootstrap/css/bootstrap-reboot.min.css | 8 + docs/vendor/bootstrap/css/bootstrap.css | 8981 ++++++++ docs/vendor/bootstrap/css/bootstrap.css.map | 1 + docs/vendor/bootstrap/css/bootstrap.min.css | 7 + .../bootstrap/css/bootstrap.min.css.map | 1 + docs/vendor/bootstrap/js/bootstrap.bundle.js | 6444 ++++++ .../bootstrap/js/bootstrap.bundle.js.map | 1 + .../bootstrap/js/bootstrap.bundle.min.js | 7 + .../bootstrap/js/bootstrap.bundle.min.js.map | 1 + docs/vendor/bootstrap/js/bootstrap.js | 3927 ++++ docs/vendor/bootstrap/js/bootstrap.js.map | 1 + docs/vendor/bootstrap/js/bootstrap.min.js | 7 + docs/vendor/bootstrap/js/bootstrap.min.js.map | 1 + docs/vendor/font-awesome/css/font-awesome.css | 2337 ++ .../font-awesome/css/font-awesome.css.map | 7 + .../font-awesome/css/font-awesome.min.css | 4 + .../vendor/font-awesome/fonts/FontAwesome.otf | Bin 0 -> 134808 bytes .../fonts/fontawesome-webfont.eot | Bin 0 -> 165742 bytes .../fonts/fontawesome-webfont.svg | 2671 +++ .../fonts/fontawesome-webfont.ttf | Bin 0 -> 165548 bytes .../fonts/fontawesome-webfont.woff | Bin 0 -> 98024 bytes .../fonts/fontawesome-webfont.woff2 | Bin 0 -> 77160 bytes docs/vendor/font-awesome/less/animated.less | 34 + .../font-awesome/less/bordered-pulled.less | 25 + docs/vendor/font-awesome/less/core.less | 12 + .../vendor/font-awesome/less/fixed-width.less | 6 + .../font-awesome/less/font-awesome.less | 18 + docs/vendor/font-awesome/less/icons.less | 789 + docs/vendor/font-awesome/less/larger.less | 13 + docs/vendor/font-awesome/less/list.less | 19 + docs/vendor/font-awesome/less/mixins.less | 60 + docs/vendor/font-awesome/less/path.less | 15 + .../font-awesome/less/rotated-flipped.less | 20 + .../font-awesome/less/screen-reader.less | 5 + docs/vendor/font-awesome/less/stacked.less | 20 + docs/vendor/font-awesome/less/variables.less | 800 + docs/vendor/font-awesome/scss/_animated.scss | 34 + .../font-awesome/scss/_bordered-pulled.scss | 25 + docs/vendor/font-awesome/scss/_core.scss | 12 + .../font-awesome/scss/_fixed-width.scss | 6 + docs/vendor/font-awesome/scss/_icons.scss | 789 + docs/vendor/font-awesome/scss/_larger.scss | 13 + docs/vendor/font-awesome/scss/_list.scss | 19 + docs/vendor/font-awesome/scss/_mixins.scss | 60 + docs/vendor/font-awesome/scss/_path.scss | 15 + .../font-awesome/scss/_rotated-flipped.scss | 20 + .../font-awesome/scss/_screen-reader.scss | 5 + docs/vendor/font-awesome/scss/_stacked.scss | 20 + docs/vendor/font-awesome/scss/_variables.scss | 800 + .../font-awesome/scss/font-awesome.scss | 18 + .../jquery.easing.compatibility.js | 59 + docs/vendor/jquery-easing/jquery.easing.js | 166 + .../vendor/jquery-easing/jquery.easing.min.js | 1 + docs/vendor/jquery/jquery.js | 10364 +++++++++ docs/vendor/jquery/jquery.min.js | 2 + docs/vendor/jquery/jquery.min.map | 1 + docs/vendor/jquery/jquery.slim.js | 8269 +++++++ docs/vendor/jquery/jquery.slim.min.js | 2 + docs/vendor/jquery/jquery.slim.min.map | 1 + .../magnific-popup/jquery.magnific-popup.js | 1860 ++ .../jquery.magnific-popup.min.js | 4 + docs/vendor/magnific-popup/magnific-popup.css | 351 + logo.png | Bin 0 -> 12734 bytes master/.gitignore | 6 + master/pom.xml | 210 + server/.gitignore | 11 + server/Dockerfile.txt | 33 + server/LICENSE.txt | 674 + server/README.md | 44 + server/docker-compose.yml | 9 + server/pom.xml | 98 + server/src/assembly/ctbrec-systemd.service | 14 + server/src/assembly/ffmpeg.txt | 10 + server/src/assembly/server-linux.sh | 71 + server/src/assembly/server-macos.sh | 8 + server/src/assembly/server.bat | 1 + server/src/assembly/server.xml | 50 + .../server/AbstractCtbrecServlet.java | 105 + .../ctbrec/recorder/server/ConfigServlet.java | 194 + .../ctbrec/recorder/server/DebugServlet.java | 115 + .../ctbrec/recorder/server/HlsServlet.java | 164 + .../ctbrec/recorder/server/HttpServer.java | 431 + .../ctbrec/recorder/server/ImageServlet.java | 133 + .../ctbrec/recorder/server/ModelServlet.java | 101 + .../recorder/server/RecorderServlet.java | 329 + .../java/ctbrec/recorder/server/Request.java | 14 + .../server/io/json/dto/RequestDto.java | 14 + .../server/io/json/mapper/RequestMapper.java | 14 + server/src/main/resources/META-INF/mime.types | 27 + .../main/resources/html/static/button-red.png | Bin 0 -> 6338 bytes .../src/main/resources/html/static/config.js | 90 + .../src/main/resources/html/static/ctbrec.svg | 155 + .../src/main/resources/html/static/custom.css | 75 + .../main/resources/html/static/favicon.png | Bin 0 -> 1262 bytes .../main/resources/html/static/favicon.svg | 108 + .../resources/html/static/freelancer-dark.css | 419 + .../main/resources/html/static/freelancer.css | 419 + .../resources/html/static/freelancer.min.js | 1 + .../src/main/resources/html/static/icon64.png | Bin 0 -> 2729 bytes .../src/main/resources/html/static/index.html | 571 + .../src/main/resources/html/static/modal.js | 16 + .../src/main/resources/html/static/models.js | 133 + .../main/resources/html/static/recordings.js | 206 + .../html/static/vendor/CryptoJS/base64.min.js | 1 + .../static/vendor/CryptoJS/hmac-sha256.js | 18 + .../vendor/bootstrap/css/bootstrap-grid.css | 1567 ++ .../bootstrap/css/bootstrap-grid.min.css | 7 + .../vendor/bootstrap/css/bootstrap-reboot.css | 342 + .../bootstrap/css/bootstrap-reboot.min.css | 8 + .../static/vendor/bootstrap/css/bootstrap.css | 8981 ++++++++ .../vendor/bootstrap/css/bootstrap.css.map | 1 + .../vendor/bootstrap/css/bootstrap.min.css | 7 + .../bootstrap/css/bootstrap.min.css.map | 1 + .../vendor/bootstrap/js/bootstrap.bundle.js | 6444 ++++++ .../bootstrap/js/bootstrap.bundle.js.map | 1 + .../bootstrap/js/bootstrap.bundle.min.js | 7 + .../bootstrap/js/bootstrap.bundle.min.js.map | 1 + .../static/vendor/bootstrap/js/bootstrap.js | 3927 ++++ .../vendor/bootstrap/js/bootstrap.js.map | 1 + .../vendor/bootstrap/js/bootstrap.min.js | 7 + .../vendor/bootstrap/js/bootstrap.min.js.map | 1 + .../html/static/vendor/cookie_js/.babelrc | 3 + .../html/static/vendor/cookie_js/.travis.yml | 4 + .../html/static/vendor/cookie_js/LICENSE | 20 + .../html/static/vendor/cookie_js/README.md | 231 + .../html/static/vendor/cookie_js/bower.json | 21 + .../vendor/cookie_js/dist/cookie.cjs.js | 178 + .../vendor/cookie_js/dist/cookie.esm.js | 174 + .../vendor/cookie_js/dist/cookie.umd.js | 184 + .../vendor/cookie_js/dist/cookie.umd.min.js | 1 + .../html/static/vendor/cookie_js/package.json | 66 + .../static/vendor/cookie_js/rollup.config.js | 50 + .../static/vendor/cookie_js/src/cookie.js | 174 + .../static/vendor/cookie_js/tests/README.md | 13 + .../vendor/cookie_js/tests/builds/cjs.js | 14 + .../vendor/cookie_js/tests/builds/esm.js | 14 + .../vendor/cookie_js/tests/builds/umd.js | 25 + .../vendor/cookie_js/tests/builds/umd.min.js | 25 + .../static/vendor/cookie_js/tests/mocha.opts | 1 + .../static/vendor/cookie_js/tests/shared.js | 287 + .../vendor/cookie_js/tests/shared_no_jsdom.js | 76 + .../html/static/vendor/flowplayer/LICENSE.md | 715 + .../static/vendor/flowplayer/flowplayer.js | 10518 +++++++++ .../vendor/flowplayer/flowplayer.min.js | 6 + .../static/vendor/flowplayer/flowplayer.swf | Bin 0 -> 8541 bytes .../vendor/flowplayer/flowplayerhls.swf | Bin 0 -> 61773 bytes .../html/static/vendor/flowplayer/index.html | 32 + .../flowplayer/skin/icons/flowplayer.eot | Bin 0 -> 17896 bytes .../flowplayer/skin/icons/flowplayer.svg | 98 + .../flowplayer/skin/icons/flowplayer.ttf | Bin 0 -> 17732 bytes .../flowplayer/skin/icons/flowplayer.woff | Bin 0 -> 17808 bytes .../flowplayer/skin/icons/flowplayer.woff2 | Bin 0 -> 7908 bytes .../static/vendor/flowplayer/skin/skin.css | 1034 + .../static/vendor/font-awesome/css/all.css | 4619 ++++ .../vendor/font-awesome/css/all.min.css | 5 + .../static/vendor/font-awesome/css/brands.css | 15 + .../vendor/font-awesome/css/brands.min.css | 5 + .../vendor/font-awesome/css/fontawesome.css | 4585 ++++ .../font-awesome/css/fontawesome.min.css | 5 + .../vendor/font-awesome/css/regular.css | 15 + .../vendor/font-awesome/css/regular.min.css | 5 + .../static/vendor/font-awesome/css/solid.css | 16 + .../vendor/font-awesome/css/solid.min.css | 5 + .../vendor/font-awesome/css/svg-with-js.css | 371 + .../font-awesome/css/svg-with-js.min.css | 5 + .../vendor/font-awesome/css/v4-shims.css | 2172 ++ .../vendor/font-awesome/css/v4-shims.min.css | 5 + .../font-awesome/webfonts/fa-brands-400.eot | Bin 0 -> 136822 bytes .../font-awesome/webfonts/fa-brands-400.svg | 3717 +++ .../font-awesome/webfonts/fa-brands-400.ttf | Bin 0 -> 136516 bytes .../font-awesome/webfonts/fa-brands-400.woff | Bin 0 -> 92136 bytes .../font-awesome/webfonts/fa-brands-400.woff2 | Bin 0 -> 78472 bytes .../font-awesome/webfonts/fa-regular-400.eot | Bin 0 -> 34350 bytes .../font-awesome/webfonts/fa-regular-400.svg | 801 + .../font-awesome/webfonts/fa-regular-400.ttf | Bin 0 -> 34052 bytes .../font-awesome/webfonts/fa-regular-400.woff | Bin 0 -> 16776 bytes .../webfonts/fa-regular-400.woff2 | Bin 0 -> 13588 bytes .../font-awesome/webfonts/fa-solid-900.eot | Bin 0 -> 204814 bytes .../font-awesome/webfonts/fa-solid-900.svg | 5028 +++++ .../font-awesome/webfonts/fa-solid-900.ttf | Bin 0 -> 204528 bytes .../font-awesome/webfonts/fa-solid-900.woff | Bin 0 -> 104280 bytes .../font-awesome/webfonts/fa-solid-900.woff2 | Bin 0 -> 80252 bytes .../html/static/vendor/hls.js/hls.js | 18209 +++++++++++++++ .../jquery.easing.compatibility.js | 59 + .../vendor/jquery-easing/jquery.easing.js | 166 + .../vendor/jquery-easing/jquery.easing.min.js | 1 + .../vendor/jquery-ui/jquery-ui-1.12.1.css | 1311 ++ .../vendor/jquery-ui/jquery-ui-1.12.1.js | 18706 ++++++++++++++++ .../html/static/vendor/jquery/jquery.js | 10364 +++++++++ .../html/static/vendor/jquery/jquery.min.js | 2 + .../html/static/vendor/jquery/jquery.min.map | 1 + .../html/static/vendor/jquery/jquery.slim.js | 8269 +++++++ .../static/vendor/jquery/jquery.slim.min.js | 2 + .../static/vendor/jquery/jquery.slim.min.map | 1 + .../vendor/knockout-orderable/README.md | 45 + .../knockout.bindings.orderable.js | 112 + .../static/vendor/knockout/knockout-3.5.0.js | 138 + .../magnific-popup/jquery.magnific-popup.js | 1860 ++ .../jquery.magnific-popup.min.js | 4 + .../vendor/magnific-popup/magnific-popup.css | 351 + .../static/vendor/notify.js/notify.min.js | 1 + server/src/main/resources/keystore.pkcs12 | Bin 0 -> 2591 bytes server/src/main/resources/logback.xml | 47 + server/src/main/resources/version | 1 + splash.bmp | Bin 0 -> 518522 bytes splash.png | Bin 0 -> 15352 bytes 1096 files changed, 329210 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/other.md create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 build-all.sh create mode 100644 client/.gitignore create mode 100644 client/LICENSE.txt create mode 100644 client/README.md create mode 100644 client/build.sh create mode 100644 client/pom.xml create mode 100644 client/src/assembly/ctbrec-linux-jre-no-splash.sh create mode 100644 client/src/assembly/ctbrec-linux-jre.sh create mode 100644 client/src/assembly/ctbrec-macos-jre-no-splash.sh create mode 100644 client/src/assembly/ctbrec-macos-jre.sh create mode 100644 client/src/assembly/ctbrec.bat create mode 100644 client/src/assembly/linux-jre.xml create mode 100644 client/src/assembly/macos-jre.xml create mode 100644 client/src/assembly/win64-jre.xml create mode 100644 client/src/main/java/ctbrec/RecordingDownload.java create mode 100644 client/src/main/java/ctbrec/docs/DocServer.java create mode 100644 client/src/main/java/ctbrec/ui/AutosizeAlert.java create mode 100644 client/src/main/java/ctbrec/ui/CamrecApplication.java create mode 100644 client/src/main/java/ctbrec/ui/ClipboardListener.java create mode 100644 client/src/main/java/ctbrec/ui/DesktopIntegration.java create mode 100644 client/src/main/java/ctbrec/ui/ExternalBrowser.java create mode 100644 client/src/main/java/ctbrec/ui/FileDownload.java create mode 100644 client/src/main/java/ctbrec/ui/Icon.java create mode 100644 client/src/main/java/ctbrec/ui/InstantProperty.java create mode 100644 client/src/main/java/ctbrec/ui/JavaFxModel.java create mode 100644 client/src/main/java/ctbrec/ui/JavaFxRecording.java create mode 100644 client/src/main/java/ctbrec/ui/Launcher.java create mode 100644 client/src/main/java/ctbrec/ui/PauseIcon.java create mode 100644 client/src/main/java/ctbrec/ui/Player.java create mode 100644 client/src/main/java/ctbrec/ui/PreviewPopupHandler.java create mode 100644 client/src/main/java/ctbrec/ui/RecordUntilDialog.java create mode 100644 client/src/main/java/ctbrec/ui/ShutdownListener.java create mode 100644 client/src/main/java/ctbrec/ui/SiteUI.java create mode 100644 client/src/main/java/ctbrec/ui/SiteUiFactory.java create mode 100644 client/src/main/java/ctbrec/ui/StreamSourceSelectionDialog.java create mode 100644 client/src/main/java/ctbrec/ui/TimeTextFieldTest.java create mode 100644 client/src/main/java/ctbrec/ui/TipDialog.java create mode 100644 client/src/main/java/ctbrec/ui/TokenLabel.java create mode 100644 client/src/main/java/ctbrec/ui/TrayIcon.java create mode 100644 client/src/main/java/ctbrec/ui/UiUtils.java create mode 100644 client/src/main/java/ctbrec/ui/UnicodeEmoji.java create mode 100644 client/src/main/java/ctbrec/ui/action/AbstractAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/AbstractModelAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/AbstractPortraitAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/AddToGroupAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/CheckModelAccountAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/EditGroupAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/EditNotesAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/FollowAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/ForcePriorityAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/IgnoreModelsAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/LaterGroupAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/MarkForLaterRecordingAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/ModelMassEditAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/OpenRecordingsDir.java create mode 100644 client/src/main/java/ctbrec/ui/action/OpenUrlAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/PauseAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/PauseGroupAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/PlayAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/RemoveTimeLimitAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/ResumeAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/ResumeGroupAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/ResumePriorityAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/SetPortraitAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/SetStopDateAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/SetThumbAsPortraitAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/StartRecordingAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/StopGroupAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/StopRecordingAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/SwitchStreamResolutionAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/TipAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/ToggleRecordingAction.java create mode 100644 client/src/main/java/ctbrec/ui/action/TriConsumer.java create mode 100644 client/src/main/java/ctbrec/ui/action/UnfollowAction.java create mode 100644 client/src/main/java/ctbrec/ui/controls/AbstractFileSelectionBox.java create mode 100644 client/src/main/java/ctbrec/ui/controls/CustomMouseBehaviorContextMenu.java create mode 100644 client/src/main/java/ctbrec/ui/controls/DateTimeCellFactory.java create mode 100644 client/src/main/java/ctbrec/ui/controls/DateTimePicker.java create mode 100644 client/src/main/java/ctbrec/ui/controls/Dialogs.java create mode 100644 client/src/main/java/ctbrec/ui/controls/DirectorySelectionBox.java create mode 100644 client/src/main/java/ctbrec/ui/controls/FasterVerticalScrollPaneSkin.java create mode 100644 client/src/main/java/ctbrec/ui/controls/FileSelectionBox.java create mode 100644 client/src/main/java/ctbrec/ui/controls/Popover.css create mode 100644 client/src/main/java/ctbrec/ui/controls/Popover.java create mode 100644 client/src/main/java/ctbrec/ui/controls/PopoverTreeList.java create mode 100644 client/src/main/java/ctbrec/ui/controls/ProgramSelectionBox.java create mode 100644 client/src/main/java/ctbrec/ui/controls/RecordingIndicator.java create mode 100644 client/src/main/java/ctbrec/ui/controls/SaveFileSelectionBox.java create mode 100644 client/src/main/java/ctbrec/ui/controls/SearchBox.css create mode 100644 client/src/main/java/ctbrec/ui/controls/SearchBox.java create mode 100644 client/src/main/java/ctbrec/ui/controls/SearchPopover.java create mode 100644 client/src/main/java/ctbrec/ui/controls/SearchPopoverTreeList.java create mode 100644 client/src/main/java/ctbrec/ui/controls/StreamPreview.java create mode 100644 client/src/main/java/ctbrec/ui/controls/TimePicker.java create mode 100644 client/src/main/java/ctbrec/ui/controls/Toast.java create mode 100644 client/src/main/java/ctbrec/ui/controls/Wizard.java create mode 100644 client/src/main/java/ctbrec/ui/controls/autocomplete/AutoFillTextField.java create mode 100644 client/src/main/java/ctbrec/ui/controls/autocomplete/ObservableListSuggester.java create mode 100644 client/src/main/java/ctbrec/ui/controls/autocomplete/Suggester.java create mode 100644 client/src/main/java/ctbrec/ui/controls/range/DiscreteRange.java create mode 100644 client/src/main/java/ctbrec/ui/controls/range/LabeledNumberAxis.java create mode 100644 client/src/main/java/ctbrec/ui/controls/range/Range.java create mode 100644 client/src/main/java/ctbrec/ui/controls/range/RangeSlider.java create mode 100644 client/src/main/java/ctbrec/ui/controls/range/RangeSliderBehavior.java create mode 100644 client/src/main/java/ctbrec/ui/controls/range/RangeSliderSkin.java create mode 100644 client/src/main/java/ctbrec/ui/controls/range/rangeslider.css create mode 100644 client/src/main/java/ctbrec/ui/controls/table/SettingTableViewStateStore.java create mode 100644 client/src/main/java/ctbrec/ui/controls/table/StatePersistingTableView.java create mode 100644 client/src/main/java/ctbrec/ui/controls/table/TableViewStateStore.java create mode 100644 client/src/main/java/ctbrec/ui/controls/table/TableViewStateStoreException.java create mode 100644 client/src/main/java/ctbrec/ui/event/PlaySound.java create mode 100644 client/src/main/java/ctbrec/ui/event/PlayerStartedEvent.java create mode 100644 client/src/main/java/ctbrec/ui/event/ShowNotification.java create mode 100644 client/src/main/java/ctbrec/ui/io/json/dto/PlayerStartedEventDto.java create mode 100644 client/src/main/java/ctbrec/ui/io/json/mapper/PlayerStartedEventMapper.java create mode 100644 client/src/main/java/ctbrec/ui/menu/FollowUnfollowHandler.java create mode 100644 client/src/main/java/ctbrec/ui/menu/ForcePriorityHandler.java create mode 100644 client/src/main/java/ctbrec/ui/menu/ModelGroupMenuBuilder.java create mode 100644 client/src/main/java/ctbrec/ui/menu/ModelMenuContributor.java create mode 100644 client/src/main/java/ctbrec/ui/menu/PauseResumeHandler.java create mode 100644 client/src/main/java/ctbrec/ui/news/Account.java create mode 100644 client/src/main/java/ctbrec/ui/news/NewsTab.java create mode 100644 client/src/main/java/ctbrec/ui/news/Status.java create mode 100644 client/src/main/java/ctbrec/ui/news/StatusPane.java create mode 100644 client/src/main/java/ctbrec/ui/settings/AbstractPostProcessingPaneFactory.java create mode 100644 client/src/main/java/ctbrec/ui/settings/ActionSettingsPanel.java create mode 100644 client/src/main/java/ctbrec/ui/settings/CacheSettingsPane.java create mode 100644 client/src/main/java/ctbrec/ui/settings/ColorSettingsPane.css create mode 100644 client/src/main/java/ctbrec/ui/settings/ColorSettingsPane.java create mode 100644 client/src/main/java/ctbrec/ui/settings/CreateContactSheetPaneFactory.java create mode 100644 client/src/main/java/ctbrec/ui/settings/CtbrecPreferencesStorage.java create mode 100644 client/src/main/java/ctbrec/ui/settings/DeleteTooShortPaneFactory.java create mode 100644 client/src/main/java/ctbrec/ui/settings/FontSettingsPane.java create mode 100644 client/src/main/java/ctbrec/ui/settings/IgnoreList.java create mode 100644 client/src/main/java/ctbrec/ui/settings/ListSelectionPane.java create mode 100644 client/src/main/java/ctbrec/ui/settings/MoverPaneFactory.java create mode 100644 client/src/main/java/ctbrec/ui/settings/PostProcessingDialogFactory.java create mode 100644 client/src/main/java/ctbrec/ui/settings/PostProcessingStepPanel.java create mode 100644 client/src/main/java/ctbrec/ui/settings/RemuxerPaneFactory.java create mode 100644 client/src/main/java/ctbrec/ui/settings/RenamerPaneFactory.java create mode 100644 client/src/main/java/ctbrec/ui/settings/RestrictResolutionSidePane.java create mode 100644 client/src/main/java/ctbrec/ui/settings/ScriptPaneFactory.java create mode 100644 client/src/main/java/ctbrec/ui/settings/SettingsTab.java create mode 100644 client/src/main/java/ctbrec/ui/settings/VariablePlayGroundDialogFactory.java create mode 100644 client/src/main/java/ctbrec/ui/settings/api/Category.java create mode 100644 client/src/main/java/ctbrec/ui/settings/api/ExclusiveSelectionProperty.java create mode 100644 client/src/main/java/ctbrec/ui/settings/api/GigabytesConverter.java create mode 100644 client/src/main/java/ctbrec/ui/settings/api/Group.java create mode 100644 client/src/main/java/ctbrec/ui/settings/api/HighlightingSupport.java create mode 100644 client/src/main/java/ctbrec/ui/settings/api/LocalTimeProperty.java create mode 100644 client/src/main/java/ctbrec/ui/settings/api/Preferences.css create mode 100644 client/src/main/java/ctbrec/ui/settings/api/Preferences.java create mode 100644 client/src/main/java/ctbrec/ui/settings/api/PreferencesStorage.java create mode 100644 client/src/main/java/ctbrec/ui/settings/api/Setting.java create mode 100644 client/src/main/java/ctbrec/ui/settings/api/SimpleDirectoryProperty.java create mode 100644 client/src/main/java/ctbrec/ui/settings/api/SimpleFileProperty.java create mode 100644 client/src/main/java/ctbrec/ui/settings/api/SimpleJoinedStringListProperty.java create mode 100644 client/src/main/java/ctbrec/ui/settings/api/SimpleRangeProperty.java create mode 100644 client/src/main/java/ctbrec/ui/settings/api/ValueConverter.java create mode 100644 client/src/main/java/ctbrec/ui/sites/AbstractConfigUI.java create mode 100644 client/src/main/java/ctbrec/ui/sites/AbstractSiteUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/AbstractTabProvider.java create mode 100644 client/src/main/java/ctbrec/ui/sites/ConfigUI.java create mode 100644 client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvConfigUI.java create mode 100644 client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvElectronLoginDialog.java create mode 100644 client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvFollowedTab.java create mode 100644 client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvSiteUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvTabProvider.java create mode 100644 client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsConfigUI.java create mode 100644 client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsElectronLoginDialog.java create mode 100644 client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsFriendsTab.java create mode 100644 client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsSiteUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsTabProvider.java create mode 100644 client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/cam4/Cam4ConfigUI.java create mode 100644 client/src/main/java/ctbrec/ui/sites/cam4/Cam4ElectronLoginDialog.java create mode 100644 client/src/main/java/ctbrec/ui/sites/cam4/Cam4FollowedTab.java create mode 100644 client/src/main/java/ctbrec/ui/sites/cam4/Cam4FollowedUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/cam4/Cam4SiteUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/cam4/Cam4TabProvider.java create mode 100644 client/src/main/java/ctbrec/ui/sites/cam4/Cam4UpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaConfigUI.java create mode 100644 client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaFollowedTab.java create mode 100644 client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaFollowedUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaSiteUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaTabProvider.java create mode 100644 client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateApiUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateConfigUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateElectronLoginDialog.java create mode 100644 client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateFollowedTab.java create mode 100644 client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateSiteUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateTabProvider.java create mode 100644 client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvConfigUI.java create mode 100644 client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvFollowedTab.java create mode 100644 client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvFollowedUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvSiteUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvTabProvider.java create mode 100644 client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamConfigUI.java create mode 100644 client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamSiteUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamTabProvider.java create mode 100644 client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/fc2live/Fc2FollowedTab.java create mode 100644 client/src/main/java/ctbrec/ui/sites/fc2live/Fc2FollowedUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/fc2live/Fc2LiveConfigUI.java create mode 100644 client/src/main/java/ctbrec/ui/sites/fc2live/Fc2LiveSiteUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/fc2live/Fc2TabProvider.java create mode 100644 client/src/main/java/ctbrec/ui/sites/fc2live/Fc2UpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/flirt4free/Flirt4FreeConfigUI.java create mode 100644 client/src/main/java/ctbrec/ui/sites/flirt4free/Flirt4FreeFavoritesTab.java create mode 100644 client/src/main/java/ctbrec/ui/sites/flirt4free/Flirt4FreeFavoritesUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/flirt4free/Flirt4FreeSiteUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/flirt4free/Flirt4FreeTabProvider.java create mode 100644 client/src/main/java/ctbrec/ui/sites/flirt4free/Flirt4FreeUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminConfigUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminElectronLoginDialog.java create mode 100644 client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminFollowedTab.java create mode 100644 client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminFollowedUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminSiteUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminTab.java create mode 100644 client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminTabProvider.java create mode 100644 client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/manyvids/MVLiveConfigUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/manyvids/MVLiveSiteUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/manyvids/MVLiveTabProvider.java create mode 100644 client/src/main/java/ctbrec/ui/sites/manyvids/MVLiveUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/myfreecams/FriendsUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/myfreecams/HDCamsUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsAbstractUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsConfigUI.java create mode 100644 client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsFriendsTab.java create mode 100644 client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsSiteUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsTabProvider.java create mode 100644 client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsTableTab.java create mode 100644 client/src/main/java/ctbrec/ui/sites/myfreecams/NewModelService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/myfreecams/OnlineCamsUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/myfreecams/PopularModelService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/myfreecams/TableUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/secretfriends/SecretFriendsConfigUI.java create mode 100644 client/src/main/java/ctbrec/ui/sites/secretfriends/SecretFriendsSiteUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/secretfriends/SecretFriendsTabProvider.java create mode 100644 client/src/main/java/ctbrec/ui/sites/secretfriends/SecretFriendsUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/showup/ShowupConfigUI.java create mode 100644 client/src/main/java/ctbrec/ui/sites/showup/ShowupElectronLoginDialog.java create mode 100644 client/src/main/java/ctbrec/ui/sites/showup/ShowupFollowedTab.java create mode 100644 client/src/main/java/ctbrec/ui/sites/showup/ShowupFollowedUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/showup/ShowupSiteUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/showup/ShowupTabProvider.java create mode 100644 client/src/main/java/ctbrec/ui/sites/showup/ShowupUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/streamate/StreamateConfigUI.java create mode 100644 client/src/main/java/ctbrec/ui/sites/streamate/StreamateFollowedService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/streamate/StreamateFollowedTab.java create mode 100644 client/src/main/java/ctbrec/ui/sites/streamate/StreamateSiteUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/streamate/StreamateTab.java create mode 100644 client/src/main/java/ctbrec/ui/sites/streamate/StreamateTabProvider.java create mode 100644 client/src/main/java/ctbrec/ui/sites/streamate/StreamateUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/streamray/AbstractStreamrayUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/streamray/StreamrayConfigUI.java create mode 100644 client/src/main/java/ctbrec/ui/sites/streamray/StreamrayElectronLoginDialog.java create mode 100644 client/src/main/java/ctbrec/ui/sites/streamray/StreamrayFavoritesService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/streamray/StreamrayFavoritesTab.java create mode 100644 client/src/main/java/ctbrec/ui/sites/streamray/StreamraySiteUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/streamray/StreamrayTabProvider.java create mode 100644 client/src/main/java/ctbrec/ui/sites/streamray/StreamrayUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/stripchat/AbstractStripchatUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/stripchat/StripchatConfigUI.java create mode 100644 client/src/main/java/ctbrec/ui/sites/stripchat/StripchatElectronLoginDialog.java create mode 100644 client/src/main/java/ctbrec/ui/sites/stripchat/StripchatFollowedTab.java create mode 100644 client/src/main/java/ctbrec/ui/sites/stripchat/StripchatFollowedUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/stripchat/StripchatSiteUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/stripchat/StripchatTabProvider.java create mode 100644 client/src/main/java/ctbrec/ui/sites/stripchat/StripchatUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/winktv/WinkTvConfigUI.java create mode 100644 client/src/main/java/ctbrec/ui/sites/winktv/WinkTvSiteUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/winktv/WinkTvTabProvider.java create mode 100644 client/src/main/java/ctbrec/ui/sites/winktv/WinkTvUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/sites/xlovecam/XloveCamConfigUI.java create mode 100644 client/src/main/java/ctbrec/ui/sites/xlovecam/XloveCamSiteUi.java create mode 100644 client/src/main/java/ctbrec/ui/sites/xlovecam/XloveCamTabProvider.java create mode 100644 client/src/main/java/ctbrec/ui/sites/xlovecam/XloveCamUpdateService.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/DonateTabFx.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/DownloadPostprocessor.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/FollowTabBlinkTransition.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/FollowedTab.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/HelpTab.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/PaginatedScheduledService.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/RecentlyWatchedTab.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/SiteTab.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/SiteTabPane.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/TabProvider.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/TabSelectionListener.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/ThumbCell.css create mode 100644 client/src/main/java/ctbrec/ui/tabs/ThumbCell.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTab.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTabSearchTask.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/UpdateTab.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/logging/CtbrecAppender.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/logging/LoggingTab.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/recorded/AbstractRecordedModelsTab.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/recorded/ClickableTableCell.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/recorded/IconTableCell.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/recorded/ImageTableCell.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/recorded/ModelExportDialog.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/recorded/ModelImportExport.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/recorded/ModelName.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/recorded/ModelNameTableCell.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/recorded/OnlineTableCell.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/recorded/RecordLaterTab.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/recorded/RecordedModelsPerSiteTab.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/recorded/RecordedModelsTab.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/recorded/RecordedTab.java create mode 100644 client/src/main/java/ctbrec/ui/tabs/recorded/RecordingTableCell.java create mode 100644 client/src/main/java/ctbrec/ui/tasks/AbstractModelTask.java create mode 100644 client/src/main/java/ctbrec/ui/tasks/FollowTask.java create mode 100644 client/src/main/java/ctbrec/ui/tasks/ForcePriorityTask.java create mode 100644 client/src/main/java/ctbrec/ui/tasks/PauseRecordingTask.java create mode 100644 client/src/main/java/ctbrec/ui/tasks/ResumePriorityTask.java create mode 100644 client/src/main/java/ctbrec/ui/tasks/ResumeRecordingTask.java create mode 100644 client/src/main/java/ctbrec/ui/tasks/StartRecordingTask.java create mode 100644 client/src/main/java/ctbrec/ui/tasks/StopRecordingTask.java create mode 100644 client/src/main/java/ctbrec/ui/tasks/TaskExecutionException.java create mode 100644 client/src/main/java/ctbrec/ui/tasks/UnfollowTask.java create mode 100644 client/src/main/resources/16/blank.png create mode 100644 client/src/main/resources/16/bookmark-new.png create mode 100644 client/src/main/resources/16/check-circle.png create mode 100644 client/src/main/resources/16/check-small.png create mode 100644 client/src/main/resources/16/check.png create mode 100644 client/src/main/resources/16/clock.png create mode 100644 client/src/main/resources/16/download.png create mode 100644 client/src/main/resources/16/media-force-record.png create mode 100644 client/src/main/resources/16/media-playback-pause.png create mode 100644 client/src/main/resources/16/media-record.png create mode 100644 client/src/main/resources/16/upload.png create mode 100644 client/src/main/resources/16/users.png create mode 100644 client/src/main/resources/32/blank.png create mode 100644 client/src/main/resources/32/check-circle.png create mode 100644 client/src/main/resources/32/check-small.png create mode 100644 client/src/main/resources/32/check-small.svg create mode 100644 client/src/main/resources/32/check.png create mode 100644 client/src/main/resources/32/clock.png create mode 100644 client/src/main/resources/32/download.png create mode 100644 client/src/main/resources/32/upload.png create mode 100644 client/src/main/resources/32/users.png create mode 100644 client/src/main/resources/META-INF/MANIFEST.MF create mode 100644 client/src/main/resources/Oxygen-Im-Highlight-Msg.mp3 create mode 100644 client/src/main/resources/anonymous.png create mode 100644 client/src/main/resources/bookmark-new.png create mode 100644 client/src/main/resources/ctb-logo.png create mode 100644 client/src/main/resources/dark.css create mode 100644 client/src/main/resources/html/bitcoin-address.png create mode 100644 client/src/main/resources/html/bitcoin.png create mode 100644 client/src/main/resources/html/buymeacoffee-fancy.png create mode 100644 client/src/main/resources/html/ethereum-address.png create mode 100644 client/src/main/resources/html/ethereum.png create mode 100644 client/src/main/resources/html/monero-address.png create mode 100644 client/src/main/resources/html/monero.png create mode 100644 client/src/main/resources/html/patreon-logo.png create mode 100644 client/src/main/resources/html/patreon-logo.svg create mode 100644 client/src/main/resources/html/pp196.png create mode 100644 client/src/main/resources/html/static/button-red.png create mode 100644 client/src/main/resources/html/static/favicon.png create mode 100644 client/src/main/resources/html/static/freelancer.css create mode 100644 client/src/main/resources/html/static/freelancer.min.js create mode 100644 client/src/main/resources/html/static/icon64.png create mode 100644 client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap-grid.css create mode 100644 client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap-grid.min.css create mode 100644 client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap-reboot.css create mode 100644 client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap-reboot.min.css create mode 100644 client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap.css create mode 100644 client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap.css.map create mode 100644 client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap.min.css create mode 100644 client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap.min.css.map create mode 100644 client/src/main/resources/html/static/vendor/bootstrap/js/bootstrap.bundle.js create mode 100644 client/src/main/resources/html/static/vendor/bootstrap/js/bootstrap.bundle.js.map create mode 100644 client/src/main/resources/html/static/vendor/bootstrap/js/bootstrap.bundle.min.js create mode 100644 client/src/main/resources/html/static/vendor/bootstrap/js/bootstrap.bundle.min.js.map create mode 100644 client/src/main/resources/html/static/vendor/bootstrap/js/bootstrap.js create mode 100644 client/src/main/resources/html/static/vendor/bootstrap/js/bootstrap.js.map create mode 100644 client/src/main/resources/html/static/vendor/bootstrap/js/bootstrap.min.js create mode 100644 client/src/main/resources/html/static/vendor/bootstrap/js/bootstrap.min.js.map create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/css/font-awesome.css create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/css/font-awesome.css.map create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/css/font-awesome.min.css create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/fonts/FontAwesome.otf create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/fonts/fontawesome-webfont.eot create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/fonts/fontawesome-webfont.svg create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/fonts/fontawesome-webfont.ttf create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/fonts/fontawesome-webfont.woff create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/fonts/fontawesome-webfont.woff2 create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/less/animated.less create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/less/bordered-pulled.less create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/less/core.less create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/less/fixed-width.less create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/less/font-awesome.less create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/less/icons.less create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/less/larger.less create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/less/list.less create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/less/mixins.less create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/less/path.less create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/less/rotated-flipped.less create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/less/screen-reader.less create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/less/stacked.less create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/less/variables.less create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/scss/_animated.scss create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/scss/_bordered-pulled.scss create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/scss/_core.scss create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/scss/_fixed-width.scss create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/scss/_icons.scss create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/scss/_larger.scss create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/scss/_list.scss create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/scss/_mixins.scss create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/scss/_path.scss create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/scss/_rotated-flipped.scss create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/scss/_screen-reader.scss create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/scss/_stacked.scss create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/scss/_variables.scss create mode 100644 client/src/main/resources/html/static/vendor/font-awesome/scss/font-awesome.scss create mode 100644 client/src/main/resources/html/static/vendor/jquery-easing/jquery.easing.compatibility.js create mode 100644 client/src/main/resources/html/static/vendor/jquery-easing/jquery.easing.js create mode 100644 client/src/main/resources/html/static/vendor/jquery-easing/jquery.easing.min.js create mode 100644 client/src/main/resources/html/static/vendor/jquery-ui/jquery-ui-1.12.1.css create mode 100644 client/src/main/resources/html/static/vendor/jquery-ui/jquery-ui-1.12.1.js create mode 100644 client/src/main/resources/html/static/vendor/jquery/jquery.js create mode 100644 client/src/main/resources/html/static/vendor/jquery/jquery.min.js create mode 100644 client/src/main/resources/html/static/vendor/jquery/jquery.min.map create mode 100644 client/src/main/resources/html/static/vendor/jquery/jquery.slim.js create mode 100644 client/src/main/resources/html/static/vendor/jquery/jquery.slim.min.js create mode 100644 client/src/main/resources/html/static/vendor/jquery/jquery.slim.min.map create mode 100644 client/src/main/resources/html/static/vendor/magnific-popup/jquery.magnific-popup.js create mode 100644 client/src/main/resources/html/static/vendor/magnific-popup/jquery.magnific-popup.min.js create mode 100644 client/src/main/resources/html/static/vendor/magnific-popup/magnific-popup.css create mode 100644 client/src/main/resources/html/token.png create mode 100644 client/src/main/resources/html/token.xcf create mode 100644 client/src/main/resources/icon.ico create mode 100644 client/src/main/resources/icon.png create mode 100644 client/src/main/resources/icon.svg create mode 100644 client/src/main/resources/icon128.png create mode 100644 client/src/main/resources/icon16.png create mode 100644 client/src/main/resources/icon32.png create mode 100644 client/src/main/resources/icon64.png create mode 100644 client/src/main/resources/image_not_found.png create mode 100644 client/src/main/resources/logback.xml create mode 100644 client/src/main/resources/media-playback-pause.png create mode 100644 client/src/main/resources/media-record.png create mode 100644 client/src/main/resources/silhouette_256.png create mode 100644 client/src/main/resources/splash.bmp create mode 100644 client/src/main/resources/splash.png create mode 100644 client/src/main/resources/splash.svg create mode 100644 client/src/main/resources/version create mode 100644 client/src/test/java/ctbrec/ui/tasks/StartRecordingTaskTest.java create mode 100644 client/src/test/resources/req-list.json create mode 100644 client/src/test/resources/req-start-pink.json create mode 100644 client/src/test/resources/req-start-queen.json create mode 100644 client/src/test/resources/req-start-uv.json create mode 100644 client/src/test/resources/req-stop-pink.json create mode 100644 client/src/test/resources/req-stop-queen.json create mode 100644 client/src/test/resources/req-stop-uv.json create mode 100644 common/.gitignore create mode 100644 common/pom.xml create mode 100644 common/src/main/antlr4/ctbrec/variableexpansion/antlr/PostProcessing.g4 create mode 100644 common/src/main/java/ctbrec/AbstractModel.java create mode 100644 common/src/main/java/ctbrec/Config.java create mode 100644 common/src/main/java/ctbrec/ErrorMessages.java create mode 100644 common/src/main/java/ctbrec/ForkProcessException.java create mode 100644 common/src/main/java/ctbrec/GlobalThreadPool.java create mode 100644 common/src/main/java/ctbrec/Hmac.java create mode 100644 common/src/main/java/ctbrec/Java.java create mode 100644 common/src/main/java/ctbrec/LoggingInterceptor.java create mode 100644 common/src/main/java/ctbrec/MigrateModel5_1_2.java create mode 100644 common/src/main/java/ctbrec/Model.java create mode 100644 common/src/main/java/ctbrec/ModelGroup.java create mode 100644 common/src/main/java/ctbrec/ModelIsIgnoredException.java create mode 100644 common/src/main/java/ctbrec/ModelNotFoundException.java create mode 100644 common/src/main/java/ctbrec/NotImplementedExcetion.java create mode 100644 common/src/main/java/ctbrec/NotLoggedInExcetion.java create mode 100644 common/src/main/java/ctbrec/OS.java create mode 100644 common/src/main/java/ctbrec/Recording.java create mode 100644 common/src/main/java/ctbrec/RemoteService.java create mode 100644 common/src/main/java/ctbrec/Settings.java create mode 100644 common/src/main/java/ctbrec/StreamNotFoundException.java create mode 100644 common/src/main/java/ctbrec/StringConstants.java create mode 100644 common/src/main/java/ctbrec/StringUtil.java create mode 100644 common/src/main/java/ctbrec/SubsequentAction.java create mode 100644 common/src/main/java/ctbrec/UnexpectedResponseException.java create mode 100644 common/src/main/java/ctbrec/UnknownModel.java create mode 100644 common/src/main/java/ctbrec/UnsupportedOperatingSystemException.java create mode 100644 common/src/main/java/ctbrec/Version.java create mode 100644 common/src/main/java/ctbrec/event/AbstractModelEvent.java create mode 100644 common/src/main/java/ctbrec/event/Action.java create mode 100644 common/src/main/java/ctbrec/event/Event.java create mode 100644 common/src/main/java/ctbrec/event/EventBusHolder.java create mode 100644 common/src/main/java/ctbrec/event/EventHandler.java create mode 100644 common/src/main/java/ctbrec/event/EventHandlerConfiguration.java create mode 100644 common/src/main/java/ctbrec/event/EventPredicate.java create mode 100644 common/src/main/java/ctbrec/event/EventTypePredicate.java create mode 100644 common/src/main/java/ctbrec/event/ExecuteProgram.java create mode 100644 common/src/main/java/ctbrec/event/MatchAllPredicate.java create mode 100644 common/src/main/java/ctbrec/event/ModelIsOnlineEvent.java create mode 100644 common/src/main/java/ctbrec/event/ModelPredicate.java create mode 100644 common/src/main/java/ctbrec/event/ModelStateChangedEvent.java create mode 100644 common/src/main/java/ctbrec/event/ModelStatePredicate.java create mode 100644 common/src/main/java/ctbrec/event/NoSpaceLeftEvent.java create mode 100644 common/src/main/java/ctbrec/event/RecordingStateChangedEvent.java create mode 100644 common/src/main/java/ctbrec/event/RecordingStatePredicate.java create mode 100644 common/src/main/java/ctbrec/image/LocalPortraitStore.java create mode 100644 common/src/main/java/ctbrec/image/PortraitStore.java create mode 100644 common/src/main/java/ctbrec/image/RemotePortraitStore.java create mode 100644 common/src/main/java/ctbrec/io/BandwidthMeter.java create mode 100644 common/src/main/java/ctbrec/io/BoundField.java create mode 100644 common/src/main/java/ctbrec/io/ByteUnitFormatter.java create mode 100644 common/src/main/java/ctbrec/io/CompletableRequestFuture.java create mode 100644 common/src/main/java/ctbrec/io/CookieJarImpl.java create mode 100644 common/src/main/java/ctbrec/io/DevNull.java create mode 100644 common/src/main/java/ctbrec/io/FlaresolverrClient.java create mode 100644 common/src/main/java/ctbrec/io/FlaresolverrResponse.java create mode 100644 common/src/main/java/ctbrec/io/FlaresolverrSolutionResponse.java create mode 100644 common/src/main/java/ctbrec/io/HtmlParser.java create mode 100644 common/src/main/java/ctbrec/io/HtmlParserException.java create mode 100644 common/src/main/java/ctbrec/io/HttpClient.java create mode 100644 common/src/main/java/ctbrec/io/HttpClientCacheProvider.java create mode 100644 common/src/main/java/ctbrec/io/HttpConstants.java create mode 100644 common/src/main/java/ctbrec/io/HttpException.java create mode 100644 common/src/main/java/ctbrec/io/IoUtils.java create mode 100644 common/src/main/java/ctbrec/io/ProcessOutputLogger.java create mode 100644 common/src/main/java/ctbrec/io/ProcessStreamRedirector.java create mode 100644 common/src/main/java/ctbrec/io/StreamRedirector.java create mode 100644 common/src/main/java/ctbrec/io/UrlUtil.java create mode 100644 common/src/main/java/ctbrec/io/XmlParserUtils.java create mode 100644 common/src/main/java/ctbrec/io/json/ObjectMapperFactory.java create mode 100644 common/src/main/java/ctbrec/io/json/dto/CookieDto.java create mode 100644 common/src/main/java/ctbrec/io/json/dto/ModelDto.java create mode 100644 common/src/main/java/ctbrec/io/json/dto/PostProcessorDto.java create mode 100644 common/src/main/java/ctbrec/io/json/dto/RecordingDto.java create mode 100644 common/src/main/java/ctbrec/io/json/dto/converter/InstantToMillisConverter.java create mode 100644 common/src/main/java/ctbrec/io/json/dto/converter/MillisToInstantConverter.java create mode 100644 common/src/main/java/ctbrec/io/json/mapper/CookieMapper.java create mode 100644 common/src/main/java/ctbrec/io/json/mapper/MappingException.java create mode 100644 common/src/main/java/ctbrec/io/json/mapper/ModelFactory.java create mode 100644 common/src/main/java/ctbrec/io/json/mapper/ModelMapper.java create mode 100644 common/src/main/java/ctbrec/io/json/mapper/PostProcessorFactory.java create mode 100644 common/src/main/java/ctbrec/io/json/mapper/PostProcessorMapper.java create mode 100644 common/src/main/java/ctbrec/io/json/mapper/RecordingMapper.java create mode 100644 common/src/main/java/ctbrec/io/json/mapper/UriMapper.java create mode 100644 common/src/main/java/ctbrec/notes/LocalModelNotesService.java create mode 100644 common/src/main/java/ctbrec/notes/ModelNotesService.java create mode 100644 common/src/main/java/ctbrec/notes/RemoteModelNotesService.java create mode 100644 common/src/main/java/ctbrec/recorder/FFmpeg.java create mode 100644 common/src/main/java/ctbrec/recorder/InvalidPlaylistException.java create mode 100644 common/src/main/java/ctbrec/recorder/InvalidTrackLengthException.java create mode 100644 common/src/main/java/ctbrec/recorder/OnlineMonitor.java create mode 100644 common/src/main/java/ctbrec/recorder/PreconditionNotMetException.java create mode 100644 common/src/main/java/ctbrec/recorder/ProgressListener.java create mode 100644 common/src/main/java/ctbrec/recorder/RecordUntilExpiredException.java create mode 100644 common/src/main/java/ctbrec/recorder/Recorder.java create mode 100644 common/src/main/java/ctbrec/recorder/RecorderHttpClient.java create mode 100644 common/src/main/java/ctbrec/recorder/RecordingManager.java create mode 100644 common/src/main/java/ctbrec/recorder/RecordingPinnedException.java create mode 100644 common/src/main/java/ctbrec/recorder/RecordingPreconditions.java create mode 100644 common/src/main/java/ctbrec/recorder/RemoteRecorder.java create mode 100644 common/src/main/java/ctbrec/recorder/SimplifiedLocalRecorder.java create mode 100644 common/src/main/java/ctbrec/recorder/Statistics.java create mode 100644 common/src/main/java/ctbrec/recorder/ThreadPoolScaler.java create mode 100644 common/src/main/java/ctbrec/recorder/download/AbstractDownload.java create mode 100644 common/src/main/java/ctbrec/recorder/download/HttpHeaderFactory.java create mode 100644 common/src/main/java/ctbrec/recorder/download/HttpHeaderFactoryImpl.java create mode 100644 common/src/main/java/ctbrec/recorder/download/ProcessExitedUncleanException.java create mode 100644 common/src/main/java/ctbrec/recorder/download/RecordingProcess.java create mode 100644 common/src/main/java/ctbrec/recorder/download/SplittingStrategy.java create mode 100644 common/src/main/java/ctbrec/recorder/download/StreamSource.java create mode 100644 common/src/main/java/ctbrec/recorder/download/VideoLengthDetector.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/ActuateType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/AdaptationSetType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/BaseURLType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/ContentComponentType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/DashDownload.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/DescriptorType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/EventStreamType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/EventType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/FfmpegMuxer.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/MPDtype.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/MetricsType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/MultipleSegmentBaseType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/ObjectFactory.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/PeriodType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/PresentationType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/ProgramInformationType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/RangeType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/RepresentationBaseType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/RepresentationType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/SegmentBaseType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/SegmentListType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/SegmentTemplateType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/SegmentTimelineType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/SegmentURLType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/SubRepresentationType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/SubsetType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/SwitchingType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/SwitchingTypeType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/URLType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/VideoScanType.java create mode 100644 common/src/main/java/ctbrec/recorder/download/dash/package-info.java create mode 100644 common/src/main/java/ctbrec/recorder/download/hls/AbstractHlsDownload.java create mode 100644 common/src/main/java/ctbrec/recorder/download/hls/CombinedSplittingStrategy.java create mode 100644 common/src/main/java/ctbrec/recorder/download/hls/Crypto.java create mode 100644 common/src/main/java/ctbrec/recorder/download/hls/FfmpegHlsDownload.java create mode 100644 common/src/main/java/ctbrec/recorder/download/hls/HlsDownload.java create mode 100644 common/src/main/java/ctbrec/recorder/download/hls/Hlsdl.java create mode 100644 common/src/main/java/ctbrec/recorder/download/hls/HlsdlDownload.java create mode 100644 common/src/main/java/ctbrec/recorder/download/hls/MergedFfmpegHlsDownload.java create mode 100644 common/src/main/java/ctbrec/recorder/download/hls/MissingSegmentException.java create mode 100644 common/src/main/java/ctbrec/recorder/download/hls/MultiFileSegmentDownload.java create mode 100644 common/src/main/java/ctbrec/recorder/download/hls/NoStreamFoundException.java create mode 100644 common/src/main/java/ctbrec/recorder/download/hls/NoopSplittingStrategy.java create mode 100644 common/src/main/java/ctbrec/recorder/download/hls/PlaylistTimeoutException.java create mode 100644 common/src/main/java/ctbrec/recorder/download/hls/PostProcessingException.java create mode 100644 common/src/main/java/ctbrec/recorder/download/hls/SegmentDownload.java create mode 100644 common/src/main/java/ctbrec/recorder/download/hls/SegmentDownloadException.java create mode 100644 common/src/main/java/ctbrec/recorder/download/hls/SegmentPlaylist.java create mode 100644 common/src/main/java/ctbrec/recorder/download/hls/SizeSplittingStrategy.java create mode 100644 common/src/main/java/ctbrec/recorder/download/hls/TimeSplittingStrategy.java create mode 100644 common/src/main/java/ctbrec/recorder/postprocessing/AbstractPlaceholderAwarePostProcessor.java create mode 100644 common/src/main/java/ctbrec/recorder/postprocessing/AbstractPostProcessor.java create mode 100644 common/src/main/java/ctbrec/recorder/postprocessing/Copy.java create mode 100644 common/src/main/java/ctbrec/recorder/postprocessing/CreateContactSheet.java create mode 100644 common/src/main/java/ctbrec/recorder/postprocessing/CreateTimelineThumbs.java create mode 100644 common/src/main/java/ctbrec/recorder/postprocessing/DeleteOriginal.java create mode 100644 common/src/main/java/ctbrec/recorder/postprocessing/DeleteTooShort.java create mode 100644 common/src/main/java/ctbrec/recorder/postprocessing/Move.java create mode 100644 common/src/main/java/ctbrec/recorder/postprocessing/PostProcessingContext.java create mode 100644 common/src/main/java/ctbrec/recorder/postprocessing/PostProcessor.java create mode 100644 common/src/main/java/ctbrec/recorder/postprocessing/RemoveKeepFile.java create mode 100644 common/src/main/java/ctbrec/recorder/postprocessing/Remux.java create mode 100644 common/src/main/java/ctbrec/recorder/postprocessing/Rename.java create mode 100644 common/src/main/java/ctbrec/recorder/postprocessing/Script.java create mode 100644 common/src/main/java/ctbrec/recorder/postprocessing/Webhook.java create mode 100644 common/src/main/java/ctbrec/servlet/AbstractDocServlet.java create mode 100644 common/src/main/java/ctbrec/servlet/MarkdownServlet.java create mode 100644 common/src/main/java/ctbrec/servlet/SearchServlet.java create mode 100644 common/src/main/java/ctbrec/servlet/StaticFileServlet.java create mode 100644 common/src/main/java/ctbrec/sites/AbstractSite.java create mode 100644 common/src/main/java/ctbrec/sites/ModelOfflineException.java create mode 100644 common/src/main/java/ctbrec/sites/NeedsManualLoginException.java create mode 100644 common/src/main/java/ctbrec/sites/Site.java create mode 100644 common/src/main/java/ctbrec/sites/SiteUtil.java create mode 100644 common/src/main/java/ctbrec/sites/amateurtv/AmateurTv.java create mode 100644 common/src/main/java/ctbrec/sites/amateurtv/AmateurTvDownload.java create mode 100644 common/src/main/java/ctbrec/sites/amateurtv/AmateurTvHttpClient.java create mode 100644 common/src/main/java/ctbrec/sites/amateurtv/AmateurTvModel.java create mode 100644 common/src/main/java/ctbrec/sites/bonga/BongaCams.java create mode 100644 common/src/main/java/ctbrec/sites/bonga/BongaCamsHttpClient.java create mode 100644 common/src/main/java/ctbrec/sites/bonga/BongaCamsModel.java create mode 100644 common/src/main/java/ctbrec/sites/cam4/Cam4.java create mode 100644 common/src/main/java/ctbrec/sites/cam4/Cam4HttpClient.java create mode 100644 common/src/main/java/ctbrec/sites/cam4/Cam4Model.java create mode 100644 common/src/main/java/ctbrec/sites/cam4/Cam4WsClient.java create mode 100644 common/src/main/java/ctbrec/sites/camsoda/Camsoda.java create mode 100644 common/src/main/java/ctbrec/sites/camsoda/CamsodaHttpClient.java create mode 100644 common/src/main/java/ctbrec/sites/camsoda/CamsodaModel.java create mode 100644 common/src/main/java/ctbrec/sites/chaturbate/Chaturbate.java create mode 100644 common/src/main/java/ctbrec/sites/chaturbate/ChaturbateHttpClient.java create mode 100644 common/src/main/java/ctbrec/sites/chaturbate/ChaturbateModel.java create mode 100644 common/src/main/java/ctbrec/sites/chaturbate/StreamInfo.java create mode 100644 common/src/main/java/ctbrec/sites/cherrytv/CherryTv.java create mode 100644 common/src/main/java/ctbrec/sites/cherrytv/CherryTvHttpClient.java create mode 100644 common/src/main/java/ctbrec/sites/cherrytv/CherryTvModel.java create mode 100644 common/src/main/java/ctbrec/sites/dreamcam/Dreamcam.java create mode 100644 common/src/main/java/ctbrec/sites/dreamcam/DreamcamDownload.java create mode 100644 common/src/main/java/ctbrec/sites/dreamcam/DreamcamHttpClient.java create mode 100644 common/src/main/java/ctbrec/sites/dreamcam/DreamcamModel.java create mode 100644 common/src/main/java/ctbrec/sites/fc2live/Fc2CookieJar.java create mode 100644 common/src/main/java/ctbrec/sites/fc2live/Fc2HlsDownload.java create mode 100644 common/src/main/java/ctbrec/sites/fc2live/Fc2HlsdlDownload.java create mode 100644 common/src/main/java/ctbrec/sites/fc2live/Fc2HttpClient.java create mode 100644 common/src/main/java/ctbrec/sites/fc2live/Fc2Live.java create mode 100644 common/src/main/java/ctbrec/sites/fc2live/Fc2MergedHlsDownload.java create mode 100644 common/src/main/java/ctbrec/sites/fc2live/Fc2Model.java create mode 100644 common/src/main/java/ctbrec/sites/flirt4free/Flirt4Free.java create mode 100644 common/src/main/java/ctbrec/sites/flirt4free/Flirt4FreeHttpClient.java create mode 100644 common/src/main/java/ctbrec/sites/flirt4free/Flirt4FreeModel.java create mode 100644 common/src/main/java/ctbrec/sites/jasmin/LiveJasmin.java create mode 100644 common/src/main/java/ctbrec/sites/jasmin/LiveJasminHttpClient.java create mode 100644 common/src/main/java/ctbrec/sites/jasmin/LiveJasminModel.java create mode 100644 common/src/main/java/ctbrec/sites/jasmin/LiveJasminModelInfo.java create mode 100644 common/src/main/java/ctbrec/sites/jasmin/LiveJasminStreamRegistration.java create mode 100644 common/src/main/java/ctbrec/sites/jasmin/LiveJasminStreamSource.java create mode 100644 common/src/main/java/ctbrec/sites/jasmin/LiveJasminTippingWebSocket.java create mode 100644 common/src/main/java/ctbrec/sites/jasmin/LiveJasminWebrtcDownload.java create mode 100644 common/src/main/java/ctbrec/sites/manyvids/MVLive.java create mode 100644 common/src/main/java/ctbrec/sites/manyvids/MVLiveClient.java create mode 100644 common/src/main/java/ctbrec/sites/manyvids/MVLiveHlsDownload.java create mode 100644 common/src/main/java/ctbrec/sites/manyvids/MVLiveHttpClient.java create mode 100644 common/src/main/java/ctbrec/sites/manyvids/MVLiveMergedHlsDownload.java create mode 100644 common/src/main/java/ctbrec/sites/manyvids/MVLiveModel.java create mode 100644 common/src/main/java/ctbrec/sites/manyvids/StreamLocation.java create mode 100644 common/src/main/java/ctbrec/sites/manyvids/wsmsg/GetBroadcastHealth.java create mode 100644 common/src/main/java/ctbrec/sites/manyvids/wsmsg/JoinChat.java create mode 100644 common/src/main/java/ctbrec/sites/manyvids/wsmsg/Message.java create mode 100644 common/src/main/java/ctbrec/sites/manyvids/wsmsg/Ping.java create mode 100644 common/src/main/java/ctbrec/sites/manyvids/wsmsg/RegisterMessage.java create mode 100644 common/src/main/java/ctbrec/sites/manyvids/wsmsg/Response.java create mode 100644 common/src/main/java/ctbrec/sites/manyvids/wsmsg/SendMessage.java create mode 100644 common/src/main/java/ctbrec/sites/mfc/DashStreamSourceProvider.java create mode 100644 common/src/main/java/ctbrec/sites/mfc/Fcext.java create mode 100644 common/src/main/java/ctbrec/sites/mfc/HlsStreamSourceProvider.java create mode 100644 common/src/main/java/ctbrec/sites/mfc/Message.java create mode 100644 common/src/main/java/ctbrec/sites/mfc/MessageTypes.java create mode 100644 common/src/main/java/ctbrec/sites/mfc/Model.java create mode 100644 common/src/main/java/ctbrec/sites/mfc/MyFreeCams.java create mode 100644 common/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java create mode 100644 common/src/main/java/ctbrec/sites/mfc/MyFreeCamsHttpClient.java create mode 100644 common/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java create mode 100644 common/src/main/java/ctbrec/sites/mfc/ServerConfig.java create mode 100644 common/src/main/java/ctbrec/sites/mfc/SessionState.java create mode 100644 common/src/main/java/ctbrec/sites/mfc/Share.java create mode 100644 common/src/main/java/ctbrec/sites/mfc/State.java create mode 100644 common/src/main/java/ctbrec/sites/mfc/StreamSourceProvider.java create mode 100644 common/src/main/java/ctbrec/sites/mfc/User.java create mode 100644 common/src/main/java/ctbrec/sites/mfc/X.java create mode 100644 common/src/main/java/ctbrec/sites/secretfriends/SecretFriends.java create mode 100644 common/src/main/java/ctbrec/sites/secretfriends/SecretFriendsHttpClient.java create mode 100644 common/src/main/java/ctbrec/sites/secretfriends/SecretFriendsModel.java create mode 100644 common/src/main/java/ctbrec/sites/secretfriends/SecretFriendsModelParser.java create mode 100644 common/src/main/java/ctbrec/sites/secretfriends/SecretFriendsWebrtcDownload.java create mode 100644 common/src/main/java/ctbrec/sites/showup/Showup.java create mode 100644 common/src/main/java/ctbrec/sites/showup/ShowupHttpClient.java create mode 100644 common/src/main/java/ctbrec/sites/showup/ShowupModel.java create mode 100644 common/src/main/java/ctbrec/sites/showup/ShowupWebrtcDownload.java create mode 100644 common/src/main/java/ctbrec/sites/streamate/Streamate.java create mode 100644 common/src/main/java/ctbrec/sites/streamate/StreamateHttpClient.java create mode 100644 common/src/main/java/ctbrec/sites/streamate/StreamateModel.java create mode 100644 common/src/main/java/ctbrec/sites/streamate/StreamateWebsocketClient.java create mode 100644 common/src/main/java/ctbrec/sites/streamray/Streamray.java create mode 100644 common/src/main/java/ctbrec/sites/streamray/StreamrayHttpClient.java create mode 100644 common/src/main/java/ctbrec/sites/streamray/StreamrayModel.java create mode 100644 common/src/main/java/ctbrec/sites/stripchat/Stripchat.java create mode 100644 common/src/main/java/ctbrec/sites/stripchat/StripchatHttpClient.java create mode 100644 common/src/main/java/ctbrec/sites/stripchat/StripchatModel.java create mode 100644 common/src/main/java/ctbrec/sites/winktv/WinkTv.java create mode 100644 common/src/main/java/ctbrec/sites/winktv/WinkTvHttpClient.java create mode 100644 common/src/main/java/ctbrec/sites/winktv/WinkTvModel.java create mode 100644 common/src/main/java/ctbrec/sites/xlovecam/XloveCam.java create mode 100644 common/src/main/java/ctbrec/sites/xlovecam/XloveCamHttpClient.java create mode 100644 common/src/main/java/ctbrec/sites/xlovecam/XloveCamModel.java create mode 100644 common/src/main/java/ctbrec/sites/xlovecam/XloveCamModelLoader.java create mode 100644 common/src/main/java/ctbrec/variableexpansion/AbstractVariableExpander.java create mode 100644 common/src/main/java/ctbrec/variableexpansion/ConfigVariableExpander.java create mode 100644 common/src/main/java/ctbrec/variableexpansion/ModelVariableExpander.java create mode 100644 common/src/main/java/ctbrec/variableexpansion/ParserVisitor.java create mode 100644 common/src/main/java/ctbrec/variableexpansion/RecordingVariableExpander.java create mode 100644 common/src/main/java/ctbrec/variableexpansion/VarArgsFunction.java create mode 100644 common/src/main/java/ctbrec/variableexpansion/VariableExpansionException.java create mode 100644 common/src/main/java/ctbrec/variableexpansion/functions/AntlrSyntacErrorAdapter.java create mode 100644 common/src/main/java/ctbrec/variableexpansion/functions/Capitalize.java create mode 100644 common/src/main/java/ctbrec/variableexpansion/functions/Format.java create mode 100644 common/src/main/java/ctbrec/variableexpansion/functions/Lower.java create mode 100644 common/src/main/java/ctbrec/variableexpansion/functions/OrElse.java create mode 100644 common/src/main/java/ctbrec/variableexpansion/functions/Sanitize.java create mode 100644 common/src/main/java/ctbrec/variableexpansion/functions/Trim.java create mode 100644 common/src/main/java/ctbrec/variableexpansion/functions/Upper.java create mode 100644 common/src/main/resources/DASH-MPD.xsd create mode 100644 common/src/main/resources/bindings.xjb create mode 100644 common/src/main/resources/docs/400.html create mode 100644 common/src/main/resources/docs/404.html create mode 100644 common/src/main/resources/docs/500.html create mode 100644 common/src/main/resources/docs/Avidemux.md create mode 100644 common/src/main/resources/docs/ConfigurationFile.md create mode 100644 common/src/main/resources/docs/FFmpeg.md create mode 100644 common/src/main/resources/docs/MKVToolNix.md create mode 100644 common/src/main/resources/docs/PostProcessing.md create mode 100644 common/src/main/resources/docs/QuestionsAndAnswers.md create mode 100644 common/src/main/resources/docs/RunningTheServer.md create mode 100644 common/src/main/resources/docs/VideoTutorials.md create mode 100644 common/src/main/resources/docs/footer.html create mode 100644 common/src/main/resources/docs/header.html create mode 100644 common/src/main/resources/generate-jaxb.sh create mode 100644 common/src/main/resources/xlink.xsd create mode 100644 common/src/test/java/ctbrec/ReflectionUtil.java create mode 100644 common/src/test/java/ctbrec/VersionTest.java create mode 100644 common/src/test/java/ctbrec/io/json/ObjectMapperRecordingTest.java create mode 100644 common/src/test/java/ctbrec/io/json/mapper/ModelMapperTest.java create mode 100644 common/src/test/java/ctbrec/io/json/mapper/RecordingMapperTest.java create mode 100644 common/src/test/java/ctbrec/recorder/RecordingPreconditionsTest.java create mode 100644 common/src/test/java/ctbrec/recorder/postprocessing/AbstractPlaceholderAwarePostProcessorTest.java create mode 100644 common/src/test/java/ctbrec/recorder/postprocessing/AbstractPpTest.java create mode 100644 common/src/test/java/ctbrec/recorder/postprocessing/CopyTest.java create mode 100644 common/src/test/java/ctbrec/recorder/postprocessing/DeleteOriginalTest.java create mode 100644 common/src/test/java/ctbrec/recorder/postprocessing/DeleteTooShortTest.java create mode 100644 common/src/test/java/ctbrec/recorder/postprocessing/MoveDirectoryTest.java create mode 100644 common/src/test/java/ctbrec/recorder/postprocessing/MoveSingleFileTest.java create mode 100644 common/src/test/java/ctbrec/recorder/postprocessing/RemoveKeepFileTest.java create mode 100644 common/src/test/java/ctbrec/recorder/postprocessing/RenameDirectoryTest.java create mode 100644 common/src/test/java/ctbrec/recorder/postprocessing/RenameSingleFileTest.java create mode 100644 common/src/test/java/ctbrec/sites/mfc/MyFreeCamsClientTest.java create mode 100644 common/src/test/java/ctbrec/variableexpansion/ModelVariableExpanderTest.java create mode 100644 common/src/test/java/ctbrec/variableexpansion/functions/CapitalizeTest.java create mode 100644 common/src/test/java/ctbrec/variableexpansion/functions/FormatTest.java create mode 100644 common/src/test/java/ctbrec/variableexpansion/functions/LowerTest.java create mode 100644 common/src/test/java/ctbrec/variableexpansion/functions/OrElseTest.java create mode 100644 common/src/test/java/ctbrec/variableexpansion/functions/SanitizeTest.java create mode 100644 common/src/test/java/ctbrec/variableexpansion/functions/TrimTest.java create mode 100644 common/src/test/java/ctbrec/variableexpansion/functions/UpperTest.java create mode 100644 docs/.gitignore create mode 100644 docs/.travis.yml create mode 100644 docs/LICENSE create mode 100644 docs/TEMPLATE.md create mode 100644 docs/css/freelancer.css create mode 100644 docs/css/freelancer.min.css create mode 100644 docs/gulpfile.js create mode 100644 docs/img/buymeacoffee-round.png create mode 100644 docs/img/buymeacoffee/.DS_Store create mode 100644 docs/img/buymeacoffee/Button/.DS_Store create mode 100644 docs/img/buymeacoffee/Button/Button-gif.gif create mode 100644 docs/img/buymeacoffee/Button/Button-orange.png create mode 100644 docs/img/buymeacoffee/Button/Button_yellow.png create mode 100644 docs/img/buymeacoffee/Button/button-red.png create mode 100644 docs/img/buymeacoffee/Button/button-red.svg create mode 100644 docs/img/buymeacoffee/Collection.eps create mode 100644 docs/img/buymeacoffee/Logo_Editable.ai create mode 100644 docs/img/buymeacoffee/Logo_black-vector.eps create mode 100644 docs/img/buymeacoffee/Logo_orange-vector.eps create mode 100644 docs/img/buymeacoffee/Logo_transparent_vector.eps create mode 100644 docs/img/buymeacoffee/Logo_yellow_vector.eps create mode 100644 docs/img/buymeacoffee/Wordmark-yellow_vector.eps create mode 100644 docs/img/buymeacoffee/Wordmark_black_vector.eps create mode 100644 docs/img/buymeacoffee/Wordmark_orange_vector.eps create mode 100644 docs/img/buymeacoffee/Wordmark_vector.eps create mode 100644 docs/img/buymeacoffee/buymeacoffee-fancy.png create mode 100644 docs/img/buymeacoffee/buymeacoffee-fancy.svg create mode 100644 docs/img/buymeacoffee/buymeacoffee.png create mode 100644 docs/img/buymeacoffee/buymeacoffee.svg create mode 100644 docs/img/favicon.png create mode 100644 docs/img/featured-s.jpg create mode 100644 docs/img/featured.jpg create mode 100644 docs/img/featured.png create mode 100644 docs/img/followed.jpg create mode 100644 docs/img/followed.png create mode 100644 docs/img/patreon-logo.png create mode 100644 docs/img/patreon-round.png create mode 100644 docs/img/paypal-round.png create mode 100644 docs/img/portfolio/cabin.png create mode 100644 docs/img/portfolio/cake.png create mode 100644 docs/img/portfolio/circus.png create mode 100644 docs/img/portfolio/game.png create mode 100644 docs/img/portfolio/safe.png create mode 100644 docs/img/portfolio/submarine.png create mode 100644 docs/img/pp196.png create mode 100644 docs/img/profile.png create mode 100644 docs/img/recording.jpg create mode 100644 docs/img/recording.png create mode 100644 docs/img/recordings.jpg create mode 100644 docs/img/recordings.png create mode 100644 docs/img/server.png create mode 100644 docs/img/settings.jpg create mode 100644 docs/img/settings.png create mode 100644 docs/img/token.png create mode 100644 docs/index.html create mode 100644 docs/js/contact_me.js create mode 100644 docs/js/contact_me.min.js create mode 100644 docs/js/freelancer.js create mode 100644 docs/js/freelancer.min.js create mode 100644 docs/js/jqBootstrapValidation.js create mode 100644 docs/js/jqBootstrapValidation.min.js create mode 100644 docs/mail/contact_me.php create mode 100644 docs/package-lock.json create mode 100644 docs/package.json create mode 100644 docs/scss/_bootstrap-overrides.scss create mode 100644 docs/scss/_contact.scss create mode 100644 docs/scss/_footer.scss create mode 100644 docs/scss/_global.scss create mode 100644 docs/scss/_masthead.scss create mode 100644 docs/scss/_mixins.scss create mode 100644 docs/scss/_navbar.scss create mode 100644 docs/scss/_portfolio.scss create mode 100644 docs/scss/_variables.scss create mode 100644 docs/scss/freelancer.scss create mode 100644 docs/vendor/bootstrap/css/bootstrap-grid.css create mode 100644 docs/vendor/bootstrap/css/bootstrap-grid.min.css create mode 100644 docs/vendor/bootstrap/css/bootstrap-reboot.css create mode 100644 docs/vendor/bootstrap/css/bootstrap-reboot.min.css create mode 100644 docs/vendor/bootstrap/css/bootstrap.css create mode 100644 docs/vendor/bootstrap/css/bootstrap.css.map create mode 100644 docs/vendor/bootstrap/css/bootstrap.min.css create mode 100644 docs/vendor/bootstrap/css/bootstrap.min.css.map create mode 100644 docs/vendor/bootstrap/js/bootstrap.bundle.js create mode 100644 docs/vendor/bootstrap/js/bootstrap.bundle.js.map create mode 100644 docs/vendor/bootstrap/js/bootstrap.bundle.min.js create mode 100644 docs/vendor/bootstrap/js/bootstrap.bundle.min.js.map create mode 100644 docs/vendor/bootstrap/js/bootstrap.js create mode 100644 docs/vendor/bootstrap/js/bootstrap.js.map create mode 100644 docs/vendor/bootstrap/js/bootstrap.min.js create mode 100644 docs/vendor/bootstrap/js/bootstrap.min.js.map create mode 100644 docs/vendor/font-awesome/css/font-awesome.css create mode 100644 docs/vendor/font-awesome/css/font-awesome.css.map create mode 100644 docs/vendor/font-awesome/css/font-awesome.min.css create mode 100644 docs/vendor/font-awesome/fonts/FontAwesome.otf create mode 100644 docs/vendor/font-awesome/fonts/fontawesome-webfont.eot create mode 100644 docs/vendor/font-awesome/fonts/fontawesome-webfont.svg create mode 100644 docs/vendor/font-awesome/fonts/fontawesome-webfont.ttf create mode 100644 docs/vendor/font-awesome/fonts/fontawesome-webfont.woff create mode 100644 docs/vendor/font-awesome/fonts/fontawesome-webfont.woff2 create mode 100644 docs/vendor/font-awesome/less/animated.less create mode 100644 docs/vendor/font-awesome/less/bordered-pulled.less create mode 100644 docs/vendor/font-awesome/less/core.less create mode 100644 docs/vendor/font-awesome/less/fixed-width.less create mode 100644 docs/vendor/font-awesome/less/font-awesome.less create mode 100644 docs/vendor/font-awesome/less/icons.less create mode 100644 docs/vendor/font-awesome/less/larger.less create mode 100644 docs/vendor/font-awesome/less/list.less create mode 100644 docs/vendor/font-awesome/less/mixins.less create mode 100644 docs/vendor/font-awesome/less/path.less create mode 100644 docs/vendor/font-awesome/less/rotated-flipped.less create mode 100644 docs/vendor/font-awesome/less/screen-reader.less create mode 100644 docs/vendor/font-awesome/less/stacked.less create mode 100644 docs/vendor/font-awesome/less/variables.less create mode 100644 docs/vendor/font-awesome/scss/_animated.scss create mode 100644 docs/vendor/font-awesome/scss/_bordered-pulled.scss create mode 100644 docs/vendor/font-awesome/scss/_core.scss create mode 100644 docs/vendor/font-awesome/scss/_fixed-width.scss create mode 100644 docs/vendor/font-awesome/scss/_icons.scss create mode 100644 docs/vendor/font-awesome/scss/_larger.scss create mode 100644 docs/vendor/font-awesome/scss/_list.scss create mode 100644 docs/vendor/font-awesome/scss/_mixins.scss create mode 100644 docs/vendor/font-awesome/scss/_path.scss create mode 100644 docs/vendor/font-awesome/scss/_rotated-flipped.scss create mode 100644 docs/vendor/font-awesome/scss/_screen-reader.scss create mode 100644 docs/vendor/font-awesome/scss/_stacked.scss create mode 100644 docs/vendor/font-awesome/scss/_variables.scss create mode 100644 docs/vendor/font-awesome/scss/font-awesome.scss create mode 100644 docs/vendor/jquery-easing/jquery.easing.compatibility.js create mode 100644 docs/vendor/jquery-easing/jquery.easing.js create mode 100644 docs/vendor/jquery-easing/jquery.easing.min.js create mode 100644 docs/vendor/jquery/jquery.js create mode 100644 docs/vendor/jquery/jquery.min.js create mode 100644 docs/vendor/jquery/jquery.min.map create mode 100644 docs/vendor/jquery/jquery.slim.js create mode 100644 docs/vendor/jquery/jquery.slim.min.js create mode 100644 docs/vendor/jquery/jquery.slim.min.map create mode 100644 docs/vendor/magnific-popup/jquery.magnific-popup.js create mode 100644 docs/vendor/magnific-popup/jquery.magnific-popup.min.js create mode 100644 docs/vendor/magnific-popup/magnific-popup.css create mode 100644 logo.png create mode 100644 master/.gitignore create mode 100644 master/pom.xml create mode 100644 server/.gitignore create mode 100644 server/Dockerfile.txt create mode 100644 server/LICENSE.txt create mode 100644 server/README.md create mode 100644 server/docker-compose.yml create mode 100644 server/pom.xml create mode 100644 server/src/assembly/ctbrec-systemd.service create mode 100644 server/src/assembly/ffmpeg.txt create mode 100644 server/src/assembly/server-linux.sh create mode 100644 server/src/assembly/server-macos.sh create mode 100644 server/src/assembly/server.bat create mode 100644 server/src/assembly/server.xml create mode 100644 server/src/main/java/ctbrec/recorder/server/AbstractCtbrecServlet.java create mode 100644 server/src/main/java/ctbrec/recorder/server/ConfigServlet.java create mode 100644 server/src/main/java/ctbrec/recorder/server/DebugServlet.java create mode 100644 server/src/main/java/ctbrec/recorder/server/HlsServlet.java create mode 100644 server/src/main/java/ctbrec/recorder/server/HttpServer.java create mode 100644 server/src/main/java/ctbrec/recorder/server/ImageServlet.java create mode 100644 server/src/main/java/ctbrec/recorder/server/ModelServlet.java create mode 100644 server/src/main/java/ctbrec/recorder/server/RecorderServlet.java create mode 100644 server/src/main/java/ctbrec/recorder/server/Request.java create mode 100644 server/src/main/java/ctbrec/recorder/server/io/json/dto/RequestDto.java create mode 100644 server/src/main/java/ctbrec/recorder/server/io/json/mapper/RequestMapper.java create mode 100644 server/src/main/resources/META-INF/mime.types create mode 100644 server/src/main/resources/html/static/button-red.png create mode 100644 server/src/main/resources/html/static/config.js create mode 100644 server/src/main/resources/html/static/ctbrec.svg create mode 100644 server/src/main/resources/html/static/custom.css create mode 100644 server/src/main/resources/html/static/favicon.png create mode 100644 server/src/main/resources/html/static/favicon.svg create mode 100644 server/src/main/resources/html/static/freelancer-dark.css create mode 100644 server/src/main/resources/html/static/freelancer.css create mode 100644 server/src/main/resources/html/static/freelancer.min.js create mode 100644 server/src/main/resources/html/static/icon64.png create mode 100644 server/src/main/resources/html/static/index.html create mode 100644 server/src/main/resources/html/static/modal.js create mode 100644 server/src/main/resources/html/static/models.js create mode 100644 server/src/main/resources/html/static/recordings.js create mode 100644 server/src/main/resources/html/static/vendor/CryptoJS/base64.min.js create mode 100644 server/src/main/resources/html/static/vendor/CryptoJS/hmac-sha256.js create mode 100644 server/src/main/resources/html/static/vendor/bootstrap/css/bootstrap-grid.css create mode 100644 server/src/main/resources/html/static/vendor/bootstrap/css/bootstrap-grid.min.css create mode 100644 server/src/main/resources/html/static/vendor/bootstrap/css/bootstrap-reboot.css create mode 100644 server/src/main/resources/html/static/vendor/bootstrap/css/bootstrap-reboot.min.css create mode 100644 server/src/main/resources/html/static/vendor/bootstrap/css/bootstrap.css create mode 100644 server/src/main/resources/html/static/vendor/bootstrap/css/bootstrap.css.map create mode 100644 server/src/main/resources/html/static/vendor/bootstrap/css/bootstrap.min.css create mode 100644 server/src/main/resources/html/static/vendor/bootstrap/css/bootstrap.min.css.map create mode 100644 server/src/main/resources/html/static/vendor/bootstrap/js/bootstrap.bundle.js create mode 100644 server/src/main/resources/html/static/vendor/bootstrap/js/bootstrap.bundle.js.map create mode 100644 server/src/main/resources/html/static/vendor/bootstrap/js/bootstrap.bundle.min.js create mode 100644 server/src/main/resources/html/static/vendor/bootstrap/js/bootstrap.bundle.min.js.map create mode 100644 server/src/main/resources/html/static/vendor/bootstrap/js/bootstrap.js create mode 100644 server/src/main/resources/html/static/vendor/bootstrap/js/bootstrap.js.map create mode 100644 server/src/main/resources/html/static/vendor/bootstrap/js/bootstrap.min.js create mode 100644 server/src/main/resources/html/static/vendor/bootstrap/js/bootstrap.min.js.map create mode 100644 server/src/main/resources/html/static/vendor/cookie_js/.babelrc create mode 100644 server/src/main/resources/html/static/vendor/cookie_js/.travis.yml create mode 100644 server/src/main/resources/html/static/vendor/cookie_js/LICENSE create mode 100644 server/src/main/resources/html/static/vendor/cookie_js/README.md create mode 100644 server/src/main/resources/html/static/vendor/cookie_js/bower.json create mode 100644 server/src/main/resources/html/static/vendor/cookie_js/dist/cookie.cjs.js create mode 100644 server/src/main/resources/html/static/vendor/cookie_js/dist/cookie.esm.js create mode 100644 server/src/main/resources/html/static/vendor/cookie_js/dist/cookie.umd.js create mode 100644 server/src/main/resources/html/static/vendor/cookie_js/dist/cookie.umd.min.js create mode 100644 server/src/main/resources/html/static/vendor/cookie_js/package.json create mode 100644 server/src/main/resources/html/static/vendor/cookie_js/rollup.config.js create mode 100644 server/src/main/resources/html/static/vendor/cookie_js/src/cookie.js create mode 100644 server/src/main/resources/html/static/vendor/cookie_js/tests/README.md create mode 100644 server/src/main/resources/html/static/vendor/cookie_js/tests/builds/cjs.js create mode 100644 server/src/main/resources/html/static/vendor/cookie_js/tests/builds/esm.js create mode 100644 server/src/main/resources/html/static/vendor/cookie_js/tests/builds/umd.js create mode 100644 server/src/main/resources/html/static/vendor/cookie_js/tests/builds/umd.min.js create mode 100644 server/src/main/resources/html/static/vendor/cookie_js/tests/mocha.opts create mode 100644 server/src/main/resources/html/static/vendor/cookie_js/tests/shared.js create mode 100644 server/src/main/resources/html/static/vendor/cookie_js/tests/shared_no_jsdom.js create mode 100644 server/src/main/resources/html/static/vendor/flowplayer/LICENSE.md create mode 100644 server/src/main/resources/html/static/vendor/flowplayer/flowplayer.js create mode 100644 server/src/main/resources/html/static/vendor/flowplayer/flowplayer.min.js create mode 100644 server/src/main/resources/html/static/vendor/flowplayer/flowplayer.swf create mode 100644 server/src/main/resources/html/static/vendor/flowplayer/flowplayerhls.swf create mode 100644 server/src/main/resources/html/static/vendor/flowplayer/index.html create mode 100644 server/src/main/resources/html/static/vendor/flowplayer/skin/icons/flowplayer.eot create mode 100644 server/src/main/resources/html/static/vendor/flowplayer/skin/icons/flowplayer.svg create mode 100644 server/src/main/resources/html/static/vendor/flowplayer/skin/icons/flowplayer.ttf create mode 100644 server/src/main/resources/html/static/vendor/flowplayer/skin/icons/flowplayer.woff create mode 100644 server/src/main/resources/html/static/vendor/flowplayer/skin/icons/flowplayer.woff2 create mode 100644 server/src/main/resources/html/static/vendor/flowplayer/skin/skin.css create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/css/all.css create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/css/all.min.css create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/css/brands.css create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/css/brands.min.css create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/css/fontawesome.css create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/css/fontawesome.min.css create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/css/regular.css create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/css/regular.min.css create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/css/solid.css create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/css/solid.min.css create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/css/svg-with-js.css create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/css/svg-with-js.min.css create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/css/v4-shims.css create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/css/v4-shims.min.css create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/webfonts/fa-brands-400.eot create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/webfonts/fa-brands-400.svg create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/webfonts/fa-brands-400.ttf create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/webfonts/fa-brands-400.woff create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/webfonts/fa-brands-400.woff2 create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/webfonts/fa-regular-400.eot create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/webfonts/fa-regular-400.svg create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/webfonts/fa-regular-400.ttf create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/webfonts/fa-regular-400.woff create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/webfonts/fa-regular-400.woff2 create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/webfonts/fa-solid-900.eot create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/webfonts/fa-solid-900.svg create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/webfonts/fa-solid-900.ttf create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/webfonts/fa-solid-900.woff create mode 100644 server/src/main/resources/html/static/vendor/font-awesome/webfonts/fa-solid-900.woff2 create mode 100644 server/src/main/resources/html/static/vendor/hls.js/hls.js create mode 100644 server/src/main/resources/html/static/vendor/jquery-easing/jquery.easing.compatibility.js create mode 100644 server/src/main/resources/html/static/vendor/jquery-easing/jquery.easing.js create mode 100644 server/src/main/resources/html/static/vendor/jquery-easing/jquery.easing.min.js create mode 100644 server/src/main/resources/html/static/vendor/jquery-ui/jquery-ui-1.12.1.css create mode 100644 server/src/main/resources/html/static/vendor/jquery-ui/jquery-ui-1.12.1.js create mode 100644 server/src/main/resources/html/static/vendor/jquery/jquery.js create mode 100644 server/src/main/resources/html/static/vendor/jquery/jquery.min.js create mode 100644 server/src/main/resources/html/static/vendor/jquery/jquery.min.map create mode 100644 server/src/main/resources/html/static/vendor/jquery/jquery.slim.js create mode 100644 server/src/main/resources/html/static/vendor/jquery/jquery.slim.min.js create mode 100644 server/src/main/resources/html/static/vendor/jquery/jquery.slim.min.map create mode 100644 server/src/main/resources/html/static/vendor/knockout-orderable/README.md create mode 100644 server/src/main/resources/html/static/vendor/knockout-orderable/knockout.bindings.orderable.js create mode 100644 server/src/main/resources/html/static/vendor/knockout/knockout-3.5.0.js create mode 100644 server/src/main/resources/html/static/vendor/magnific-popup/jquery.magnific-popup.js create mode 100644 server/src/main/resources/html/static/vendor/magnific-popup/jquery.magnific-popup.min.js create mode 100644 server/src/main/resources/html/static/vendor/magnific-popup/magnific-popup.css create mode 100644 server/src/main/resources/html/static/vendor/notify.js/notify.min.js create mode 100644 server/src/main/resources/keystore.pkcs12 create mode 100644 server/src/main/resources/logback.xml create mode 100644 server/src/main/resources/version create mode 100644 splash.bmp create mode 100644 splash.png diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..257a08c8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Auto detect text files and perform LF normalization +* text=auto + +# +# The above will handle all files NOT found below +# +# These files are text and should be normalized (Convert crlf => lf) +*.bat text eol=crlf +*.java text diff=java +*.sh text eol=lf 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/.gitignore b/.gitignore new file mode 100644 index 00000000..4cc8f457 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.idea +**.log +.debug-config +.vscode +.settings/ +.classpath +.project +*/.factorypath +**/.antlr/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..6c3fc96b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1257 @@ +5.3.0 +======================== +* Added menu entry to force recording of models without changing the prio +* Added blacklist and whitelist settings to automatically filter out models +* Added setting to delete orphaned recording metadata (switched off by default) +* Doubled Bongacams page size +* Fixed thumbnail caching +* Made sure, that @winkru's fix to faster check, if a Chaturbate model is + online is merged correctly into my codebase. This should reduce 429 errors + and speed up the online check quite a bit. +* Java 21 is now required +* Changes from @WinkRU's fork + * Added setting to restrict recording by bit rate + * Added setting to use the shortest side to restrict the resolution + * Cam4: Fixed stream URLs search. Slightly increased chances to find good one. + * Camsoda: Added "Voyeur" tab + * Chaturbate: Added "Gaming" tab + * Flirt4Free: Added support for new "secure" stream URLs format. + * Streamate: + - Fixed "Couldn't load model ID" error while adding models by URL or by + nickname + - Online / Offline switch on all tabs. Up to 10 000 offline models in each + category. How do you like it, Elon Musk? + - Added "New Girls" tab and adjusted others. All same models on less tabs + * Stripchat: + - Added "Private" tab + - Improved Search + - CTBRec can record your Spy/Private/Ticket shows (login required) + * Streamray: + - Added models tags + - Added Online / Offline switch on "Favorites" tab + +5.2.3 +======================== +* Fix one directory per group +* Add Stripchat tags thx to @winkru +* Fix follow / unfollow for Stripchat thx to @winkru +* Fix: Loading the config failed with model URLs, which contained spaces +* Fix recording size not properly being reported and transferred between + server and client + +5.2.2 +======================== +* Fix: MyFreeCams model state updates +* Fix: app won't start, if cache is disabled +* Fix: excessive spawning of threads +* Fix: show notification and clear input field after adding a model + +5.2.1 +======================== +* Fixed playlist parsing for Dreamcam +* Fixed adding models in the web interface +* Added help section to web interface (thx @Jafea for the idea) +* Added re-run post-processing to web interface (thx @Jafea) + +5.2.0 +======================== +* Fix Chaturbate browsing +* Remove recommended tab from Chaturbate, since it is not working anymore +* Fix export / import of model notes and portraits when running in client + server mode +* Change recording size updates. The recording size is now tracked during the + the recording process. Should reduce file system access drastically +* Added user defined browser. Code by @XxInvictus +* Upgrade to Java 21.0.1 +* Update to JavaFX 21.0.1 +* Changes by @WinkRU + * added Dreamcams, Streamray and WinkTV + * decrease requests to the Chaturbate main domain by using a clever online + detection mechanism + * Added "New" tab for XLoveCam + * Fixed StripChat recordings, plus much less requests to site + * Banned or deactivated StripChat models are automatically moved to the + "Later" list. The "Check URL" button removes them completely. + * Fixed online check for Flirt4Free. If there are spaces in the model name, + it might not work correctly + * Added some integration with CamGirlFinder.net + CamGirlFinder will help you find your favorite models on other cam sites. + You can use ctbrec context menu to search by preview image or by model name + * Stripchat: New tab "Girls VR" and support for recording VR streams + (disabled by default, check settings). + * Amateur.tv: Added model search to UI + * BongaCams: Fixed model search, authorization not required now. + * Flirt4Free: Fixed "New Girls" tab. Now it shows all new models, not just + random one or two. + * New option in settings: "Check for new versions on startup". + Enabled by default. + * LiveJasmin: Fixed navigation on models preview tabs. Now you can see all of + them, not only first page. + * "Recordings" tab: Added new column: "Site" + * "Recording" tab: Fixed a bug, which caused the model list take a long time + to load + * Implemented file cache for thumbnails. This will reduce network traffic and + (sometimes) speed up tabs. The maximum cache size can be changed in the + settings. + * Streamray: Added "Favorites" tab + * Stripchat: Added "Mobile" tab + * Cam4: Added alternative method to get stream URL if standard doesn't work + * MVLive, Showup: Fixed tabs navigation. Same models were displayed on all + pages. + * Amateur.TV: Fixed navigation. After last site changes same models were + displayed on all tabs. + * Cam4: Some streams may have been recorded without an audio. This version + will fix it (probably). + * New recordings directory structure: "One directory for each group". + If model not in any group, it work as "One for each model" + * Stripchat: Added "Online"/"Offline" switch on "Followed" tab. + * Streamray: Added "Follow"/"Unfollow" in context menu. + * Chaturbate: Fixed login on "Followed" tab. + +5.1.2 +======================== +* !! See also the changes in 5.1.1, if you update from 5.1.0 +* Fix unwanted delay between recordings if the recording is split by time or + size +* Implement file system monitoring to monitor the size of recordings +* Fix bug in search popup, which caused the results to be glitched +* Fix bug in Streamate causing many requests to the search +* Upgrade to JavaFX 20.0.1 +* Upgrade to Java Adoptium 17.0.7 + +5.1.1 +======================== +* Fixed StripChat recordings, thanks to @WinkRU for figuring it out +* Fixed bug, which caused all model user names with their display name. + This is a bad one and probably the cause, that many models are not being + detected as online or that the recordings don't start. + If possible, delete the 5.1.0 configuration and let ctbrec migrate an + old configuration again. + Alternatively you can delete the models and re-add them to fix their + user names, but this will reset the timestamps in the columns "last seen", + "last recorded" and "added at" + +5.1.0 +======================== +* Partially fixed LiveJasmin recordings. They only worked, if manually started + from the GUI and you have to have an account configured. Otherwise the + recording will stop after about two and a half minutes +* Changed recorder logic to prevent stalled recordings +* Model notes are now stored on the server +* Model potraits are now stored on the server +* The path to the recording metadata file is now adjusted after the config + has been copied + +5.0.3 +======================== +* Fixed MV Live +* Reduced the amount of filesystem reads for recordings +* Fixed bugs in the migration of Chatubate model names to lower case + If you lost model portraits or model notes, revert your config to a + known good backup (probably 4.7.17) and delete or move all newer configs. + Then start 5.0.3 +* Added a few more tabs for Chaturbate + +5.0.2 +======================== +* Fix Stripchat recordings + +5.0.1 +======================== +* Fix ConcurrentModificationException during conversion of Chaturbate model + names to lower case + +5.0.0 +======================== +* Fixed recording of original quality for Stripchat +* Add option to start ctbrec minimized. You might want to use the no-splash + starters, so that the splash screen does not pop up on start +* Convert Chaturbate model names to lower case +* Added checkbox to disable post-processors +* Changed post-processing variable processing. + !!! This change is not compatible with previous versions. You must change / + adjust your configuration !!! + See the help section for details. + +4.7.17 +======================== +* Fixed Cam4 pages +* Fixed tray icon bug where the GUI would not open again, if it was minimized + by clicking in the tray icon +* Ignored models will not be added to the recorder + +4.7.16 +======================== +* Fixed Bongacams online check + +4.7.15 +======================== +* Fixed bug in new config backup mechanism, which throws an error on systems + where ctbrec has not been run before + +4.7.14 +======================== +* Fixed bug with model groups. The check, if another model from the same + group is already or could be recorded used an potentially outdated model + object from the persisted groups.json file. Now the model state is updated + before performing the check. +* Fixed: File handles not released for failed segments +* Changed config backup mechanism: Instead of creating a backup of the config + in case of an error, ctbrec now creates a backup of the config on each start. + Up to 5 backups are kept, older backups will be deleted automatically + The backup will be created right next to the current config dir. E.g.: + ``` + ctbrec + |- 4.7.13 + |- 4.7.13_backup_2022-11-12_18-03-35_712 + ``` + +4.7.13 +======================== +* Added "Trans" tab for Cam4 +* Added login through minimal browser for Chaturbate +* Token label is now loaded on tab selection and not on creation of the GUI + +4.7.12 +======================== +* Fixed BongaCams +* Fixed MVLive +* Fixed loading of ShowupTV thumbnails +* Upgrade to Java 17 +* Upgrade to JavaFX 19 +* Known Problems: + * Chaturbate login does not work + * Chaturbate is much more aggressive with the 429 errors. It seems they also + have switched on the Cloudfare browser check + * Cherry TV login does not work + +4.7.11 +======================== +* Fix Stripchat thumbnails + +4.7.10 +======================== +* Fix Cam4 stackvaults stream recordings +* Add a volume setting for sound notifications +* HLS recordings can now be streamed continuously while the recording is running + +4.7.9 +======================== +* Fix Flirt4Free recordings +* Fix stream resolution detection for Camsoda +* Fix bug in settings where panels would be empty +* Fix bug in MyFreeCams online detection +* Update JavaFX to 18.0.1 + +4.7.8 +======================== +* Fix Stripchat recordings + +4.7.7 +======================== +* Fix cherry.tv overview pages + +4.7.6 +======================== +* Save config in a sub-directory for each version. +* Fix minimize to tray +* Add setting to disable tab dragging, because that might be the cause for tab + freezes +* Fix Stripchat recordings +* Fix Stripchat thumbsnails +* Fix MVLive tabs + +4.7.5 +======================== +* Add setting to show the number of active recordings in the tray +* Add a timeout of 2 seconds for each model check to make sure the online + check doesn't get blocked somehow +* Increased log level for the online check +* Increased max heap size to 1GiB +* Ignore recordings without actual video data instead of deleting the metadata + +4.7.4 +======================== +* Fixed AmateurTV recordings + +4.7.3 +======================== +* Fixed loading of config / MVLiveModels + +4.7.2 +======================== +* Fixed Camsoda recordings (thx @Ban) +* Fixed Camsoda followed tab +* Fixed MVLive tab +* Fixed LiveJasmin Followed tab +* Fixed Cherry.TV overview pages +* Fixed thumbnails in Camsoda search results +* Updated minimal browser to electron 17.0.1 + +4.7.1 +======================== +* Fix: model groups not exported from server but from local config +* Fix: Threshold for segment errors didn't trigger restart of recording in + standalone mode (and when recording to single file) +* Add setting to customize the date time format in the GUI + +4.7.0 +======================== +* This version requires Java 16 +* Add import / export function for models +* Add setting to define a default duration for "record until" +* Improved online detection for MFC models +* Fixed detection of stalled HLS recordings +* Added threshold for segment errors. If a recording exceeds a certain amount + of segment download errors per time period the recording is stopped. The idea + behind that is, that a restarted recording gets connected to a different CDN + server and has fewer errors (with Chaturbate in mind especially). +* Variables are now properly passed to the media player (in standalone mode) + +4.6.1 +======================== +* Fixed adding of Streamate models +* Fixed Flirt4Free +* Updated bundled Java to 17.0.1 + +4.6.0 +======================== +* Added SecretFriends +* Added Cherry.tv +* Fixed Streamte +* Fixed Camsoda thumbnails +* Fixed LiveJasmin Search +* Added couples tab to LiveJasmin +* Added couples tab to Flirt4Free +* Added setting to enable a Recording tab per site +* Added a toggle to disable events + +4.5.5 +======================== +* Fixed AmateurTV recordings +* Fixed a bug in stalled recording detection +* Added confirmation dialog back in for model removal +* Fixed a bug in Showup recordings, which would restart a recording, if the + post-processing was restarted + +4.5.4 +======================== +* Fix LiveJasmin followed tab +* Fix: two recordings starting for one model at the same time +* Fix: starting recordings from the "record later" tab did not work in client / + server mode +* Added model notes to the recordings table +* Added resolution to the recordings table +* The server now writes the playlist on-the-fly based on the segment information + from the original playlist. This allows to stream the recording while it is + still running. +* Model placeholders can now be used for player params + - ${modelName} + - ${modelDisplayName} + - ${modelSanitizedName} + - ${modelNotes} + - ${siteName} + - ${siteSanitizedName} +* Add buttons to settings to delete cookies per site +* Fix bug in minimal browser + +4.5.3 +======================== +* Fix Cam4 login +* Remove Camsoda shows tab +* Add setting to configure a timeout window when not record. In this timeframe + no new recordings are started. Ongoing recordings will not be interrupted + though + +4.5.2 +======================== +* Fix Flirt4Free recordings +* Added column "added at" to model tables +* Increased max priority value to 10,000 +* Added setting to set a default priority +* Added support for absolute paths in the create contact sheet post-proc. step +* Change group settings only if the user clicks on OK +* Start recording only if the user clicked on OK in the record until dialog +* Added "mark for later" as additional action in the record until dialog +* Bugfix: Tabs locking up, if an error occurs + +4.5.1 +======================== +* Fixed perfromance problem in recorded models tabs (I think :)) +* Main tabs can now be rearranged +* Added setting to change the font +* Added setting to hide table grid lines +* Fixed thumbs in LiveJasmin followed tab + +4.5.0 +======================== +* Added portrait column to Recording tab. The image to show can be selected in + the context menu. This feature is a client-side only feature. +* Added button to configure, which columns should be shown on the Recording tab +* Added data transfer detection to HLS downloads, so that downloads don't + get stuck in recording state. Recordings will stop now, if now segment was + downloaded for 30 seconds. +* Fix: record until clock not showing up in recorded models tab + in client / server mode +* Improved account existance check for chaturbate +* Improved account existance check for bongacams + +4.4.5 +======================== +* Fixed Stripchat recordings +* Fixed ConcurrentModificationException, which caused the recorded models tab + to turn blank in client / server mode + +4.4.4 +======================== +* Fixed Camsoda token label +* Removed Camsoda Shows Tab +* Added Chaturbate configuration parameter to throttle requests to avoid + 429 errors. Be aware that this also slows down the online check for Chaturbate + models, especially, if you have a lot of models in your list. + You have to play around a bit to find a value, which works for you. +* Fixed ConcurrentModificationException, which caused the recorded models tab + to turn blank +* Fixed recordings not stopping, if playlist requests returned 403 or 404 +* LiveJasmin recordings now first check the high res stream and fall back to + the low res stream, if it is not available +* Added data transfer detection to ShowupWebrtcDownload, so that downloads don't + get stuck in recording state + +4.4.3 +======================== +* Changed Camsoda audio codec back to AAC. Sound should be back for recordings +* Unified all model related context menus + +4.4.2 +======================== +* Fixed memory leak caused by minimizing to tray +* Fixed Camsoda online check + +4.4.1 +======================== +* Fixed Camsoda stream URLs + +4.4.0 +======================== +* Added Amateur.TV +* Added XloveCam +* Improved Chaturbate search +* Fixed problem with MFC segment downloads by restricting MFC to HTTP/1.1 +* Fixed tipping function +* Fixed bug in recording precondition check, which caused recordings to get + restarted. The bug occured when model groups were used in combination with + priorities. + +4.3.1 +======================== +* Fixed bug in server communication. The server always returned HTTP 400, + because of an inverted "if-condition". +* Fixed bug in the post-processing variable replacement. The error occurs, + if you use a variable, which value resolves to nothing (null) + +4.3.0 +======================== +* Added mechanism to group models. This mechanism can also be used to define + a model alias. Just create a new group with only one model +* Added new post-processing variables modelGroupName and modelGroupId +* Added possibility to define a default value for post-processing variables + For example: ``${modelGroupName?${modelSanitizedName}}`` +* Added time to "stop recording at" + +4.2.1 +======================== +* Fixed Showup.tv downloads using the websocket stream instead of HLS +* Fixed bug, which caused the window to stay invisible after being minimized to + tray on windows + +4.2.0 +======================== +* App can now be minimized to tray +* Fixed unfollow for Cam4 models + +4.1.3 +======================== +* Fixed Stripchat pagination bug +* Fix bug, which causes the deletion of the ignored models list + +4.1.2 +======================== +* Fixed bug, which caused some recordings to get stuck +* Fixed follow/unfollow for CamSoda +* Fixed MVLive downloads +* Fixed bug in cookie handling, which also prevent MVLive downloads from working +* Ignore list is now saved as URLs only. The old format is not compatible + anymore, so make sure, that you export them again, if you created a backup + before. + +4.1.1 +======================== +* Added open in browser to context menu of thumb overviews +* Fixed timestamp parsing bug in playlist parser + (Should fix recording problems with Camsoda and Stripchat) +* Fixed thumbnails for BongaCams +* Removed some donation options. Thanks PayPal, for nothing. You suck! + +4.1.0 +======================== +* Added dark mode for the server web interface (improvements on the CSS + are welcome, I hate fiddling with CSS) +* Fixed Camsoda. They changed the construction of the playlist URLs again +* Remove "Bad selector" warning for Bongacams + +4.0.0 +======================== +* Rewrite of the recorder internals +* Creation of contact sheets is much faster +* You can now add timestamps to the contact sheet +* Fix online state detection for Bongacams + +3.13.1 +======================== +* Fixed Streamate tabs +* Fixed MVLive recordings + +3.13.0 +======================== +* Added "Recently watched" tab. Can be disabled in Settings -> General +* Recording size now takes all associated files into account +* Removed restriction of download thread pool size (was 100 before) + +3.12.2 +======================== +* Fix: Some Cam4 URLs were broken +* Fix: Cam4 search didn't work +* Stop hlsdl if the recording size didn't change for 90 seconds + +3.12.1 +======================== +* Fix: "Resume all" started the recordings of models marked for later recording +* Fix: Login dialogs don't open +* Use 16:9 thumbnail format for MFC + +3.12.0 +======================== +* Added "record later" tab to "bookmark" models +* Added config option to show the total number of models in the title bar +* Added support for hlsdl. Some sites (MV Live, LiveJasmin, Showup) are + excluded, because they need some special behavior while the download is + running. hlsdl can be activated in the settings under "Advanced" or with + the config properties "useHlsdl", "hlsdlExecutable" and "loghlsdlOutput". + The used bandwidth calculation does not work with hlsdl. +* Fixed problem with Cam4 playlist URLs, thanks @gohufrapoc + +3.11.0 +======================== +* Added config option for faster scroll speed +* Added a few more settings to the web interface +* Added config option to show confirmation dialogs for irreversible actions +* Disabled right click in context menus +* Fixed unjustified chaturbate follow / unfollow error dialog +* Use lowercase model names for Cam4. This should resolve recording problems +* Updated Configration.md page in help section +* Updated bundled Java to version 15.0.1 +* Improved robustness of live previews (still experimental though) +* Some smaller UI tweaks here and there + +3.10.10 +======================== +* Fixed MVLive recordings once again +* Fixed MVLive models being detected as online while being offline +* Fix: "Check URLs" button stays inactive after the first run +* Fix: recordings for some Cam4 models still didn't start +* Added "space used" to recordings tab +* Added menu item to add models in paused state to the "Recording" tab +* Added server setting to choose between fast and accurate playlist generation +* Some smaller tweaks here and there + +3.10.9 +======================== +* Added more category tabs for CamSoda +* Added button to the "Recording" tab to go over all model URLs and check, if + the account still exists +* Fix: some Cam4 models were not detected as online + +3.10.8 +======================== +* Fixed Stripchat recordings. For some models the recording didn't start, + even if they were online and publicly visible in the browser +* Fixed Bongacams "New" tab. It didn't show new models. +* Added setting to switch FFmpeg logging on/off (category Advanced/Devtools) + +3.10.7 +======================== +* Fixed streaming of recordings from the server (the file path was duplicated + if single file was used) +* Fixed credentials related bugs for Streamate and Stripchat. + They used the user name from Chaturbate for some requests. Whoopsie! +* Renamed settings for Chaturbate's user name and password +* Added setting to split recordings by size +* Added setting to monitor the clipboard for model URLs and automatically add + them to the recorder +* Fixed moving of segment recordings on the server (post-processing) +* Fixed minimal browser on macOS +* Minimal browser config is now stored in ctbrec's config directory + +3.10.6 +======================== +* Fixed Cam4 downloads + +3.10.5 +======================== +* Fixed MV Live downloads +* MFC web socket now uses the TLS URL +* Fix: date placeholders with patterns with more than one occurrence are + replaced with the value of the first one +* Some smaller UI tweaks + * adjusted component sizes for small resolutions + * recording indicator can now be used to pause / resume the recording + * adjusted scroll speed in the thumbnail overviews + * added shortcuts for the thumbnail overviews (keys 1-9 and arrow keys) + * added "stop" and "pause" to Recordings tab + * added "follow" to Recordings tab + +3.10.4 +======================== +* Fix: Bongacams login +* Fix: Minimal browser would freeze on windows +* Update minimal browser to Electron 10.1.5 + +3.10.3 +======================== +* Fix: Recordings couldn't be found in client server setup, if the client was + running on Windows and the server on Linux +* Fix: Video length detection was done on the original file instead of the + post-processed one +* Added scrollbars to the settings tab to support smaller screens +* Added auto-redirect to the web-interface +* Added button to pause recording entirely without pausing all models + +3.10.2 +======================== +* Fix: Flirt4Free browsing + +3.10.1 +======================== +* Recordings now start immediately after resuming +* Improved Bongacams online state detection +* Fix: Stripchat models with @ in their name were not recorded +* Fix: Camsoda browsing, the "New" tab is gone though. The information + is not available anymore +* You can now use variable to define the contactsheet file name + +3.10.0 +======================== +* New post-processing +* Added support for thumbnails with different aspect ratios than 4:3 +* Fix: MV Live models with spaces in the name not indicated as recording +* Fix: MV Live recordings stop every few minutes, if recorded with server +* Fix: Kind of fixed Showup.tv recordings. It does record now, but the + recordings stop after a couple of minutes, because they now require you + to be logged in. This has to be addressed in a future release +* Fix: Bongacams online check +* Fix: Bongacams unfollow model +* Fix: Streamate Followed tab +* Flirt4Free thumbnails are now actual previews instead of the bio pictures + (thx @ward) +* Streamate thumbnails are now actual previews instead of the bio pictures + (thx @WinkRU) +* Removed setting to delete too short recordings. This is now a post-process + step, which has to be added in the settings +* Removed setting to remove a recording after post-processing. This is now + a post-process step, which has to be added in the settings + +3.9.0 +======================== +* Added support for Manyvids Live. + Things that work: + * Recording streams. Even more than one (this was a problem first, because + they allow only one stream per session) + * Search + Things that don't work: + * login / favorites + * tipping + * media player isn't working because of their authetication mechanism +* Fixed bug in recorder servlet. Actions for unpin and notes were mixed up + and not properly synchronized between the server and the client +* Recordings now start immediately for newly added models +* Added confirmation dialog for "Pause All", "Resume All" and shutdown +* Fix: recording started event was not fired in client / server mode +* CTB Recorder now stops recording, if less than 100 MiB space is left +* New event, which is fired, if the disk is full (or less than the configured + threshold is available) +* Fixed: MFC models changing to other models (I think, I found the problem. + Can't be sure 100%) + +3.8.6 +======================== +* Added setting to disable the online check for paused models +* Speed up shutdown process by stopping all recordings simultaneously +* Fixed Streamate followed tab once again +* Fixed: Flirt4Free models loose their name after some time +* Made loading of config file more robust for Flirt4Free models +* Added tab which shows the log output + +3.8.5 +======================== +* Fixed Stripchat followed tab. It didn't work, if you have many favorited + models +* Fixed: Some Stripchat models didn't get recorded +* Fixed: Some LiveJasmin models didn't get recorded +* Added support for temporary recordings. On the recording tab you can now set + a date, when to stop recording a model and what to do afterwards + (pause or remove the model) +* Changed the look of the model table in the web interface a bit + +3.8.4 +======================== +* Added support for xHamsterLive (go to Settings -> Sites -> Stripchat, + switch to xHamsterLive, enter your credentials and restart) +* Fixed follow / unfollow for Stripchat +* Enable rerun PP for multiple recordings +* Fixed bug, which prevented recordings to finish properly on app + shutdown. Recordings now shouldn't end up in state waiting anymore + +3.8.3 +======================== +* Fixed Streamate +* Fixed favorites tab for Cam4; kind of, because only the online tab works. + I currently don't see a way to retrieve the offline favorites +* Fixed favorites tab for CamSoda +* Fixed CamSoda recordings +* Added external login dialog for Stripchat to support the captcha + +3.8.2 +======================== +* Fixed misconfiguration in global connection pool, which caused a lot of + threads to spawn while browsing in the thumbnail overviews +* Improved memory handling for the thumbnail overviews. Thumbnail images were + not released, when a tab was switched. This caused a huge memory consumption, + if you opened a lot of different tabs. +* Fixed a bug in MFC websocket client, which caused to spawn a bunch of + "keep-alive" threads, if there was a problem with the connection +* Reworked the settings tab +* Fire recording finished event, if a download from the server is finished +* Ignore min/max resolution, if the resolution is unknown + +3.8.1 +======================== +* Fixed recent MFC error +* Added log file rotation +* Fixed a bug with the resolution slider + +3.8.0 +======================== +* Server Settings are now editable in the web-interface +* Models can be added by name in the web-interface +* Added a bandwidth monitor +* Added possibility to add notes to recordings +* Added range slider to restrict the recording resolution in a range; e.g. 480p - 1080p +* Improved MFC SD downloads (much less blocking, I think) + +3.7.3 +======================== +* Fixed problem, that MFC wouldn't show any models anymore + +3.7.2 +======================== +* Fixed Chaturbate Login +* Added "New" tab to each site where it was missing +* Reverted change: Clear selection after deleting a recording + +3.7.1 +======================== +* Server now logs in on startup, if credentials are set +* Show confirmation dialog on shutdown, if the are active downloads from the + server +* Added setting to remove recordings after post-processing +* Added max resolution setting for the player (click on the gear!) +* Added systemd service example for the server +* Server now returns the version in the HTML and HTTP headers +* Improved server download progress calculation + +3.7.0 +======================== +* Fixed the problem, that media players won't start anymore +* Fixed Stripchat login and favorites +* Added basic support for Showup.tv + This version supports only recording, there is no support for: + - stream resolution detection + - login + - favorites + - search + - tipping + +3.6.4 +======================== +* Fixed race condition causing orphaned FFmpeg processes + The problem was, that an error occured before FFmpeg was completely + launched. ctbrec called internalStop, but the FFmpeg fields still + pointed to null. ctbrec then finished the recording. In the meantime + FFmpeg fired up and was abandoned by the recording. + +3.6.3 +======================== +* Reactivated "Rerun post-processing" for the standalone version +* Fixed regression in last release. Only a few players would start +* Fixed possible error in code for merged downloads + +3.6.2 +======================== +* Fixed regression in FFmpeg recording code introduced by last update + Recording MP4 now works again +* Fixed bug in player launcher, which prevented recordings starting + with '-' from starting, because it was interpreted as command line option +* Fixed minor bug in the actions panel in the settings tab +* Updates in help section + +3.6.1 +======================== +* Fix Streamate +* Removed outdated settings for MFC +* Fix JSON parsing bug for MFC +* Added button to jump to the first page +* Improve handling of recording termination. Hopefully this will fix the + problem with left over FFmpeg processes + +3.6.0 +======================== +* Fixed MFC downloads (fingers crossed) +* Added Girls HD tab for Stripchat +* Fix follow/unfollow for BongaCams +* Save column order in tables +* Increase models per page for Streamate favorites +* Take model description into account when filtering + +3.5.0 +======================== +* Filter terms can now be negated by prepending them with a "!" +* Added pinning for recordings. Pinned recordings cannot be deleted +* Added possibility to specify media player parameters +* Added config setting for the number of post-processing threads +* Added config setting for the HTTP User-Agent header +* Improved caching of stream resolution information + +3.4.0 +======================== +* Added support for Stripchat +* Fixed login browser popups on Windows + +3.3.0 +======================== +!! Caution: There is a new flag in the recordings meta-data. To be safe make +a backup of your recordings or move them to a different directory. + +* Re-implement direct downloads to a single file. + The download of the segments is still done by ctbrec. But the merging is done + by FFmpeg. The merging is now done on-the-fly without downloading the + segments first. + There are new settings for the recorder with which you can define the file + format for recordings. Default is still MPEG transport stream, because it + works the best. +* Enabled the server to record to a single file. Set the following variables in + the server.json: + + "ffmpegFileSuffix": "mp4", + "ffmpegMergedDownloadArgs": "-c:v copy -c:a copy -movflags faststart -y -f mp4", + "recordSingleFile": true, + +3.2.1 +======================== +* Fixed LiveJasmin HD recordings +* Fixed LiveJasmin followed tab + +3.2.0 +======================== +* Fixed Streamate +* Added jump by letter key for tables on the Recording and Recordings tabs +* Added "Ignore" to the context menu of the Recording tab +* Fix: High CPU load by the MFC websocket +* Marked MyFreeCams as broken (but left it in) + +3.1.0 +======================== +* Added recording priorities for models. If you restrict the number of + concurrent downloads, models with high priority will be favored over models + with low prio. Running recordings of models with low prio might even get + stopped, so that models with higher prio can get recorded. Models with + higher priority will also get checked first in the online check loop. + You can adjust the prio on the "Recording" tab by double-clicking on the + value or by using your scroll wheel while holding down CTRL. +* Added columns "last recorded" and "last seen" to models table +* Added menu entry to open the recording dir of a model + +3.0.4 +======================== +* MFC now uses DASH again :) You can switch betwenn DASH and HLS in the settings + for MFC +* The stream quality selection dialog now contains the entry "Best", which lets + you switch back to the default setting +* The online state of models is now checked in parallel for the different + camsites. +* Fix: possible OutOfMemoryError because of too large thumbnail images +* Add possibility to export and import the ignore list +* Add manual refresh to context menu of thumbnail overviews +* New start script for the linux server (can possibly also be used on macOS) + +3.0.3 +======================== +* MFC now uses HLS again +* Fix: In some cases a lot of recordings have been created, because they + failed immediately after start +* Fix: Recorded models now don't switch their positions in the thumb overview +* HLS downloads now try to update the segment playlist URL, if the playlist cannot + be loaded. +* DASH downloads stop faster, if the manifest cannot be loaded, because the model + went offline +* The output from FFmpeg is now stored in merge.log in the segments directory of + a recording +* Fix: Possible deadlock in recorder + +3.0.2 +======================== +* Fix: HLS downloads now create a temporary directory (ending with .part) + similar to DASH downloads. This should fix video corruption and the problem + of accidental file deletions +* Fix: CamSoda recordings notworking for some models. This was caused by + new stream URLs, which are used for some, but not all models +* Retry to download DASH playlist 10 times before finally giving up +* Improved error handling for Cam4 +* Improved error handling in the recorder code + +3.0.1 +======================== +* Fix: "Delete recordings shorter than" deleted all HLS recordings +* Fix: Post-Processing scripts now run on DASH and HLS downloads + !! Attention !! You might have to check you PP-scripts and adjust them +* Change condition, if the PP context menu is shown or not +* Improved DASH download behaviour: + - if the loaded init segments are empty ctbrec now retries to download them + - if segment downloads fail, ctbrec retries 10 times with an increasing + amount of time in between the reties; this has decreased the number of + missing segments drastically + +3.0.0 +======================== +* Reenabled MFC +* Add support for MFC DASH streams +* Both HLS and DASH downloads use FFmpeg to merge segments to MP4 files +* Fix: Flirt4Free overviews didn't work anymore +* Fix: Favorites page for Streamate + + +2.2.0 +======================== +* Added HMAC authentication support to the webinterface +* Added support for SSL/TLS +* Added support to change the context path of the server. This is helpful, if + you want to run ctbrec behind a proxy. E.g. Apache or NGINX + + +2.1.0 +======================== +This release is mainly for the server. + +* Added webinterface for the server. Has to be activated in the server config + (webinterface). You can access it via http://host:port/static/index.html +* Disabled MyFreeCams for the time being. (If you are brave, you can still + use an older version, but don't blame me, if your account or IP is getting + blocked) +* Fix: Corrupt config files prevented the app from starting +* A few smaller fixes + + +2.0.1 +======================== +* Fix: ctbrec freezes on shutdown +* Fix: download and playback in client / server mode, if recordingsDir ends + with a / +* Fix: Flirt4Free thumb overviews and recording + + +2.0.0 +======================== +* Complete rewrite of the recording code +* Added split recordings for the server +* Added menu entry to rerun the post-processing script +* Fix: CamSoda overview +* Fix: BongaCams model online check +* Fix: Downloads not working in client/server setup (regression in last version) +* Fix: post-processing for split recordings +* Fix: All recordings are finished properly on shutdown (with playlist + generation on the server and post-processing) + +1.21.1 +======================== +* Added support for Flirt4Free + * Live previews don't work + * Some players might not be able to play the stream, because Flirt4Free + uses HLS AES encryption (it works with VLC) + * Server recordings are played as singular segments and not one stream. + Not sure why that happens. Probably something is off in the MPEG + transport stream + +1.20.0 +======================== +* Fix: (This time for real, I think ;) ) Online status detection for BongaCams +* Fix: The login dialogs sometimes caused several error messages to pop up +* Added documentation: http://localhost:5689/docs/index.md + Please contribute, if you think something is missing and could be explained + here. +* Added notes column for recorded models +* Added filter for recorded models +* Added mechanism to ignore models. Ignored models will not show up in the + thumbnail overviews anymore. This might be useful, if you like to browse for + new models to record, and want to hide models you don't like, so that they + don't show up again in the future +* Multi-selection in the thumbnail overview with ctrl instead of shift + +1.19.1 +======================== +* Fix: Online status detection for BongaCams +* Fix: Streamate search +* Added URL setting for BongaCams +* Fix: Memory leak in MFC client +* Fix: Previews showing up despite being disabled +* Updated bundled Java version to JDK 12 +* Updated JavaFX version to 12 + +1.19.0 +======================== +* Added news tab, which shows my Mastodon timeline @ctbrec@mastodon.cloud +* Implemented follow/unfollow for BongaCams +* Added a limit setting for concurrent recordings +* Added menu entry to regenerate the playlist in case something went wrong +* Fixed: Playlist generator fails, if a segment's duration cannot be determined +* Added 5 min option for split recordings +* Improved server postprocessing +* Improved deletion of too short recordings for server-mode +* Use the model ID in the file name instead of the model name for FC2Live + +1.18.0 +======================== +* Added FC2Live +* Fix #156 Multiple Windows 10 notification icons +* Implemented adding LiveJasmin models by URL +* Added active recording counter to the title (#155) +* Fix #141: Added seconds and milliseconds to recording timestamp + !!! Caution !!! Existing recordings won't show up on the recordings + tab unless you change the filename to match the new format + +1.17.1 +======================== +* Improved LiveJasmin recordings. Login is not required anymore (thanks to M1h43ly) + HD recordings should also work much better +* Added setting for the base URL for LiveJasmin +* Fixed CamSoda thumbnail overviews + +1.17.0 +======================== +* Added LiveJasmin + There are some issues, though: + * live previews don't work + * it's best to have an account and to be logged in, otherwise you might get + errors after some time + * the pagination and sorting of the models is random, because the + pagination LiveJasmin uses is quite obscure +* Added an electron based external browser component, which makes logins, which are + secured by Google's recaptcha, more reliable. This should also fix the login problems + with BongaCams (#58) +* Added a docker file for the server (thanks to bounty1342) +* Fixed Streamate favorites tab +* Added a setting for the thumbnail overview update interval + +1.16.0 +======================== +* Thumbnails can show a live preview. Can be switched on in the settings. +* Live preview is experimental for now, because I noticed some funky behavior + of the the internal media player. You can use it on your own risk. +* 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: BongaCams search fails with JSON exception +* 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 + recording +* Fix: MyFreeCams model names were case sensitive +* Text input on "Recording"-tab now does auto completion for the site name +* Added menu entry to open the directory of a recording +* Post-processing script is now run outside ot the recordings directory + Make sure, you use absolute paths +* Added setting to configure the directory structure for recordings +* Split up client and server into separat packages. The server package + only depends on Java 1.8 and can be run with the 32-bit JRE, too. + +1.9.0 +======================== +* Dropped support for Windows 32 bit +* Include JavaFX, so that ctbrec works with OpenJRE and Java >= 11 +* Updated embedded Java versions to 11.0.1 +* Added column "Recording" to recorded models tab, which indicates that + a recording is currently running +* Fix: BongaCams recordings didn't start +* Fix: Unfollow for Cam4 didn't work +* Fix: Post-Processing script couldn't be removed +* A lot of smaller changes under the hood + +1.8.0 +======================== +* Added BongaCams +* Added possibility to suspend the recording for a model. The model stays in + the list of recorded models, but the actual recording is suspended +* HTTP sessions are restored on startup. This should reduce the number of + logins needed (especially for Cam4, BongaCams and CamSoda). +* Server can run now run on OpenJRE +* Added JVM parameter to define the configuration directory + (``-Dctbrec.config.dir``) +* Improved memory management for MyFreeCams + +1.7.0 +======================== +* Added CamSoda +* Added detection of model name changes for MyFreeCams +* Added setting to define a maximum resolution +* Fixed sorting by date in recordings table + +1.6.1 +======================== +* Fixed UI freeze, which occured for a high number of recorded models +* Added Cam4 +* Updated the embedded JRE for the Windows bundles to 8u192 + +1.6.0 +======================== +* Added support for multiple cam sites +* Sites can be switched on and off in the settings +* Added MyFreeCams +* Fixed proxy authentication for HTTP and SOCKS + +1.5.4 +======================== +* Lots of little tweaks for the GUI + +1.5.3 +======================== +* Recording time is now converted to local timezone and formatted nicely +* The state is now displayed in the resolution tag, if the room is not + public (e.g. private, group, offline, away) +* You can now filter for public rooms with the keyword "public", if + the display of resolution is enabled +* Added possibility to switch between online and offline models in the + followed tab +* Added possibility to send tips + +1.5.2 +======================== +* Added possibility to select multiple models in the overview tabs by + holding SHIFT while clicking +* Added possibility to stop a recording in the recordings tab +* The delete key can now be used in the recorded models tab and in the + followed tab to unfollow one ore more models + +1.5.1 +======================== +* Added setting to split up the recording after x minutes +* Fixed possible OutOfMemoryError, which was caused by invalid transport + stream packets +* Fixed possible OutOfMemoryError, which could occur, if the stream was + downloaded faster than it could be written to the hard drive + +1.5.0 +======================== +* Recordings are now stored in a single file +* The server still saves segments, downloads are + one single file, too +* Recordings and downloads are now proper transport streams + (continuity and timestamps get fixed, if invalid) + +1.4.3 +======================== +* Added possibility to switch the video resolution for a recording +* Added selection box below the overview pages to change the thumbnail size +* Save and restore window size, location and maximized state +* Added check for OpenJDK and JavaFX on start to print out a better error, + if JavaFX is not available + +1.4.2 +======================== +* Enabled proxy authentication for SOCKS4 and HTTP +* Empty recording directories are now ignored instead of cluttering the log + file with error messages + +1.4.1 +======================== +* Added proxy settings +* Made playlist generator more robust +* Fixed some issues with the file merging +* Fixed memory leak caused by the model filter function diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 00000000..f288702d --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 00000000..e23af823 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# CTB Recorder + +A free recording software for different camsites. Currently supported: BongaCams, Cam4, CamSoda, Chaturbate, FC2Live, LiveJasmin, MyFreeCams, Streamate + +![Screenshot](https://raw.githubusercontent.com/0xboobface/ctbrec/master/docs/img/featured-s.jpg) + +If you ever wanted to record a cam girl show to watch it later or if your favorite model lives in another timezone and is never online when you are, CTB Recorder is the solution for you. + +CTB Recorder allows you to record any public show on different cam sites. It is very easy to use and set up in minutes. You can even run the recorder on a server and control it with the graphical user interface, so that you never miss a show again. + +For more visit the [homepage](https://0xboobface.github.io/ctbrec) + +## Installation +[Download](https://github.com/0xboobface/ctbrec/releases) the bundle ending in -jre for your platform and follow the instructions in the README.md contained in the zip. + +## License +CTB Recorder is licensed under the GPLv3. See [LICENSE.txt](https://raw.githubusercontent.com/0xboobface/ctbrec/master/LICENSE.txt). diff --git a/build-all.sh b/build-all.sh new file mode 100644 index 00000000..6b72b215 --- /dev/null +++ b/build-all.sh @@ -0,0 +1,6 @@ +#!/bin/sh +mvn clean -f ./master +mvn verify -am -f ./master -pl :client -Djavafx.platform=win +mvn verify -am -f ./master -pl :client -Djavafx.platform=linux +mvn verify -am -f ./master -pl :client -Djavafx.platform=mac +mvn verify -am -f ./master -pl :server \ No newline at end of file diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 00000000..cbb2eefa --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,16 @@ +/bin/ +/target/ +*~ +*.bak +/*.log +/ctbrec-tunnel.sh +/jre/ +/server-local.sh +/browser/ +/ffmpeg/ +/client.iml +/.idea/ + +.settings/ +.classpath +.project \ No newline at end of file diff --git a/client/LICENSE.txt b/client/LICENSE.txt new file mode 100644 index 00000000..f288702d --- /dev/null +++ b/client/LICENSE.txt @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/client/README.md b/client/README.md new file mode 100644 index 00000000..152f298d --- /dev/null +++ b/client/README.md @@ -0,0 +1,19 @@ +# CTB Recorder + +A free recording software for Chaturbate, MyFreeCams, CamSoda, Cam4 and BongaCams. + +## Requirements +* Java 64-bit version >= 10 (already included in the bundles, which end in -jre) + +## Installation +* Download the ctbrec bundle ending in -jre for your platform. +* Unzip it +* Open the new ctbrec directory and start ctbrec by executing ctbrec.exe or ctbrec.sh +* If you want to use features like follow/unfollow or tipping, set up your credentials + in the settings tab. **!!Caution!!** The credentials are stored unencrypted on your hard drive. + +Alternatively you can [download](https://www.oracle.com/technetwork/java/javase/downloads/index.html) and install Java +yourself and use the smaller bundles without JRE. Make sure to add the java binary to your PATH environment variable. + +## License +CTB Recorder is licensed under the GPLv3. See [LICENSE.txt](https://raw.githubusercontent.com/0xboobface/ctbrec/master/LICENSE.txt). diff --git a/client/build.sh b/client/build.sh new file mode 100644 index 00000000..ad8a59c4 --- /dev/null +++ b/client/build.sh @@ -0,0 +1,5 @@ +#!/bin/bash +mvn clean +mvn -Djavafx.platform=win verify +mvn -Djavafx.platform=linux verify +mvn -Djavafx.platform=mac verify diff --git a/client/pom.xml b/client/pom.xml new file mode 100644 index 00000000..672b942f --- /dev/null +++ b/client/pom.xml @@ -0,0 +1,279 @@ + + + 4.0.0 + client + + + ctbrec + master + 5.3.2 + ../master + + + + ${project.groupId}-${project.version} + + + + + + src/main/resources + false + + + src/main/java + false + + **/*.css + + + + src/main/resources + true + + version + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + src/main/resources/META-INF/MANIFEST.MF + + true + lib/ + + + + + + + + + + ctbrec + common + + + ch.qos.logback + logback-classic + compile + + + org.openjfx + javafx-controls + + + org.openjfx + javafx-media + + + org.openjfx + javafx-swing + + + org.eclipse.jetty + jetty-servlet + + + com.github.usefulness + webp-imageio + 0.9.0 + + + + + + win + + + javafx.platform + win + + + + win + + + + + com.akathist.maven.plugins.launch4j + launch4j-maven-plugin + 2.4.1 + + gui + ${name.final}.jar + true + src/main/resources/icon.ico + ctbrec + + ctbrec.ui.Launcher + true + lib/ + . + + https://jdk.java.net/ + + jre + true + 15 + 1024 + + -Dfile.encoding=utf-8 + + + + ${project.version}.0 + ${project.version}.0 + Software to record live streams + 2023 0xb00bface + ${project.version}.0 + ${project.version}.0 + CTB Recorder + ctbrec + ctbrec.exe + + + + + l4j-win + package + + launch4j + + + target/ctbrec.exe + + src/main/resources/splash.bmp + true + 60 + true + + + + + l4j-win-no-splash + package + + launch4j + + + target/ctbrec-no-splash.exe + + + + + + maven-assembly-plugin + 3.1.0 + + + zip + package + + single + + + ctbrec-${project.version} + + src/assembly/win64-jre.xml + + + + + + + + + + linux + + + javafx.platform + linux + + + + linux + + + + + maven-assembly-plugin + 3.1.0 + + + zip + package + + single + + + ctbrec-${project.version} + + src/assembly/linux-jre.xml + + + + + + + + + + macos + + + javafx.platform + mac + + + + mac + + + + + + maven-assembly-plugin + 3.1.0 + + + zip + package + + single + + + ctbrec-${project.version} + + src/assembly/macos-jre.xml + + + + + + + + + + diff --git a/client/src/assembly/ctbrec-linux-jre-no-splash.sh b/client/src/assembly/ctbrec-linux-jre-no-splash.sh new file mode 100644 index 00000000..21067e3f --- /dev/null +++ b/client/src/assembly/ctbrec-linux-jre-no-splash.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +DIR="$(dirname "$0")" +pushd "${DIR}" +JAVA=./jre/bin/java +$JAVA -version +$JAVA -Xmx1g -Djdk.gtk.version=3 -Dfile.encoding=utf-8 -jar ${name.final}.jar +popd diff --git a/client/src/assembly/ctbrec-linux-jre.sh b/client/src/assembly/ctbrec-linux-jre.sh new file mode 100644 index 00000000..294ea60d --- /dev/null +++ b/client/src/assembly/ctbrec-linux-jre.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +DIR="$(dirname "$0")" +pushd "${DIR}" +JAVA=./jre/bin/java +$JAVA -version +$JAVA -splash:splash.png -Xmx1g -Djdk.gtk.version=3 -Dfile.encoding=utf-8 -jar ${name.final}.jar +popd diff --git a/client/src/assembly/ctbrec-macos-jre-no-splash.sh b/client/src/assembly/ctbrec-macos-jre-no-splash.sh new file mode 100644 index 00000000..eb23e7a6 --- /dev/null +++ b/client/src/assembly/ctbrec-macos-jre-no-splash.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +DIR=$(dirname $0) +pushd "$DIR" +JAVA_HOME="$DIR/jre/Contents/Home" +JAVA="$JAVA_HOME/bin/java" +$JAVA -version +$JAVA -Xmx1g -Dfile.encoding=utf-8 -jar ${name.final}.jar +popd diff --git a/client/src/assembly/ctbrec-macos-jre.sh b/client/src/assembly/ctbrec-macos-jre.sh new file mode 100644 index 00000000..04194027 --- /dev/null +++ b/client/src/assembly/ctbrec-macos-jre.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +DIR=$(dirname $0) +pushd "$DIR" +JAVA_HOME="$DIR/jre/Contents/Home" +JAVA="$JAVA_HOME/bin/java" +$JAVA -version +$JAVA -splash:splash.png -Xmx1g -Dfile.encoding=utf-8 -jar ${name.final}.jar +popd diff --git a/client/src/assembly/ctbrec.bat b/client/src/assembly/ctbrec.bat new file mode 100644 index 00000000..65ce3151 --- /dev/null +++ b/client/src/assembly/ctbrec.bat @@ -0,0 +1 @@ +jre\bin\java -Xmx1g -Dfile.encoding=utf-8 -jar ${name.final}.jar diff --git a/client/src/assembly/linux-jre.xml b/client/src/assembly/linux-jre.xml new file mode 100644 index 00000000..ecb791a3 --- /dev/null +++ b/client/src/assembly/linux-jre.xml @@ -0,0 +1,70 @@ + + + linux-jre + + zip + + false + + + ctbrec/lib + false + false + runtime + + + + + ${project.basedir}/src/assembly/ctbrec-linux-jre.sh + ctbrec + true + ctbrec.sh + + + ${project.basedir}/src/assembly/ctbrec-linux-jre-no-splash.sh + ctbrec + true + ctbrec-no-splash.sh + + + ${project.build.directory}/${project.artifactId}-${project.version}.jar + ctbrec + ${name.final}.jar + + + ${project.basedir}/LICENSE.txt + ctbrec + + + ${project.basedir}/README.md + ctbrec + + + ${project.basedir}/src/main/resources/splash.png + ctbrec + + + ${project.basedir}/ffmpeg/ffmpeg-linux64 + ctbrec/lib/ffmpeg + ffmpeg + + + + + jre/jdk_linux + + **/* + + ctbrec/jre + false + + + browser/ctbrec-minimal-browser-linux-x64 + + **/* + + ctbrec/lib/browser + false + + + diff --git a/client/src/assembly/macos-jre.xml b/client/src/assembly/macos-jre.xml new file mode 100644 index 00000000..6f51cae3 --- /dev/null +++ b/client/src/assembly/macos-jre.xml @@ -0,0 +1,71 @@ + + + macos-jre + + zip + + false + + + ctbrec/lib + false + false + runtime + + + + + ${project.basedir}/src/assembly/ctbrec-macos-jre.sh + ctbrec + true + ctbrec.sh + + + ${project.basedir}/src/assembly/ctbrec-macos-jre-no-splash.sh + ctbrec + true + ctbrec-no-splash.sh + + + ${project.build.directory}/${project.artifactId}-${project.version}.jar + + ctbrec + ${name.final}.jar + + + ${project.basedir}/LICENSE.txt + ctbrec + + + ${project.basedir}/README.md + ctbrec + + + ${project.basedir}/src/main/resources/splash.png + ctbrec + + + ${project.basedir}/ffmpeg/ffmpeg-macos64 + ctbrec/lib/ffmpeg + ffmpeg + + + + + jre/jdk_macos + + **/* + + ctbrec/jre + false + + + browser/ctbrec-minimal-browser-darwin-x64 + + **/* + + ctbrec/lib/browser + false + + + diff --git a/client/src/assembly/win64-jre.xml b/client/src/assembly/win64-jre.xml new file mode 100644 index 00000000..0f85f278 --- /dev/null +++ b/client/src/assembly/win64-jre.xml @@ -0,0 +1,67 @@ + + + win64-jre + + zip + + false + + + ctbrec/lib + false + false + runtime + + + + + ${project.build.directory}/ctbrec.exe + ctbrec + + + ${project.build.directory}/ctbrec-no-splash.exe + ctbrec + + + ${project.build.directory}/${project.artifactId}-${project.version}.jar + ctbrec + ${name.final}.jar + + + ${project.basedir}/LICENSE.txt + ctbrec + + + ${project.basedir}/README.md + ctbrec + + + ${project.basedir}/src/assembly/ctbrec.bat + ctbrec + true + + + ${project.basedir}/ffmpeg/ffmpeg-win64.exe + ctbrec/lib/ffmpeg + ffmpeg.exe + + + + + jre/jdk_windows + + **/* + + ctbrec/jre + false + + + browser/ctbrec-minimal-browser-win32-x64 + + **/* + + ctbrec/lib/browser + false + + + diff --git a/client/src/main/java/ctbrec/RecordingDownload.java b/client/src/main/java/ctbrec/RecordingDownload.java new file mode 100644 index 00000000..7c437e69 --- /dev/null +++ b/client/src/main/java/ctbrec/RecordingDownload.java @@ -0,0 +1,89 @@ +package ctbrec; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.concurrent.ExecutorService; + +import com.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.PlaylistException; + +import ctbrec.io.HttpClient; +import ctbrec.io.HttpException; +import ctbrec.recorder.ProgressListener; +import ctbrec.recorder.download.hls.MergedFfmpegHlsDownload; +import ctbrec.recorder.download.hls.SegmentPlaylist; +import ctbrec.recorder.download.hls.SegmentPlaylist.Segment; +import okhttp3.Request; +import okhttp3.Response; + +public class RecordingDownload extends MergedFfmpegHlsDownload { + + public RecordingDownload(HttpClient client) { + super(client); + } + + @Override + public void init(Config config, Model model, Instant startTime, ExecutorService executorService) throws IOException { + this.config = config; + this.model = model; + this.startTime = startTime; + this.downloadExecutor = executorService; + splittingStrategy = initSplittingStrategy(config.getSettings()); + } + + public void downloadFinishedRecording(String segmentPlaylistUri, File target, ProgressListener progressListener, long sizeInBytes) + throws InvalidKeyException, NoSuchAlgorithmException, IllegalStateException, InterruptedException, IOException, ParseException, PlaylistException { + running = true; + if (Config.getInstance().getSettings().requireAuthentication) { + URL u = new URL(segmentPlaylistUri); + String path = u.getPath(); + byte[] key = Config.getInstance().getSettings().key; + if (!Config.getInstance().getContextPath().isEmpty()) { + path = path.substring(Config.getInstance().getContextPath().length()); + } + String hmac = Hmac.calculate(path, key); + segmentPlaylistUri = segmentPlaylistUri + "?hmac=" + hmac; + } + + startFfmpegProcess(target); + for (int i = 0; i < 10 && ffmpegStdIn == null; i++) { + Thread.sleep(100); + } + + SegmentPlaylist segmentPlaylist = getNextSegments(segmentPlaylistUri); + long loadedBytes = 0; + for (Segment segment : segmentPlaylist.segments) { + loadedBytes += downloadFile(segment.url, loadedBytes, sizeInBytes, progressListener); + int progress = (int) (loadedBytes / (double) sizeInBytes * 100); + progressListener.update(progress); + } + + internalStop(); + } + + private long downloadFile(String fileUri, long loadedBytes, long totalBytes, ProgressListener progressListener) throws IOException { + long fileLoadedBytes = 0; + Request request = new Request.Builder().url(fileUri).addHeader("connection", "keep-alive").build(); + try (Response response = client.execute(request)) { + if (response.isSuccessful()) { + InputStream in = response.body().byteStream(); + byte[] b = new byte[1024 * 100]; + int length = -1; + while ((length = in.read(b)) >= 0) { + ffmpegStdIn.write(b, 0, length); + fileLoadedBytes += length; + int progress = (int) ((loadedBytes + fileLoadedBytes) / (double) totalBytes * 100); + progressListener.update(progress); + } + } else { + throw new HttpException(response.code(), response.message()); + } + } + return fileLoadedBytes; + } +} diff --git a/client/src/main/java/ctbrec/docs/DocServer.java b/client/src/main/java/ctbrec/docs/DocServer.java new file mode 100644 index 00000000..898e74ab --- /dev/null +++ b/client/src/main/java/ctbrec/docs/DocServer.java @@ -0,0 +1,78 @@ +package ctbrec.docs; + +import ctbrec.servlet.AbstractDocServlet; +import ctbrec.servlet.MarkdownServlet; +import ctbrec.servlet.SearchServlet; +import ctbrec.servlet.StaticFileServlet; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.jetty.server.*; +import org.eclipse.jetty.server.handler.HandlerList; +import org.eclipse.jetty.servlet.ServletHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.eclipse.jetty.util.component.LifeCycle; + +import java.net.BindException; + +@Slf4j +public class DocServer { + + private static volatile boolean started = false; + + private DocServer() { + } + + public static synchronized void start() throws Exception { + start(() -> { + }); + } + + public static synchronized void start(Runnable callback) throws Exception { + log.info("DocServer.start"); + if (started) { + log.info("if started"); + callback.run(); + return; + } + + started = true; + var server = new Server(); + + var config = new HttpConfiguration(); + config.setSendServerVersion(false); + var http = new ServerConnector(server, new HttpConnectionFactory(config)); + http.setPort(5689); + server.addConnector(http); + + var handler = new ServletHandler(); + server.setHandler(handler); + var handlers = new HandlerList(); + handlers.setHandlers(new Handler[]{handler}); + server.setHandler(handlers); + + var markdownServlet = new MarkdownServlet(); + var holder = new ServletHolder(markdownServlet); + handler.addServletWithMapping(holder, "/docs/*"); + + AbstractDocServlet searchServlet = new SearchServlet(); + holder = new ServletHolder(searchServlet); + handler.addServletWithMapping(holder, "/search/*"); + + var staticFileServlet = new StaticFileServlet("/html", false); + holder = new ServletHolder(staticFileServlet); + handler.addServletWithMapping(holder, "/static/*"); + + server.addLifeCycleListener(new LifeCycle.Listener() { + @Override + public void lifeCycleStarted(LifeCycle event) { + callback.run(); + } + }); + + try { + server.start(); + server.join(); + } catch (BindException e) { + log.error("Port {} is already in use", http.getPort(), e); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/AutosizeAlert.java b/client/src/main/java/ctbrec/ui/AutosizeAlert.java new file mode 100644 index 00000000..1e59a634 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/AutosizeAlert.java @@ -0,0 +1,43 @@ +package ctbrec.ui; + +import java.io.InputStream; + +import ctbrec.ui.controls.Dialogs; +import javafx.scene.Scene; +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonType; +import javafx.scene.image.Image; +import javafx.scene.layout.Region; +import javafx.stage.Stage; + +public class AutosizeAlert extends Alert { + + private Scene parent; + + public AutosizeAlert(AlertType type) { + super(type, null); + } + + public AutosizeAlert(AlertType type, Scene parent) { + super(type); + this.parent = parent; + init(); + } + + public AutosizeAlert(AlertType type, String text, Scene parent, ButtonType... buttons) { + super(type, text, buttons); + this.parent = parent; + init(); + } + + private void init() { + setResizable(true); + getDialogPane().setMinHeight(Region.USE_PREF_SIZE); + if(parent != null) { + var stage = (Stage) getDialogPane().getScene().getWindow(); + stage.getScene().getStylesheets().addAll(parent.getStylesheets()); + InputStream icon = Dialogs.class.getResourceAsStream("/icon.png"); + stage.getIcons().add(new Image(icon)); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/CamrecApplication.java b/client/src/main/java/ctbrec/ui/CamrecApplication.java new file mode 100644 index 00000000..517ff09c --- /dev/null +++ b/client/src/main/java/ctbrec/ui/CamrecApplication.java @@ -0,0 +1,653 @@ +package ctbrec.ui; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.type.TypeReference; +import com.google.common.eventbus.Subscribe; +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.StringUtil; +import ctbrec.Version; +import ctbrec.event.Event; +import ctbrec.event.EventBusHolder; +import ctbrec.event.EventHandler; +import ctbrec.event.EventHandlerConfiguration; +import ctbrec.image.LocalPortraitStore; +import ctbrec.image.PortraitStore; +import ctbrec.image.RemotePortraitStore; +import ctbrec.io.*; +import ctbrec.io.json.ObjectMapperFactory; +import ctbrec.notes.LocalModelNotesService; +import ctbrec.notes.ModelNotesService; +import ctbrec.notes.RemoteModelNotesService; +import ctbrec.recorder.OnlineMonitor; +import ctbrec.recorder.Recorder; +import ctbrec.recorder.RemoteRecorder; +import ctbrec.recorder.SimplifiedLocalRecorder; +import ctbrec.sites.Site; +import ctbrec.sites.amateurtv.AmateurTv; +import ctbrec.sites.bonga.BongaCams; +import ctbrec.sites.cam4.Cam4; +import ctbrec.sites.camsoda.Camsoda; +import ctbrec.sites.chaturbate.Chaturbate; +import ctbrec.sites.cherrytv.CherryTv; +import ctbrec.sites.dreamcam.Dreamcam; +import ctbrec.sites.fc2live.Fc2Live; +import ctbrec.sites.flirt4free.Flirt4Free; +import ctbrec.sites.jasmin.LiveJasmin; +import ctbrec.sites.manyvids.MVLive; +import ctbrec.sites.mfc.MyFreeCams; +import ctbrec.sites.secretfriends.SecretFriends; +import ctbrec.sites.showup.Showup; +import ctbrec.sites.streamate.Streamate; +import ctbrec.sites.streamray.Streamray; +import ctbrec.sites.stripchat.Stripchat; +import ctbrec.sites.winktv.WinkTv; +import ctbrec.sites.xlovecam.XloveCam; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.news.NewsTab; +import ctbrec.ui.settings.SettingsTab; +import ctbrec.ui.tabs.*; +import ctbrec.ui.tabs.logging.LoggingTab; +import ctbrec.ui.tabs.recorded.RecordedTab; +import javafx.application.Application; +import javafx.application.HostServices; +import javafx.application.Platform; +import javafx.beans.value.ObservableValue; +import javafx.geometry.Insets; +import javafx.scene.Scene; +import javafx.scene.control.Label; +import javafx.scene.control.*; +import javafx.scene.image.Image; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.paint.Color; +import javafx.stage.Stage; +import javafx.stage.WindowEvent; +import lombok.Data; +import okhttp3.Request; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.awt.*; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.*; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import static ctbrec.event.Event.Type.*; +import static javafx.scene.control.TabPane.TabDragPolicy.FIXED; +import static javafx.scene.control.TabPane.TabDragPolicy.REORDER; + +public class CamrecApplication extends Application { + + static final Logger LOG = LoggerFactory.getLogger(CamrecApplication.class); + + private Config config; + private Recorder recorder; + private OnlineMonitor onlineMonitor; + static HostServices hostServices; + private final BorderPane rootPane = new BorderPane(); + private final HBox statusBar = new HBox(); + private final Label statusLabel = new Label(); + private final TabPane tabPane = new TabPane(); + private final List sites = new ArrayList<>(); + public static HttpClient httpClient; + public static PortraitStore portraitStore; + public static ModelNotesService modelNotesService; + public static String title; + private Stage primaryStage; + private RecordingsTab recordingsTab; + private ScheduledExecutorService scheduler; + private int activeRecordings = 0; + private double bytesPerSecond = 0; + + @Override + public void start(Stage primaryStage) throws Exception { + this.primaryStage = primaryStage; + scheduler = Executors.newScheduledThreadPool(1, r -> { + var t = new Thread(r); + t.setDaemon(true); + t.setName("Scheduler"); + return t; + }); + + logEnvironment(); + createSites(); + loadConfig(); + registerAlertSystem(); + registerActiveRecordingsCounter(); + registerBandwidthMeterListener(); + createHttpClient(); + hostServices = getHostServices(); + createRecorder(); + initSites(); + startOnlineMonitor(); + createPortraitStore(); + createModelNotesService(); + createGui(primaryStage); + if (config.getSettings().checkForUpdates) { + checkForUpdates(); + } + registerClipboardListener(); + registerTrayIconListener(); + } + + private void createPortraitStore() { + if (config.getSettings().localRecording) { + portraitStore = new LocalPortraitStore(config); + } else { + portraitStore = new RemotePortraitStore(httpClient, config); + } + } + + private void createModelNotesService() { + if (config.getSettings().localRecording) { + modelNotesService = new LocalModelNotesService(config); + } else { + modelNotesService = new RemoteModelNotesService(httpClient, config); + } + } + + private void registerTrayIconListener() { + EventBusHolder.BUS.register(new Object() { + @Subscribe + public void trayActionRequest(Map evt) { + if (Objects.equals("shutdown", evt.get("event"))) { + LOG.debug("Shutdown request from tray icon"); + try { + Platform.runLater(() -> { + primaryStage.show(); + shutdown(); + }); + } catch (Exception ex) { + LOG.error(ex.getMessage(), ex); + } + } + if (Objects.equals("stage_restored", evt.get("event"))) { + LOG.debug("Main stage restored"); + Platform.runLater(() -> { + if (tabPane.getSelectionModel().getSelectedItem() instanceof TabSelectionListener listener) { + listener.selected(); + } + }); + } + } + }); + } + + private void createSites() { + sites.add(new AmateurTv()); + sites.add(new BongaCams()); + sites.add(new Cam4()); + sites.add(new Camsoda()); + sites.add(new Chaturbate()); + sites.add(new CherryTv()); + sites.add(new Dreamcam()); + sites.add(new Fc2Live()); + sites.add(new Flirt4Free()); + sites.add(new LiveJasmin()); + sites.add(new MVLive()); + sites.add(new MyFreeCams()); + sites.add(new SecretFriends()); + sites.add(new Showup()); + sites.add(new Streamate()); + sites.add(new Stripchat()); + sites.add(new Streamray()); + sites.add(new WinkTv()); + sites.add(new XloveCam()); + } + + private void registerClipboardListener() { + if (config.getSettings().monitorClipboard) { + var clipboardListener = new ClipboardListener(recorder, sites); + scheduler.scheduleAtFixedRate(clipboardListener, 0, 1, TimeUnit.SECONDS); + } + } + + private void initSites() { + sites.forEach(site -> { + try { + site.setRecorder(recorder); + site.setConfig(config); + if (site.isEnabled()) { + site.init(); + } + } catch (Exception e) { + LOG.error("Error while initializing site {}", site.getName(), e); + } + }); + } + + private void startOnlineMonitor() { + onlineMonitor = new OnlineMonitor(recorder, config); + onlineMonitor.start(); + } + + 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")); + LOG.debug("JavaFX:\t{} ({})", System.getProperty("javafx.version"), System.getProperty("javafx.runtime.version")); + } + + private void createGui(Stage primaryStage) throws IOException { + LOG.debug("Creating GUI"); + DesktopIntegration.setRecorder(recorder); + DesktopIntegration.setPrimaryStage(primaryStage); + CamrecApplication.title = "CTB Recorder " + Version.getVersion(); + primaryStage.setTitle(title); + InputStream icon = getClass().getResourceAsStream("/icon.png"); + primaryStage.getIcons().add(new Image(icon)); + int windowWidth = Config.getInstance().getSettings().windowWidth; + int windowHeight = Config.getInstance().getSettings().windowHeight; + + var scene = new Scene(rootPane, windowWidth, windowHeight); + primaryStage.setScene(scene); + Dialogs.setScene(scene); + rootPane.setCenter(tabPane); + rootPane.setBottom(statusBar); + for (Site site : sites) { + if (site.isEnabled()) { + var siteTab = new SiteTab(site, scene); + tabPane.getTabs().add(siteTab); + } + } + + var modelsTab = new RecordedTab(recorder, sites); + tabPane.getTabs().add(modelsTab); + recordingsTab = new RecordingsTab("Recordings", recorder, config, modelNotesService); + tabPane.getTabs().add(recordingsTab); + if (config.getSettings().recentlyWatched) { + tabPane.getTabs().add(new RecentlyWatchedTab(recorder, sites)); + } + tabPane.getTabs().add(new SettingsTab(sites, recorder)); + tabPane.getTabs().add(new NewsTab(config)); + tabPane.getTabs().add(new DonateTabFx()); + tabPane.getTabs().add(new HelpTab()); + tabPane.getTabs().add(new LoggingTab()); + tabPane.setTabDragPolicy(config.getSettings().tabsSortable ? REORDER : FIXED); + + restoreTabOrder(); + switchToStartTab(); + writeColorSchemeStyleSheet(); + var base = Color.web(Config.getInstance().getSettings().colorBase); + if (!base.equals(Color.WHITE)) { + loadStyleSheet(primaryStage, "color.css"); + } + loadStyleSheet(primaryStage, "style.css"); + loadStyleSheet(primaryStage, "font.css"); + primaryStage.getScene().getStylesheets().add("/ctbrec/ui/settings/ColorSettingsPane.css"); + primaryStage.getScene().getStylesheets().add("/ctbrec/ui/controls/SearchBox.css"); + primaryStage.getScene().getStylesheets().add("/ctbrec/ui/controls/Popover.css"); + primaryStage.getScene().getStylesheets().add("/ctbrec/ui/settings/api/Preferences.css"); + primaryStage.getScene().getStylesheets().add("/ctbrec/ui/tabs/ThumbCell.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()); + primaryStage.setMaximized(Config.getInstance().getSettings().windowMaximized); + primaryStage.maximizedProperty().addListener((observable, oldVal, newVal) -> Config.getInstance().getSettings().windowMaximized = newVal); + Player.setScene(primaryStage.getScene()); + primaryStage.setX(Config.getInstance().getSettings().windowX); + primaryStage.setY(Config.getInstance().getSettings().windowY); + primaryStage.xProperty().addListener((observable, oldVal, newVal) -> { + if (newVal.doubleValue() + primaryStage.getWidth() > 0) { + Config.getInstance().getSettings().windowX = newVal.intValue(); + } + }); + primaryStage.yProperty().addListener((observable, oldVal, newVal) -> { + if (newVal.doubleValue() + primaryStage.getHeight() > 0) { + Config.getInstance().getSettings().windowY = newVal.intValue(); + } + }); + + if (config.getSettings().startMinimized) { + LOG.info("Minimize to tray on start"); + DesktopIntegration.minimizeToTray(primaryStage); + } else { + LOG.info("Showing primary stage"); + primaryStage.show(); + } + primaryStage.setOnCloseRequest(createShutdownHandler()); + Runtime.getRuntime().addShutdownHook(new Thread(() -> Platform.runLater(this::shutdown))); + setWindowMinimizeListener(primaryStage); + + // register changelistener to activate / deactivate tabs, when the user switches between them + tabPane.getSelectionModel().selectedItemProperty().addListener(this::tabChanged); + + statusBar.getChildren().add(statusLabel); + HBox.setMargin(statusLabel, new Insets(10, 10, 10, 10)); + + Optional.ofNullable(SplashScreen.getSplashScreen()).ifPresent(SplashScreen::close); + } + + private void setWindowMinimizeListener(Stage primaryStage) { + primaryStage.iconifiedProperty().addListener((obs, oldV, newV) -> { + if (Boolean.TRUE.equals(newV) && Config.getInstance().getSettings().minimizeToTray && primaryStage.isShowing()) { + DesktopIntegration.minimizeToTray(primaryStage); + suspendTabUpdates(); + } + }); + + } + + private void tabChanged(ObservableValue ov, Tab from, Tab to) { + try { + if (from instanceof TabSelectionListener l) { + l.deselected(); + } + if (to instanceof TabSelectionListener l) { + l.selected(); + } + } catch (Exception e) { + LOG.error("Error switching tabs", e); + } + } + + private void suspendTabUpdates() { + tabPane.getTabs().stream() + .filter(TabSelectionListener.class::isInstance) + .forEach(t -> ((TabSelectionListener) t).deselected()); + } + + private javafx.event.EventHandler createShutdownHandler() { + return e -> { + e.consume(); + shutdown(); + }; + } + + private void shutdown() { + // check for active downloads + if (recordingsTab.isDownloadRunning()) { + boolean exitAnyway = Dialogs.showConfirmDialog("Shutdown", "Do you want to exit anyway?", "There are downloads running", primaryStage.getScene()); + if (!exitAnyway) { + return; + } + } + + // check for active recordings + var shutdownNow = false; + if (config.getSettings().localRecording) { + try { + if (!recorder.getCurrentlyRecording().isEmpty()) { + ButtonType result = Dialogs.showShutdownDialog(primaryStage.getScene()); + if (result == ButtonType.NO) { + return; + } else if (result == ButtonType.YES) { + shutdownNow = true; + } + } + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException ex) { + LOG.warn("Couldn't check, if recordings are running"); + } + } + + Alert shutdownInfo = new AutosizeAlert(Alert.AlertType.INFORMATION, primaryStage.getScene()); + shutdownInfo.setTitle("Shutdown"); + shutdownInfo.setContentText("Shutting down. Please wait while recordings are finished..."); + shutdownInfo.show(); + final boolean immediately = shutdownNow; + new Thread(() -> { + List tabOrder = Config.getInstance().getSettings().tabOrder; + tabOrder.clear(); + for (Tab tab : tabPane.getTabs()) { + tabOrder.add(tab.getText()); + if (tab instanceof ShutdownListener l) { + l.onShutdown(); + } + } + onlineMonitor.shutdown(); + recorder.shutdown(immediately); + scheduler.shutdownNow(); + for (Site site : sites) { + if (site.isEnabled()) { + site.shutdown(); + } + } + try { + Config.getInstance().save(); + clearHttpCache(); + LOG.info("Shutdown complete. Goodbye!"); + 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, primaryStage.getScene()); + alert.setTitle("Error saving settings"); + alert.setContentText("Couldn't save settings: " + e1.getLocalizedMessage()); + alert.showAndWait(); + System.exit(1); + }); + } + try { + ExternalBrowser.getInstance().close(); + } catch (IOException e12) { + // noop + } + }).start(); + + } + + private void clearHttpCache() throws IOException { + try { + File httpCacheDir = new File(config.getConfigDir(), "cache"); + LOG.debug("Deleting http cache {}", httpCacheDir); + HttpClientCacheProvider.getCache(config).evictAll(); + HttpClientCacheProvider.getCache(config).close(); + IoUtils.deleteDirectory(httpCacheDir); + LOG.debug("Cache has been deleted"); + } catch (Exception e) { + LOG.info("The HTTP cache was not completely deleted: {}", e.getLocalizedMessage()); + } + } + + private void registerAlertSystem() { + for (EventHandlerConfiguration eventHandlerConfig : Config.getInstance().getSettings().eventHandlers) { + var handler = new EventHandler(eventHandlerConfig); + EventBusHolder.register(handler); + LOG.debug("Registered event handler for {} {}", eventHandlerConfig.getEvent(), eventHandlerConfig.getName()); + } + LOG.debug("Alert System registered"); + } + + private void registerActiveRecordingsCounter() { + EventBusHolder.BUS.register(new Object() { + @Subscribe + public void handleEvent(Event evt) { + if (evt.getType() == MODEL_ONLINE || evt.getType() == MODEL_STATUS_CHANGED || evt.getType() == RECORDING_STATUS_CHANGED) { + try { + int modelCount = recorder.getModelCount(); + List currentlyRecording = recorder.getCurrentlyRecording(); + activeRecordings = currentlyRecording.size(); + DesktopIntegration.updateTrayIcon(activeRecordings); + String windowTitle = getActiveRecordings(activeRecordings, modelCount) + title; + Platform.runLater(() -> primaryStage.setTitle(windowTitle)); + updateStatus(); + } catch (Exception e) { + LOG.warn("Couldn't update window title", e); + } + } + } + + private String getActiveRecordings(int activeRecordings, int modelCount) { + if (activeRecordings > 0) { + StringBuilder s = new StringBuilder("(").append(activeRecordings); + if (config.getSettings().totalModelCountInTitle) { + s.append("/").append(modelCount); + } + s.append(") "); + return s.toString(); + } else { + return ""; + } + } + }); + } + + private void registerBandwidthMeterListener() { + BandwidthMeter.addListener((bytes, dur) -> { + long millis = dur.toMillis(); + double bytesPerMilli = bytes / (double) millis; + bytesPerSecond = bytesPerMilli * 1000; + updateStatus(); + }); + } + + private void updateStatus() { + if (activeRecordings == 0) { + bytesPerSecond = 0; + } + String humanReadable = ByteUnitFormatter.format(bytesPerSecond); + var status = String.format("Recording %s / %s models (%s grouped) @ %s/s", activeRecordings, recorder.getModelCount(), recorder.getGroupedModelCount(), humanReadable); + Platform.runLater(() -> statusLabel.setText(status)); + } + + private void writeColorSchemeStyleSheet() { + var colorCss = new File(Config.getInstance().getConfigDir(), "color.css"); + try (var 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(StandardCharsets.UTF_8)); + } catch (Exception e) { + LOG.error("Couldn't write stylesheet for user defined color theme"); + } + } + + public static void loadStyleSheet(Stage primaryStage, String filename) { + var css = new File(Config.getInstance().getConfigDir(), filename); + if (css.exists() && css.isFile()) { + primaryStage.getScene().getStylesheets().add(css.toURI().toString()); + } + } + + private void restoreTabOrder() { + List tabOrder = Config.getInstance().getSettings().tabOrder; + for (int i = 0; i < tabOrder.size(); i++) { + Tab matched = null; + for (Tab tab : tabPane.getTabs()) { + if (Objects.equals(tabOrder.get(i), tab.getText())) { + matched = tab; + } + } + if (matched != null) { + tabPane.getTabs().remove(matched); + int max = tabPane.getTabs().size(); + tabPane.getTabs().add(Math.min(i, max), matched); + } + } + } + + private void switchToStartTab() { + String startTab = Config.getInstance().getSettings().startTab; + if (StringUtil.isNotBlank(startTab)) { + for (Tab tab : tabPane.getTabs()) { + if (Objects.equals(startTab, tab.getText())) { + tabPane.getSelectionModel().select(tab); + break; + } + } + } + if (tabPane.getSelectionModel().getSelectedItem() instanceof TabSelectionListener l) { + l.selected(); + } + } + + private void createRecorder() { + if (config.getSettings().localRecording) { + try { + recorder = new SimplifiedLocalRecorder(config, sites); + } catch (IOException e) { + LOG.error("Couldn't initialize recorder", e); + Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, primaryStage.getScene()); + alert.setTitle("Whoopsie"); + alert.setContentText("Couldn't initialize recorder: " + e.getLocalizedMessage()); + alert.showAndWait(); + } + } else { + recorder = new RemoteRecorder(config, httpClient, sites); + } + } + + private void loadConfig() { + try { + Config.init(sites); + } catch (Exception e) { + LOG.error("Couldn't load settings", e); + Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, primaryStage.getScene()); + alert.setTitle("Whoopsie"); + alert.setContentText("Couldn't load settings. Falling back to defaults. A backup of your settings has been created."); + alert.showAndWait(); + } + config = Config.getInstance(); + } + + private void createHttpClient() { + httpClient = new HttpClient("camrec", config) { + @Override + public boolean login() { + return false; + } + }; + } + + public static void main(String[] args) { + launch(args); + } + + private void checkForUpdates() { + var updateCheck = new Thread(() -> { + var url = "https://pastebin.com/raw/mUxtKzyB"; + var request = new Request.Builder().url(url).build(); + try (var response = httpClient.execute(request)) { + var body = response.body().string(); + LOG.trace("Version check respone: {}", body); + if (response.isSuccessful()) { + List releases = ObjectMapperFactory.getMapper().readValue(body, new TypeReference<>() { + }); + var latest = releases.get(0); + var latestVersion = latest.getVersion(); + var ctbrecVersion = Version.getVersion(); + if (latestVersion.compareTo(ctbrecVersion) > 0) { + LOG.debug("Update available {} < {}", ctbrecVersion, latestVersion); + Platform.runLater(() -> tabPane.getTabs().add(new UpdateTab(latest))); + } else { + LOG.debug("ctbrec is up-to-date {}", ctbrecVersion); + } + } else { + throw new HttpException(response.code(), response.message()); + } + } catch (Exception e) { + LOG.warn("Update check failed: {}", e.getMessage()); + } + }); + updateCheck.setName("Update Check"); + updateCheck.setDaemon(true); + updateCheck.start(); + } + + @Data + public static class Release { + private String name; + @JsonProperty("tag_name") + private String tagName; + @JsonProperty("html_url") + private String htmlUrl; + + public Version getVersion() { + return Version.of(tagName); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/ClipboardListener.java b/client/src/main/java/ctbrec/ui/ClipboardListener.java new file mode 100644 index 00000000..7643edd5 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/ClipboardListener.java @@ -0,0 +1,65 @@ +package ctbrec.ui; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.Objects; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.recorder.Recorder; +import ctbrec.sites.Site; +import javafx.application.Platform; +import javafx.scene.input.Clipboard; + +public class ClipboardListener implements Runnable { + + private static final Logger LOG = LoggerFactory.getLogger(ClipboardListener.class); + private Recorder recorder; + private List sites; + private Clipboard systemClipboard; + private String lastUrl = null; + + public ClipboardListener(Recorder recorder, List sites) { + this.recorder = recorder; + this.sites = sites; + systemClipboard = Clipboard.getSystemClipboard(); + } + + @Override + public void run() { + Platform.runLater(() -> { + try { + String url = null; + if (systemClipboard.hasUrl()) { + url = systemClipboard.getUrl(); + } else if (systemClipboard.hasString()) { + url = systemClipboard.getString(); + } + if (url != null && !Objects.equals(url, lastUrl)) { + lastUrl = url; + addModelIfUrlMatches(url); + } + } catch (Exception e) { + LOG.error("Error in clipboard polling loop", e); + } + }); + } + + private void addModelIfUrlMatches(String url) { + for (Site site : sites) { + var m = site.createModelFromUrl(url); + if (m != null) { + try { + recorder.addModel(m); + DesktopIntegration.notification("Add from clipboard", "Model added", "Model " + m.getDisplayName() + " added"); + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { + DesktopIntegration.notification("Add from clipboard", "Error", "Couldn't add URL from clipboard: " + e.getLocalizedMessage()); + } + break; + } + } + } +} diff --git a/client/src/main/java/ctbrec/ui/DesktopIntegration.java b/client/src/main/java/ctbrec/ui/DesktopIntegration.java new file mode 100644 index 00000000..7673458b --- /dev/null +++ b/client/src/main/java/ctbrec/ui/DesktopIntegration.java @@ -0,0 +1,229 @@ +package ctbrec.ui; + +import ctbrec.Config; +import ctbrec.OS; +import ctbrec.StringUtil; +import ctbrec.io.StreamRedirector; +import ctbrec.recorder.Recorder; +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.scene.control.Alert; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import javafx.scene.layout.BorderPane; +import javafx.stage.Stage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.awt.*; +import java.awt.TrayIcon.MessageType; +import java.io.File; +import java.io.IOException; +import java.net.URI; + +public class DesktopIntegration { + + private DesktopIntegration() { + } + + private static final Logger LOG = LoggerFactory.getLogger(DesktopIntegration.class); + + private static Recorder recorder; + private static Stage primaryStage; + private static TrayIcon trayIcon; + + public static void open(String uri) { + Config cfg = Config.getInstance(); + Runtime rt = Runtime.getRuntime(); + String[] cmdline = createCmdline(uri); + + if (!cfg.getSettings().browserOverride.isEmpty()) { + try { + rt.exec(cmdline); + return; + } catch (Exception e) { + LOG.debug("Couldn't open URL with user-defined {} {}", cmdline, uri); + } + } + + if (!cfg.getSettings().forceBrowserOverride) { + try { + CamrecApplication.hostServices.showDocument(uri); + return; + } catch (Exception e) { + LOG.debug("Couldn't open URL with host services {}", uri); + } + + // opening with HostServices failed, now try Desktop + try { + Desktop.getDesktop().browse(new URI(uri)); + return; + } catch (Exception e) { + LOG.debug("Couldn't open URL with Desktop {}", uri); + } + + // try external helpers + var externalHelpers = new String[]{"kde-open5", "kde-open", "gnome-open", "xdg-open"}; + for (String helper : externalHelpers) { + try { + rt.exec(helper + " " + uri); + return; + } catch (IOException e) { + LOG.debug("Couldn't open URL with {} {}", helper, uri); + } + } + } + + // all attempts failed, show a dialog with URL at least + Alert info = new AutosizeAlert(Alert.AlertType.ERROR); + info.setTitle("Open URL"); + info.setContentText("Couldn't open URL"); + var pane = new BorderPane(); + pane.setTop(new Label()); + var urlField = new TextField(uri); + urlField.setPadding(new Insets(10)); + urlField.setEditable(false); + pane.setCenter(urlField); + info.getDialogPane().setExpandableContent(pane); + info.getDialogPane().setExpanded(true); + info.show(); + } + + private static String[] createCmdline(String streamUrl) { + Config cfg = Config.getInstance(); + String params = cfg.getSettings().browserParams.trim(); + + String[] cmdline; + if (params.isEmpty()) { + cmdline = new String[2]; + } else { + String[] playerArgs = StringUtil.splitParams(params); + cmdline = new String[playerArgs.length + 2]; + System.arraycopy(playerArgs, 0, cmdline, 1, playerArgs.length); + } + cmdline[0] = cfg.getSettings().browserOverride; + cmdline[cmdline.length - 1] = streamUrl; + return cmdline; + } + + public static void open(File f) { + try { + Desktop.getDesktop().open(f); + return; + } catch (Exception e) { + LOG.debug("Couldn't open file with Desktop {}", f); + } + + // try external helpers + var externalHelpers = new String[]{"kde-open5", "kde-open", "gnome-open", "xdg-open"}; + var rt = Runtime.getRuntime(); + for (String helper : externalHelpers) { + try { + rt.exec(helper + " " + f.getAbsolutePath()); + return; + } catch (IOException e) { + LOG.debug("Couldn't open file with {} {}", helper, f); + } + } + + // all attempts failed, show a dialog with path at least + Alert info = new AutosizeAlert(Alert.AlertType.ERROR); + info.setTitle("Open file"); + info.setContentText("Couldn't open file"); + var pane = new BorderPane(); + pane.setTop(new Label()); + var urlField = new TextField(f.toString()); + urlField.setPadding(new Insets(10)); + urlField.setEditable(false); + pane.setCenter(urlField); + info.getDialogPane().setExpandableContent(pane); + info.getDialogPane().setExpanded(true); + info.show(); + } + + 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.replace("-", "\\\\-").replace("\\s", "\\\\ "), + "--icon=dialog-information" + }); + new Thread(new StreamRedirector(p.getInputStream(), System.out)).start(); // NOSONAR + new Thread(new StreamRedirector(p.getErrorStream(), System.err)).start(); // NOSONAR + } catch (NullPointerException e) { + // can happen at start, ignore + } 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 synchronized void notifySystemTray(String title, String header, String msg) { + if (SystemTray.isSupported()) { + createTrayIcon(primaryStage); + trayIcon.displayMessage(header, msg, MessageType.INFO); + } else { + LOG.error("SystemTray notifications not supported by this OS"); + } + } + + public static void minimizeToTray(Stage primaryStage) { + Platform.setImplicitExit(false); + boolean supported = createTrayIcon(primaryStage); + if (supported) { + primaryStage.hide(); + } + } + + private static boolean createTrayIcon(Stage stage) { + if (SystemTray.isSupported()) { + boolean created = true; + if (trayIcon == null) { + trayIcon = new ctbrec.ui.TrayIcon(stage, recorder); + created = trayIcon.createTrayIcon(); + } + return created; + } else { + LOG.error("SystemTray notifications not supported by this OS"); + return false; + } + } + + public static void setRecorder(Recorder recorder) { + DesktopIntegration.recorder = recorder; + } + + public static void setPrimaryStage(Stage primaryStage) { + DesktopIntegration.primaryStage = primaryStage; + } + + public static void updateTrayIcon(int activeRecordings) { + if (trayIcon != null) { + trayIcon.updateActiveRecordings(activeRecordings); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/ExternalBrowser.java b/client/src/main/java/ctbrec/ui/ExternalBrowser.java new file mode 100644 index 00000000..e4de6000 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/ExternalBrowser.java @@ -0,0 +1,238 @@ +package ctbrec.ui; + +import ctbrec.Config; +import ctbrec.OS; +import ctbrec.io.ProcessOutputLogger; +import lombok.extern.slf4j.Slf4j; +import org.json.JSONObject; + +import java.io.*; +import java.net.Socket; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; + +import static java.nio.charset.StandardCharsets.UTF_8; + +@Slf4j +public class ExternalBrowser implements AutoCloseable { // NOSONAR singleton is wanted + private static final ExternalBrowser INSTANCE = new ExternalBrowser(); + private static final String MSGID = "msgid"; + private final Lock lock = new ReentrantLock(); + private Socket socket; + + private Consumer messageListener; + private InputStream in; + private OutputStream out; + private Thread reader; + private volatile boolean stopped = true; + private volatile boolean browserReady = false; + private final Object browserReadyLock = new Object(); + + private final Map> responseFutures = new HashMap<>(); + private Runnable onReadyCallback; + + public static ExternalBrowser getInstance() { + return INSTANCE; + } + + public void run(JSONObject jsonConfig, Consumer messageListener) throws InterruptedException, IOException { + log.debug("Running browser with config {}", jsonConfig); + lock.lock(); + try { + stopped = false; + this.messageListener = messageListener; + + addProxyConfig(jsonConfig.getJSONObject("config")); + + var configDir = new File(Config.getInstance().getConfigDir(), "ctbrec-minimal-browser"); + String[] cmdline = OS.getBrowserCommand(configDir.getCanonicalPath()); + Process p = new ProcessBuilder(cmdline).start(); + new Thread(new ProcessOutputLogger(p.getInputStream(), "ExternalBrowser stdout")).start(); + new Thread(new ProcessOutputLogger(p.getErrorStream(), "ExternalBrowser stderr")).start(); + log.debug("Browser started: {}", Arrays.toString(cmdline)); + + connectToRemoteControlSocket(); + while (!browserReady) { + synchronized (browserReadyLock) { + browserReadyLock.wait(100); + } + } + if (log.isTraceEnabled()) { + log.debug("Connected to remote control server. Sending config {}", jsonConfig); + } else { + log.debug("Connected to remote control server. Sending config"); + } + out.write(jsonConfig.toString().getBytes(UTF_8)); + out.write('\n'); + out.flush(); + + Optional.ofNullable(onReadyCallback).ifPresent(Runnable::run); + + log.debug("Waiting for browser to terminate"); + p.waitFor(); + int exitValue = p.exitValue(); + reader = null; + in = null; + out = null; + this.messageListener = null; + log.debug("Browser Process terminated with {}", exitValue); + } finally { + lock.unlock(); + } + } + + private void connectToRemoteControlSocket() throws IOException { + for (var i = 0; i < 20; i++) { + try { + socket = new Socket("localhost", 3202); + in = socket.getInputStream(); + out = socket.getOutputStream(); + reader = new Thread(this::readBrowserOutput); + reader.start(); + log.debug("Connected to control socket"); + return; + } catch (IOException e) { + if (i == 19) { + log.error("Connection to remote control socket failed", e); + throw e; + } + } + + // wait a bit, so that the remote server socket can be opened by electron + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + public CompletableFuture executeJavaScript(String javaScript) throws IOException { + String id = UUID.randomUUID().toString(); + var future = new CompletableFuture<>(); + var script = new JSONObject(); + script.put(MSGID, id); + script.put("execute", javaScript); + if (out != null) { + out.write(script.toString().getBytes(UTF_8)); + out.write('\n'); + out.flush(); + responseFutures.put(id, future); + } + if (javaScript.equals("quit")) { + stopped = true; + } + return future; + } + + @Override + public void close() throws IOException { + if (stopped) { + return; + } + stopped = true; + executeJavaScript("quit"); + } + + private void readBrowserOutput() { + log.debug("Browser output reader started"); + try (var br = new BufferedReader(new InputStreamReader(in))) { + String line; + synchronized (browserReadyLock) { + browserReady = true; + browserReadyLock.notifyAll(); + } + while (!Thread.interrupted() && (line = br.readLine()) != null) { + log.debug("Browser output: {}", line); + if (line.startsWith("{")) { + JSONObject json = new JSONObject(line); + if (json.has(MSGID)) { + handleExecuteScriptResponse(json); + } else { + if (messageListener != null) { + messageListener.accept(line); + } + } + } + } + } catch (IOException e) { + if (!stopped) { + log.error("Couldn't read browser output", e); + } + } finally { + stopped = true; + synchronized (browserReadyLock) { + browserReady = true; + browserReadyLock.notifyAll(); + } + } + } + + private void handleExecuteScriptResponse(JSONObject json) { + var msgid = json.getString(MSGID); + log.debug("Future {}", msgid); + CompletableFuture future = responseFutures.get(msgid); + if (future != null) { + responseFutures.remove(msgid); + final String RESULT = "result"; + if (json.has(RESULT)) { + log.debug("Future {} done. Result: {}", msgid, json.get(RESULT)); + future.complete(json.get(RESULT)); + } else if (json.has("error")) { + log.debug("Future {} failed", msgid); + future.completeExceptionally(new Exception(json.getJSONObject("error").toString())); + } + } else { + log.warn("No future for previous request {}", msgid); + } + } + + private void addProxyConfig(JSONObject jsonConfig) { + var proxyType = Config.getInstance().getSettings().proxyType; + var proxy = new JSONObject(); + final String KEY_ADDRESS = "address"; + final String KEY_PROXY = "proxy"; + switch (proxyType) { + case HTTP: + proxy.put(KEY_ADDRESS, + "http=" + Config.getInstance().getSettings().proxyHost + ':' + Config.getInstance().getSettings().proxyPort + + ";https=" + Config.getInstance().getSettings().proxyHost + ':' + Config.getInstance().getSettings().proxyPort); + if (Config.getInstance().getSettings().proxyUser != null && !Config.getInstance().getSettings().proxyUser.isEmpty()) { + String username = Config.getInstance().getSettings().proxyUser; + String password = Config.getInstance().getSettings().proxyPassword; + proxy.put("user", username); + proxy.put("password", password); + } + jsonConfig.put(KEY_PROXY, proxy); + break; + case SOCKS4: + proxy = new JSONObject(); + proxy.put(KEY_ADDRESS, "socks4://" + Config.getInstance().getSettings().proxyHost + ':' + Config.getInstance().getSettings().proxyPort); + jsonConfig.put(KEY_PROXY, proxy); + break; + case SOCKS5: + proxy = new JSONObject(); + proxy.put(KEY_ADDRESS, "socks5://" + Config.getInstance().getSettings().proxyHost + ':' + Config.getInstance().getSettings().proxyPort); + if (Config.getInstance().getSettings().proxyUser != null && !Config.getInstance().getSettings().proxyUser.isEmpty()) { + String username = Config.getInstance().getSettings().proxyUser; + String password = Config.getInstance().getSettings().proxyPassword; + proxy.put("user", username); + proxy.put("password", password); + } + jsonConfig.put(KEY_PROXY, proxy); + break; + case DIRECT: + default: + // nothing to do here + break; + } + } + + public ExternalBrowser onReady(Runnable onReadyCallback) { + this.onReadyCallback = onReadyCallback; + return this; + } +} diff --git a/client/src/main/java/ctbrec/ui/FileDownload.java b/client/src/main/java/ctbrec/ui/FileDownload.java new file mode 100644 index 00000000..748fec7e --- /dev/null +++ b/client/src/main/java/ctbrec/ui/FileDownload.java @@ -0,0 +1,53 @@ +package ctbrec.ui; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.io.HttpClient; +import ctbrec.recorder.ProgressListener; +import okhttp3.Request; + +public class FileDownload { + + private static final Logger LOG = LoggerFactory.getLogger(FileDownload.class); + + private HttpClient httpClient; + private ProgressListener downloadListener; + + public FileDownload(HttpClient httpClient, ProgressListener downloadListener) { + this.httpClient = httpClient; + this.downloadListener = downloadListener; + } + + public void start(URL url, File target) throws IOException { + LOG.trace("Downloading file {} to {}", url, target); + var request = new Request.Builder().url(url).addHeader("connection", "keep-alive").build(); + var response = httpClient.execute(request); + var fileSize = Long.parseLong(response.header("Content-Length", String.valueOf(Long.MAX_VALUE))); + InputStream in = null; + try (var fos = new FileOutputStream(target)) { + in = response.body().byteStream(); + var b = new byte[1024 * 100]; + long totalBytesRead = 0; + int length = -1; + while ((length = in.read(b)) >= 0) { + fos.write(b, 0, length); + totalBytesRead += length; + int progress = (int)(totalBytesRead * 100d / fileSize); + downloadListener.update(progress); + } + } finally { + if (in != null) { + in.close(); + } + response.close(); + } + } + +} diff --git a/client/src/main/java/ctbrec/ui/Icon.java b/client/src/main/java/ctbrec/ui/Icon.java new file mode 100644 index 00000000..4686af09 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/Icon.java @@ -0,0 +1,23 @@ +package ctbrec.ui; + +public enum Icon { + + BLANK_16(Icon.class.getResource("/16/blank.png").toExternalForm()), + BOOKMARK_16(Icon.class.getResource("/16/bookmark-new.png").toExternalForm()), + CHECK_16(Icon.class.getResource("/16/check-small.png").toExternalForm()), + CLOCK_16(Icon.class.getResource("/16/clock.png").toExternalForm()), + GROUP_16(Icon.class.getResource("/16/users.png").toExternalForm()), + MEDIA_PLAYBACK_PAUSE_16(Icon.class.getResource("/16/media-playback-pause.png").toExternalForm()), + MEDIA_RECORD_16(Icon.class.getResource("/16/media-record.png").toExternalForm()), + MEDIA_FORCE_RECORD_16(Icon.class.getResource("/16/media-force-record.png").toExternalForm()); + + private String url; + + private Icon(String url) { + this.url = url; + } + + public String url() { + return url; + } +} diff --git a/client/src/main/java/ctbrec/ui/InstantProperty.java b/client/src/main/java/ctbrec/ui/InstantProperty.java new file mode 100644 index 00000000..d9efdd9c --- /dev/null +++ b/client/src/main/java/ctbrec/ui/InstantProperty.java @@ -0,0 +1,18 @@ +package ctbrec.ui; + +import java.time.Instant; + +import javafx.beans.property.ObjectPropertyBase; + +public class InstantProperty extends ObjectPropertyBase { + + @Override + public Object getBean() { + return get(); + } + + @Override + public String getName() { + return "Instant"; + } +} diff --git a/client/src/main/java/ctbrec/ui/JavaFxModel.java b/client/src/main/java/ctbrec/ui/JavaFxModel.java new file mode 100644 index 00000000..ccdd4b3d --- /dev/null +++ b/client/src/main/java/ctbrec/ui/JavaFxModel.java @@ -0,0 +1,396 @@ +package ctbrec.ui; + +import com.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.PlaylistException; +import ctbrec.Model; +import ctbrec.SubsequentAction; +import ctbrec.recorder.download.HttpHeaderFactory; +import ctbrec.recorder.download.RecordingProcess; +import ctbrec.recorder.download.StreamSource; +import ctbrec.sites.Site; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.beans.property.SimpleObjectProperty; + +import javax.xml.bind.JAXBException; +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +/** + * Just a wrapper for Model, which augments it with JavaFX value binding properties, so that UI widgets get updated proeprly + */ +public class JavaFxModel implements Model { + private final transient BooleanProperty onlineProperty = new SimpleBooleanProperty(); + private final transient BooleanProperty recordingProperty = new SimpleBooleanProperty(); + private final transient BooleanProperty pausedProperty = new SimpleBooleanProperty(); + private final transient BooleanProperty forcePriorityProperty = new SimpleBooleanProperty(); + private final transient SimpleIntegerProperty priorityProperty = new SimpleIntegerProperty(); + private final transient SimpleObjectProperty lastSeenProperty = new SimpleObjectProperty<>(); + private final transient SimpleObjectProperty lastRecordedProperty = new SimpleObjectProperty<>(); + private final transient SimpleObjectProperty onlineStateProperty = new SimpleObjectProperty<>(); + + private final Model delegate; + + public JavaFxModel(Model delegate) { + this.delegate = delegate; + setPriority(delegate.getPriority()); + setLastSeen(delegate.getLastSeen()); + setLastRecorded(delegate.getLastRecorded()); + try { // TODO: split online state updates and reading from cache, so that we don't need try-catch + onlineStateProperty.setValue(delegate.getOnlineState(true)); + } catch (Exception e) {} + } + + public void updateFrom(JavaFxModel other) { + this.setSuspended(other.isSuspended()); + this.setForcePriority(other.isForcePriority()); + this.getOnlineProperty().set(other.getOnlineProperty().get()); + this.getRecordingProperty().set(other.getRecordingProperty().get()); + this.lastRecordedProperty().set(other.lastRecordedProperty().get()); + this.lastSeenProperty().set(other.lastSeenProperty().get()); + this.setRecordUntil(other.getRecordUntil()); + this.setRecordUntilSubsequentAction(other.getRecordUntilSubsequentAction()); + this.onlineStateProperty().set(other.onlineStateProperty().get()); + } + + @Override + public String getUrl() { + return delegate.getUrl(); + } + + @Override + public void setUrl(String url) { + delegate.setUrl(url); + } + + @Override + public String getName() { + return delegate.getName(); + } + + @Override + public void setName(String name) { + delegate.setName(name); + } + + @Override + public String getSanitizedNamed() { + return delegate.getSanitizedNamed(); + } + + @Override + public String getPreview() { + return delegate.getPreview(); + } + + @Override + public void setPreview(String preview) { + delegate.setPreview(preview); + } + + @Override + public List getTags() { + return delegate.getTags(); + } + + @Override + public void setTags(List tags) { + delegate.setTags(tags); + } + + @Override + public int hashCode() { + return delegate.hashCode(); + } + + @Override + public boolean equals(Object obj) { + return delegate.equals(obj); + } + + @Override + public String toString() { + return delegate.toString(); + } + + public BooleanProperty getOnlineProperty() { + return onlineProperty; + } + + public void setOnlineProperty(boolean online) { + this.onlineProperty.set(online); + } + + public BooleanProperty getRecordingProperty() { + return recordingProperty; + } + + public void setRecordingProperty(boolean recording) { + this.recordingProperty.setValue(recording); + } + + public BooleanProperty getPausedProperty() { + return pausedProperty; + } + + public BooleanProperty getForcePriorityProperty() { + return forcePriorityProperty; + } + + public SimpleIntegerProperty getPriorityProperty() { + return priorityProperty; + } + + public Model getDelegate() { + return delegate; + } + + @Override + public boolean isOnline() throws IOException, ExecutionException, InterruptedException { + return delegate.isOnline(); + } + + @Override + public boolean isOnline(boolean ignoreCache) throws IOException, ExecutionException, InterruptedException { + return delegate.isOnline(ignoreCache); + } + + public void setOnlineStateProperty(State state) throws IOException, ExecutionException { + onlineStateProperty.set(state); + } + + @Override + public State getOnlineState(boolean failFast) throws IOException, ExecutionException { + return delegate.getOnlineState(failFast); + } + + @Override + public List getStreamSources() throws IOException, ExecutionException, ParseException, PlaylistException, JAXBException { + return delegate.getStreamSources(); + } + + @Override + public void invalidateCacheEntries() { + delegate.invalidateCacheEntries(); + } + + @Override + public void receiveTip(Double tokens) throws IOException { + SiteUiFactory.getUi(getSite()).login(); + delegate.receiveTip(tokens); + } + + @Override + public int[] getStreamResolution(boolean b) throws ExecutionException { + return delegate.getStreamResolution(b); + } + + @Override + public boolean follow() throws IOException { + SiteUiFactory.getUi(getSite()).login(); + return delegate.follow(); + } + + @Override + public boolean unfollow() throws IOException { + SiteUiFactory.getUi(getSite()).login(); + return delegate.unfollow(); + } + + @Override + public void setSite(Site site) { + delegate.setSite(site); + } + + @Override + public Site getSite() { + return delegate.getSite(); + } + + @Override + public void readSiteSpecificData(Map data) { + delegate.readSiteSpecificData(data); + } + + @Override + public void writeSiteSpecificData(Map data) { + delegate.writeSiteSpecificData(data); + } + + @Override + public String getDescription() { + return delegate.getDescription(); + } + + @Override + public void setDescription(String description) { + delegate.setDescription(description); + } + + @Override + public int getStreamUrlIndex() { + return delegate.getStreamUrlIndex(); + } + + @Override + public void setStreamUrlIndex(int streamUrlIndex) { + delegate.setStreamUrlIndex(streamUrlIndex); + } + + @Override + public boolean isSuspended() { + return delegate.isSuspended(); + } + + @Override + public void setSuspended(boolean suspended) { + delegate.setSuspended(suspended); + pausedProperty.set(suspended); + } + + @Override + public void delay() { + delegate.delay(); + } + + @Override + public boolean isDelayed() { + return delegate.isDelayed(); + } + + @Override + public String getDisplayName() { + return delegate.getDisplayName(); + } + + @Override + public void setDisplayName(String name) { + delegate.setDisplayName(name); + } + + @Override + public void setPriority(int priority) { + delegate.setPriority(priority); + priorityProperty.set(priority); + } + + @Override + public int getPriority() { + return delegate.getPriority(); + } + + + @Override + public boolean isForcePriority() { + return delegate.isForcePriority(); + } + + @Override + public void setForcePriority(boolean forcePriority) { + delegate.setForcePriority(forcePriority); + forcePriorityProperty.set(forcePriority); + } + + public SimpleObjectProperty lastSeenProperty() { + return lastSeenProperty; + } + + @Override + public void setLastSeen(Instant timestamp) { + delegate.setLastSeen(timestamp); + lastSeenProperty.set(timestamp); + } + + @Override + public Instant getLastSeen() { + return delegate.getLastSeen(); + } + + public SimpleObjectProperty lastRecordedProperty() { + return lastRecordedProperty; + } + + public SimpleObjectProperty onlineStateProperty() { + return onlineStateProperty; + } + + @Override + public void setLastRecorded(Instant timestamp) { + delegate.setLastRecorded(timestamp); + lastRecordedProperty.set(timestamp); + } + + @Override + public Instant getLastRecorded() { + return delegate.getLastRecorded(); + } + + @Override + public Instant getAddedTimestamp() { + return delegate.getAddedTimestamp(); + } + + @Override + public void setAddedTimestamp(Instant timestamp) { + delegate.setAddedTimestamp(timestamp); + } + + @Override + public int compareTo(Model o) { + return delegate.compareTo(o); + } + + @Override + public RecordingProcess createDownload() { + return delegate.createDownload(); + } + + @Override + public HttpHeaderFactory getHttpHeaderFactory() { + return delegate.getHttpHeaderFactory(); + } + + @Override + public Instant getRecordUntil() { + return delegate.getRecordUntil(); + } + + @Override + public void setRecordUntil(Instant instant) { + delegate.setRecordUntil(instant); + } + + @Override + public SubsequentAction getRecordUntilSubsequentAction() { + return delegate.getRecordUntilSubsequentAction(); + } + + @Override + public void setRecordUntilSubsequentAction(SubsequentAction action) { + delegate.setRecordUntilSubsequentAction(action); + } + + @Override + public boolean exists() throws IOException { + return delegate.exists(); + } + + @Override + public boolean isRecordingTimeLimited() { + return delegate.isRecordingTimeLimited(); + } + + @Override + public boolean isMarkedForLaterRecording() { + return delegate.isMarkedForLaterRecording(); + } + + @Override + public void setMarkedForLaterRecording(boolean marked) { + delegate.setMarkedForLaterRecording(marked); + } +} diff --git a/client/src/main/java/ctbrec/ui/JavaFxRecording.java b/client/src/main/java/ctbrec/ui/JavaFxRecording.java new file mode 100644 index 00000000..0ab26a97 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/JavaFxRecording.java @@ -0,0 +1,310 @@ +package ctbrec.ui; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.Recording; +import ctbrec.recorder.download.RecordingProcess; +import javafx.beans.property.LongProperty; +import javafx.beans.property.SimpleLongProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; + +import java.io.File; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.Set; + +public class JavaFxRecording extends Recording { + + private final transient StringProperty statusProperty = new SimpleStringProperty(); + private final transient StringProperty progressProperty = new SimpleStringProperty(); + private final transient StringProperty notesProperty = new SimpleStringProperty(); + private final transient LongProperty sizeProperty = new SimpleLongProperty(); + private final Recording delegate; + private long lastValue = 0; + + public JavaFxRecording(Recording recording) { + this.delegate = recording; + setStatus(recording.getStatus()); + setSizeInByte(recording.getSizeInByte()); + setProgress(recording.getProgress()); + setNote(recording.getNote()); + notesProperty.addListener((obs, oldV, newV) -> delegate.setNote(newV)); + } + + public Recording getDelegate() { + return delegate; + } + + @Override + public Model getModel() { + return delegate.getModel(); + } + + @Override + public void setModel(Model model) { + delegate.setModel(model); + } + + @Override + public Instant getStartDate() { + return delegate.getStartDate(); + } + + @Override + public void setStartDate(Instant startDate) { + delegate.setStartDate(startDate); + } + + @Override + public State getStatus() { + return delegate.getStatus(); + } + + public StringProperty getStatusProperty() { + return statusProperty; + } + + @Override + public void setStatus(State status) { + delegate.setStatus(status); + switch (status) { + case RECORDING: + statusProperty.set("recording"); + break; + case GENERATING_PLAYLIST: + statusProperty.set("generating playlist"); + break; + case FINISHED: + statusProperty.set("finished"); + break; + case DOWNLOADING: + statusProperty.set("downloading"); + break; + case POST_PROCESSING: + statusProperty.set("post-processing"); + break; + case DELETED: + statusProperty.set("deleted"); + break; + case DELETING: + statusProperty.set("deleting"); + break; + case FAILED: + statusProperty.set("failed"); + break; + case WAITING: + statusProperty.set("waiting"); + break; + case UNKNOWN: + default: + statusProperty.set("unknown"); + break; + } + if (isPinned()) { + statusProperty.set(statusProperty.get() + " 🔒"); + } + } + + @Override + public int getProgress() { + return delegate.getProgress(); + } + + @Override + public void setProgress(int progress) { + delegate.setProgress(progress); + if (progress >= 0) { + progressProperty.set(progress + "%"); + } else { + progressProperty.set(""); + } + } + + @Override + public void setSizeInByte(long sizeInByte) { + delegate.setSizeInByte(sizeInByte); + sizeProperty.set(sizeInByte); + } + + public StringProperty getProgressProperty() { + return progressProperty; + } + + @Override + public int hashCode() { + return delegate.hashCode(); + } + + @Override + public boolean equals(Object obj) { + return delegate.equals(obj); + } + + @Override + public String toString() { + return delegate.toString(); + } + + public void update(Recording updated) { + if (!Config.getInstance().getSettings().localRecording) { + 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; + } + } + setStatus(updated.getStatus()); + setProgress(updated.getProgress()); + setSizeInByte(updated.getSizeInByte()); + setSingleFile(updated.isSingleFile()); + setAssociatedFiles(updated.getAssociatedFiles()); + } + + @Override + public void setPath(String path) { + delegate.setPath(path); + } + + @Override + public long getSizeInByte() { + return delegate.getSizeInByte(); + } + + public LongProperty getSizeProperty() { + return sizeProperty; + } + + @Override + public void setMetaDataFile(String metaDataFile) { + delegate.setMetaDataFile(metaDataFile); + } + + @Override + public String getMetaDataFile() { + return delegate.getMetaDataFile(); + } + + @Override + public boolean isSingleFile() { + return delegate.isSingleFile(); + } + + @Override + public void setSingleFile(boolean singleFile) { + delegate.setSingleFile(singleFile); + } + + @Override + public boolean isPinned() { + return delegate.isPinned(); + } + + @Override + public void setPinned(boolean pinned) { + delegate.setPinned(pinned); + setStatus(getStatus()); + } + + public boolean valueChanged() { + boolean changed = getSizeInByte() != lastValue; + lastValue = getSizeInByte(); + return changed; + } + + @Override + public String getNote() { + return delegate.getNote(); + } + + @Override + public void setNote(String note) { + delegate.setNote(note); + notesProperty.set(note); + } + + public StringProperty getNoteProperty() { + return notesProperty; + } + + @Override + public File getAbsoluteFile() { + return delegate.getAbsoluteFile(); + } + + @Override + public void setAbsoluteFile(File absoluteFile) { + delegate.setAbsoluteFile(absoluteFile); + } + + @Override + public String getId() { + return delegate.getId(); + } + + @Override + public void setId(String id) { + delegate.setId(id); + } + + @Override + public void setStatusWithEvent(State status) { + delegate.setStatusWithEvent(status); + } + + @Override + public File getPostProcessedFile() { + return delegate.getPostProcessedFile(); + } + + @Override + public void setPostProcessedFile(File postProcessedFile) { + delegate.setPostProcessedFile(postProcessedFile); + } + + @Override + public RecordingProcess getRecordingProcess() { + return delegate.getRecordingProcess(); + } + + @Override + public void setRecordingProcess(RecordingProcess recordingProcess) { + delegate.setRecordingProcess(recordingProcess); + } + + @Override + public Duration getLength() { + return delegate.getLength(); + } + + @Override + public void refresh() { + delegate.refresh(); + } + + @Override + public boolean canBePostProcessed() { + return delegate.canBePostProcessed(); + } + + @Override + public Set getAssociatedFiles() { + return delegate.getAssociatedFiles(); + } + + @Override + public void setAssociatedFiles(Set associatedFiles) { + delegate.setAssociatedFiles(associatedFiles); + } + + @Override + public Optional getContactSheet() { + return delegate.getContactSheet(); + } + + @Override + public int getSelectedResolution() { + return delegate.getSelectedResolution(); + } +} diff --git a/client/src/main/java/ctbrec/ui/Launcher.java b/client/src/main/java/ctbrec/ui/Launcher.java new file mode 100644 index 00000000..691d14ff --- /dev/null +++ b/client/src/main/java/ctbrec/ui/Launcher.java @@ -0,0 +1,23 @@ +package ctbrec.ui; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Java; + +public class Launcher { + + private static final Logger LOG = LoggerFactory.getLogger(Launcher.class); + + public static void main(String[] args) { + int javaVersion = Java.version(); + if(javaVersion == 0) { + LOG.warn("Unknown Java version {}. App might not work as expected", javaVersion); + } else if (javaVersion < 10) { + LOG.error("Your Java version ({}) is too old. Please update to Java 10 or newer", javaVersion); + System.exit(1); + } + System.setProperty("awt.useSystemAAFontSettings","lcd"); + CamrecApplication.main(args); + } +} diff --git a/client/src/main/java/ctbrec/ui/PauseIcon.java b/client/src/main/java/ctbrec/ui/PauseIcon.java new file mode 100644 index 00000000..30a1ff24 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/PauseIcon.java @@ -0,0 +1,22 @@ +package ctbrec.ui; + + +import javafx.scene.paint.Color; +import javafx.scene.shape.Polygon; + +public class PauseIcon extends Polygon { + + public PauseIcon(Color color, int size) { + super( + 0, size, + 0, 0, + (size * 2.0 / 5.0), 0, + (size * 2.0 / 5.0), size, + (size * 3.0 / 5.0), size, + (size * 3.0 / 5.0), 0, + size, 0, + size, size + ); + setFill(color); + } +} diff --git a/client/src/main/java/ctbrec/ui/Player.java b/client/src/main/java/ctbrec/ui/Player.java new file mode 100644 index 00000000..b3e3dbb9 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/Player.java @@ -0,0 +1,240 @@ +package ctbrec.ui; + +import com.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.PlaylistException; +import ctbrec.*; +import ctbrec.event.EventBusHolder; +import ctbrec.io.StreamRedirector; +import ctbrec.io.UrlUtil; +import ctbrec.recorder.download.StreamSource; +import ctbrec.recorder.download.hls.NoStreamFoundException; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.event.PlayerStartedEvent; +import ctbrec.variableexpansion.ModelVariableExpander; +import javafx.scene.Scene; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import javax.xml.bind.JAXBException; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.net.MalformedURLException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.ExecutionException; + +@Slf4j +public class Player { + private static PlayerThread playerThread; + private static Scene scene; + + private Player() { + } + + public static boolean play(Recording rec) { + boolean singlePlayer = Config.getInstance().getSettings().singlePlayer; + try { + if (singlePlayer && playerThread != null && playerThread.isRunning()) { + playerThread.stopThread(); + } + + playerThread = new PlayerThread(rec); + return true; + } catch (Exception e1) { + log.error("Couldn't start player", e1); + return false; + } + } + + public static boolean play(Model model) { + return play(model, true); + } + + public static boolean play(Model model, boolean async) { + try { + if (model.isOnline(true)) { + boolean singlePlayer = Config.getInstance().getSettings().singlePlayer; + if (singlePlayer && playerThread != null && playerThread.isRunning()) { + playerThread.stopThread(); + } + + EventBusHolder.BUS.post(new PlayerStartedEvent(model)); + + if (singlePlayer && playerThread != null && playerThread.isRunning()) { + playerThread.stopThread(); + } + + playerThread = new PlayerThread(model); + if (!async) { + playerThread.join(); + } + return true; + } else { + Dialogs.showError(scene, "Room not public", "Room is currently not public", null); + return false; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("Couldn't get stream information for model {}", model, e); + Dialogs.showError(scene, "Couldn't determine stream URL", e.getLocalizedMessage(), e); + return false; + } catch (Exception e) { + log.error("Couldn't get stream information for model {}", model, e); + Dialogs.showError(scene, "Couldn't determine stream URL", e.getLocalizedMessage(), e); + return false; + } + } + + public static void stop() { + if (playerThread != null) { + playerThread.stopThread(); + } + } + + private static class PlayerThread extends Thread { + @Getter + private boolean running = false; + private Process playerProcess; + private Recording rec; + private Model model; + + PlayerThread(Model model) { + this.model = model; + setName(getClass().getName()); + start(); + } + + PlayerThread(Recording rec) { + this.rec = rec; + this.model = rec.getModel(); + setName(getClass().getName()); + start(); + } + + @Override + public void run() { + running = true; + Runtime rt = Runtime.getRuntime(); + Config cfg = Config.getInstance(); + try { + if (cfg.getSettings().localRecording && rec != null) { + File file = rec.getAbsoluteFile(); + String[] cmdline = createCmdline(file.getAbsolutePath(), model); + playerProcess = rt.exec(cmdline, OS.getEnvironment(), file.getParentFile()); + } else { + String url = null; + if (rec != null) { + url = getRemoteRecordingUrl(rec, cfg); + model = rec.getModel(); + } else if (model != null) { + url = getPlaylistUrl(model); + } + log.debug("Playing {}", url); + String[] cmdline = createCmdline(url, model); + log.debug("Player command line: {}", Arrays.toString(cmdline)); + playerProcess = rt.exec(cmdline); + } + + // 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 StreamRedirector(playerProcess.getInputStream(), OutputStream.nullOutputStream())); + std.setName("Player stdout pipe"); + std.setDaemon(true); + std.start(); + Thread err = new Thread(new StreamRedirector(playerProcess.getErrorStream(), OutputStream.nullOutputStream())); + err.setName("Player stderr pipe"); + err.setDaemon(true); + err.start(); + + playerProcess.waitFor(); + log.debug("Media player finished."); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("Error in player thread", e); + Dialogs.showError(scene, "Playback failed", "Couldn't start playback", e); + } catch (Exception e) { + log.error("Error in player thread", e); + Dialogs.showError(scene, "Playback failed", "Couldn't start playback", e); + } + running = false; + } + + private static String getPlaylistUrl(Model model) throws IOException, ExecutionException, ParseException, PlaylistException, JAXBException { + List sources = model.getStreamSources(); + Collections.sort(sources); + StreamSource best; + int maxRes = Config.getInstance().getSettings().maximumResolutionPlayer; + if (maxRes > 0 && !sources.isEmpty()) { + for (Iterator iterator = sources.iterator(); iterator.hasNext(); ) { + StreamSource streamSource = iterator.next(); + if (streamSource.getHeight() > 0 && maxRes < streamSource.getHeight()) { + log.trace("Res too high {} > {}", streamSource.getHeight(), maxRes); + iterator.remove(); + } + } + } + if (sources.isEmpty()) { + throw new NoStreamFoundException("No stream left in playlist, because player resolution is set to " + maxRes); + } else { + log.debug("{} selected {}", model.getName(), sources.get(sources.size() - 1)); + best = sources.get(sources.size() - 1); + } + return best.getMediaPlaylistUrl(); + } + + private void expandPlaceHolders(String[] cmdline) { + ModelVariableExpander expander = new ModelVariableExpander(model, CamrecApplication.modelNotesService, null, null); + for (int i = 1; i < cmdline.length; i++) { + var param = cmdline[i]; + param = expander.expand(param); + cmdline[i] = param; + } + } + + private String[] createCmdline(String mediaSource, Model model) { + Config cfg = Config.getInstance(); + String params = cfg.getSettings().mediaPlayerParams.trim(); + + String[] cmdline; + if (params.isEmpty()) { + cmdline = new String[2]; + } else { + String[] playerArgs = StringUtil.splitParams(params); + cmdline = new String[playerArgs.length + 2]; + System.arraycopy(playerArgs, 0, cmdline, 1, playerArgs.length); + } + cmdline[0] = cfg.getSettings().mediaPlayer; + cmdline[cmdline.length - 1] = mediaSource; + if (model != null) { + expandPlaceHolders(cmdline); + } + return cmdline; + } + + private String getRemoteRecordingUrl(Recording rec, Config cfg) + throws MalformedURLException, InvalidKeyException, NoSuchAlgorithmException, UnsupportedEncodingException { + String hlsBase = Config.getInstance().getServerUrl() + "/hls"; + String recUrl = hlsBase + '/' + rec.getId() + (rec.isSingleFile() ? "" : "/playlist.m3u8"); + if (cfg.getSettings().requireAuthentication) { + recUrl = UrlUtil.addHmac(recUrl, cfg); + } + return recUrl; + } + + public void stopThread() { + if (playerProcess != null) { + playerProcess.destroy(); + } + } + } + + public static void setScene(Scene scene) { + Player.scene = scene; + } +} 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..40cfb937 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/PreviewPopupHandler.java @@ -0,0 +1,190 @@ +package ctbrec.ui; + +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +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 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) || !Config.getInstance().getSettings().livePreviews) { + 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 newModel = getModel(event); + if(newModel != null) { + closeCountdown = -1; + boolean modelChanged = newModel != this.model; + this.model = newModel; + 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 columnOffset = 0; + double width = 0; + for (TableColumn col : table.getColumns()) { + columnOffset += width; + width = col.getWidth(); + if(Objects.equals(col.getId(), "preview")) { + Point2D screenToLocal = table.screenToLocal(event.getScreenX(), event.getScreenY()); + double x = screenToLocal.getX(); + return x >= columnOffset && x <= columnOffset + 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(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + 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/RecordUntilDialog.java b/client/src/main/java/ctbrec/ui/RecordUntilDialog.java new file mode 100644 index 00000000..1f307ab7 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/RecordUntilDialog.java @@ -0,0 +1,105 @@ +package ctbrec.ui; + +import static ctbrec.SubsequentAction.*; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; + +import ctbrec.Config; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Model; +import ctbrec.SubsequentAction; +import ctbrec.ui.controls.DateTimePicker; +import ctbrec.ui.controls.Dialogs; +import javafx.geometry.Insets; +import javafx.geometry.VPos; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.control.RadioButton; +import javafx.scene.control.ToggleGroup; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.VBox; + +public class RecordUntilDialog { + + private static final Logger LOG = LoggerFactory.getLogger(RecordUntilDialog.class); + + private final Node source; + private final Model model; + private final GridPane gridPane = new GridPane(); + + private RadioButton pauseButton; + private RadioButton removeButton; + private DateTimePicker datePicker; + + private Config config = Config.getInstance(); + + public RecordUntilDialog(Node source, Model model) { + this.source = source; + this.model = model; + createGui(); + } + + private void createGui() { + source.setCursor(Cursor.WAIT); + datePicker = new DateTimePicker(); + gridPane.setHgap(10); + gridPane.setVgap(10); + gridPane.setPadding(new Insets(20, 150, 10, 10)); + gridPane.add(new Label("Stop at"), 0, 0); + gridPane.add(datePicker, 1, 0); + Label l = new Label("And then"); + l.setPadding(new Insets(5, 0, 0, 0)); + gridPane.add(l, 0, 1); + GridPane.setValignment(l, VPos.TOP); + var toggleGroup = new ToggleGroup(); + pauseButton = new RadioButton("pause recording"); + pauseButton.setSelected(model.getRecordUntilSubsequentAction() == PAUSE); + pauseButton.setToggleGroup(toggleGroup); + removeButton = new RadioButton("remove model"); + removeButton.setSelected(model.getRecordUntilSubsequentAction() == REMOVE); + removeButton.setToggleGroup(toggleGroup); + RadioButton recordLaterButton = new RadioButton("mark for later"); + recordLaterButton.setSelected(model.getRecordUntilSubsequentAction() == RECORD_LATER); + recordLaterButton.setToggleGroup(toggleGroup); + var row = new VBox(); + row.getChildren().addAll(pauseButton, removeButton, recordLaterButton); + VBox.setMargin(pauseButton, new Insets(5)); + VBox.setMargin(removeButton, new Insets(5)); + VBox.setMargin(recordLaterButton, new Insets(5)); + gridPane.add(row, 1, 1); + if (model.isRecordingTimeLimited()) { + var localDate = LocalDateTime.ofInstant(model.getRecordUntil(), ZoneId.systemDefault()); + datePicker.setDateTimeValue(localDate); + } else { + var localDate = LocalDateTime.now().plusMinutes(config.getSettings().recordUntilDefaultDurationInMinutes); + datePicker.setDateTimeValue(localDate); + } + } + + public boolean showAndWait() { + boolean confirmed = Dialogs.showCustomInput(source.getScene(), "Stop Recording of " + model.getDisplayName() + " at", gridPane); + if (confirmed) { + SubsequentAction action = getSubsequentAction(); + LOG.info("Stop at {} and {}", datePicker.getDateTimeValue(), action); + var stopAt = Instant.from(datePicker.getDateTimeValue().atZone(ZoneId.systemDefault())); + model.setRecordUntil(stopAt); + model.setRecordUntilSubsequentAction(action); + } + return confirmed; + } + + private SubsequentAction getSubsequentAction() { + if (pauseButton.isSelected()) { + return PAUSE; + } else if (removeButton.isSelected()) { + return REMOVE; + } else { + return RECORD_LATER; + } + } +} diff --git a/client/src/main/java/ctbrec/ui/ShutdownListener.java b/client/src/main/java/ctbrec/ui/ShutdownListener.java new file mode 100644 index 00000000..cb5d95d4 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/ShutdownListener.java @@ -0,0 +1,5 @@ +package ctbrec.ui; + +public interface ShutdownListener { + void onShutdown(); +} diff --git a/client/src/main/java/ctbrec/ui/SiteUI.java b/client/src/main/java/ctbrec/ui/SiteUI.java new file mode 100644 index 00000000..d7b75957 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/SiteUI.java @@ -0,0 +1,18 @@ +package ctbrec.ui; + +import ctbrec.Model; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; + +import java.io.IOException; + +public interface SiteUI { + + TabProvider getTabProvider(); + + ConfigUI getConfigUI(); + + boolean login() throws IOException; + + boolean play(Model model); +} diff --git a/client/src/main/java/ctbrec/ui/SiteUiFactory.java b/client/src/main/java/ctbrec/ui/SiteUiFactory.java new file mode 100644 index 00000000..187ccb0d --- /dev/null +++ b/client/src/main/java/ctbrec/ui/SiteUiFactory.java @@ -0,0 +1,168 @@ +package ctbrec.ui; + +import ctbrec.sites.Site; +import ctbrec.sites.amateurtv.AmateurTv; +import ctbrec.sites.bonga.BongaCams; +import ctbrec.sites.cam4.Cam4; +import ctbrec.sites.camsoda.Camsoda; +import ctbrec.sites.chaturbate.Chaturbate; +import ctbrec.sites.cherrytv.CherryTv; +import ctbrec.sites.dreamcam.Dreamcam; +import ctbrec.sites.fc2live.Fc2Live; +import ctbrec.sites.flirt4free.Flirt4Free; +import ctbrec.sites.jasmin.LiveJasmin; +import ctbrec.sites.manyvids.MVLive; +import ctbrec.sites.mfc.MyFreeCams; +import ctbrec.sites.secretfriends.SecretFriends; +import ctbrec.sites.showup.Showup; +import ctbrec.sites.streamate.Streamate; +import ctbrec.sites.streamray.Streamray; +import ctbrec.sites.stripchat.Stripchat; +import ctbrec.sites.winktv.WinkTv; +import ctbrec.sites.xlovecam.XloveCam; +import ctbrec.ui.sites.amateurtv.AmateurTvSiteUi; +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.cherrytv.CherryTvSiteUi; +import ctbrec.ui.sites.dreamcam.DreamcamSiteUi; +import ctbrec.ui.sites.fc2live.Fc2LiveSiteUi; +import ctbrec.ui.sites.flirt4free.Flirt4FreeSiteUi; +import ctbrec.ui.sites.jasmin.LiveJasminSiteUi; +import ctbrec.ui.sites.manyvids.MVLiveSiteUi; +import ctbrec.ui.sites.myfreecams.MyFreeCamsSiteUi; +import ctbrec.ui.sites.secretfriends.SecretFriendsSiteUi; +import ctbrec.ui.sites.showup.ShowupSiteUi; +import ctbrec.ui.sites.streamate.StreamateSiteUi; +import ctbrec.ui.sites.streamray.StreamraySiteUi; +import ctbrec.ui.sites.stripchat.StripchatSiteUi; +import ctbrec.ui.sites.winktv.WinkTvSiteUi; +import ctbrec.ui.sites.xlovecam.XloveCamSiteUi; + +public class SiteUiFactory { + + private static AmateurTvSiteUi amateurTvUi; + private static BongaCamsSiteUi bongaSiteUi; + private static Cam4SiteUi cam4SiteUi; + private static CamsodaSiteUi camsodaSiteUi; + private static ChaturbateSiteUi ctbSiteUi; + private static CherryTvSiteUi cherryTvSiteUi; + private static Fc2LiveSiteUi fc2SiteUi; + private static Flirt4FreeSiteUi flirt4FreeSiteUi; + private static LiveJasminSiteUi jasminSiteUi; + private static MVLiveSiteUi mvLiveSiteUi; + private static MyFreeCamsSiteUi mfcSiteUi; + private static SecretFriendsSiteUi secretFriendsSiteUi; + private static ShowupSiteUi showupSiteUi; + private static StreamateSiteUi streamateSiteUi; + private static StripchatSiteUi stripchatSiteUi; + private static XloveCamSiteUi xloveCamSiteUi; + private static StreamraySiteUi streamraySiteUi; + private static WinkTvSiteUi winkTvSiteUi; + private static DreamcamSiteUi dreamcamSiteUi; + + private SiteUiFactory() { + } + + public static synchronized SiteUI getUi(Site site) { // NOSONAR + if (site instanceof AmateurTv) { + if (amateurTvUi == null) { + amateurTvUi = new AmateurTvSiteUi((AmateurTv) site); + } + return amateurTvUi; + } else if (site instanceof BongaCams) { + if (bongaSiteUi == null) { + bongaSiteUi = new BongaCamsSiteUi((BongaCams) site); + } + return bongaSiteUi; + } else if (site instanceof Cam4) { + if (cam4SiteUi == null) { + cam4SiteUi = new Cam4SiteUi((Cam4) site); + } + return cam4SiteUi; + } else if (site instanceof Camsoda) { + if (camsodaSiteUi == null) { + camsodaSiteUi = new CamsodaSiteUi((Camsoda) site); + } + return camsodaSiteUi; + } else if (site instanceof Chaturbate) { + if (ctbSiteUi == null) { + ctbSiteUi = new ChaturbateSiteUi((Chaturbate) site); + } + return ctbSiteUi; + } else if (site instanceof CherryTv) { + if (cherryTvSiteUi == null) { + cherryTvSiteUi = new CherryTvSiteUi((CherryTv) site); + } + return cherryTvSiteUi; + } else if (site instanceof Dreamcam) { + if (dreamcamSiteUi == null) { + dreamcamSiteUi = new DreamcamSiteUi((Dreamcam) site); + } + return dreamcamSiteUi; + } else if (site instanceof Fc2Live) { + if (fc2SiteUi == null) { + fc2SiteUi = new Fc2LiveSiteUi((Fc2Live) site); + } + return fc2SiteUi; + } else if (site instanceof Flirt4Free) { + if (flirt4FreeSiteUi == null) { + flirt4FreeSiteUi = new Flirt4FreeSiteUi((Flirt4Free) site); + } + return flirt4FreeSiteUi; + } else if (site instanceof MVLive) { + if (mvLiveSiteUi == null) { + mvLiveSiteUi = new MVLiveSiteUi((MVLive) site); + } + return mvLiveSiteUi; + } else if (site instanceof MyFreeCams) { + if (mfcSiteUi == null) { + mfcSiteUi = new MyFreeCamsSiteUi((MyFreeCams) site); + } + return mfcSiteUi; + } else if (site instanceof SecretFriends) { + if (secretFriendsSiteUi == null) { + secretFriendsSiteUi = new SecretFriendsSiteUi((SecretFriends) site); + } + return secretFriendsSiteUi; + } else if (site instanceof Showup) { + if (showupSiteUi == null) { + showupSiteUi = new ShowupSiteUi((Showup) site); + } + return showupSiteUi; + } else if (site instanceof Streamate) { + if (streamateSiteUi == null) { + streamateSiteUi = new StreamateSiteUi((Streamate) site); + } + return streamateSiteUi; + } else if (site instanceof LiveJasmin) { + if (jasminSiteUi == null) { + jasminSiteUi = new LiveJasminSiteUi((LiveJasmin) site); + } + return jasminSiteUi; + } else if (site instanceof Stripchat) { + if (stripchatSiteUi == null) { + stripchatSiteUi = new StripchatSiteUi((Stripchat) site); + } + return stripchatSiteUi; + } else if (site instanceof Streamray) { + if (streamraySiteUi == null) { + streamraySiteUi = new StreamraySiteUi((Streamray) site); + } + return streamraySiteUi; + } else if (site instanceof WinkTv) { + if (winkTvSiteUi == null) { + winkTvSiteUi = new WinkTvSiteUi((WinkTv) site); + } + return winkTvSiteUi; + } else if (site instanceof XloveCam) { + if (xloveCamSiteUi == null) { + xloveCamSiteUi = new XloveCamSiteUi((XloveCam) site); + } + return xloveCamSiteUi; + } + 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 new file mode 100644 index 00000000..bb5b87d8 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/StreamSourceSelectionDialog.java @@ -0,0 +1,131 @@ +package ctbrec.ui; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutionException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.GlobalThreadPool; +import ctbrec.Model; +import ctbrec.recorder.download.StreamSource; +import ctbrec.ui.controls.Dialogs; +import javafx.concurrent.Task; +import javafx.concurrent.WorkerStateEvent; +import javafx.scene.Cursor; +import javafx.scene.Scene; +import javafx.scene.control.ButtonType; +import javafx.scene.control.ChoiceDialog; +import javafx.scene.image.Image; +import javafx.stage.Stage; + +public class StreamSourceSelectionDialog extends ChoiceDialog { + + private static final Logger LOG = LoggerFactory.getLogger(StreamSourceSelectionDialog.class); + + public static final StreamSource BEST = new BestStreamSource(); + public static final StreamSource LOADING = new LoadingStreamSource(); + private Scene parent; + private Model model; + private Task> loadStreamSources; + + + public StreamSourceSelectionDialog(Scene parent, Model model) { + this.parent = parent; + this.model = model; + if (parent != null) { + getDialogPane().getScene().getStylesheets().addAll(parent.getStylesheets()); + } + changeCursorTo(Cursor.WAIT); + getItems().add(LOADING); + setSelectedItem(LOADING); + setTitle("Stream Quality"); + setHeaderText("Select your preferred stream quality for " + model.getDisplayName()); + var icon = Dialogs.class.getResourceAsStream("/icon.png"); + var stage = (Stage) getDialogPane().getScene().getWindow(); + stage.getIcons().add(new Image(icon)); + setResultConverter(bt -> (bt == ButtonType.OK) ? getSelectedItem() : null); + loadStreamSources = new Task>() { + @Override + protected List call() throws Exception { + List sources = model.getStreamSources(); + Collections.sort(sources); + sources.add(BEST); + return sources; + } + }; + loadStreamSources.setOnFailed(this::onFailed); + loadStreamSources.setOnSucceeded(this::onSucceeded); + GlobalThreadPool.submit(loadStreamSources); + } + + private void onFailed(WorkerStateEvent evt) { + changeCursorTo(Cursor.DEFAULT); + try { + loadStreamSources.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOG.error("Couldn't fetch available stream sources", e); + } catch (ExecutionException e) { + LOG.error("Couldn't fetch available stream sources", e); + } + + boolean confirmed = Dialogs.showConfirmDialog("Error loading stream resolutions", "Do you want to add the model anyway?", "Stream resolutions unknown", parent); + if (confirmed) { + getItems().clear(); + getItems().add(BEST); + setSelectedItem(BEST); + } else { + close(); + } + } + + private void onSucceeded(WorkerStateEvent evt) { + changeCursorTo(Cursor.DEFAULT); + List sources; + try { + sources = loadStreamSources.get(); + getItems().remove(LOADING); + getItems().addAll(sources); + var selectedIndex = model.getStreamUrlIndex() > -1 ? Math.min(model.getStreamUrlIndex(), sources.size() - 1) : sources.size() - 1; + setSelectedItem(getItems().get(selectedIndex)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + showError(e); + } catch (ExecutionException e) { + showError(e); + } + } + + void changeCursorTo(Cursor cursor) { + parent.setCursor(cursor); + getDialogPane().setCursor(cursor); + } + + private void showError(Exception e) { + Dialogs.showError(parent, getHeaderText(), getContentText(), e); + } + + private static class BestStreamSource extends StreamSource { + @Override + public String toString() { + return "Best"; + } + } + + private static class LoadingStreamSource extends StreamSource { + @Override + public String toString() { + return "Loading..."; + } + } + + public int indexOf(StreamSource selectedSource) { + int index = -1; + if (selectedSource != LOADING && selectedSource != BEST) { + index = getItems().indexOf(selectedSource); + } + return index; + } +} diff --git a/client/src/main/java/ctbrec/ui/TimeTextFieldTest.java b/client/src/main/java/ctbrec/ui/TimeTextFieldTest.java new file mode 100644 index 00000000..ed8c405b --- /dev/null +++ b/client/src/main/java/ctbrec/ui/TimeTextFieldTest.java @@ -0,0 +1,258 @@ +package ctbrec.ui; +import java.util.regex.Pattern; + +import javafx.application.Application; +import javafx.beans.binding.Bindings; +import javafx.beans.binding.IntegerBinding; +import javafx.beans.property.ReadOnlyIntegerProperty; +import javafx.beans.property.ReadOnlyIntegerWrapper; +import javafx.geometry.Insets; +import javafx.scene.Scene; +import javafx.scene.control.IndexRange; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; + +public class TimeTextFieldTest extends Application { + + @Override + public void start(Stage primaryStage) { + VBox root = new VBox(5); + root.setPadding(new Insets(5)); + Label hrLabel = new Label(); + Label minLabel = new Label(); + Label secLabel = new Label(); + TimeTextField timeTextField = new TimeTextField(); + hrLabel.textProperty().bind(Bindings.format("Hours: %d", timeTextField.hoursProperty())); + minLabel.textProperty().bind(Bindings.format("Minutes: %d", timeTextField.minutesProperty())); + secLabel.textProperty().bind(Bindings.format("Seconds: %d", timeTextField.secondsProperty())); + + root.getChildren().addAll(timeTextField, hrLabel, minLabel, secLabel); + Scene scene = new Scene(root); + primaryStage.setScene(scene); + primaryStage.show(); + } + + public static void main(String[] args) { + launch(args); + + } + + public static class TimeTextField extends TextField { + + enum Unit { + HOURS, MINUTES, SECONDS + }; + + private final Pattern timePattern; + private final ReadOnlyIntegerWrapper hours; + private final ReadOnlyIntegerWrapper minutes; + private final ReadOnlyIntegerWrapper seconds; + + public TimeTextField() { + this("00:00:00"); + } + + public TimeTextField(String time) { + super(time); + timePattern = Pattern.compile("\\d\\d:\\d\\d:\\d\\d"); + if (!validate(time)) { + throw new IllegalArgumentException("Invalid time: " + time); + } + hours = new ReadOnlyIntegerWrapper(this, "hours"); + minutes = new ReadOnlyIntegerWrapper(this, "minutes"); + seconds = new ReadOnlyIntegerWrapper(this, "seconds"); + hours.bind(new TimeTextField.TimeUnitBinding(Unit.HOURS)); + minutes.bind(new TimeTextField.TimeUnitBinding(Unit.MINUTES)); + seconds.bind(new TimeTextField.TimeUnitBinding(Unit.SECONDS)); + } + + public ReadOnlyIntegerProperty hoursProperty() { + return hours.getReadOnlyProperty(); + } + + public int getHours() { + return hours.get(); + } + + public ReadOnlyIntegerProperty minutesProperty() { + return minutes.getReadOnlyProperty(); + } + + public int getMinutes() { + return minutes.get(); + } + + public ReadOnlyIntegerProperty secondsProperty() { + return seconds.getReadOnlyProperty(); + } + + public int getSeconds() { + return seconds.get(); + } + + @Override + public void appendText(String text) { + // Ignore this. Our text is always 8 characters long, we cannot append anything + } + + @Override + public boolean deleteNextChar() { + + boolean success = false; + + // If there's a selection, delete it: + final IndexRange selection = getSelection(); + if (selection.getLength() > 0) { + int selectionEnd = selection.getEnd(); + this.deleteText(selection); + this.positionCaret(selectionEnd); + success = true; + } else { + // If the caret preceeds a digit, replace that digit with a zero and move the caret forward. Else just move the caret forward. + int caret = this.getCaretPosition(); + if (caret % 3 != 2) { // not preceeding a colon + String currentText = this.getText(); + setText(currentText.substring(0, caret) + "0" + currentText.substring(caret + 1)); + success = true; + } + this.positionCaret(Math.min(caret + 1, this.getText().length())); + } + return success; + } + + @Override + public boolean deletePreviousChar() { + + boolean success = false; + + // If there's a selection, delete it: + final IndexRange selection = getSelection(); + if (selection.getLength() > 0) { + int selectionStart = selection.getStart(); + this.deleteText(selection); + this.positionCaret(selectionStart); + success = true; + } else { + // If the caret is after a digit, replace that digit with a zero and move the caret backward. Else just move the caret back. + int caret = this.getCaretPosition(); + if (caret % 3 != 0) { // not following a colon + String currentText = this.getText(); + setText(currentText.substring(0, caret - 1) + "0" + currentText.substring(caret)); + success = true; + } + this.positionCaret(Math.max(caret - 1, 0)); + } + return success; + } + + @Override + public void deleteText(IndexRange range) { + this.deleteText(range.getStart(), range.getEnd()); + } + + @Override + public void deleteText(int begin, int end) { + // Replace all digits in the given range with zero: + StringBuilder builder = new StringBuilder(this.getText()); + for (int c = begin; c < end; c++) { + if (c % 3 != 2) { // Not at a colon: + builder.replace(c, c + 1, "0"); + } + } + this.setText(builder.toString()); + } + + @Override + public void insertText(int index, String text) { + // Handle an insert by replacing the range from index to index+text.length() with text, if that results in a valid string: + StringBuilder builder = new StringBuilder(this.getText()); + builder.replace(index, index + text.length(), text); + final String testText = builder.toString(); + if (validate(testText)) { + this.setText(testText); + } + this.positionCaret(index + text.length()); + } + + @Override + public void replaceSelection(String replacement) { + final IndexRange selection = this.getSelection(); + if (selection.getLength() == 0) { + this.insertText(selection.getStart(), replacement); + } else { + this.replaceText(selection.getStart(), selection.getEnd(), replacement); + } + } + + @Override + public void replaceText(IndexRange range, String text) { + this.replaceText(range.getStart(), range.getEnd(), text); + } + + @Override + public void replaceText(int begin, int end, String text) { + if (begin == end) { + this.insertText(begin, text); + } else { + // only handle this if text.length() is equal to the number of characters being replaced, and if the replacement results in a valid string: + if (text.length() == end - begin) { + StringBuilder builder = new StringBuilder(this.getText()); + builder.replace(begin, end, text); + String testText = builder.toString(); + if (validate(testText)) { + this.setText(testText); + } + this.positionCaret(end); + } + } + } + + private boolean validate(String time) { + if (!timePattern.matcher(time).matches()) { + return false; + } + String[] tokens = time.split(":"); + assert tokens.length == 3; + try { + int hours = Integer.parseInt(tokens[0]); + int mins = Integer.parseInt(tokens[1]); + int secs = Integer.parseInt(tokens[2]); + if (hours < 0 || hours > 23) { + return false; + } + if (mins < 0 || mins > 59) { + return false; + } + if (secs < 0 || secs > 59) { + return false; + } + return true; + } catch (NumberFormatException nfe) { + // regex matching should assure we never reach this catch block + assert false; + return false; + } + } + + private final class TimeUnitBinding extends IntegerBinding { + + final Unit unit; + + TimeUnitBinding(Unit unit) { + this.bind(textProperty()); + this.unit = unit; + } + + @Override + protected int computeValue() { + // Crazy enum magic + String token = getText().split(":")[unit.ordinal()]; + return Integer.parseInt(token); + } + + } + + } +} diff --git a/client/src/main/java/ctbrec/ui/TipDialog.java b/client/src/main/java/ctbrec/ui/TipDialog.java new file mode 100644 index 00000000..bd2cfd00 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/TipDialog.java @@ -0,0 +1,102 @@ +package ctbrec.ui; + +import java.text.DecimalFormat; +import java.util.Objects; +import java.util.concurrent.ExecutionException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.GlobalThreadPool; +import ctbrec.sites.Site; +import javafx.application.Platform; +import javafx.concurrent.Task; +import javafx.scene.Scene; +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonType; +import javafx.scene.control.TextInputDialog; +import javafx.stage.Stage; + +public class TipDialog extends TextInputDialog { + + private static final Logger LOG = LoggerFactory.getLogger(TipDialog.class); + private Site site; + private Scene parent; + + public TipDialog(Scene parent, Site site) { + this.parent = parent; + this.site = site; + setTitle("Send Tip"); + loadTokenBalance(); + setHeaderText("Loading token balance…"); + setContentText("Amount of tokens to tip:"); + setResizable(true); + getEditor().setDisable(true); + if (parent != null) { + Stage stage = (Stage) getDialogPane().getScene().getWindow(); + stage.getScene().getStylesheets().addAll(parent.getStylesheets()); + } + } + + private void loadTokenBalance() { + Task task = new Task() { + @Override + protected Double call() throws Exception { + if (!Objects.equals(System.getenv("CTBREC_DEV"), "1")) { + SiteUiFactory.getUi(site).login(); + return site.getTokenBalance(); + } else { + return 1_000_000d; + } + } + + @Override + protected void done() { + try { + double tokens = get(); + Platform.runLater(() -> { + if (tokens <= 0) { + 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, parent, ButtonType.NO, ButtonType.YES); + buyTokens.setTitle("No tokens"); + buyTokens.setHeaderText("You don't have any tokens"); + buyTokens.showAndWait(); + TipDialog.this.close(); + if (buyTokens.getResult() == ButtonType.YES) { + DesktopIntegration.open(site.getAffiliateLink()); + } + } else { + getEditor().setDisable(false); + DecimalFormat df = new DecimalFormat("0.##"); + setHeaderText("Current token balance: " + df.format(tokens)); + } + }); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + handleExcetion(e); + } catch (ExecutionException e) { + handleExcetion(e); + } + } + }; + GlobalThreadPool.submit(task); + } + + private void handleExcetion(Exception e) { + LOG.error("Couldn't retrieve account balance", e); + showErrorDialog(e); + } + + private void showErrorDialog(Throwable throwable) { + Platform.runLater(() -> { + Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, parent); + alert.setTitle("Error"); + alert.setHeaderText("Couldn't retrieve token balance"); + alert.setContentText("Error while loading your token balance: " + throwable.getLocalizedMessage()); + alert.showAndWait(); + TipDialog.this.close(); + }); + } + +} diff --git a/client/src/main/java/ctbrec/ui/TokenLabel.java b/client/src/main/java/ctbrec/ui/TokenLabel.java new file mode 100644 index 00000000..d09d8793 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/TokenLabel.java @@ -0,0 +1,97 @@ +package ctbrec.ui; + +import com.google.common.eventbus.Subscribe; +import ctbrec.GlobalThreadPool; +import ctbrec.event.EventBusHolder; +import ctbrec.sites.Site; +import javafx.application.Platform; +import javafx.concurrent.Task; +import javafx.scene.control.Label; +import javafx.scene.control.Tooltip; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.text.DecimalFormat; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutionException; + +public class TokenLabel extends Label { + + private static final Logger LOG = LoggerFactory.getLogger(TokenLabel.class); + private double tokens = -1; + private final Site site; + + public TokenLabel(Site site) { + this.site = site; + setText("Tokens: loading…"); + EventBusHolder.BUS.register(new Object() { + @Subscribe + public void tokensUpdates(Map e) { + if (Objects.equals("tokens", e.get("event"))) { + tokens = (double) e.get("amount"); + updateText(); + } else if (Objects.equals("tokens.sent", e.get("event"))) { + tokens -= (double) e.get("amount"); + updateText(); + } + } + }); + } + + public void decrease(int tokens) { + this.tokens -= tokens; + updateText(); + } + + public void update(double tokens) { + this.tokens = tokens; + updateText(); + } + + private void updateText() { + Platform.runLater(() -> { + DecimalFormat df = new DecimalFormat("0.##"); + setText("Tokens: " + df.format(tokens)); + }); + } + + public void loadBalance() { + if (tokens != -1) { + return; + } + Task task = new Task<>() { + @Override + protected Double call() throws Exception { + if (!Objects.equals(System.getenv("CTBREC_DEV"), "1")) { + SiteUiFactory.getUi(site).login(); + return site.getTokenBalance(); + } else { + return 1_000_000d; + } + } + + @Override + protected void done() { + try { + tokens = get(); + update(tokens); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + handleException(e); + } catch (ExecutionException e) { + handleException(e); + } + } + + private void handleException(Exception e) { + LOG.error("Couldn't retrieve account balance", e); + Platform.runLater(() -> { + setText("Tokens: error"); + setTooltip(new Tooltip(e.getMessage())); + }); + } + }; + GlobalThreadPool.submit(task); + } +} diff --git a/client/src/main/java/ctbrec/ui/TrayIcon.java b/client/src/main/java/ctbrec/ui/TrayIcon.java new file mode 100644 index 00000000..133cac10 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/TrayIcon.java @@ -0,0 +1,182 @@ +package ctbrec.ui; + +import ctbrec.Config; +import ctbrec.event.EventBusHolder; +import ctbrec.recorder.Recorder; +import ctbrec.ui.controls.Dialogs; +import javafx.application.Platform; +import javafx.stage.Stage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.imageio.ImageIO; +import javax.swing.*; +import java.awt.*; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.font.LineMetrics; +import java.awt.geom.Rectangle2D; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Map; + +import static java.awt.Font.BOLD; +import static java.awt.RenderingHints.KEY_ANTIALIASING; +import static java.awt.RenderingHints.VALUE_ANTIALIAS_ON; + +public class TrayIcon { + + private static final Logger LOG = LoggerFactory.getLogger(TrayIcon.class); + + private final Stage stage; + private final Recorder recorder; + private SystemTray tray; + private java.awt.TrayIcon awtTrayIcon; + private BufferedImage background; + + public TrayIcon(Stage stage, Recorder recorder) { + this.stage = stage; + this.recorder = recorder; + } + + boolean createTrayIcon() { + if (SystemTray.isSupported()) { + if (tray == null) { + String title = CamrecApplication.title; + tray = SystemTray.getSystemTray(); + BufferedImage image = null; + try { + image = createImage(recorder.getCurrentlyRecording().size()); + } catch (Exception e) { + // fail silently + } + + PopupMenu menu = createTrayContextMenu(stage); + awtTrayIcon = new java.awt.TrayIcon(image, title, menu); + awtTrayIcon.setImageAutoSize(true); + awtTrayIcon.setToolTip(title); + try { + tray.add(awtTrayIcon); + } catch (AWTException e) { + LOG.error("Couldn't add tray icon", e); + } + awtTrayIcon.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + if (SwingUtilities.isLeftMouseButton(e)) { + toggleVisibility(stage); + } + } + }); + } + return true; + } else { + LOG.error("SystemTray notifications not supported by this OS"); + return false; + } + } + + private PopupMenu createTrayContextMenu(Stage stage) { + var menu = new PopupMenu(); + var show = new MenuItem("Show"); + show.addActionListener(evt -> restoreStage(stage)); + menu.add(show); + menu.addSeparator(); + var pauseRecording = new MenuItem("Pause recording"); + pauseRecording.addActionListener(evt -> { + try { + recorder.pause(); + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { + Dialogs.showError(stage.getScene(), "Pausing recording", "Pausing of the recorder failed", e); + } + }); + menu.add(pauseRecording); + var resumeRecording = new MenuItem("Resume recording"); + resumeRecording.addActionListener(evt -> { + try { + recorder.resume(); + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { + Dialogs.showError(stage.getScene(), "Resuming recording", "Resuming of the recorder failed", e); + } + }); + menu.add(resumeRecording); + menu.addSeparator(); + var exit = new MenuItem("Exit"); + exit.addActionListener(evt -> exit()); + menu.add(exit); + return menu; + } + + private void restoreStage(Stage stage) { + Platform.runLater(() -> { + stage.setX(Config.getInstance().getSettings().windowX); + stage.setY(Config.getInstance().getSettings().windowY); + LOG.debug("Restoring window location {},{}", stage.getX(), stage.getY()); + stage.setIconified(false); + stage.show(); + stage.toFront(); + EventBusHolder.BUS.post(Map.of("event", "stage_restored")); + }); + } + + private void toggleVisibility(Stage stage) { + if (stage.isShowing()) { + Platform.setImplicitExit(false); + Platform.runLater(stage::hide); + } else { + restoreStage(stage); + } + } + + private void exit() { + EventBusHolder.BUS.post(Map.of("event", "shutdown")); + } + + public void displayMessage(String header, String msg, java.awt.TrayIcon.MessageType info) { + createTrayIcon(); + awtTrayIcon.displayMessage(header, msg, info); + } + + public void updateActiveRecordings(int activeRecordings) { + try { + createTrayIcon(); + if (awtTrayIcon != null) { + awtTrayIcon.setImage(createImage(activeRecordings)); + } + } catch (IOException e) { + LOG.error("Couldn't update tray icon image", e); + } + } + + private BufferedImage createImage(int number) throws IOException { + if (this.background == null) { + this.background = ImageIO.read(TrayIcon.class.getResource("/icon64.png")); + } + + BufferedImage image = new BufferedImage(64, 64, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2 = (Graphics2D) image.getGraphics(); + g2.drawImage(background, 0, 0, (img, infoflags, x, y, width, height) -> false); + if (number > 0 && Config.getInstance().getSettings().showActiveRecordingsInTray) { + g2.setColor(Color.decode("#dc4444")); + g2.fillOval(0, 0, 64, 64); + + String text = String.valueOf(number); + String fontFamily = Config.getInstance().getSettings().showActiveRecordingsInTrayFont; + int fontSize = Config.getInstance().getSettings().showActiveRecordingsInTrayFontSize; + g2.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON); + Font font = new Font(fontFamily, BOLD, fontSize); + g2.setFont(font); + FontMetrics fontMetrics = g2.getFontMetrics(font); + LineMetrics lineMetrics = fontMetrics.getLineMetrics(text, g2); + Rectangle2D stringBounds = fontMetrics.getStringBounds(text, g2); + g2.setColor(Color.decode(Config.getInstance().getSettings().showActiveRecordingsInTrayColor)); + int x = (int) (image.getWidth() - stringBounds.getWidth()) / 2; + int y = (int) (((image.getHeight() - lineMetrics.getAscent()) / 2) - 8 - stringBounds.getY()); + g2.drawString(text, x, y); + g2.dispose(); + } + return image; + } +} diff --git a/client/src/main/java/ctbrec/ui/UiUtils.java b/client/src/main/java/ctbrec/ui/UiUtils.java new file mode 100644 index 00000000..a4e1cb96 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/UiUtils.java @@ -0,0 +1,33 @@ +package ctbrec.ui; + +import javafx.geometry.Bounds; +import javafx.scene.Node; +import javafx.scene.control.ContextMenu; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; + +public class UiUtils { + + private UiUtils() {} + + public static void disableRightClickFor(ContextMenu menu) { + menu.addEventFilter(MouseEvent.MOUSE_RELEASED, event -> { + if (event.getButton() == MouseButton.SECONDARY) { + event.consume(); + } + }); + } + + public static void ignoreMouseReleasedIfMouseExited(ContextMenu menu) { + menu.addEventFilter(MouseEvent.MOUSE_RELEASED, evt -> { + if (evt.getTarget() instanceof Node) { + Node target = (Node) evt.getTarget(); + Bounds screenBounds = target.localToScreen(target.getBoundsInLocal()); + boolean releasedOnOriginalMouseItem = screenBounds.contains(evt.getScreenX(), evt.getScreenY()); + if (!releasedOnOriginalMouseItem) { + evt.consume(); + } + } + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/UnicodeEmoji.java b/client/src/main/java/ctbrec/ui/UnicodeEmoji.java new file mode 100644 index 00000000..7c981df9 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/UnicodeEmoji.java @@ -0,0 +1,8 @@ +package ctbrec.ui; + +public class UnicodeEmoji { + + public static final String HEAVY_CHECK_MARK = "✔"; + public static final String CLOCK = "🕒"; + +} diff --git a/client/src/main/java/ctbrec/ui/action/AbstractAction.java b/client/src/main/java/ctbrec/ui/action/AbstractAction.java new file mode 100644 index 00000000..08d37fd7 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/AbstractAction.java @@ -0,0 +1,56 @@ +package ctbrec.ui.action; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +import ctbrec.ui.tasks.TaskExecutionException; +import javafx.application.Platform; + +public abstract class AbstractAction { + + private R result; + + public CompletableFuture> execute(P param) { + try { + result = doExecute(param); + Platform.runLater(() -> onSuccess(Optional.ofNullable(result))); + return CompletableFuture.completedFuture(Optional.of(result)); + } catch (Exception e) { + Platform.runLater(() -> onError(e)); + return CompletableFuture.failedFuture(e); + } finally { + Platform.runLater(() -> done(Optional.ofNullable(result))); + } + } + + protected abstract R doExecute(P param) throws InvalidKeyException, NoSuchAlgorithmException, IOException; + + public CompletableFuture> executeAsync(P param) { + return CompletableFuture.supplyAsync(() -> { + try { + result = doExecute(param); + Platform.runLater(() -> onSuccess(Optional.ofNullable(result))); + return Optional.of(result); + } catch (Exception e) { + Platform.runLater(() -> onError(e)); + throw new TaskExecutionException(e); + } finally { + Platform.runLater(() -> done(Optional.ofNullable(result))); + } + }); + } + + @SuppressWarnings("unchecked") + public > T beforeOnGuiThread(Runnable r) { + return (T) this; + } + + protected void onSuccess(Optional result) {} + + protected void onError(Exception e) {} + + protected void done(Optional result) {} +} diff --git a/client/src/main/java/ctbrec/ui/action/AbstractModelAction.java b/client/src/main/java/ctbrec/ui/action/AbstractModelAction.java new file mode 100644 index 00000000..1071a6bf --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/AbstractModelAction.java @@ -0,0 +1,91 @@ +package ctbrec.ui.action; + +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +import ctbrec.GlobalThreadPool; +import ctbrec.Model; +import ctbrec.recorder.Recorder; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.tasks.AbstractModelTask; +import javafx.application.Platform; +import javafx.scene.Cursor; +import javafx.scene.Node; + +public abstract class AbstractModelAction { + + protected Node source; + protected List models; + protected Recorder recorder; + private AbstractModelTask task; + + protected AbstractModelAction(Node source, List models, Recorder recorder, AbstractModelTask task) { + this.source = source; + this.models = models; + this.recorder = recorder; + this.task = task; + } + + protected CompletableFuture> execute(String errorHeader, String errorMsg) { + source.setCursor(Cursor.WAIT); + return CompletableFuture.supplyAsync(() -> internalExecute(errorHeader, errorMsg), GlobalThreadPool.get()); + } + + protected CompletableFuture> executeSync(String errorHeader, String errorMsg) { + source.setCursor(Cursor.WAIT); + return CompletableFuture.completedFuture(internalExecute(errorHeader, errorMsg)); + } + + protected List internalExecute(String errorHeader, String errorMsg) { + final List result = new ArrayList<>(models.size()); + final List> futures = new ArrayList<>(models.size()); + for (Model model : models) { + futures.add(task + .executeSync(model) + .whenComplete((mdl, ex) -> + result.add(new Result(model, ex)))); + } + Platform.runLater(() -> source.setCursor(Cursor.DEFAULT)); + checkResultForErrors(errorHeader, errorMsg, result); + return result; + } + + protected void checkResultForErrors(String errorHeader, String errorMsg, List result) { + List failed = result.stream().filter(Result::failed).collect(Collectors.toList()); + if (!failed.isEmpty()) { + Throwable t = failed.get(0).getThrowable(); + String failedModelList = failed.stream().map(Result::getModel).map(Model::getDisplayName).collect(Collectors.joining(", ")); + String msg = MessageFormat.format(errorMsg, failedModelList); + Dialogs.showError(source.getScene(), errorHeader, msg, t); + } + } + + public static class Result { + private Model model; + private Throwable throwable; + + public Result(Model model, Throwable t) { + this.model = model; + this.throwable = t; + } + + public boolean successful() { + return throwable == null; + } + + public boolean failed() { + return throwable != null; + } + + public Model getModel() { + return model; + } + + public Throwable getThrowable() { + return throwable; + } + } +} diff --git a/client/src/main/java/ctbrec/ui/action/AbstractPortraitAction.java b/client/src/main/java/ctbrec/ui/action/AbstractPortraitAction.java new file mode 100644 index 00000000..f9aeef04 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/AbstractPortraitAction.java @@ -0,0 +1,80 @@ +package ctbrec.ui.action; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.event.EventBusHolder; +import ctbrec.ui.CamrecApplication; +import javafx.scene.Node; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; + +public abstract class AbstractPortraitAction { + public static final String FORMAT = "jpg"; + + protected Node source; + protected Model model; + + protected BufferedImage convertToScaledJpg(BufferedImage original) { + java.awt.Image scaledPortrait = original.getScaledInstance(-1, 256, java.awt.Image.SCALE_SMOOTH); + BufferedImage bimage = new BufferedImage(scaledPortrait.getWidth(null), scaledPortrait.getHeight(null), BufferedImage.TYPE_INT_RGB); + Graphics2D bGr = bimage.createGraphics(); + bGr.drawImage(scaledPortrait, 0, 0, null); + bGr.dispose(); + return bimage; + } + + protected boolean store(String modelUrl, BufferedImage portrait) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + ImageIO.write(portrait, FORMAT, bytes); + CamrecApplication.portraitStore.writePortrait(modelUrl, bytes.toByteArray()); + return true; + } + + protected File getPortraitFile(String portraitId) { + File configDir = Config.getInstance().getConfigDir(); + File portraitDir = new File(configDir, "portraits"); + File output = new File(portraitDir, portraitId + '.' + FORMAT); + return output; + } + + protected BufferedImage cropImage(BufferedImage img) { + int w = img.getWidth(); + int h = img.getHeight(); + if (w > h) { + return cropSides(img); + } else { + return cropTopAndBottom(img); + } + } + + protected BufferedImage cropSides(BufferedImage img) { + int overlap = img.getWidth() - img.getHeight(); + return img.getSubimage(overlap / 2, 0, img.getHeight(), img.getHeight()); + } + + protected BufferedImage cropTopAndBottom(BufferedImage img) { + int overlap = img.getHeight() - img.getWidth(); + return img.getSubimage(0, overlap / 2, img.getWidth(), img.getWidth()); + } + + protected void firePortraitChanged() { + EventBusHolder.BUS.post(new PortraitChangedEvent(model)); + } + + public static class PortraitChangedEvent { + private final Model mdl; + + public PortraitChangedEvent(Model model) { + this.mdl = model; + } + + public Model getModel() { + return mdl; + } + } +} diff --git a/client/src/main/java/ctbrec/ui/action/AddToGroupAction.java b/client/src/main/java/ctbrec/ui/action/AddToGroupAction.java new file mode 100644 index 00000000..d1cea603 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/AddToGroupAction.java @@ -0,0 +1,206 @@ +package ctbrec.ui.action; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +import ctbrec.Model; +import ctbrec.ModelGroup; +import ctbrec.StringUtil; +import ctbrec.recorder.Recorder; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.controls.autocomplete.ObservableListSuggester; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Region; + +public class AddToGroupAction { + + private Node source; + private List models; + private Recorder recorder; + + public AddToGroupAction(Node source, Recorder recorder, List models) { + this.source = source; + this.recorder = recorder; + this.models = models; + } + + public void execute() { + execute(() -> {}); + } + + public void execute(Runnable callback) { + source.setCursor(Cursor.WAIT); + try { + var dialog = new AddModelGroupDialog(); + boolean ok = Dialogs.showCustomInput(source.getScene(), "Add model to group", dialog.getMainPane(), (obs, ov, nv) -> dialog.requestFocus()); + if (ok) { + String text = dialog.getText(); + if (StringUtil.isBlank(text)) { + return; + } + Set modelGroups = recorder.getModelGroups(); + Optional existingGroup = modelGroups.stream().filter(mg -> mg.getName().equalsIgnoreCase(text)).findFirst(); + if (existingGroup.isPresent()) { + for (Model model : models) { + existingGroup.get().add(model); + } + recorder.saveModelGroup(existingGroup.get()); + } else { + var group = new ModelGroup(); + group.setId(UUID.randomUUID()); + group.setName(text); + for (Model model : models) { + group.add(model); + } + modelGroups.add(group); + recorder.saveModelGroup(group); + } + } + } catch (IOException | InvalidKeyException | NoSuchAlgorithmException e) { + Dialogs.showError(source.getScene(), "Add model to group", "Saving model group failed", e); + } finally { + callback.run(); + source.setCursor(Cursor.DEFAULT); + } + } + + private class AddModelGroupDialog { + private ComboBox comboBox; + private TextField editor; + private ObservableListSuggester suggester; + + public String getText() { + return comboBox.getEditor().getText(); + } + + void requestFocus() { + editor.requestFocus(); + editor.positionCaret(0); + editor.selectAll(); + } + + Region getMainPane() { + var dialogPane = new GridPane(); + Set modelGroups; + modelGroups = recorder.getModelGroups(); + List comboBoxItems = modelGroups.stream().map(ModelGroupListItem::new).sorted().collect(Collectors.toList()); + ObservableList comboBoxModel = FXCollections.observableArrayList(comboBoxItems); + comboBox = new ComboBox<>(comboBoxModel); + comboBox.setEditable(true); + editor = comboBox.getEditor(); + comboBox.getEditor().addEventHandler(KeyEvent.KEY_RELEASED, evt -> { + if (evt.getCode().isLetterKey() || evt.getCode().isDigitKey()) { + autocomplete(false); + } else if (evt.getCode() == KeyCode.ENTER) { + if (editor.getSelection().getLength() > 0) { + editor.selectRange(0, 0); + editor.insertText(editor.lengthProperty().get(), ":"); + editor.positionCaret(editor.lengthProperty().get()); + evt.consume(); + } + } else if (evt.getCode() == KeyCode.SPACE && evt.isControlDown()) { + autocomplete(true); + } + }); + comboBox.setPlaceholder(new Label(" type in a name to a add a new group ")); + dialogPane.add(new Label("Model group "), 0, 0); + dialogPane.add(comboBox, 1, 0); + suggestInitialName(modelGroups); + suggester = new ObservableListSuggester(comboBoxModel); + return dialogPane; + } + + private void suggestInitialName(Set modelGroups) { + String bestName = models.get(0).getDisplayName(); + for (ModelGroup modelGroup : modelGroups) { + if (StringUtil.percentageOfEquality(bestName, modelGroup.getName()) > 70) { + bestName = modelGroup.getName(); + break; + } + } + editor.setText(bestName); + } + + private void autocomplete(boolean fulltextSearch) { + String oldtext = getOldText(); + if(oldtext.isEmpty()) { + return; + } + + Optional match; + if (fulltextSearch) { + match = suggester.fulltext(oldtext); + } else { + match = suggester.startsWith(oldtext); + } + + if (match.isPresent()) { + editor.setText(match.get()); + int pos = oldtext.length(); + editor.positionCaret(pos); + editor.selectRange(pos, match.get().length()); + } + } + + private String getOldText() { + if(editor.getSelection().getLength() > 0) { + return editor.getText().substring(0, editor.getSelection().getStart()); + } else { + return editor.getText(); + } + } + } + + private static class ModelGroupListItem implements Comparable { + private ModelGroup modelGroup; + + public ModelGroupListItem(ModelGroup modelGroup) { + this.modelGroup = modelGroup; + } + + @Override + public String toString() { + return this.modelGroup.getName(); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(modelGroup); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + ModelGroupListItem other = (ModelGroupListItem) obj; + return java.util.Objects.equals(modelGroup, other.modelGroup); + } + + @Override + public int compareTo(ModelGroupListItem o) { + return this.modelGroup.getName().compareTo(o.modelGroup.getName()); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/action/CheckModelAccountAction.java b/client/src/main/java/ctbrec/ui/action/CheckModelAccountAction.java new file mode 100644 index 00000000..47fad1c1 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/CheckModelAccountAction.java @@ -0,0 +1,85 @@ +package ctbrec.ui.action; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.GlobalThreadPool; +import ctbrec.Model; +import ctbrec.recorder.Recorder; +import ctbrec.ui.controls.Dialogs; +import javafx.application.Platform; +import javafx.scene.control.Button; + +public class CheckModelAccountAction { + private static final Logger LOG = LoggerFactory.getLogger(CheckModelAccountAction.class); + + private Button b; + + private Recorder recorder; + + private String buttonText; + + public CheckModelAccountAction(Button b, Recorder recorder) { + this.b = b; + this.recorder = recorder; + buttonText = b.getText(); + } + + public void execute(Predicate filter) { + b.setDisable(true); + Runnable checker = (() -> { + List deletedAccounts = new ArrayList<>(); + try { + checkModelAccounts(filter, deletedAccounts); + } finally { + showResult(deletedAccounts); + } + }); + GlobalThreadPool.submit(checker); + } + + private void showResult(List deletedAccounts) { + Platform.runLater(() -> { + b.setDisable(false); + b.setText(buttonText); + if (!deletedAccounts.isEmpty()) { + var sb = new StringBuilder(); + for (Model deletedModel : deletedAccounts) { + String name = deletedModel.getDisplayName() + " ".repeat(30); + name = name.substring(0, 30); + sb.append(name).append(' ').append('(').append(deletedModel.getUrl()).append(')').append('\n'); + } + boolean remove = Dialogs.showConfirmDialog("Deleted Accounts", sb.toString(), + "The following accounts seem to have been deleted. Do you want to remove them?", b.getScene()); + if (remove) { + new StopRecordingAction(b, deletedAccounts, recorder).execute(); + } + } + }); + } + + private void checkModelAccounts(Predicate filter, List deletedAccounts) { + List models = recorder.getModels().stream() // + .filter(filter) // + .collect(Collectors.toList()); + int total = models.size(); + for (var i = 0; i < total; i++) { + final int counter = i+1; + Platform.runLater(() -> b.setText(buttonText + ' ' + counter + '/' + total)); + var modelToCheck = models.get(i); + try { + if (!modelToCheck.exists()) { + deletedAccounts.add(modelToCheck); + } + } catch (IOException e) { + LOG.warn("Couldn't check, if model account still exists", e); + } + } + } +} diff --git a/client/src/main/java/ctbrec/ui/action/EditGroupAction.java b/client/src/main/java/ctbrec/ui/action/EditGroupAction.java new file mode 100644 index 00000000..363d5ee0 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/EditGroupAction.java @@ -0,0 +1,179 @@ +package ctbrec.ui.action; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +import ctbrec.Model; +import ctbrec.ModelGroup; +import ctbrec.recorder.Recorder; +import ctbrec.ui.controls.Dialogs; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.ListView; +import javafx.scene.control.TextField; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; + +public class EditGroupAction { + + private static final String DIALOG_TITLE = "Edit model group"; + + private Node source; + private Model model; + private Recorder recorder; + + public EditGroupAction(Node source, Recorder recorder, Model model) { + this.source = source; + this.recorder = recorder; + this.model = model; + } + + public void execute() { + execute(m -> {}); + } + + public void execute(Consumer callback) { + source.setCursor(Cursor.WAIT); + try { + var dialog = new EditModelGroupDialog(model); + boolean ok = Dialogs.showCustomInput(source.getScene(), DIALOG_TITLE, dialog); + if (ok) { + var group = dialog.getModelGroup(); + group.setName(dialog.getGroupName()); + group.getModelUrls().clear(); + group.getModelUrls().addAll(dialog.getUrls()); + recorder.saveModelGroup(group); + if (dialog.getUrls().isEmpty()) { + boolean delete = Dialogs.showConfirmDialog(DIALOG_TITLE, "Do you want to delete the group?", "Group is empty", source.getScene()); + if (delete) { + recorder.deleteModelGroup(group); + } + } + } + } catch (Exception e) { + Dialogs.showError(source.getScene(), DIALOG_TITLE, "Editing model group failed", e); + } finally { + Optional.ofNullable(callback).ifPresent(c -> c.accept(model)); + source.setCursor(Cursor.DEFAULT); + } + } + + private class EditModelGroupDialog extends GridPane { + private TextField groupName; + private ListView urlListView; + private ObservableList urlList; + private ModelGroup modelGroup; + private List urls; + + public EditModelGroupDialog(Model model) { + Optional optionalModelGroup = recorder.getModelGroup(model); + if (optionalModelGroup.isPresent()) { + modelGroup = optionalModelGroup.get(); + urls = new ArrayList<>(modelGroup.getModelUrls()); + createGui(modelGroup); + } else { + Dialogs.showError(getScene(), DIALOG_TITLE, "No group found for model", null); + } + } + + public ModelGroup getModelGroup() { + return modelGroup; + } + + public List getUrls() { + return urls; + } + + public String getGroupName() { + return groupName.getText(); + } + + void createGui(ModelGroup modelGroup) { + setHgap(5); + vgapProperty().bind(hgapProperty()); + + groupName = new TextField(modelGroup.getName()); + var up = createUpButton(); + var down = createDownButton(); + var add = createAddButton(); + var remove = createRemoveButton(); + var buttons = new VBox(3, up, down, add, remove); + urlList = FXCollections.observableList(urls); + urlList.addListener((ListChangeListener) change -> urls = new ArrayList<>(urlList)); + urlListView = new ListView<>(urlList); + urlListView.setPrefWidth(600); + GridPane.setHgrow(urlListView, Priority.ALWAYS); + + var row = 0; + add(groupName, 0, row++); + add(urlListView, 0, row); + add(buttons, 1, row); + + urlListView.getSelectionModel().selectedIndexProperty().addListener((obs, oldV, newV) -> { + var idx = newV.intValue(); + boolean noSelection = idx == -1; + up.setDisable(noSelection || idx == 0); + down.setDisable(noSelection || idx == urlList.size() - 1); + remove.setDisable(noSelection); + }); + } + + private Button createUpButton() { + var button = createButton("\u25B4", "Move up"); + button.setOnAction(evt -> { + int idx = urlListView.getSelectionModel().getSelectedIndex(); + String selectedItem = urlListView.getSelectionModel().getSelectedItem(); + urlList.remove(idx); + urlList.add(idx - 1, selectedItem); + urlListView.getSelectionModel().select(idx - 1); + }); + return button; + } + + private Button createDownButton() { + var button = createButton("\u25BE", "Move down"); + button.setOnAction(evt -> { + int idx = urlListView.getSelectionModel().getSelectedIndex(); + String selectedItem = urlListView.getSelectionModel().getSelectedItem(); + urlList.remove(idx); + urlList.add(idx + 1, selectedItem); + urlListView.getSelectionModel().select(idx + 1); + }); + return button; + } + + private Button createAddButton() { + var button = createButton("+", "Add selected URL"); + button.setDisable(false); + button.setOnAction(evt -> Dialogs.showTextInput(getScene(), "Add URL", "Add new model URL", "").ifPresent(urlList::add)); + return button; + } + + private Button createRemoveButton() { + var button = createButton("-", "Remove selected URL"); + button.setOnAction(evt -> { + String selectedItem = urlListView.getSelectionModel().getSelectedItem(); + if (selectedItem != null) { + urlList.remove(selectedItem); + } + }); + return button; + } + + private Button createButton(String text, String tooltip) { + var b = new Button(text); + b.setTooltip(new Tooltip(tooltip)); + b.setDisable(true); + b.setPrefSize(32, 32); + return b; + } + } +} diff --git a/client/src/main/java/ctbrec/ui/action/EditNotesAction.java b/client/src/main/java/ctbrec/ui/action/EditNotesAction.java new file mode 100644 index 00000000..8202f17e --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/EditNotesAction.java @@ -0,0 +1,57 @@ +package ctbrec.ui.action; + +import ctbrec.Model; +import ctbrec.notes.ModelNotesService; +import ctbrec.ui.CamrecApplication; +import ctbrec.ui.controls.Dialogs; +import javafx.scene.Cursor; +import javafx.scene.Node; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.Optional; + +public class EditNotesAction { + private static final Logger LOG = LoggerFactory.getLogger(EditNotesAction.class); + + private final Node source; + private final Model model; + private final Runnable callback; + + public EditNotesAction(Node source, Model selectedModel, Runnable callback) { + this.source = source; + this.model = selectedModel; + this.callback = callback; + } + + public void execute() { + source.setCursor(Cursor.WAIT); + ModelNotesService notesService = CamrecApplication.modelNotesService; + try { + String notes = notesService.loadModelNotes(model.getUrl()).orElse(""); + Optional newNotes = Dialogs.showTextInput(source.getScene(), "Model Notes", "Notes for " + model.getName(), notes); + newNotes.ifPresent(n -> { + try { + if (!n.trim().isEmpty()) { + notesService.writeModelNotes(model.getUrl(), n); + } else { + notesService.removeModelNotes(model.getUrl()); + } + } catch (IOException e) { + LOG.warn("Couldn't save config", e); + } + }); + if (callback != null) { + try { + callback.run(); + } catch (Exception e) { + LOG.error("Error while executing callback", e); + } + } + } catch (Exception e) { + Dialogs.showError(source.getScene(), "Model Notes", "Could not change model notes", e); + } + source.setCursor(Cursor.DEFAULT); + } +} 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..f60cd099 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/FollowAction.java @@ -0,0 +1,20 @@ +package ctbrec.ui.action; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import ctbrec.Model; +import ctbrec.recorder.Recorder; +import ctbrec.ui.tasks.FollowTask; +import javafx.scene.Node; + +public class FollowAction extends AbstractModelAction { + + public FollowAction(Node source, List models, Recorder recorder) { + super(source, models, recorder, new FollowTask(recorder)); + } + + public CompletableFuture> execute() { + return super.execute("Couldn't follow model", "Following of {0} failed:"); + } +} diff --git a/client/src/main/java/ctbrec/ui/action/ForcePriorityAction.java b/client/src/main/java/ctbrec/ui/action/ForcePriorityAction.java new file mode 100644 index 00000000..eccdb346 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/ForcePriorityAction.java @@ -0,0 +1,20 @@ +package ctbrec.ui.action; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import ctbrec.Model; +import ctbrec.recorder.Recorder; +import ctbrec.ui.tasks.ForcePriorityTask; +import javafx.scene.Node; + +public class ForcePriorityAction extends AbstractModelAction { + + public ForcePriorityAction(Node source, List models, Recorder recorder) { + super(source, models, recorder, new ForcePriorityTask(recorder)); + } + + public CompletableFuture> execute() { + return super.execute("Couldn't force ignoring priority", "Force priority of {0} failed:"); + } +} \ No newline at end of file diff --git a/client/src/main/java/ctbrec/ui/action/IgnoreModelsAction.java b/client/src/main/java/ctbrec/ui/action/IgnoreModelsAction.java new file mode 100644 index 00000000..e4e95531 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/IgnoreModelsAction.java @@ -0,0 +1,66 @@ +package ctbrec.ui.action; + +import java.util.List; +import java.util.function.Consumer; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.recorder.Recorder; +import ctbrec.ui.JavaFxModel; +import ctbrec.ui.action.AbstractModelAction.Result; +import ctbrec.ui.controls.Dialogs; +import javafx.scene.Node; + +public class IgnoreModelsAction { + private Node source; + private List selectedModels; + private Recorder recorder; + private boolean withRemoveDialog; + + public IgnoreModelsAction(Node source, List selectedModels, Recorder recorder, boolean withRemoveDialog) { + this.source = source; + this.selectedModels = selectedModels; + this.recorder = recorder; + this.withRemoveDialog = withRemoveDialog; + } + + public void execute() { + execute(model -> {}); + } + + public void execute(Consumer callback) { + var settings = Config.getInstance().getSettings(); + var confirmed = true; + if (settings.confirmationForDangerousActions) { + int n = selectedModels.size(); + String plural = n > 1 ? "s" : ""; + String header = "This will add " + n + " model" + plural + " to the ignore list"; + confirmed = Dialogs.showConfirmDialog("Ignore Models", "Continue?", header, source.getScene()); + } + if (confirmed) { + for (Model model : selectedModels) { + var modelToIgnore = unwrap(model); + settings.ignoredModels.add(modelToIgnore.getUrl()); + } + if (withRemoveDialog) { + boolean removeAsWell = Dialogs.showConfirmDialog("Ignore Model", null, "Remove as well?", source.getScene()); + if (removeAsWell) { + new StopRecordingAction(source, selectedModels, recorder).execute() + .whenComplete((r, ex) -> r.stream().map(Result::getModel).forEach(callback::accept)); + } + } else { + for (Model model : selectedModels) { + callback.accept(model); + } + } + } + } + + private Model unwrap(Model model) { + if (model instanceof JavaFxModel) { + return ((JavaFxModel) model).getDelegate(); + } else { + return model; + } + } +} diff --git a/client/src/main/java/ctbrec/ui/action/LaterGroupAction.java b/client/src/main/java/ctbrec/ui/action/LaterGroupAction.java new file mode 100644 index 00000000..2e1751cf --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/LaterGroupAction.java @@ -0,0 +1,51 @@ +package ctbrec.ui.action; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import ctbrec.Model; +import ctbrec.ModelGroup; +import ctbrec.recorder.Recorder; +import ctbrec.ui.controls.Dialogs; +import javafx.application.Platform; +import javafx.scene.Node; + +public class LaterGroupAction extends ModelMassEditAction { + + private Recorder recorder; + private Model model; + + public LaterGroupAction(Node source, Recorder recorder, Model model) { + super.source = source; + this.recorder = recorder; + this.model = model; + + action = m -> { + try { + if (recorder.isMarkedForLaterRecording(m) == false) { + recorder.markForLaterRecording(m, true); + } + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { + Platform.runLater(() -> Dialogs.showError(source.getScene(), "Couldn't change model state", "Mark for later recording of " + m.getName() + " failed", e)); + } + }; + } + + @Override + protected List getModels() { + Optional optionalGroup = recorder.getModelGroup(model); + if (optionalGroup.isPresent()) { + ModelGroup group = optionalGroup.get(); + return recorder.getModels().stream() // + .filter(m -> group.getModelUrls().contains(m.getUrl())) // + .collect(Collectors.toList()); + } else { + return Collections.emptyList(); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/action/MarkForLaterRecordingAction.java b/client/src/main/java/ctbrec/ui/action/MarkForLaterRecordingAction.java new file mode 100644 index 00000000..915cb952 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/MarkForLaterRecordingAction.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 MarkForLaterRecordingAction extends ModelMassEditAction { + + public MarkForLaterRecordingAction(Node source, List models, boolean recordLater, Recorder recorder) { + super(source, models); + action = m -> { + try { + recorder.markForLaterRecording(m, recordLater); + } catch (Exception e) { + Platform.runLater(() -> Dialogs.showError(source.getScene(), "Couldn't model mark model for later recording", + "Marking for later recording of " + m.getName() + " failed", 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..b342f18b --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/ModelMassEditAction.java @@ -0,0 +1,81 @@ +package ctbrec.ui.action; + +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.function.Consumer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.GlobalThreadPool; +import ctbrec.Model; +import ctbrec.ui.controls.Dialogs; +import javafx.application.Platform; +import javafx.scene.Cursor; +import javafx.scene.Node; + +public class ModelMassEditAction { + + private static final Logger LOG = LoggerFactory.getLogger(ModelMassEditAction.class); + + protected List models; + protected Consumer action; + protected Node source; + + protected ModelMassEditAction() { + } + + 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 executeSync(Consumer callback) { + Platform.runLater(() -> source.setCursor(Cursor.WAIT)); + Consumer cb = Objects.requireNonNull(callback, "Callback is null, call execute() instead"); + List> futures = new LinkedList<>(); + for (Model model : getModels()) { + futures.add(GlobalThreadPool.submit(() -> { + action.accept(model); + cb.accept(model); + })); + } + Exception ex = null; + for (Future future : futures) { + try { + future.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (ExecutionException e) { + ex = e; + } + } + if (ex != null) { + LOG.error("Error while executing model mass edit", ex); + Dialogs.showError(source.getScene(), "Error", "Error while execution action", ex); + } + Platform.runLater(() -> source.setCursor(Cursor.DEFAULT)); + } + + public void execute(Consumer callback) { + GlobalThreadPool.submit(() -> executeSync(callback)); + } + + @SuppressWarnings("unchecked") + protected List getModels() { + return (List) models; + } +} diff --git a/client/src/main/java/ctbrec/ui/action/OpenRecordingsDir.java b/client/src/main/java/ctbrec/ui/action/OpenRecordingsDir.java new file mode 100644 index 00000000..6bdb03d8 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/OpenRecordingsDir.java @@ -0,0 +1,50 @@ +package ctbrec.ui.action; + +import ctbrec.Config; +import ctbrec.GlobalThreadPool; +import ctbrec.Model; +import ctbrec.ui.DesktopIntegration; +import ctbrec.ui.controls.Dialogs; +import javafx.scene.Cursor; +import javafx.scene.Node; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.io.File; +import java.time.Instant; + +@Slf4j +@RequiredArgsConstructor +public class OpenRecordingsDir { + + private final Node source; + private final Model selectedModel; + + public void execute() { + source.setCursor(Cursor.WAIT); + var dir = Config.getInstance().getFileForRecording(selectedModel, ".mp4", Instant.now()); + dir = findDeepestExistingDir(dir); + log.info("Directory for model is {}", dir.getAbsolutePath()); + if (dir.exists()) { + final File directory = dir; + GlobalThreadPool.submit(() -> DesktopIntegration.open(directory)); + } else { + Dialogs.showError(source.getScene(), "Directory does not exist", "There are no recordings for this model", null); + } + source.setCursor(Cursor.DEFAULT); + } + + private File findDeepestExistingDir(File dir) { + while (!dir.exists()) { + if (dir.getParentFile() == null) { + return dir; + } else { + dir = dir.getParentFile(); + if (dir.exists()) { + break; + } + } + } + return dir; + } +} diff --git a/client/src/main/java/ctbrec/ui/action/OpenUrlAction.java b/client/src/main/java/ctbrec/ui/action/OpenUrlAction.java new file mode 100644 index 00000000..a0acc666 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/OpenUrlAction.java @@ -0,0 +1,15 @@ +package ctbrec.ui.action; + +import java.util.List; + +import ctbrec.Model; +import ctbrec.ui.DesktopIntegration; +import javafx.scene.Node; + +public class OpenUrlAction extends ModelMassEditAction { + + public OpenUrlAction(Node source, List models) { + super(source, models); + action = m -> DesktopIntegration.open(m.getUrl()); + } +} 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..9b0425df --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/PauseAction.java @@ -0,0 +1,20 @@ +package ctbrec.ui.action; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import ctbrec.Model; +import ctbrec.recorder.Recorder; +import ctbrec.ui.tasks.PauseRecordingTask; +import javafx.scene.Node; + +public class PauseAction extends AbstractModelAction { + + public PauseAction(Node source, List models, Recorder recorder) { + super(source, models, recorder, new PauseRecordingTask(recorder)); + } + + public CompletableFuture> execute() { + return super.execute("Couldn't pause recording", "Pausing recording of {0} failed:"); + } +} diff --git a/client/src/main/java/ctbrec/ui/action/PauseGroupAction.java b/client/src/main/java/ctbrec/ui/action/PauseGroupAction.java new file mode 100644 index 00000000..76839341 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/PauseGroupAction.java @@ -0,0 +1,49 @@ +package ctbrec.ui.action; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import ctbrec.Model; +import ctbrec.ModelGroup; +import ctbrec.recorder.Recorder; +import ctbrec.ui.controls.Dialogs; +import javafx.application.Platform; +import javafx.scene.Node; + +public class PauseGroupAction extends ModelMassEditAction { + + private Recorder recorder; + private Model model; + + public PauseGroupAction(Node source, Recorder recorder, Model model) { + super.source = source; + this.recorder = recorder; + this.model = model; + + action = m -> { + try { + recorder.suspendRecording(m); + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { + Platform.runLater(() -> Dialogs.showError(source.getScene(), "Couldn't pause model", "Pausing recording of " + m.getName() + " failed", e)); + } + }; + } + + @Override + protected List getModels() { + Optional optionalGroup = recorder.getModelGroup(model); + if (optionalGroup.isPresent()) { + ModelGroup group = optionalGroup.get(); + return recorder.getModels().stream() // + .filter(m -> group.getModelUrls().contains(m.getUrl())) // + .collect(Collectors.toList()); + } else { + return Collections.emptyList(); + } + } +} 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..631fbfe0 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/PlayAction.java @@ -0,0 +1,37 @@ +package ctbrec.ui.action; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.ui.SiteUiFactory; +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); + var t = new Thread(() -> { + var siteUI = SiteUiFactory.getUi(selectedModel.getSite()); + boolean started = siteUI.play(selectedModel); + Platform.runLater(() -> { + if (started && Config.getInstance().getSettings().showPlayerStarting) { + Toast.makeText(source.getScene(), "Starting Player", 2000, 500, 500); + } + source.setCursor(Cursor.DEFAULT); + }); + }); + t.setName("Player " + selectedModel); + t.setDaemon(true); + t.start(); + } +} diff --git a/client/src/main/java/ctbrec/ui/action/RemoveTimeLimitAction.java b/client/src/main/java/ctbrec/ui/action/RemoveTimeLimitAction.java new file mode 100644 index 00000000..20dd201d --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/RemoveTimeLimitAction.java @@ -0,0 +1,42 @@ +package ctbrec.ui.action; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.concurrent.CompletableFuture; + +import ctbrec.GlobalThreadPool; +import ctbrec.Model; +import ctbrec.recorder.Recorder; +import ctbrec.ui.controls.Dialogs; +import javafx.scene.Cursor; +import javafx.scene.Node; + +public class RemoveTimeLimitAction { + + private Model selectedModel; + private Node source; + private Recorder recorder; + + public RemoveTimeLimitAction(Node source, Model selectedModel, Recorder recorder) { + this.source = source; + this.selectedModel = selectedModel; + this.recorder = recorder; + } + + public CompletableFuture execute() { + source.setCursor(Cursor.WAIT); + var unlimited = Instant.ofEpochMilli(Model.RECORD_INDEFINITELY); + return CompletableFuture.supplyAsync(() -> { + try { + selectedModel.setRecordUntil(unlimited); + recorder.stopRecordingAt(selectedModel); + return true; + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { + Dialogs.showError(source.getScene(), "Error", "Couln't remove stop date", e); + return false; + } + }, GlobalThreadPool.get()).whenComplete((r,e) -> source.setCursor(Cursor.DEFAULT)); + } +} 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..cab269e9 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/ResumeAction.java @@ -0,0 +1,20 @@ +package ctbrec.ui.action; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import ctbrec.Model; +import ctbrec.recorder.Recorder; +import ctbrec.ui.tasks.ResumeRecordingTask; +import javafx.scene.Node; + +public class ResumeAction extends AbstractModelAction { + + public ResumeAction(Node source, List models, Recorder recorder) { + super(source, models, recorder, new ResumeRecordingTask(recorder)); + } + + public CompletableFuture> execute() { + return super.execute("Couldn't resume recording", "Resuming recording of {0} failed:"); + } +} diff --git a/client/src/main/java/ctbrec/ui/action/ResumeGroupAction.java b/client/src/main/java/ctbrec/ui/action/ResumeGroupAction.java new file mode 100644 index 00000000..3e60e88e --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/ResumeGroupAction.java @@ -0,0 +1,49 @@ +package ctbrec.ui.action; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import ctbrec.Model; +import ctbrec.ModelGroup; +import ctbrec.recorder.Recorder; +import ctbrec.ui.controls.Dialogs; +import javafx.application.Platform; +import javafx.scene.Node; + +public class ResumeGroupAction extends ModelMassEditAction { + + private Recorder recorder; + private Model model; + + public ResumeGroupAction(Node source, Recorder recorder, Model model) { + super.source = source; + this.recorder = recorder; + this.model = model; + + action = m -> { + try { + recorder.resumeRecording(m); + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { + Platform.runLater(() -> Dialogs.showError(source.getScene(), "Couldn't resume model", "Resuming recording of " + m.getName() + " failed", e)); + } + }; + } + + @Override + protected List getModels() { + Optional optionalGroup = recorder.getModelGroup(model); + if (optionalGroup.isPresent()) { + ModelGroup group = optionalGroup.get(); + return recorder.getModels().stream() // + .filter(m -> group.getModelUrls().contains(m.getUrl())) // + .collect(Collectors.toList()); + } else { + return Collections.emptyList(); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/action/ResumePriorityAction.java b/client/src/main/java/ctbrec/ui/action/ResumePriorityAction.java new file mode 100644 index 00000000..79a98d88 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/ResumePriorityAction.java @@ -0,0 +1,20 @@ +package ctbrec.ui.action; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import ctbrec.Model; +import ctbrec.recorder.Recorder; +import ctbrec.ui.tasks.ResumePriorityTask; +import javafx.scene.Node; + +public class ResumePriorityAction extends AbstractModelAction { + + public ResumePriorityAction(Node source, List models, Recorder recorder) { + super(source, models, recorder, new ResumePriorityTask(recorder)); + } + + public CompletableFuture> execute() { + return super.execute("Couldn't resume respecting priority", "Resuming priority of {0} failed:"); + } +} diff --git a/client/src/main/java/ctbrec/ui/action/SetPortraitAction.java b/client/src/main/java/ctbrec/ui/action/SetPortraitAction.java new file mode 100644 index 00000000..1bccd140 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/SetPortraitAction.java @@ -0,0 +1,104 @@ +package ctbrec.ui.action; + +import ctbrec.Model; +import ctbrec.StringUtil; +import ctbrec.ui.CamrecApplication; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.controls.FileSelectionBox; +import javafx.geometry.Insets; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.layout.GridPane; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.util.Arrays; +import java.util.function.Consumer; + +public class SetPortraitAction extends AbstractPortraitAction { + private static final Logger LOG = LoggerFactory.getLogger(SetPortraitAction.class); + + private final Consumer callback; + + public SetPortraitAction(Node source, Model selectedModel, Consumer callback) { + this.source = source; + this.model = selectedModel; + this.callback = callback; + } + + public void execute() { + source.setCursor(Cursor.WAIT); + GridPane pane = new GridPane(); + Label l = new Label("Select a portrait image. Leave empty to remove a portrait again."); + pane.add(l, 0, 0); + FileSelectionBox portraitSelectionBox = new FileSelectionBox(); + pane.add(portraitSelectionBox, 0, 1); + GridPane.setMargin(l, new Insets(5)); + GridPane.setMargin(portraitSelectionBox, new Insets(5)); + boolean accepted = Dialogs.showCustomInput(source.getScene(), "Select a portrait image", pane); + if (!accepted) { + source.setCursor(Cursor.DEFAULT); + return; + } + String selectedFile = portraitSelectionBox.fileProperty().getValue(); + + if (StringUtil.isBlank(selectedFile)) { + removePortrait(model.getUrl()); + } else { + LOG.debug("User selected {}", selectedFile); + boolean success = processImageFile(selectedFile); + if (success) { + try { + firePortraitChanged(); + runCallback(); + } catch (Exception e) { + Dialogs.showError("Set Portrait", "Couldn't change portrait image: ", e); + } + } + } + source.setCursor(Cursor.DEFAULT); + } + + private void removePortrait(String modelUrl) { + try { + CamrecApplication.portraitStore.removePortrait(modelUrl); + firePortraitChanged(); + runCallback(); + } catch (IOException e) { + Dialogs.showError("Remove Portrait", "Couldn't remove portrait image: ", e); + } + } + + private void runCallback() { + if (callback != null) { + try { + callback.accept(model); + } catch (Exception e) { + LOG.error("Error while executing callback", e); + } + } + } + + private boolean processImageFile(String selectedFile) { + try { + BufferedImage original = ImageIO.read(new File(selectedFile)); + BufferedImage croppedImage = cropImage(original); + BufferedImage portrait = convertToScaledJpg(croppedImage); + boolean success = store(model.getUrl(), portrait); + if (!success) { + LOG.debug("Available formats: {}", Arrays.toString(ImageIO.getWriterFormatNames())); + throw new IOException("No suitable writer found for image format " + FORMAT); + } + return success; + } catch (IOException e) { + LOG.error("Error while changing portrait image", e); + Dialogs.showError("Set Portrait", "Couldn't change portrait image: ", e); + return false; + } + } +} diff --git a/client/src/main/java/ctbrec/ui/action/SetStopDateAction.java b/client/src/main/java/ctbrec/ui/action/SetStopDateAction.java new file mode 100644 index 00000000..e373f204 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/SetStopDateAction.java @@ -0,0 +1,83 @@ +package ctbrec.ui.action; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.concurrent.CompletableFuture; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.GlobalThreadPool; +import ctbrec.Model; +import ctbrec.recorder.Recorder; +import ctbrec.ui.RecordUntilDialog; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.tasks.StartRecordingTask; +import javafx.application.Platform; +import javafx.scene.Cursor; +import javafx.scene.Node; + +public class SetStopDateAction { + + private static final Logger LOG = LoggerFactory.getLogger(SetStopDateAction.class); + + private Node source; + private Model model; + private Recorder recorder; + + public SetStopDateAction(Node source, Model model, Recorder recorder) { + this.source = source; + this.model = model; + this.recorder = recorder; + } + + public CompletableFuture execute() { + source.setCursor(Cursor.WAIT); + RecordUntilDialog dialog = new RecordUntilDialog(source, model); + boolean userClickedOk = dialog.showAndWait(); + return createAsyncTask(userClickedOk); + } + + private CompletableFuture createAsyncTask(boolean userClickedOk) { + return CompletableFuture.supplyAsync(() -> { + if (userClickedOk) { + setRecordingTimeLimit(); + } + return true; + }, GlobalThreadPool.get()).whenComplete((r, e) -> { + Platform.runLater(() -> source.setCursor(Cursor.DEFAULT)); + if (e != null) { + LOG.error("Error", e); + } + }); + } + + private void setRecordingTimeLimit() { + try { + if (!recorder.isTracked(model) || model.isMarkedForLaterRecording()) { + new StartRecordingTask(recorder).executeSync(model) + .thenAccept(m -> { + try { + recorder.stopRecordingAt(m); + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e1) { + showError(e1); + } + }).exceptionally(ex -> { + showError(ex); + return null; + }); + } else { + recorder.stopRecordingAt(model); + } + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { + showError(e); + } + } + + private void showError(Throwable t) { + Platform.runLater(() -> Dialogs.showError(source.getScene(), "Error", "Couln't set stop date", t)); + } + + +} diff --git a/client/src/main/java/ctbrec/ui/action/SetThumbAsPortraitAction.java b/client/src/main/java/ctbrec/ui/action/SetThumbAsPortraitAction.java new file mode 100644 index 00000000..308f00f0 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/SetThumbAsPortraitAction.java @@ -0,0 +1,49 @@ +package ctbrec.ui.action; + +import ctbrec.GlobalThreadPool; +import ctbrec.Model; +import ctbrec.ui.controls.Dialogs; +import javafx.application.Platform; +import javafx.embed.swing.SwingFXUtils; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.image.Image; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.awt.image.BufferedImage; + +public class SetThumbAsPortraitAction extends AbstractPortraitAction { + + private static final Logger LOG = LoggerFactory.getLogger(SetThumbAsPortraitAction.class); + + private final Image image; + + public SetThumbAsPortraitAction(Node source, Model model, Image image) { + this.source = source; + this.model = model; + this.image = image; + } + + public void execute() { + source.setCursor(Cursor.WAIT); + GlobalThreadPool.submit(() -> { + try { + BufferedImage bufferedImage = convertFxImageToAwt(image); + BufferedImage croppedImage = cropImage(bufferedImage); + BufferedImage portrait = convertToScaledJpg(croppedImage); + store(model.getUrl(), portrait); + firePortraitChanged(); + } catch (Exception e) { + LOG.error("Error while changing portrait image", e); + Platform.runLater(() -> Dialogs.showError("Set Portrait", "Couldn't change portrait image: ", e)); + } finally { + Platform.runLater(() -> source.setCursor(Cursor.DEFAULT)); + } + }); + } + + private BufferedImage convertFxImageToAwt(Image img) { + return SwingFXUtils.fromFXImage(img, null); + } +} 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..076d5e60 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/StartRecordingAction.java @@ -0,0 +1,81 @@ +package ctbrec.ui.action; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.recorder.Recorder; +import ctbrec.recorder.download.StreamSource; +import ctbrec.ui.RecordUntilDialog; +import ctbrec.ui.StreamSourceSelectionDialog; +import ctbrec.ui.tasks.StartRecordingTask; +import javafx.application.Platform; +import javafx.scene.Cursor; +import javafx.scene.Node; + +public class StartRecordingAction extends AbstractModelAction { + + private static final String ERROR_HEADER = "Couldn't start recording"; + private static final String ERROR_MSG = "Starting recording of {0} failed:"; + + private boolean showRecordUntilDialog = false; + + public StartRecordingAction(Node source, List models, Recorder recorder) { + super(source, models, recorder, new StartRecordingTask(recorder)); + } + + public StartRecordingAction showRecordUntilDialog() { + showRecordUntilDialog = true; + return this; + } + + public CompletableFuture> execute() { + boolean selectSource = Config.getInstance().getSettings().chooseStreamQuality; + if (selectSource || showRecordUntilDialog) { + var future = executeSync(); + Platform.runLater(() -> source.setCursor(Cursor.DEFAULT)); + return future; + } else { + return super.execute(ERROR_HEADER, ERROR_MSG); + } + } + + private CompletableFuture> executeSync() { + var result = new ArrayList(models.size()); + for (final Model model : models) { + if (showRecordUntilDialog) { + RecordUntilDialog dialog = new RecordUntilDialog(source, model); + var confirmed = dialog.showAndWait(); + if (!confirmed) { + continue; + } + } + + boolean selectSource = Config.getInstance().getSettings().chooseStreamQuality; + if (selectSource) { + var dialog = new StreamSourceSelectionDialog(source.getScene(), model); + Optional selection = dialog.showAndWait(); + if (selection.isPresent()) { + StreamSource src = selection.get(); + if (src != StreamSourceSelectionDialog.LOADING) { + int index = dialog.indexOf(src); + model.setStreamUrlIndex(index); + } + } else { + continue; + } + } + + if (showRecordUntilDialog) { + model.setSuspended(false); + } + new StartRecordingTask(recorder).executeSync(model) + .whenComplete((mdl, ex) -> result.add(new Result(mdl, ex))); + } + checkResultForErrors(ERROR_HEADER, ERROR_MSG, result); + return CompletableFuture.completedFuture(result); + } +} diff --git a/client/src/main/java/ctbrec/ui/action/StopGroupAction.java b/client/src/main/java/ctbrec/ui/action/StopGroupAction.java new file mode 100644 index 00000000..cc50a969 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/StopGroupAction.java @@ -0,0 +1,49 @@ +package ctbrec.ui.action; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import ctbrec.Model; +import ctbrec.ModelGroup; +import ctbrec.recorder.Recorder; +import ctbrec.ui.controls.Dialogs; +import javafx.application.Platform; +import javafx.scene.Node; + +public class StopGroupAction extends ModelMassEditAction { + + private Recorder recorder; + private Model model; + + public StopGroupAction(Node source, Recorder recorder, Model model) { + super.source = source; + this.recorder = recorder; + this.model = model; + + action = m -> { + try { + recorder.stopRecording(m); + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { + Platform.runLater(() -> Dialogs.showError(source.getScene(), "Couldn't stop model", "Stopping recording of " + m.getName() + " failed", e)); + } + }; + } + + @Override + protected List getModels() { + Optional optionalGroup = recorder.getModelGroup(model); + if (optionalGroup.isPresent()) { + ModelGroup group = optionalGroup.get(); + return recorder.getModels().stream() // + .filter(m -> group.getModelUrls().contains(m.getUrl())) // + .collect(Collectors.toList()); + } else { + return Collections.emptyList(); + } + } +} 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..be18e0ef --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/StopRecordingAction.java @@ -0,0 +1,20 @@ +package ctbrec.ui.action; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import ctbrec.Model; +import ctbrec.recorder.Recorder; +import ctbrec.ui.tasks.StopRecordingTask; +import javafx.scene.Node; + +public class StopRecordingAction extends AbstractModelAction { + + public StopRecordingAction(Node source, List models, Recorder recorder) { + super(source, models, recorder, new StopRecordingTask(recorder)); + } + + public CompletableFuture> execute() { + return super.execute("Couldn't stop recording", "Stopping recording of {0} failed:"); + } +} diff --git a/client/src/main/java/ctbrec/ui/action/SwitchStreamResolutionAction.java b/client/src/main/java/ctbrec/ui/action/SwitchStreamResolutionAction.java new file mode 100644 index 00000000..c8a438c6 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/SwitchStreamResolutionAction.java @@ -0,0 +1,83 @@ +package ctbrec.ui.action; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +import org.eclipse.jetty.io.RuntimeIOException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.GlobalThreadPool; +import ctbrec.Model; +import ctbrec.recorder.Recorder; +import ctbrec.recorder.download.StreamSource; +import ctbrec.sites.ModelOfflineException; +import ctbrec.ui.StreamSourceSelectionDialog; +import ctbrec.ui.controls.Dialogs; +import javafx.application.Platform; +import javafx.scene.Cursor; +import javafx.scene.Node; + +public class SwitchStreamResolutionAction { + private static final Logger LOG = LoggerFactory.getLogger(SwitchStreamResolutionAction.class); + + private Node source; + private Model selectedModel; + private Recorder recorder; + + public SwitchStreamResolutionAction(Node source, Model selectedModel, Recorder recorder) { + this.source = source; + this.selectedModel = selectedModel; + this.recorder = recorder; + } + + public CompletableFuture execute() { + source.setCursor(Cursor.WAIT); + var couldntSwitchHeaderText = "Couldn't switch stream resolution"; + + return CompletableFuture.supplyAsync(() -> { + checkOnlineState(); + return selectedModel; + }, GlobalThreadPool.get()) + .thenAccept(m -> Platform.runLater(() -> { + StreamSourceSelectionDialog dialog = new StreamSourceSelectionDialog(source.getScene(), selectedModel); + Optional selectedSource = dialog.showAndWait(); + if (selectedSource.isPresent()) { + StreamSource src = selectedSource.get(); + if (src != StreamSourceSelectionDialog.LOADING) { + int index = dialog.indexOf(selectedSource.get()); + selectedModel.setStreamUrlIndex(index); + try { + recorder.switchStreamSource(selectedModel); + } catch (InvalidKeyException | NoSuchAlgorithmException | IllegalStateException | IOException e) { + LOG.error(couldntSwitchHeaderText, e); + Dialogs.showError(source.getScene(), "Couldn't switch stream resolution", "Error while switching stream resolution", e); + } + } + } + source.setCursor(Cursor.DEFAULT); + })) + .exceptionally(ex -> { + Dialogs.showError(source.getScene(), couldntSwitchHeaderText, "The resolution can only be changed when the model is online", null); + Platform.runLater(() -> source.setCursor(Cursor.DEFAULT)); + return null; + }); + } + + private void checkOnlineState() { + try { + if (!selectedModel.isOnline(true)) { + throw new ModelOfflineException(selectedModel); + } + } catch (IOException | ExecutionException e) { + throw new RuntimeIOException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); // NOSONAR + } + } +} diff --git a/client/src/main/java/ctbrec/ui/action/TipAction.java b/client/src/main/java/ctbrec/ui/action/TipAction.java new file mode 100644 index 00000000..1270b850 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/TipAction.java @@ -0,0 +1,60 @@ +package ctbrec.ui.action; + +import static ctbrec.ui.controls.Dialogs.*; + +import java.io.IOException; +import java.text.DecimalFormat; +import java.util.HashMap; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Model; +import ctbrec.event.EventBusHolder; +import ctbrec.ui.SiteUiFactory; +import ctbrec.ui.TipDialog; +import javafx.scene.Cursor; +import javafx.scene.Node; + +public class TipAction { + + private static final Logger LOG = LoggerFactory.getLogger(TipAction.class); + + private Model model; + private Node source; + + public TipAction(Model model, Node source) { + this.model = model; + this.source = source; + } + + public void execute() { + source.setCursor(Cursor.WAIT); + try { + var site = model.getSite(); + var tipDialog = new TipDialog(source.getScene(), site); + tipDialog.showAndWait(); + String tipText = tipDialog.getResult(); + if (tipText != null) { + var df = new DecimalFormat("0.##"); + try { + Number tokens = df.parse(tipText); + SiteUiFactory.getUi(site).login(); + model.receiveTip(tokens.doubleValue()); + Map event = new HashMap<>(); + event.put("event", "tokens.sent"); + event.put("amount", tokens.doubleValue()); + EventBusHolder.BUS.post(event); + } catch (IOException ex) { + LOG.error("An error occurred while sending tip", ex); + showError(source.getScene(), "Couldn't send tip", "An error occurred while sending tip:", ex); + } catch (Exception ex) { + showError(source.getScene(), "Couldn't send tip", "You entered an invalid amount of tokens", ex); + } + } + } finally { + source.setCursor(Cursor.DEFAULT); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/action/ToggleRecordingAction.java b/client/src/main/java/ctbrec/ui/action/ToggleRecordingAction.java new file mode 100644 index 00000000..0e550f50 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/ToggleRecordingAction.java @@ -0,0 +1,41 @@ +package ctbrec.ui.action; + +import ctbrec.GlobalThreadPool; +import ctbrec.recorder.Recorder; +import ctbrec.ui.controls.Dialogs; +import javafx.application.Platform; +import javafx.scene.Cursor; +import javafx.scene.control.ToggleButton; + +public class ToggleRecordingAction { + + private ToggleButton toggleButton; + private Recorder recorder; + private boolean pause; + + public ToggleRecordingAction(ToggleButton toggleButton, Recorder recorder) { + this.toggleButton = toggleButton; + this.recorder = recorder; + pause = toggleButton.isSelected(); + } + + public void execute() { + toggleButton.setCursor(Cursor.WAIT); + GlobalThreadPool.submit(() -> { + try { + if (pause) { + recorder.pause(); + Platform.runLater(() -> toggleButton.setText("Resume Recorder")); + } else { + recorder.resume(); + Platform.runLater(() -> toggleButton.setText("Pause Recorder")); + } + } catch (Exception e) { + Dialogs.showError(toggleButton.getScene(), "Toggle Recorder", "An error ocurred while toggling the recorder", e); + Platform.runLater(() -> toggleButton.setSelected(!toggleButton.isSelected())); + } finally { + Platform.runLater(() -> toggleButton.setCursor(Cursor.DEFAULT)); + } + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/action/TriConsumer.java b/client/src/main/java/ctbrec/ui/action/TriConsumer.java new file mode 100644 index 00000000..0125400b --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/TriConsumer.java @@ -0,0 +1,7 @@ +package ctbrec.ui.action; + +@FunctionalInterface +public interface TriConsumer { + + void accept(T t, U u, V v); +} diff --git a/client/src/main/java/ctbrec/ui/action/UnfollowAction.java b/client/src/main/java/ctbrec/ui/action/UnfollowAction.java new file mode 100644 index 00000000..2a7e81d8 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/action/UnfollowAction.java @@ -0,0 +1,20 @@ +package ctbrec.ui.action; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import ctbrec.Model; +import ctbrec.recorder.Recorder; +import ctbrec.ui.tasks.UnfollowTask; +import javafx.scene.Node; + +public class UnfollowAction extends AbstractModelAction { + + public UnfollowAction(Node source, List models, Recorder recorder) { + super(source, models, recorder, new UnfollowTask(recorder)); + } + + public CompletableFuture> execute() { + return super.execute("Couldn't unfollow model", "Unfollowing of {0} failed:"); + } +} 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..b43daa62 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/AbstractFileSelectionBox.java @@ -0,0 +1,167 @@ +package ctbrec.ui.controls; + +import java.io.File; +import java.io.IOException; +import java.util.Objects; + +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.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 Logger LOG = LoggerFactory.getLogger(AbstractFileSelectionBox.class); + + private final StringProperty fileProperty = new SimpleStringProperty(); + private final Tooltip validationError = new Tooltip(); + protected TextField fileInput; + protected boolean allowEmptyValue = false; + protected boolean saveDialog = false; + protected boolean validationDisabled = false; + + protected AbstractFileSelectionBox() { + super(5); + fileInput = new TextField(); + fileInput.textProperty().addListener(textListener()); + fileInput.focusedProperty().addListener((obs, o, n) -> { + if (Objects.equals(Boolean.FALSE, n)) { + validationError.hide(); + } + }); + var browse = createBrowseButton(); + browse.disableProperty().bind(disableProperty()); + browse.prefHeightProperty().bind(fileInput.prefWidthProperty()); + fileInput.disableProperty().bind(disableProperty()); + fileInput.textProperty().bindBidirectional(fileProperty); + getChildren().addAll(fileInput, browse); + HBox.setHgrow(fileInput, Priority.ALWAYS); + + disabledProperty().addListener((obs, oldV, newV) -> { + if (Objects.equals(Boolean.TRUE, newV)) { + hideValidationHints(); + } else { + if (StringUtil.isNotBlank(fileInput.getText())) { + setFile(new File(fileInput.getText())); + } + } + }); + } + + protected AbstractFileSelectionBox(String initialValue) { + this(); + fileInput.setText(initialValue); + } + + protected ChangeListener textListener() { + return (obs, o, n) -> { + var input = fileInput.getText(); + if (StringUtil.isBlank(input)) { + if (allowEmptyValue) { + fileProperty.set(""); + hideValidationHints(); + } + } else { + var program = new File(input); + setFile(program); + } + }; + } + + protected void setFile(File file) { + var 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); + var 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 (isDisabled() || validationDisabled) { + return null; + } + + if (file == null || !file.exists()) { + return "File does not exist"; + } else { + return null; + } + } + + public void allowEmptyValue() { + this.allowEmptyValue = true; + } + + public void useSaveDialog() { + this.saveDialog = true; + } + + public void disableValidation() { + validationDisabled = true; + } + + private Button createBrowseButton() { + var button = new Button("Select"); + button.setOnAction(e -> choose()); + button.prefHeightProperty().bind(this.heightProperty()); + button.prefWidthProperty().set(70); + return button; + } + + protected void choose() { + var chooser = new FileChooser(); + File program; + if (saveDialog) { + program = chooser.showSaveDialog(null); + } else { + program = chooser.showOpenDialog(null); + } + if (program != null) { + try { + fileInput.setText(program.getCanonicalPath()); + } catch (IOException e1) { + LOG.error("Couldn't determine path", e1); + var alert = new AutosizeAlert(Alert.AlertType.ERROR, getScene()); + 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/controls/CustomMouseBehaviorContextMenu.java b/client/src/main/java/ctbrec/ui/controls/CustomMouseBehaviorContextMenu.java new file mode 100644 index 00000000..a0c90890 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/CustomMouseBehaviorContextMenu.java @@ -0,0 +1,17 @@ +package ctbrec.ui.controls; + +import ctbrec.ui.UiUtils; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.MenuItem; + +public class CustomMouseBehaviorContextMenu extends ContextMenu { + + public CustomMouseBehaviorContextMenu(MenuItem...items) { + super(items); + UiUtils.disableRightClickFor(this); + UiUtils.ignoreMouseReleasedIfMouseExited(this); + setAutoHide(true); + setHideOnEscape(true); + setAutoFix(true); + } +} diff --git a/client/src/main/java/ctbrec/ui/controls/DateTimeCellFactory.java b/client/src/main/java/ctbrec/ui/controls/DateTimeCellFactory.java new file mode 100644 index 00000000..50f5f0ad --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/DateTimeCellFactory.java @@ -0,0 +1,35 @@ +package ctbrec.ui.controls; + +import javafx.scene.control.TableCell; +import javafx.scene.control.TableColumn; +import javafx.util.Callback; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +public class DateTimeCellFactory implements Callback, TableCell> { + + private final DateTimeFormatter formatter; + + public DateTimeCellFactory (DateTimeFormatter formatter) { + this.formatter = formatter; + } + + @Override + public TableCell call(TableColumn param) { + return new TableCell() { + @Override + protected void updateItem(Instant item, boolean empty) { + if (empty || item == null) { + setText(""); + } else { + var dateTime = LocalDateTime.ofInstant(item, ZoneId.systemDefault()); + String formattedDateTime = formatter.format(dateTime); + setText(item.equals(Instant.EPOCH) ? "" : formattedDateTime); + } + } + }; + } +} diff --git a/client/src/main/java/ctbrec/ui/controls/DateTimePicker.java b/client/src/main/java/ctbrec/ui/controls/DateTimePicker.java new file mode 100644 index 00000000..5814a516 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/DateTimePicker.java @@ -0,0 +1,115 @@ + +package ctbrec.ui.controls; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.scene.control.DatePicker; +import javafx.util.StringConverter; + +/** + * A DateTimePicker with configurable datetime format where both date and time can be changed + * via the text field and the date can additionally be changed via the JavaFX default date picker. + * + * @author Edvin Syse + * @author Rene Fischer + * + * copyright TornadoFX + * license Apache 2.0 + * + */ +public class DateTimePicker extends DatePicker { + private static final Logger LOG = LoggerFactory.getLogger(DateTimePicker.class); + public static final String DefaultFormat = "yyyy-MM-dd HH:mm"; + + private DateTimeFormatter formatter; + private ObjectProperty dateTimeValue = new SimpleObjectProperty<>(LocalDateTime.now()); + private ObjectProperty format = new SimpleObjectProperty() { + @Override + public void set(String newValue) { + super.set(newValue); + formatter = DateTimeFormatter.ofPattern(newValue); + } + }; + + public DateTimePicker() { + getStyleClass().add("datetime-picker"); + setFormat(DefaultFormat); + setConverter(new InternalConverter()); + + // Syncronize changes to the underlying date value back to the dateTimeValue + valueProperty().addListener((observable, oldValue, newValue) -> { + if (newValue == null) { + dateTimeValue.set(null); + } else { + if (dateTimeValue.get() == null) { + dateTimeValue.set(LocalDateTime.of(newValue, LocalTime.now())); + } else { + var time = dateTimeValue.get().toLocalTime(); + dateTimeValue.set(LocalDateTime.of(newValue, time)); + } + } + LOG.debug("{} {}", newValue, dateTimeValue); + }); + + editorProperty().get().textProperty().addListener((obs, ov, nv) -> { + try { + getConverter().fromString(nv); + } catch (Exception e) { + } + }); + + // Syncronize changes to dateTimeValue back to the underlying date value + dateTimeValue.addListener((observable, oldValue, newValue) -> setValue(newValue == null ? null : newValue.toLocalDate())); + } + + public LocalDateTime getDateTimeValue() { + return dateTimeValue.get(); + } + + public void setDateTimeValue(LocalDateTime dateTimeValue) { + this.dateTimeValue.set(dateTimeValue); + } + + public ObjectProperty dateTimeValueProperty() { + return dateTimeValue; + } + + public String getFormat() { + return format.get(); + } + + public ObjectProperty formatProperty() { + return format; + } + + public void setFormat(String format) { + this.format.set(format); + } + + class InternalConverter extends StringConverter { + @Override + public String toString(LocalDate object) { + LocalDateTime value = getDateTimeValue(); + return (value != null) ? value.format(formatter) : ""; + } + + @Override + public LocalDate fromString(String value) { + if (value == null) { + dateTimeValue.set(null); + return null; + } + + dateTimeValue.set(LocalDateTime.parse(value, formatter)); + return dateTimeValue.get().toLocalDate(); + } + } +} 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..340cfca0 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/Dialogs.java @@ -0,0 +1,138 @@ +package ctbrec.ui.controls; + +import ctbrec.ui.AutosizeAlert; +import javafx.application.Platform; +import javafx.beans.value.ChangeListener; +import javafx.geometry.Insets; +import javafx.scene.Scene; +import javafx.scene.control.*; +import javafx.scene.control.Alert.AlertType; +import javafx.scene.image.Image; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Region; +import javafx.stage.Modality; +import javafx.stage.Stage; + +import java.io.InputStream; +import java.util.Optional; + +import static javafx.scene.control.ButtonType.*; + +public class Dialogs { + + private Dialogs() {} + + private static Scene scene; + + public static void setScene(Scene scene) { + Dialogs.scene = scene; + } + + public static void showError(String header, String text, Throwable t) { + showError(scene, header, text, t); + } + + public static void showError(Scene parent, String header, String text, Throwable t) { + Runnable r = () -> { + Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, parent); + alert.setTitle("Error"); + alert.setHeaderText(header); + String content = text; + if (t != null) { + content += " " + t.getLocalizedMessage(); + } + alert.setContentText(content); + if (parent != null) { + var stage = (Stage) alert.getDialogPane().getScene().getWindow(); + stage.getScene().getStylesheets().addAll(parent.getStylesheets()); + } + alert.showAndWait(); + }; + + if (Platform.isFxApplicationThread()) { + r.run(); + } else { + Platform.runLater(r); + } + } + + public static Optional showTextInput(Scene parent, String title, String header, String text) { + Dialog dialog = new Dialog<>(); + dialog.setTitle(title); + dialog.setHeaderText(header); + dialog.getDialogPane().getButtonTypes().addAll(OK, CANCEL); + dialog.initModality(Modality.APPLICATION_MODAL); + dialog.setResizable(true); + InputStream icon = Dialogs.class.getResourceAsStream("/icon.png"); + var stage = (Stage) dialog.getDialogPane().getScene().getWindow(); + stage.getIcons().add(new Image(icon)); + if (parent != null) { + stage.getScene().getStylesheets().addAll(parent.getStylesheets()); + } + + var grid = new GridPane(); + grid.setHgap(10); + grid.setVgap(10); + grid.setPadding(new Insets(20, 150, 10, 10)); + + var notes = new TextArea(text); + notes.setPrefRowCount(3); + grid.add(notes, 0, 0); + dialog.getDialogPane().setContent(grid); + + Platform.runLater(notes::requestFocus); + + dialog.setResultConverter(dialogButton -> { + if (dialogButton == OK) { + return notes.getText(); + } + return null; + }); + + return dialog.showAndWait(); + } + + @SafeVarargs + public static Boolean showCustomInput(Scene parent, String title, Region region, ChangeListener ...showingListener) { + Dialog dialog = new Dialog<>(); + dialog.setTitle(title); + dialog.getDialogPane().getButtonTypes().addAll(OK, CANCEL); + dialog.initModality(Modality.APPLICATION_MODAL); + dialog.setResizable(true); + InputStream icon = Dialogs.class.getResourceAsStream("/icon.png"); + var stage = (Stage) dialog.getDialogPane().getScene().getWindow(); + stage.getIcons().add(new Image(icon)); + if (parent != null) { + stage.getScene().getStylesheets().addAll(parent.getStylesheets()); + } + dialog.getDialogPane().setContent(region); + for (ChangeListener changeListener : showingListener) { + dialog.showingProperty().addListener(changeListener); + } + dialog.showAndWait(); + return dialog.getResult() == OK; + } + + public static boolean showConfirmDialog(String title, String message, String header, Scene parent) { + var confirm = new AutosizeAlert(AlertType.CONFIRMATION, message, parent, YES, NO); + confirm.setTitle(title); + confirm.setHeaderText(header); + confirm.showAndWait(); + return confirm.getResult() == YES; + } + + public static ButtonType showShutdownDialog(Scene parent) { + var message = "There are recordings in progress"; + var confirm = new AutosizeAlert(AlertType.CONFIRMATION, "", parent, YES, FINISH, NO); + confirm.setTitle("Shutdown"); + confirm.setHeaderText(message); + ((Button) confirm.getDialogPane().lookupButton(ButtonType.YES)).setText("Shutdown Now"); + ((Button) confirm.getDialogPane().lookupButton(ButtonType.YES)).setDefaultButton(false); + ((Button) confirm.getDialogPane().lookupButton(ButtonType.FINISH)).setText("Shutdown Gracefully"); + ((Button) confirm.getDialogPane().lookupButton(ButtonType.FINISH)).setDefaultButton(true); + ((Button) confirm.getDialogPane().lookupButton(ButtonType.NO)).setText("Keep Running"); + ((Button) confirm.getDialogPane().lookupButton(ButtonType.NO)).setDefaultButton(false); + confirm.showAndWait(); + return confirm.getResult(); + } +} 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..ec9c858d --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/DirectorySelectionBox.java @@ -0,0 +1,43 @@ +package ctbrec.ui.controls; + +import java.io.File; +import java.util.Optional; + +import javafx.stage.DirectoryChooser; + +public class DirectorySelectionBox extends AbstractFileSelectionBox { + public DirectorySelectionBox(String dir) { + super(dir); + } + + @Override + protected void choose() { + var chooser = new DirectoryChooser(); + var preselection = Optional.ofNullable(fileProperty().get()).orElse("."); + var currentDir = new File(preselection); + 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) { + if (isDisabled()) { + return null; + } + + 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/FasterVerticalScrollPaneSkin.java b/client/src/main/java/ctbrec/ui/controls/FasterVerticalScrollPaneSkin.java new file mode 100644 index 00000000..8d4e6e4d --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/FasterVerticalScrollPaneSkin.java @@ -0,0 +1,31 @@ +package ctbrec.ui.controls; + +import javafx.scene.control.ScrollPane; +import javafx.scene.control.skin.ScrollPaneSkin; +import javafx.scene.input.ScrollEvent; + +public class FasterVerticalScrollPaneSkin extends ScrollPaneSkin { + + public FasterVerticalScrollPaneSkin(final ScrollPane scrollPane) { + super(scrollPane); + + getSkinnable().addEventFilter(ScrollEvent.SCROLL, event -> { + var ratio = scrollPane.getViewportBounds().getHeight() / scrollPane.getContent().getBoundsInLocal().getHeight(); + var baseUnitIncrement = 0.15; + var unitIncrement = baseUnitIncrement * ratio * 1.25; + getVerticalScrollBar().setUnitIncrement(unitIncrement); + + if (event.getDeltaX() < 0) { + getHorizontalScrollBar().increment(); + } else if (event.getDeltaX() > 0) { + getHorizontalScrollBar().decrement(); + } + if (event.getDeltaY() < 0) { + getVerticalScrollBar().increment(); + } else if (event.getDeltaY() > 0) { + getVerticalScrollBar().decrement(); + } + event.consume(); + }); + } +} 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..caf3efd5 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/FileSelectionBox.java @@ -0,0 +1,28 @@ +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) { + if (isDisabled() || validationDisabled) { + return null; + } + + 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..47f0eed9 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/Popover.java @@ -0,0 +1,456 @@ +/* + * 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.Point2D; +import javafx.scene.Node; +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(); + } + }; + } + + /** + * 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) { + var n = page.getPageNode(); + if (n != null) { + var insets = getInsets(); + return insets.getLeft() + n.minWidth(-1) + insets.getRight(); + } + } + return 200; + } + + @Override protected double computeMinHeight(double width) { + var insets = getInsets(); + return insets.getLeft() + 100 + insets.getRight(); + } + + @Override protected double computePrefWidth(double height) { + Page page = pages.isEmpty() ? null : pages.getFirst(); + if (page != null) { + var n = page.getPageNode(); + if (n != null) { + var 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) { + var page = pages.getFirst(); + if (page != null) { + var 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) { + var 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 var 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 var 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); + + var pageX = 0; + for (Node page : pagesPane.getChildren()) { + page.resizeRelocate(pageX, 0, pageWidth, pageHeight); + pageX += pageWidth + PAGE_GAP; + } + + int buttonHeight = Math.min(30, (int) (leftButton.prefHeight(-1))); + final int buttonTop = (int) ((top - buttonHeight) / 2d); + final int leftButtonWidth = (int) snapSizeX(leftButton.prefWidth(-1)); + leftButton.resizeRelocate(left, (double)buttonTop + offset, leftButtonWidth, buttonHeight); + final int rightButtonWidth = (int) snapSizeX(rightButton.prefWidth(-1)); + rightButton.resizeRelocate(width - (double)right - rightButtonWidth, (double)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() { + var oldPage = pages.pop(); + oldPage.handleHidden(); + oldPage.setPopover(null); + var page = pages.getFirst(); + leftButton.setVisible(page.leftButtonText() != null); + leftButton.setText(page.leftButtonText()); + rightButton.setVisible(page.rightButtonText() != null); + rightButton.setText(page.rightButtonText()); + if (!pages.isEmpty()) { + final var 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 var pageNode = page.getPageNode(); + pageNode.setManaged(false); + pagesPane.getChildren().add(pageNode); + final var 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 + (pageWidth - title.getLayoutBounds().getWidth()) / 2d); + titlesPane.getChildren().add(title); + + if (!pages.isEmpty() && isVisible()) { + final var 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 var 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); + + if (fadeAnimation != null) { + fadeAnimation.stop(); + setVisible(true); // for good measure + } else { + popoverHeight.set(-1); + setVisible(true); + } + + var fade = new FadeTransition(Duration.seconds(.1), this); + fade.setToValue(1.0); + fade.setOnFinished((ActionEvent event) -> fadeAnimation = null); + + var scale = new ScaleTransition(Duration.seconds(.1), this); + scale.setToX(1); + scale.setToY(1); + + var tx = new ParallelTransition(fade, scale); + fadeAnimation = tx; + tx.play(); + } + } + + public void hide(){ + if (isVisible() || fadeAnimation != null) { + getScene().removeEventFilter(MouseEvent.MOUSE_CLICKED, popoverHideHandler); + + if (fadeAnimation != null) { + fadeAnimation.stop(); + } + + var fade = new FadeTransition(Duration.seconds(.1), this); + fade.setToValue(0); + fade.setOnFinished((ActionEvent event) -> { + fadeAnimation = null; + setVisible(false); + if (onHideCallback != null) onHideCallback.run(); + }); + + var scale = new ScaleTransition(Duration.seconds(.1), this); + scale.setToX(.8); + scale.setToY(.8); + + var 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..99fae047 --- /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) { /* nothing to do */ } + + 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..d2628a8a --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/ProgramSelectionBox.java @@ -0,0 +1,28 @@ +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) { + if (isDisabled()) { + return null; + } + + 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/RecordingIndicator.java b/client/src/main/java/ctbrec/ui/controls/RecordingIndicator.java new file mode 100644 index 00000000..720f40b7 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/RecordingIndicator.java @@ -0,0 +1,36 @@ +package ctbrec.ui.controls; + +import javafx.scene.Cursor; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Paint; +import javafx.scene.shape.Rectangle; + +public class RecordingIndicator extends StackPane { + + private ImageView icon; + private Rectangle clickPanel; + + public RecordingIndicator(int size) { + setMaxSize(size, size); + + icon = new ImageView(); + icon.setVisible(false); + icon.prefHeight(size); + icon.prefWidth(size); + icon.maxHeight(size); + icon.maxWidth(size); + clickPanel = new Rectangle(size, size); + clickPanel.setCursor(Cursor.HAND); + clickPanel.setFill(Paint.valueOf("#00000000")); + getChildren().add(icon); + getChildren().add(clickPanel); + + icon.visibleProperty().bindBidirectional(visibleProperty()); + } + + public void setImage(Image img) { + icon.setImage(img); + } +} diff --git a/client/src/main/java/ctbrec/ui/controls/SaveFileSelectionBox.java b/client/src/main/java/ctbrec/ui/controls/SaveFileSelectionBox.java new file mode 100644 index 00000000..402a75ce --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/SaveFileSelectionBox.java @@ -0,0 +1,28 @@ +package ctbrec.ui.controls; + +import javafx.beans.value.ChangeListener; + +import java.io.File; + +public class SaveFileSelectionBox extends FileSelectionBox { + + public SaveFileSelectionBox() { + disableValidation(); + useSaveDialog(); + } + + @Override + protected void setFile(File file) { + // nothing to do + } + + @Override + protected ChangeListener textListener() { + return (obs, o, n) -> {}; + } + + @Override + protected String validate(File file) { + 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..2a9fd739 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/SearchBox.java @@ -0,0 +1,103 @@ +/* + * 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.KeyCode; +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 static final int PREF_HEIGHT = 26; + private final Button clearButton = new Button(); + private final Region innerBackground = new Region(); + private final Region icon = new Region(); + + public SearchBox() { + getStyleClass().addAll("search-box"); + icon.getStyleClass().setAll("search-box-icon"); + innerBackground.getStyleClass().setAll("search-box-inner"); + setPromptText("Search"); + textProperty().addListener(this); + setPrefHeight(PREF_HEIGHT); + 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); + + setOnKeyPressed(evt -> { + if(evt.getCode() == KeyCode.ESCAPE) { + setText(""); + } + }); + } + + 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,PREF_HEIGHT); + clearButton.setLayoutX(getWidth() - PREF_HEIGHT); + clearButton.setLayoutY(0); + clearButton.resize(PREF_HEIGHT, PREF_HEIGHT); + } + + @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..4f05ad2a --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/SearchPopoverTreeList.java @@ -0,0 +1,346 @@ +/* + * 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 ctbrec.Config; +import ctbrec.GlobalThreadPool; +import ctbrec.Model; +import ctbrec.StringUtil; +import ctbrec.recorder.Recorder; +import ctbrec.ui.action.PlayAction; +import ctbrec.ui.action.SetThumbAsPortraitAction; +import ctbrec.ui.menu.ModelMenuContributor; +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.*; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.input.ContextMenuEvent; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; +import javafx.scene.shape.Rectangle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URL; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * 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 Logger LOG = LoggerFactory.getLogger(SearchPopoverTreeList.class); + + private Popover popover; + + private Recorder recorder; + + ContextMenu popup; + + @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() { + // not used + } + + @Override + public String rightButtonText() { + return "Done"; + } + + @Override + public void handleRightButton() { + popover.hide(); + } + + @Override + public void handleShown() { + // not used + } + + @Override + public void handleHidden() { + // not used + } + + private class SearchItemListCell extends ListCell implements Skin, EventHandler { + + private Label title = new Label(); + private Button follow; + private Button startRecording; + private Model model; + private ImageView thumb = new ImageView(); + private int thumbSize = 64; + private Node tallest = thumb; + + private SearchItemListCell() { + super(); + String highlightClass = "highlight"; + setSkin(this); + getStyleClass().setAll("search-tree-list-cell"); + setOnMouseClicked(this); + setOnMouseEntered(evt -> getStyleClass().add(highlightClass)); + setOnMouseExited(evt -> getStyleClass().remove(highlightClass)); + addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, event -> { + popup = new CustomMouseBehaviorContextMenu(); + ModelMenuContributor.newContributor(this, Config.getInstance(), recorder) // + .withStartStopCallback(m -> setCursor(Cursor.DEFAULT)) // + .contributeToMenu(List.of(model), popup); + var useImageAsPortrait = new MenuItem("Use As Portrait"); + useImageAsPortrait.setOnAction(e -> new SetThumbAsPortraitAction(this, model, thumb.getImage()).execute()); + popup.getItems().add(useImageAsPortrait); + popup.show(this, event.getScreenX(), event.getScreenY()); + event.consume(); + }); + addEventHandler(MouseEvent.MOUSE_PRESSED, event -> { + if (popup != null) { + popup.hide(); + } + }); + + 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); + GlobalThreadPool.submit(new Task() { + @Override + protected Boolean call() throws Exception { + model.getSite().login(); + return model.follow(); + } + + @Override + protected void done() { + try { + get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOG.warn("Search failed: {}", e.getMessage()); + } catch (Exception e) { + LOG.warn("Search failed: {}", e.getMessage()); + } + Platform.runLater(() -> setCursor(Cursor.DEFAULT)); + } + }); + }); + startRecording = new Button("Record"); + startRecording.setOnAction(evt -> { + setCursor(Cursor.WAIT); + GlobalThreadPool.submit(new Task() { + @Override + protected Void call() throws Exception { + recorder.addModel(model); + return null; + } + + @Override + protected void done() { + Platform.runLater(() -> setCursor(Cursor.DEFAULT)); + } + }); + }); + getChildren().addAll(thumb, title, follow, startRecording); + + startRecording.visibleProperty().bind(title.visibleProperty()); + thumb.visibleProperty().bind(title.visibleProperty()); + } + + @Override + public void handle(MouseEvent t) { + if (t.getButton() == MouseButton.PRIMARY && t.getClickCount() == 2) { + 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()); + follow.setDisable(!model.getSite().credentialsAvailable()); + 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") && StringUtil.isNotBlank(previewUrl)) { + 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); + + startRecording.setStyle("-fx-font-size: 10px;"); + startRecording.setLayoutX(w - 10); + startRecording.setLayoutY((h - buttonH) / 2); + startRecording.resize(buttonW, buttonH); + } + + @Override + protected double computeMinWidth(double height) { + final Insets insets = getInsets(); + final double h = 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 = 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 = 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.0; + } + + @Override + protected double computeMaxHeight(double width) { + return thumbSize + 20.0; + } + + @Override + public SearchItemListCell getSkinnable() { + return this; + } + + @Override + public Node getNode() { + return null; + } + + @Override + public void dispose() { + // nothing to do + } + } + + public void setRecorder(Recorder recorder) { + this.recorder = recorder; + } +} 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..66c21239 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/StreamPreview.java @@ -0,0 +1,196 @@ +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.GlobalThreadPool; +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 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 Future future; + private volatile boolean running = false; + + 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) { + if (running) { + return; + } + running = true; + LOG.debug("Starting preview stream for model {}", model); + 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) { /* nothing to do */ } + } + + if(future != null && !future.isDone()) { + future.cancel(true); + } + future = executor.submit(() -> { + try { + List sources = model.getStreamSources(); + Collections.sort(sources); + StreamSource best = sources.get(0); + LOG.debug("StreamSource {}", best); + 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) { + Thread.currentThread().interrupt(); + // 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() { + running = false; + MediaPlayer old = videoPlayer; + Future oldFuture = future; + GlobalThreadPool.submit(() -> { + if(oldFuture != null && !oldFuture.isDone()) { + oldFuture.cancel(true); + } + if(old != null) { + old.dispose(); + } + }); + } + + private void onError(MediaPlayer videoPlayer) { + LOG.error("Error while starting preview stream", videoPlayer.getError()); + 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()) { + Thread.currentThread().interrupt(); + throw new InterruptedException(); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/controls/TimePicker.java b/client/src/main/java/ctbrec/ui/controls/TimePicker.java new file mode 100644 index 00000000..223cb418 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/TimePicker.java @@ -0,0 +1,67 @@ +package ctbrec.ui.controls; + +import java.time.LocalTime; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; +import java.util.Optional; + +import javafx.scene.control.Spinner; +import javafx.scene.control.SpinnerValueFactory; +import javafx.scene.input.KeyEvent; +import javafx.scene.input.ScrollEvent; + +public class TimePicker extends Spinner { + + public TimePicker() { + this(LocalTime.now().truncatedTo(ChronoUnit.MINUTES)); + } + + public TimePicker(LocalTime value) { + setValueFactory(new TimePickerValueFactory()); + getValueFactory().setValue(value); + setEditable(true); + getEditor().setOnKeyReleased(this::updateValueFromInput); + getEditor().focusedProperty().addListener((obs, oldV, focused) -> { + if (Boolean.FALSE.equals(focused)) { + getEditor().setText(getValue().toString()); + } + }); + setOnScroll(this::onScroll); + } + + private void onScroll(ScrollEvent evt) { + int d = (int) evt.getDeltaY(); + int units = (int) (Math.abs(d) / evt.getMultiplierY()); + if (d > 0) { + getValueFactory().increment(units); + } else { + getValueFactory().decrement(units); + } + evt.consume(); + } + + private void updateValueFromInput(KeyEvent evt) { + String input = getEditor().getText(); + try { + LocalTime newValue = LocalTime.parse(input); + getValueFactory().setValue(newValue); + } catch (DateTimeParseException e) { + // input is invalid, we do nothing + } + } + + private class TimePickerValueFactory extends SpinnerValueFactory { + @Override + public void decrement(int steps) { + LocalTime time = Optional.ofNullable(getValue()).orElse(LocalTime.now().truncatedTo(ChronoUnit.MINUTES)); + setValue(time.minusMinutes(steps)); + + } + + @Override + public void increment(int steps) { + LocalTime time = Optional.ofNullable(getValue()).orElse(LocalTime.now().truncatedTo(ChronoUnit.MINUTES)); + setValue(time.plusMinutes(steps)); + } + } +} 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..28e3953c --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/Toast.java @@ -0,0 +1,55 @@ +package ctbrec.ui.controls; + +import ctbrec.GlobalThreadPool; +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 { + private 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 -> GlobalThreadPool.submit(() -> { + try { + Thread.sleep(toastDelay); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + 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(); + })); + 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..00a6a807 --- /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(Wizard.this.getScene(), "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/controls/autocomplete/AutoFillTextField.java b/client/src/main/java/ctbrec/ui/controls/autocomplete/AutoFillTextField.java new file mode 100644 index 00000000..8b50191c --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/autocomplete/AutoFillTextField.java @@ -0,0 +1,68 @@ +package ctbrec.ui.controls.autocomplete; + +import java.util.Optional; + +import javafx.event.ActionEvent; +import javafx.event.EventHandler; +import javafx.scene.control.TextField; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; + +public class AutoFillTextField extends TextField { + + private EventHandler handler; + private Suggester suggester; + + public AutoFillTextField(Suggester suggester) { + this.suggester = suggester; + addEventHandler(KeyEvent.KEY_RELEASED, evt -> { + if (evt.getCode().isLetterKey() || evt.getCode().isDigitKey()) { + autocomplete(false); + } else if (evt.getCode() == KeyCode.ENTER) { + if (getSelection().getLength() > 0) { + selectRange(0, 0); + insertText(lengthProperty().get(), ":"); + positionCaret(lengthProperty().get()); + evt.consume(); + } else { + handler.handle(new ActionEvent(this, null)); + } + } else if (evt.getCode() == KeyCode.SPACE && evt.isControlDown()) { + autocomplete(true); + } + }); + } + + private void autocomplete(boolean fulltextSearch) { + String oldtext = getOldText(); + if (oldtext.isEmpty()) { + return; + } + + Optional match; + if (fulltextSearch) { + match = suggester.fulltext(oldtext); + } else { + match = suggester.startsWith(oldtext); + } + + if (match.isPresent()) { + setText(match.get()); + int pos = oldtext.length(); + positionCaret(pos); + selectRange(pos, match.get().length()); + } + } + + private String getOldText() { + if (getSelection().getLength() > 0) { + return getText().substring(0, getSelection().getStart()); + } else { + return getText(); + } + } + + public void onActionHandler(EventHandler handler) { + this.handler = handler; + } +} diff --git a/client/src/main/java/ctbrec/ui/controls/autocomplete/ObservableListSuggester.java b/client/src/main/java/ctbrec/ui/controls/autocomplete/ObservableListSuggester.java new file mode 100644 index 00000000..a4970b32 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/autocomplete/ObservableListSuggester.java @@ -0,0 +1,37 @@ +package ctbrec.ui.controls.autocomplete; + +import java.util.Optional; + +import javafx.collections.ObservableList; + +public class ObservableListSuggester implements Suggester { + + private ObservableList suggestions; + + public ObservableListSuggester(ObservableList suggestions) { + this.suggestions = suggestions; + } + + @Override + public Optional startsWith(String search) { + for (Object sug : suggestions) { + boolean startsWith = sug.toString().toLowerCase().startsWith(search.toLowerCase()); + if (startsWith) { + return Optional.of(sug.toString()); + } + } + return Optional.empty(); + } + + @Override + public Optional fulltext(String search) { + for (Object sug : suggestions) { + boolean startsWith = sug.toString().toLowerCase().contains(search.toLowerCase()); + if (startsWith) { + return Optional.of(sug.toString()); + } + } + return Optional.empty(); + } + +} diff --git a/client/src/main/java/ctbrec/ui/controls/autocomplete/Suggester.java b/client/src/main/java/ctbrec/ui/controls/autocomplete/Suggester.java new file mode 100644 index 00000000..f3e1e8c2 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/autocomplete/Suggester.java @@ -0,0 +1,9 @@ +package ctbrec.ui.controls.autocomplete; + +import java.util.Optional; + +public interface Suggester { + + public Optional startsWith(String search); + public Optional fulltext(String search); +} \ No newline at end of file diff --git a/client/src/main/java/ctbrec/ui/controls/range/DiscreteRange.java b/client/src/main/java/ctbrec/ui/controls/range/DiscreteRange.java new file mode 100644 index 00000000..cc80a0c3 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/range/DiscreteRange.java @@ -0,0 +1,46 @@ +package ctbrec.ui.controls.range; + +import java.util.List; + +public class DiscreteRange implements Range { + + private List values; + private List labels; + + + public DiscreteRange(List values, List labels) { + this.values = values; + this.labels = labels; + if (values == null) { + throw new IllegalArgumentException("Values cannot be null"); + } + if (labels == null) { + throw new IllegalArgumentException("Labels cannot be null"); + } + + } + + @Override + public T getMinimum() { + return values.get(0); + } + + @Override + public T getMaximum() { + return values.get(values.size()-1); + } + + @Override + public List getTicks() { + return values; + } + + public List getLabels() { + return labels; + } + + @Override + public String toString() { + return labels.toString(); + } +} diff --git a/client/src/main/java/ctbrec/ui/controls/range/LabeledNumberAxis.java b/client/src/main/java/ctbrec/ui/controls/range/LabeledNumberAxis.java new file mode 100644 index 00000000..fe61590b --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/range/LabeledNumberAxis.java @@ -0,0 +1,49 @@ +package ctbrec.ui.controls.range; + +import java.util.Collections; +import java.util.List; + +import javafx.scene.chart.ValueAxis; + +public class LabeledNumberAxis extends ValueAxis { + + private DiscreteRange range; + + public LabeledNumberAxis(DiscreteRange range) { + this.range = range; + } + + @Override + protected List calculateMinorTickMarks() { + return Collections.emptyList(); + } + + @SuppressWarnings("unchecked") + @Override + protected void setRange(Object range, boolean animate) { + if (!(range instanceof DiscreteRange)) { + throw new IllegalArgumentException("Range has to be of type DiscreteRange"); + } + this.range = (DiscreteRange) range; + } + + @Override + protected Object getRange() { + return range; + } + + @Override + protected List calculateTickValues(double length, Object range) { + if (!(range instanceof Range)) { + throw new IllegalArgumentException("Range has to be of type ctbrec.ui.controls.range.Range"); + } + @SuppressWarnings("unchecked") + Range discreteRange = (Range) range; + return discreteRange.getTicks(); + } + + @Override + protected String getTickMarkLabel(Number value) { + return range.getLabels().get(value.intValue()).toString(); + } +} diff --git a/client/src/main/java/ctbrec/ui/controls/range/Range.java b/client/src/main/java/ctbrec/ui/controls/range/Range.java new file mode 100644 index 00000000..9ba36ec9 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/range/Range.java @@ -0,0 +1,10 @@ +package ctbrec.ui.controls.range; + +import java.util.List; + +public interface Range { + + public T getMinimum(); + public T getMaximum(); + public List getTicks(); +} diff --git a/client/src/main/java/ctbrec/ui/controls/range/RangeSlider.java b/client/src/main/java/ctbrec/ui/controls/range/RangeSlider.java new file mode 100644 index 00000000..b1c27951 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/range/RangeSlider.java @@ -0,0 +1,89 @@ +package ctbrec.ui.controls.range; + +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.geometry.Orientation; +import javafx.scene.control.Control; +import javafx.scene.control.Skin; + +public class RangeSlider extends Control { + + private static final String DEFAULT_STYLE_CLASS = "rangeslider"; + + private Range range; + private ObjectProperty low; + private ObjectProperty high; + private boolean showTickMarks = false; + private boolean showTickLabels = false; + private Orientation orientation = Orientation.HORIZONTAL; + + @SuppressWarnings({ "unchecked", "rawtypes" }) + public RangeSlider(Range range) { + this.range = range; + low = new SimpleObjectProperty(getMinimum()); + high = new SimpleObjectProperty(getMaximum()); + getStyleClass().setAll(DEFAULT_STYLE_CLASS); + } + + @Override + protected Skin createDefaultSkin() { + return new RangeSliderSkin(this, new RangeSliderBehavior<>(this)); + } + + @Override + public String getUserAgentStylesheet() { + return RangeSlider.class.getResource("rangeslider.css").toExternalForm(); + } + + public ObjectProperty getLow() { + return low; + } + + public void setLow(T newPosition) { + low.set(newPosition); + } + + public ObjectProperty getHigh() { + return high; + } + + public void setHigh(T newPosition) { + this.high.set(newPosition); + } + + public T getMinimum() { + return range.getMinimum(); + } + + public T getMaximum() { + return range.getMaximum(); + } + + public boolean isShowTickMarks() { + return showTickMarks; + } + + public void setShowTickMarks(boolean showTickMarks) { + this.showTickMarks = showTickMarks; + } + + public boolean isShowTickLabels() { + return showTickLabels; + } + + public void setShowTickLabels(boolean showTickLabels) { + this.showTickLabels = showTickLabels; + } + + public Orientation getOrientation() { + return orientation; + } + + public void setOrientation(Orientation orientation) { + this.orientation = orientation; + } + + public Range getRange() { + return range; + } +} diff --git a/client/src/main/java/ctbrec/ui/controls/range/RangeSliderBehavior.java b/client/src/main/java/ctbrec/ui/controls/range/RangeSliderBehavior.java new file mode 100644 index 00000000..43972045 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/range/RangeSliderBehavior.java @@ -0,0 +1,95 @@ +package ctbrec.ui.controls.range; + +import java.util.List; + +import com.sun.javafx.scene.control.behavior.BehaviorBase; // NOSONAR +import com.sun.javafx.scene.control.inputmap.InputMap; // NOSONAR + +import javafx.scene.Node; +import javafx.scene.input.MouseEvent; + +public class RangeSliderBehavior extends BehaviorBase> { + + private RangeSlider rangeSlider; + + public RangeSliderBehavior(RangeSlider rangeSlider) { + super(rangeSlider); + this.rangeSlider = rangeSlider; + rangeSlider.addEventFilter(MouseEvent.MOUSE_CLICKED, this::sliderClicked); + } + + private void sliderClicked(MouseEvent me) { + Node source = (Node) me.getSource(); + double positionPercentage = me.getX() / source.getBoundsInParent().getWidth(); + moveClosestThumbTo(positionPercentage); + } + + @Override + public InputMap> getInputMap() { + InputMap> inputMap = new InputMap<>(rangeSlider); + return inputMap; + } + + /** + * @param position + * The mouse position on track with 0.0 being beginning of track and 1.0 being the end + */ + public void lowThumbDragged(double position) { + var newPosition = getNewPosition(position); + var high = getHigh(); + if (newPosition.doubleValue() >= high.doubleValue()) { + newPosition = getLow(); + } + rangeSlider.setLow(newPosition); + } + + /** + * @param position + * The mouse position on track with 0.0 being beginning of track and 1.0 being the end + */ + public void highThumbDragged(double position) { + var newPosition = getNewPosition(position); + var low = getLow(); + if (newPosition.doubleValue() <= low.doubleValue()) { + newPosition = getHigh(); + } + rangeSlider.setHigh(newPosition); + } + + public void moveClosestThumbTo(double positionPercentage) { + var newPosition = getNewPosition(positionPercentage); + var low = getLow(); + var high = getHigh(); + double distToLow = Math.abs(low.doubleValue() - newPosition.doubleValue()); + double distToHigh = Math.abs(high.doubleValue() - newPosition.doubleValue()); + if (distToLow < distToHigh) { + rangeSlider.setLow(newPosition); + } else { + rangeSlider.setHigh(newPosition); + } + } + + private T getLow() { + return rangeSlider.getRange().getTicks().get(rangeSlider.getLow().get().intValue()); + } + + private T getHigh() { + return rangeSlider.getRange().getTicks().get(rangeSlider.getHigh().get().intValue()); + } + + /** + * Calculate the new position of the thumb given the clicked/dragged position + * + * @param position + * clicked position + * @return new position + */ + private T getNewPosition(double position) { + List ticks = rangeSlider.getRange().getTicks(); + double percentPerTick = 1d / (ticks.size() - 1); + int index = (int) Math.round(position / percentPerTick); + index = Math.min(ticks.size() - 1, Math.max(0, index)); + return ticks.get(index); + } + +} diff --git a/client/src/main/java/ctbrec/ui/controls/range/RangeSliderSkin.java b/client/src/main/java/ctbrec/ui/controls/range/RangeSliderSkin.java new file mode 100644 index 00000000..7a15568b --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/range/RangeSliderSkin.java @@ -0,0 +1,275 @@ +package ctbrec.ui.controls.range; + +import javafx.geometry.Orientation; +import javafx.geometry.Point2D; +import javafx.geometry.Side; +import javafx.scene.chart.Axis; +import javafx.scene.chart.NumberAxis; +import javafx.scene.chart.ValueAxis; +import javafx.scene.control.SkinBase; +import javafx.scene.layout.StackPane; + +public class RangeSliderSkin extends SkinBase> { + + private static final double TRACK_TO_TICK_GAP = 2; + private Axis tickLine = null; + private StackPane track; + private ThumbRange thumbRange = new ThumbRange(); + private RangeSliderBehavior behavior; + private double thumbWidth; + private double thumbHeight; + private boolean showTickMarks; + private double trackStart; + private double trackLength; + private double lowThumbPos; + + private double preDragPos; // used as a temp value for low and high thumbsRange + private Point2D preDragThumbPoint; // in skin coordinates + + protected RangeSliderSkin(RangeSlider control, RangeSliderBehavior behavior) { + super(control); + this.behavior = behavior; + initTrack(); + initThumbs(thumbRange); + registerChangeListener(control.getLow(), obsVal -> getSkinnable().requestLayout()); + registerChangeListener(control.getHigh(), obsVal -> getSkinnable().requestLayout()); + } + + private void initThumbs(ThumbRange t) { + setShowTickMarks(getSkinnable().isShowTickMarks(), getSkinnable().isShowTickLabels()); + + getChildren().addAll(t.low, t.high, t.rangeBar); + + t.low.setOnMousePressed(me -> { + preDragThumbPoint = t.low.localToParent(me.getX(), me.getY()); + preDragPos = (getSkinnable().getLow().get().doubleValue() - getSkinnable().getMinimum().doubleValue()) / (getMaxMinusMinNoZero()); + }); + t.low.setOnMouseDragged(me -> { + Point2D cur = t.low.localToParent(me.getX(), me.getY()); + double dragPos = (isHorizontal()) ? cur.getX() - preDragThumbPoint.getX() : -(cur.getY() - preDragThumbPoint.getY()); + behavior.lowThumbDragged(preDragPos + dragPos / trackLength); + }); + + t.high.setOnMousePressed(me -> { + preDragThumbPoint = t.high.localToParent(me.getX(), me.getY()); + preDragPos = (getSkinnable().getHigh().get().doubleValue() - getSkinnable().getMinimum().doubleValue()) / (getMaxMinusMinNoZero()); + }); + t.high.setOnMouseDragged(me -> { + boolean orientation = getSkinnable().getOrientation() == Orientation.HORIZONTAL; + double trackLen = orientation ? track.getWidth() : track.getHeight(); + Point2D cur = t.high.localToParent(me.getX(), me.getY()); + double dragPos = getSkinnable().getOrientation() != Orientation.HORIZONTAL ? -(cur.getY() - preDragThumbPoint.getY()) : cur.getX() - preDragThumbPoint.getX(); + behavior.highThumbDragged(preDragPos + dragPos / trackLen); + }); + } + + private boolean isHorizontal() { + return getSkinnable().getOrientation() == Orientation.HORIZONTAL; + } + + private void initTrack() { + track = new StackPane(); + track.getStyleClass().setAll("track"); + + getChildren().clear(); + getChildren().add(track); + } + + private void setShowTickMarks(boolean ticksVisible, boolean labelsVisible) { + showTickMarks = (ticksVisible || labelsVisible); + var rangeSlider = getSkinnable(); + if (showTickMarks) { + if (tickLine == null) { + var range = rangeSlider.getRange(); + tickLine = createAxis(range, ticksVisible, labelsVisible); + getChildren().addAll(tickLine); + } else { + tickLine.setTickLabelsVisible(labelsVisible); + tickLine.setTickMarkVisible(ticksVisible); + } + } + + getSkinnable().requestLayout(); + } + + @SuppressWarnings("unchecked") + private Axis createAxis(Range range, boolean ticksVisible, boolean labelsVisible) { + ValueAxis axis; + if (range instanceof DiscreteRange) { + axis = new LabeledNumberAxis((DiscreteRange) range); + } else { + axis = new NumberAxis(); + } + + axis.setUpperBound(range.getMaximum().doubleValue()); + axis.setLowerBound(range.getMinimum().doubleValue()); + axis.setMinorTickVisible(false); + axis.setMinorTickCount(0); + axis.setAutoRanging(false); + axis.setAnimated(false); + axis.setSide(isHorizontal() ? Side.BOTTOM : Side.RIGHT); + axis.setTickMarkVisible(ticksVisible); + axis.setTickLabelsVisible(labelsVisible); + return axis; + } + + @Override + protected void layoutChildren(final double x, final double y, final double w, final double h) { + if (thumbRange != null) { + thumbWidth = thumbRange.low.prefWidth(-1); + thumbHeight = thumbRange.low.prefHeight(-1); + thumbRange.low.resize(thumbWidth, thumbHeight); + thumbRange.high.resize(thumbWidth, thumbHeight); + } + + + double radius = track.getBackground().getFills().isEmpty() ? 0 : track.getBackground().getFills().get(0).getRadii().getTopLeftHorizontalRadius(); + double trackRadius = track.getBackground() == null ? 0 : radius; + + double tickLineHeight = (showTickMarks) ? tickLine.prefHeight(-1) : 0; + double trackHeight = 5; + double trackAreaHeight = Math.max(trackHeight, thumbHeight); + double totalHeightNeeded = trackAreaHeight + ((showTickMarks) ? TRACK_TO_TICK_GAP + tickLineHeight : 0); + double startY = y + ((h - totalHeightNeeded) / 2); // center slider in available height vertically + + trackLength = w - thumbWidth; + trackStart = x + (thumbWidth / 2); + + double trackTop = (int) (startY + ((trackAreaHeight - trackHeight) / 2)); + lowThumbPos = (int) (startY + ((trackAreaHeight - thumbHeight) / 2)); + + // layout track + track.resizeRelocate(trackStart - trackRadius, trackTop, trackLength + trackRadius + trackRadius, trackHeight); + + positionThumbs(); + + if (showTickMarks) { + tickLine.setLayoutX(trackStart); + tickLine.setLayoutY(trackTop + trackHeight + TRACK_TO_TICK_GAP); + tickLine.resize(trackLength, tickLineHeight); + tickLine.requestAxisLayout(); + } else { + if (tickLine != null) { + tickLine.resize(0, 0); + tickLine.requestAxisLayout(); + } + tickLine = null; + } + } + + private void positionThumbs() { + RangeSlider s = getSkinnable(); + + double lxl = trackStart + (trackLength * ((s.getLow().get().doubleValue() - s.getMinimum().doubleValue()) / (getMaxMinusMinNoZero())) - thumbWidth / 2D); + double lxh = trackStart + (trackLength * ((s.getHigh().get().doubleValue() - s.getMinimum().doubleValue()) / (getMaxMinusMinNoZero())) - thumbWidth / 2D); + double ly = lowThumbPos; + + if (thumbRange != null) { + thumbRange.low.setLayoutX(lxl); + thumbRange.low.setLayoutY(ly); + + thumbRange.high.setLayoutX(lxh); + thumbRange.high.setLayoutY(ly); + + thumbRange.rangeBar.resizeRelocate(thumbRange.low.getLayoutX() + thumbRange.low.getWidth(), track.getLayoutY(), + thumbRange.high.getLayoutX() - thumbRange.low.getLayoutX() - thumbRange.low.getWidth(), track.getHeight()); + } + } + + private double minTrackLength() { + return 2 * ((thumbRange != null) ? thumbRange.low.prefWidth(-1) : 1); + } + + @Override + protected double computeMinWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { + if (isHorizontal()) { + return (leftInset + minTrackLength() + ((thumbRange != null) ? thumbRange.low.prefWidth(-1) : 1) + rightInset); + } else { + return (leftInset + ((thumbRange != null) ? thumbRange.low.prefWidth(-1) : 1) + rightInset); + } + } + + @Override + protected double computeMinHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { + if (isHorizontal()) { + return (topInset + ((thumbRange != null) ? thumbRange.low.prefHeight(-1) : 1) + bottomInset); + } else { + return (topInset + minTrackLength() + ((thumbRange != null) ? thumbRange.low.prefHeight(-1) : 1) + bottomInset); + } + } + + @Override + protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { + if (isHorizontal()) { + if (showTickMarks) { + double w = Math.max(140, tickLine.prefWidth(-1)); + return w; + } else { + return 140; + } + } else { + return leftInset + Math.max(((thumbRange != null) ? thumbRange.low.prefWidth(-1) : 1), track.prefWidth(-1)) + + ((showTickMarks) ? (TRACK_TO_TICK_GAP + tickLine.prefWidth(-1)) : 0) + rightInset; + } + } + + @Override + protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { + if (isHorizontal()) { + return getSkinnable().getInsets().getTop() + Math.max(((thumbRange != null) ? thumbRange.low.prefHeight(-1) : 1), track.prefHeight(-1)) + + ((showTickMarks) ? (TRACK_TO_TICK_GAP + tickLine.prefHeight(-1)) : 0) + bottomInset; + } else { + if (showTickMarks) { + return Math.max(140, tickLine.prefHeight(-1)); + } else { + return 140; + } + } + } + + @Override + protected double computeMaxWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { + if (isHorizontal()) { + return Double.MAX_VALUE; + } else { + return getSkinnable().prefWidth(-1); + } + } + + @Override + protected double computeMaxHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { + if (isHorizontal()) { + return getSkinnable().prefHeight(width); + } else { + return Double.MAX_VALUE; + } + } + + private double getMaxMinusMinNoZero() { + RangeSlider s = getSkinnable(); + return s.getMaximum().doubleValue() - s.getMinimum().doubleValue() == 0 ? 1 : s.getMaximum().doubleValue() - s.getMinimum().doubleValue(); + } + + private static class ThumbPane extends StackPane { + } + + private static class ThumbRange { + ThumbPane low; + ThumbPane high; + StackPane rangeBar; + + ThumbRange() { + low = new ThumbPane(); + low.getStyleClass().setAll("low-thumb"); + low.setFocusTraversable(false); + + high = new ThumbPane(); + high.getStyleClass().setAll("high-thumb"); + high.setFocusTraversable(false); + + rangeBar = new StackPane(); + rangeBar.getStyleClass().setAll("range-bar"); + rangeBar.setFocusTraversable(false); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/controls/range/rangeslider.css b/client/src/main/java/ctbrec/ui/controls/range/rangeslider.css new file mode 100644 index 00000000..4f04487a --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/range/rangeslider.css @@ -0,0 +1,78 @@ +.rangeslider .low-thumb, +.rangeslider .high-thumb { + -fx-background-color: + linear-gradient(to bottom, derive(-fx-text-box-border, -20%), derive(-fx-text-box-border, -30%)), + -fx-inner-border, + -fx-body-color; + -fx-background-insets: 0, 1, 2; + -fx-background-radius: 1.0em; /* makes sure this remains circular */ + -fx-padding: 0.583333em; /* 7 */ + -fx-effect: dropshadow(two-pass-box , rgba(0, 0, 0, 0.1), 5, 0.0 , 0, 2); +} + +.rangeslider:focused .low-thumb, +.rangeslider:focused .high-thumb { + -fx-background-radius: 1.0em; /* makes sure this remains circular */ +} + +.rangeslider .low-thumb:focused, +.rangeslider .high-thumb:focused { + -fx-background-color: + -fx-focus-color, + derive(-fx-color,-36%), + derive(-fx-color,73%), + linear-gradient(to bottom, derive(-fx-color,-19%),derive(-fx-color,61%)); + -fx-background-insets: -1.4, 0, 1, 2; + -fx-background-radius: 1.0em; /* makes sure this remains circular */ +} + +.rangeslider .low-thumb:hover, +.rangeslider .high-thumb:hover { + -fx-color: -fx-hover-base; +} + +.rangeslider .range-bar { + -fx-background-color: -fx-accent; +} + +.rangeslider .low-thumb:pressed, +.rangeslider .high-thumb:pressed { + -fx-color: -fx-pressed-base; +} + +.rangeslider .track { + -fx-background-color: + -fx-shadow-highlight-color, + linear-gradient(to bottom, derive(-fx-text-box-border, -10%), -fx-text-box-border), + linear-gradient(to bottom, + derive(-fx-control-inner-background, -9%), + derive(-fx-control-inner-background, 0%), + derive(-fx-control-inner-background, -5%), + derive(-fx-control-inner-background, -12%) + ); + -fx-background-insets: 0 0 -1 0, 0, 1; + -fx-background-radius: 0.25em, 0.25em, 0.166667em; /* 3 3 2 */ + -fx-padding: 0.25em; /* 3 */ +} + +.rangeslider:vertical .track { + -fx-background-color: + -fx-shadow-highlight-color, + -fx-text-box-border, + linear-gradient(to right, + derive(-fx-control-inner-background, -9%), + -fx-control-inner-background, + derive(-fx-control-inner-background, -9%) + ); +} + +.rangeslider .axis { + -fx-tick-label-fill: derive(-fx-text-background-color, 30%); + -fx-tick-length: 5px; + -fx-minor-tick-length: 3px; + -fx-border-color: null; +} + +.rangeslider:disabled { + -fx-opacity: 0.4; +} diff --git a/client/src/main/java/ctbrec/ui/controls/table/SettingTableViewStateStore.java b/client/src/main/java/ctbrec/ui/controls/table/SettingTableViewStateStore.java new file mode 100644 index 00000000..f2aaedea --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/table/SettingTableViewStateStore.java @@ -0,0 +1,115 @@ +package ctbrec.ui.controls.table; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; + +import ctbrec.Config; +import ctbrec.Settings; +import ctbrec.StringUtil; +import javafx.scene.control.TableColumn.SortType; + +public class SettingTableViewStateStore implements TableViewStateStore { + + private final Config config; + private final String settingPrefix; + private final String columnOrderSetting; + private final String columnWidthSetting; + private final String columnVisibilitySetting; + private final String sortColumnSetting; + private final String sortTypeSetting; + + public SettingTableViewStateStore(Config config, String settingPrefix) { + this.config = config; + this.settingPrefix = settingPrefix; + columnOrderSetting = settingPrefix + "ColumnOrder"; + columnWidthSetting = settingPrefix + "ColumnWidth"; + columnVisibilitySetting = settingPrefix + "ColumnVisibility"; + sortColumnSetting = settingPrefix + "SortColumn"; + sortTypeSetting = settingPrefix + "SortType"; + } + + @Override + public List loadColumnOrder() { + return loadSetting(columnOrderSetting); + } + + @Override + public Map loadColumnWidths() { + return loadSetting(columnWidthSetting); + } + + @Override + public Map loadColumnVisibility() { + return loadSetting(columnVisibilitySetting); + } + + @Override + public String loadSortColumn() { + return loadSetting(sortColumnSetting); + } + + @Override + public SortType loadSortType() { + String sortType = loadSetting(sortTypeSetting); + if (StringUtil.isBlank(sortType)) { + return SortType.ASCENDING; + } else { + return SortType.valueOf(sortType); + } + } + + @SuppressWarnings("unchecked") + private T loadSetting(String name) { + try { + Field field = Settings.class.getDeclaredField(name); + return (T) field.get(config.getSettings()); + } catch (NoSuchFieldException | SecurityException | IllegalArgumentException | IllegalAccessException e) { + throw new TableViewStateStoreException(e); + } + } + + private void setSetting(String name, Object value) { + try { + Field field = Settings.class.getDeclaredField(name); + field.set(config.getSettings(), value); // NOSONAR + } catch (NoSuchFieldException | SecurityException | IllegalArgumentException | IllegalAccessException e) { + throw new TableViewStateStoreException(e); + } + } + + @Override + public void saveColumnOrder(List columnIds) throws IOException { + setSetting(columnOrderSetting, columnIds); + save(); + } + + @Override + public void saveColumnWidths(Map columnIdsToWidth) throws IOException { + setSetting(columnWidthSetting, columnIdsToWidth); + save(); + } + + @Override + public void saveColumnVisibility(Map columnIdsToVisibility) throws IOException { + setSetting(columnVisibilitySetting, columnIdsToVisibility); + save(); + } + + @Override + public void saveSorting(String columnId, SortType sortType) throws IOException { + setSetting(sortColumnSetting, columnId); + setSetting(sortTypeSetting, sortType.name()); + save(); + } + + private void save() throws IOException { + config.save(); + } + + @Override + public String getName() { + return settingPrefix; + } +} diff --git a/client/src/main/java/ctbrec/ui/controls/table/StatePersistingTableView.java b/client/src/main/java/ctbrec/ui/controls/table/StatePersistingTableView.java new file mode 100644 index 00000000..49bd44db --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/table/StatePersistingTableView.java @@ -0,0 +1,147 @@ +package ctbrec.ui.controls.table; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.StringUtil; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; + +public class StatePersistingTableView extends TableView { + + private static final Logger LOG = LoggerFactory.getLogger(StatePersistingTableView.class); + + private Instant initialized; + private TableViewStateStore stateStore; + + public StatePersistingTableView(TableViewStateStore stateStore) { + super(); + this.stateStore = stateStore; + setTableMenuButtonVisible(true); + initialized = Instant.now(); + } + + public void restoreState() { + restoreColumnOrder(); + restoreColumnWidths(); + restoreColumnVisibility(); + restoreSorting(); + + addStateListeners(); + } + + private void addStateListeners() { + // column order + getColumns().addListener((ListChangeListener>) c -> saveColumnOrder()); + // column visibility + getColumns().forEach(tc -> tc.visibleProperty().addListener((obs, oldV, newV) -> saveColumnVisibility())); + // column width + getColumns().forEach(tc -> tc.widthProperty().addListener((obs, oldV, newV) -> saveColumnWidths())); + // sort order + getSortOrder().addListener((ListChangeListener>) c -> saveSorting()); + getColumns().forEach(tc -> tc.sortTypeProperty().addListener((obs, oldV, newV) -> saveSorting())); + } + + protected void restoreColumnVisibility() { + Map visibility = stateStore.loadColumnVisibility(); + for (TableColumn tc : getColumns()) { + tc.setVisible(visibility.getOrDefault(tc.getId(), tc.isVisible())); + } + } + + protected void restoreColumnWidths() { + Map widths = stateStore.loadColumnWidths(); + for (TableColumn tc : getColumns()) { + tc.setPrefWidth(widths.getOrDefault(tc.getId(), tc.getWidth())); + } + } + + protected void restoreColumnOrder() { + List order = stateStore.loadColumnOrder(); + ObservableList> tableColumns = getColumns(); + for (var i = 0; i < order.size(); i++) { + for (var j = 0; j < getColumns().size(); j++) { + if (Objects.equals(order.get(i), tableColumns.get(j).getId())) { + TableColumn col = tableColumns.get(j); + tableColumns.remove(j); + tableColumns.add(Math.min(i, tableColumns.size()), col); + } + } + } + } + + protected void restoreSorting() { + String sortCol = stateStore.loadSortColumn(); + if (StringUtil.isNotBlank(sortCol)) { + for (TableColumn col : getColumns()) { + if (Objects.equals(sortCol, col.getId())) { + col.setSortType(stateStore.loadSortType()); + getSortOrder().clear(); + getSortOrder().add(col); + break; + } + } + } + } + + public void saveState() { + saveColumnOrder(); + saveColumnWidths(); + saveColumnVisibility(); + saveSorting(); + } + + protected void saveSorting() { + if (!getSortOrder().isEmpty()) { + TableColumn col = getSortOrder().get(0); + saveSetting(() -> stateStore.saveSorting(col.getId(), col.getSortType())); + } else { + saveSetting(() -> stateStore.saveSorting(null, stateStore.loadSortType())); + } + } + + protected void saveColumnVisibility() { + saveSetting(() -> { + Map columnIdToVisible = getColumns().stream().collect(Collectors.toMap(TableColumn::getId, TableColumn::isVisible)); + stateStore.saveColumnVisibility(columnIdToVisible); + }); + } + + protected void saveColumnWidths() { + saveSetting(() -> { + Map columnIdToWidth = getColumns().stream().collect(Collectors.toMap(TableColumn::getId, TableColumn::getWidth)); + stateStore.saveColumnWidths(columnIdToWidth); + }); + } + + protected void saveColumnOrder() { + saveSetting(() -> { + List tableIds = getColumns().stream().map(TableColumn::getId).collect(Collectors.toList()); + stateStore.saveColumnOrder(tableIds); + }); + } + + private void saveSetting(ThrowingRunnable r) { + if (Duration.between(initialized, Instant.now()).getSeconds() > 1) { + try { + r.run(); + } catch (Exception e) { + LOG.error("Couldn't safe table view state with prefix {}", stateStore.getName(), e); + } + } + } + + @FunctionalInterface + private interface ThrowingRunnable { + void run() throws Exception; // NOSONAR + } +} diff --git a/client/src/main/java/ctbrec/ui/controls/table/TableViewStateStore.java b/client/src/main/java/ctbrec/ui/controls/table/TableViewStateStore.java new file mode 100644 index 00000000..44a9b8e5 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/table/TableViewStateStore.java @@ -0,0 +1,21 @@ +package ctbrec.ui.controls.table; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import javafx.scene.control.TableColumn.SortType; + +public interface TableViewStateStore { + List loadColumnOrder(); + Map loadColumnWidths(); + Map loadColumnVisibility(); + String loadSortColumn(); + SortType loadSortType(); + + void saveColumnOrder(List columnIds) throws IOException; + void saveColumnWidths(Map columnIdsToWidth) throws IOException; + void saveColumnVisibility(Map columnIdsToVisibility) throws IOException; + void saveSorting(String columnId, SortType sortType) throws IOException; + String getName(); +} diff --git a/client/src/main/java/ctbrec/ui/controls/table/TableViewStateStoreException.java b/client/src/main/java/ctbrec/ui/controls/table/TableViewStateStoreException.java new file mode 100644 index 00000000..52504e30 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/controls/table/TableViewStateStoreException.java @@ -0,0 +1,9 @@ +package ctbrec.ui.controls.table; + +public class TableViewStateStoreException extends RuntimeException { + + public TableViewStateStoreException(Exception e) { + super(e); + } + +} 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..46f58b8b --- /dev/null +++ b/client/src/main/java/ctbrec/ui/event/PlaySound.java @@ -0,0 +1,39 @@ +package ctbrec.ui.event; + +import ctbrec.event.Action; +import ctbrec.event.Event; +import ctbrec.event.EventHandlerConfiguration.ActionConfiguration; +import javafx.scene.media.AudioClip; + +import java.io.File; +import java.net.URL; + +public class PlaySound extends Action { + + private URL url; + private double volume = 1.0d; + + public PlaySound() { + name = "play sound"; + } + + public PlaySound(URL url, double volume) { + this(); + this.url = url; + this.volume = volume; + } + + @Override + public void accept(Event evt) { + var clip = new AudioClip(url.toString()); + clip.setVolume(volume); + clip.play(); + } + + @Override + public void configure(ActionConfiguration config) throws Exception { + var file = new File((String) config.getConfiguration().get("file")); + volume = Double.parseDouble(String.valueOf(config.getConfiguration().getOrDefault("volume", "1.0"))); + url = file.toURI().toURL(); + } +} diff --git a/client/src/main/java/ctbrec/ui/event/PlayerStartedEvent.java b/client/src/main/java/ctbrec/ui/event/PlayerStartedEvent.java new file mode 100644 index 00000000..c06dd012 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/event/PlayerStartedEvent.java @@ -0,0 +1,40 @@ +package ctbrec.ui.event; + +import ctbrec.Model; +import ctbrec.ui.JavaFxModel; +import lombok.*; + +import java.time.Instant; + +@Getter +@Setter +@EqualsAndHashCode(of = "timestamp") +@ToString +@NoArgsConstructor +public class PlayerStartedEvent { + + private Model model; + private Instant timestamp; + + public PlayerStartedEvent(Model model) { + this(model, Instant.now()); + } + + public PlayerStartedEvent(Model model, Instant timestamp) { + this.model = unwrap(model); + this.timestamp = timestamp; + } + + public void setModel(Model model) { + this.model = unwrap(model); + } + + private Model unwrap(Model model) { + if (model instanceof JavaFxModel fxModel) { + return fxModel.getDelegate(); + } else { + return model; + } + } + +} 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..7333f7f0 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/event/ShowNotification.java @@ -0,0 +1,46 @@ +package ctbrec.ui.event; + +import ctbrec.Model; +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; +import ctbrec.ui.DesktopIntegration; + +public class ShowNotification extends Action { + + public ShowNotification() { + name = "show notification"; + } + + @Override + public void accept(Event evt) { + var header = evt.getType().toString(); + String msg; + switch(evt.getType()) { + case MODEL_STATUS_CHANGED: + ModelStateChangedEvent modelEvent = (ModelStateChangedEvent) evt; + if (modelEvent.getOldState() == Model.State.UNCHECKED) { + return; + } + var 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(); + } + DesktopIntegration.notification(CamrecApplication.title, header, msg); + } + + @Override + public void configure(ActionConfiguration config) throws Exception { + // nothing to do here + } +} diff --git a/client/src/main/java/ctbrec/ui/io/json/dto/PlayerStartedEventDto.java b/client/src/main/java/ctbrec/ui/io/json/dto/PlayerStartedEventDto.java new file mode 100644 index 00000000..a5538f8a --- /dev/null +++ b/client/src/main/java/ctbrec/ui/io/json/dto/PlayerStartedEventDto.java @@ -0,0 +1,18 @@ +package ctbrec.ui.io.json.dto; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import ctbrec.io.json.dto.ModelDto; +import ctbrec.io.json.dto.converter.InstantToMillisConverter; +import ctbrec.io.json.dto.converter.MillisToInstantConverter; +import lombok.Data; + +import java.time.Instant; + +@Data +public class PlayerStartedEventDto { + private ModelDto model; + @JsonSerialize(converter = InstantToMillisConverter.class) + @JsonDeserialize(converter = MillisToInstantConverter.class) + private Instant timestamp; +} diff --git a/client/src/main/java/ctbrec/ui/io/json/mapper/PlayerStartedEventMapper.java b/client/src/main/java/ctbrec/ui/io/json/mapper/PlayerStartedEventMapper.java new file mode 100644 index 00000000..7001896f --- /dev/null +++ b/client/src/main/java/ctbrec/ui/io/json/mapper/PlayerStartedEventMapper.java @@ -0,0 +1,14 @@ +package ctbrec.ui.io.json.mapper; + +import ctbrec.io.json.mapper.ModelMapper; +import ctbrec.ui.event.PlayerStartedEvent; +import ctbrec.ui.io.json.dto.PlayerStartedEventDto; +import org.mapstruct.Mapper; + +@Mapper(uses = ModelMapper.class) +public interface PlayerStartedEventMapper { + + PlayerStartedEventDto toDto(PlayerStartedEvent event); + + PlayerStartedEvent toEvent(PlayerStartedEventDto dto); +} diff --git a/client/src/main/java/ctbrec/ui/menu/FollowUnfollowHandler.java b/client/src/main/java/ctbrec/ui/menu/FollowUnfollowHandler.java new file mode 100644 index 00000000..3adfb9a4 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/menu/FollowUnfollowHandler.java @@ -0,0 +1,49 @@ +package ctbrec.ui.menu; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Model; +import ctbrec.recorder.Recorder; +import ctbrec.ui.action.AbstractModelAction.Result; +import ctbrec.ui.action.FollowAction; +import ctbrec.ui.action.TriConsumer; +import ctbrec.ui.action.UnfollowAction; +import javafx.scene.Node; + +public class FollowUnfollowHandler { + + private static final Logger LOG = LoggerFactory.getLogger(FollowUnfollowHandler.class); + + private Node source; + private Recorder recorder; + private TriConsumer callback; + + public FollowUnfollowHandler(Node source, Recorder recorder, TriConsumer callback) { + this.source = source; + this.recorder = recorder; + this.callback = callback; + } + + protected void follow(List selectedModels) { + new FollowAction(source, selectedModels, recorder).execute().thenAccept(r -> { + r.stream().filter(rs -> rs.getThrowable() == null).map(Result::getModel).forEach(m -> callback.accept(m, true, true)); + r.stream().filter(rs -> rs.getThrowable() != null).map(Result::getModel).forEach(m -> callback.accept(m, true, false)); + }).exceptionally(ex -> { + LOG.error("Couldn't follow model", ex); + return null; + }); + } + + protected void unfollow(List selectedModels) { + new UnfollowAction(source, selectedModels, recorder).execute().thenAccept(r -> { + r.stream().filter(rs -> rs.getThrowable() == null).map(Result::getModel).forEach(m -> callback.accept(m, false, true)); + r.stream().filter(rs -> rs.getThrowable() != null).map(Result::getModel).forEach(m -> callback.accept(m, false, false)); + }).exceptionally(ex -> { + LOG.error("Couldn't unfollow model", ex); + return null; + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/menu/ForcePriorityHandler.java b/client/src/main/java/ctbrec/ui/menu/ForcePriorityHandler.java new file mode 100644 index 00000000..809c73dc --- /dev/null +++ b/client/src/main/java/ctbrec/ui/menu/ForcePriorityHandler.java @@ -0,0 +1,51 @@ +package ctbrec.ui.menu; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Model; +import ctbrec.recorder.Recorder; +import ctbrec.ui.action.ForcePriorityAction; +import ctbrec.ui.action.ResumePriorityAction; +import javafx.scene.Node; + +public class ForcePriorityHandler { + + private static final Logger LOG = LoggerFactory.getLogger(ForcePriorityHandler.class); + + private Node source; + private Recorder recorder; + private Runnable callback; + + public ForcePriorityHandler(Node source, Recorder recorder, Runnable callback) { + this.source = source; + this.recorder = recorder; + this.callback = callback; + } + + protected void forcePriority(List selectedModels) { + new ForcePriorityAction(source, selectedModels, recorder).execute() + .exceptionally(ex -> { + LOG.error("Error while forcing ignore priority", ex); + return null; + }).whenComplete((r, ex) -> executeCallback()); + } + + protected void resumePriority(List selectedModels) { + new ResumePriorityAction(source, selectedModels, recorder).execute() + .exceptionally(ex -> { + LOG.error("Error while resuming respecting priority", ex); + return null; + }).whenComplete((r, ex) -> executeCallback()); + } + + private void executeCallback() { + try { + callback.run(); + } catch (Exception e) { + LOG.error("Error while executing menu callback", e); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/menu/ModelGroupMenuBuilder.java b/client/src/main/java/ctbrec/ui/menu/ModelGroupMenuBuilder.java new file mode 100644 index 00000000..7a18b72a --- /dev/null +++ b/client/src/main/java/ctbrec/ui/menu/ModelGroupMenuBuilder.java @@ -0,0 +1,68 @@ +package ctbrec.ui.menu; + +import ctbrec.Model; +import ctbrec.recorder.Recorder; +import ctbrec.ui.action.*; +import javafx.scene.Node; +import javafx.scene.control.Menu; +import javafx.scene.control.MenuItem; + +import java.util.Objects; +import java.util.Optional; +import java.util.function.Consumer; + +public class ModelGroupMenuBuilder { + + private Model model; + private Recorder recorder; + private Node source; + private Consumer callback; + + public ModelGroupMenuBuilder model(Model model) { + this.model = Objects.requireNonNull(model, "Model cannot be null"); + return this; + } + + public ModelGroupMenuBuilder recorder(Recorder recorder) { + this.recorder = Objects.requireNonNull(recorder, "Recorder cannot be null"); + return this; + } + + public ModelGroupMenuBuilder node(Node source) { + this.source = Objects.requireNonNull(source, "Node cannot be null"); + return this; + } + + public ModelGroupMenuBuilder callback(Consumer callback) { + this.callback = callback; + return this; + } + + public Menu build() { + Objects.requireNonNull(model, "Model has to be set"); + Objects.requireNonNull(recorder, "Recorder has to be set"); + Objects.requireNonNull(source, "Node has to be set"); + callback = Optional.ofNullable(callback).orElse(m -> { + }); + + var menu = new Menu("Group"); + + var editGroup = new MenuItem("Edit group"); + editGroup.setOnAction(e -> new EditGroupAction(source, recorder, model).execute(callback)); + + var resumeAllOfGroup = new MenuItem("Resume all in group"); + resumeAllOfGroup.setOnAction(e -> new ResumeGroupAction(source, recorder, model).execute(callback)); + + var pauseAllOfGroup = new MenuItem("Pause all in group"); + pauseAllOfGroup.setOnAction(e -> new PauseGroupAction(source, recorder, model).execute(callback)); + + var stopAllOfGroup = new MenuItem("Stop all in group"); + stopAllOfGroup.setOnAction(e -> new StopGroupAction(source, recorder, model).execute(callback)); + + var laterAllOfGroup = new MenuItem("Record later all in group"); + laterAllOfGroup.setOnAction(e -> new LaterGroupAction(source, recorder, model).execute(callback)); + + menu.getItems().addAll(editGroup, resumeAllOfGroup, pauseAllOfGroup, stopAllOfGroup, laterAllOfGroup); + return menu; + } +} diff --git a/client/src/main/java/ctbrec/ui/menu/ModelMenuContributor.java b/client/src/main/java/ctbrec/ui/menu/ModelMenuContributor.java new file mode 100644 index 00000000..73bb762c --- /dev/null +++ b/client/src/main/java/ctbrec/ui/menu/ModelMenuContributor.java @@ -0,0 +1,449 @@ +package ctbrec.ui.menu; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.ModelGroup; +import ctbrec.recorder.Recorder; +import ctbrec.ui.DesktopIntegration; +import ctbrec.ui.action.AbstractModelAction.Result; +import ctbrec.ui.action.*; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.tabs.FollowedTab; +import javafx.event.ActionEvent; +import javafx.event.EventHandler; +import javafx.scene.Node; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.MenuItem; +import javafx.scene.control.SeparatorMenuItem; +import javafx.scene.control.TabPane; +import javafx.scene.input.Clipboard; +import javafx.scene.input.ClipboardContent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URLEncoder; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +import static java.nio.charset.StandardCharsets.UTF_8; + +public class ModelMenuContributor { + + private static final Logger LOG = LoggerFactory.getLogger(ModelMenuContributor.class); + + private final Config config; + private final Recorder recorder; + private final Node source; + private Consumer startStopCallback; + private TriConsumer followCallback; + private Consumer ignoreCallback; + private Consumer portraitCallback; + private boolean removeWithIgnore = false; + private Runnable callback; + + private ModelMenuContributor(Node source, Config config, Recorder recorder) { + this.source = source; + this.config = config; + this.recorder = recorder; + } + + public static ModelMenuContributor newContributor(Node source, Config config, Recorder recorder) { + return new ModelMenuContributor(source, config, recorder); + } + + public ModelMenuContributor withStartStopCallback(Consumer callback) { + this.startStopCallback = callback; + return this; + } + + public ModelMenuContributor withFollowCallback(TriConsumer callback) { + this.followCallback = callback; + return this; + } + + public ModelMenuContributor withIgnoreCallback(Consumer ignoreCallback) { + this.ignoreCallback = ignoreCallback; + return this; + } + + public ModelMenuContributor withPortraitCallback(Consumer portraitCallback) { + this.portraitCallback = portraitCallback; + return this; + } + + + public ModelMenuContributor removeModelAfterIgnore(boolean yes) { + this.removeWithIgnore = yes; + return this; + } + + public void contributeToMenu(List selectedModels, ContextMenu menu) { + startStopCallback = Optional.ofNullable(startStopCallback).orElse(m -> { + }); + followCallback = Optional.ofNullable(followCallback).orElse((m, f, s) -> { + }); + ignoreCallback = Optional.ofNullable(ignoreCallback).orElse(m -> { + }); + portraitCallback = Optional.ofNullable(portraitCallback).orElse(m -> { + }); + callback = Optional.ofNullable(callback).orElse(() -> { + }); + addOpenInPlayer(menu, selectedModels); + addOpenInBrowser(menu, selectedModels); + addCopyUrl(menu, selectedModels); + menu.getItems().add(new SeparatorMenuItem()); + + addStartOrStop(menu, selectedModels); + addStartRecordingWithTimeLimit(menu, selectedModels); + addRemoveTimeLimit(menu, selectedModels); + addSwitchStreamSource(menu, selectedModels); + addStartPaused(menu, selectedModels); + addRecordLater(menu, selectedModels); + addPauseResume(menu, selectedModels); + addForceRecord(menu, selectedModels); + addGroupMenu(menu, selectedModels); + menu.getItems().add(new SeparatorMenuItem()); + + addFollowUnfollow(menu, selectedModels); + addSendTip(menu, selectedModels); + addIgnore(menu, selectedModels); + addOpenRecDir(menu, selectedModels); + addNotes(menu, selectedModels); + addPortrait(menu, selectedModels); + addOpenOnCamGirlFinder(menu, selectedModels); + } + + public ModelMenuContributor afterwards(Runnable callback) { + this.callback = callback; + return this; + } + + private void addNotes(ContextMenu menu, List selectedModels) { + var notes = new MenuItem("Notes"); + notes.setDisable(selectedModels.size() != 1); + notes.setOnAction(e -> new EditNotesAction(source, selectedModels.get(0), callback).execute()); + menu.getItems().add(notes); + } + + private void addPortrait(ContextMenu menu, List selectedModels) { + var portrait = new MenuItem("Select Portrait"); + portrait.setDisable(selectedModels.size() != 1); + portrait.setOnAction(e -> new SetPortraitAction(source, selectedModels.get(0), portraitCallback).execute()); + menu.getItems().add(portrait); + } + + private void addOpenRecDir(ContextMenu menu, List selectedModels) { + if (selectedModels == null || selectedModels.isEmpty()) { + return; + } + var model = selectedModels.get(0); + var openRecDir = new MenuItem("Open recording directory"); + openRecDir.setDisable(selectedModels.size() != 1); + openRecDir.setOnAction(e -> { + new OpenRecordingsDir(source, model).execute(); + executeCallback(); + }); + menu.getItems().add(openRecDir); + } + + private void addOpenInBrowser(ContextMenu menu, List selectedModels) { + var openInBrowser = new MenuItem("Open in browser"); + openInBrowser.setOnAction(e -> selectedModels.forEach(model -> DesktopIntegration.open(model.getUrl()))); + menu.getItems().add(openInBrowser); + } + + private void addOpenOnCamGirlFinder(ContextMenu menu, List selectedModels) { + var openOnCamGirlFinder = new MenuItem("Search on CamGirlFinder"); + openOnCamGirlFinder.setOnAction(e -> { + for (Model model : selectedModels) { + String preview = model.getPreview(); + if (preview != null && !preview.isEmpty()) { + String query = URLEncoder.encode(preview, UTF_8); + DesktopIntegration.open("https://camgirlfinder.net/search?url=" + query); + } else { + String query = URLEncoder.encode(model.getName(), UTF_8); + DesktopIntegration.open("https://camgirlfinder.net/models?m=" + query + "&p=a&g=a"); + } + } + }); + menu.getItems().add(openOnCamGirlFinder); + } + + private void addCopyUrl(ContextMenu menu, List selectedModels) { + if (selectedModels == null || selectedModels.isEmpty()) { + return; + } + var copyUrl = new MenuItem("Copy URL"); + copyUrl.setOnAction(e -> { + var sb = new StringBuilder(); + for (Model model : selectedModels) { + sb.append(model.getUrl()).append('\n'); + } + sb.deleteCharAt(sb.length() - 1); + final var content = new ClipboardContent(); + content.putString(sb.toString()); + Clipboard.getSystemClipboard().setContent(content); + }); + menu.getItems().add(copyUrl); + } + + private void addSendTip(ContextMenu menu, List selectedModels) { + var model = selectedModels.get(0); + var site = model.getSite(); + if (site.supportsTips()) { + var sendTip = new MenuItem("Send Tip"); + sendTip.setOnAction(e -> new TipAction(model, source).execute()); + sendTip.setDisable(!site.credentialsAvailable()); + sendTip.setDisable(selectedModels.size() != 1); + menu.getItems().add(sendTip); + } + } + + private void addIgnore(ContextMenu menu, List selectedModels) { + var ignore = new MenuItem("Ignore"); + ignore.setOnAction(e -> ignore(selectedModels)); + menu.getItems().add(ignore); + } + + private void ignore(List selectedModels) { + new IgnoreModelsAction(source, selectedModels, recorder, removeWithIgnore).execute(ignoreCallback); + } + + private void addFollowUnfollow(ContextMenu menu, List selectedModels) { + var site = selectedModels.get(0).getSite(); + if (site.supportsFollow()) { + var follow = new MenuItem("Follow"); + follow.setOnAction(e -> new FollowUnfollowHandler(source, recorder, followCallback).follow(selectedModels)); + var unfollow = new MenuItem("Unfollow"); + unfollow.setOnAction(e -> new FollowUnfollowHandler(source, recorder, followCallback).unfollow(selectedModels)); + + var followOrUnFollow = isFollowedTab() ? unfollow : follow; + followOrUnFollow.setDisable(!site.credentialsAvailable()); + menu.getItems().add(followOrUnFollow); + followOrUnFollow.setDisable(!site.credentialsAvailable()); + } + } + + private boolean isFollowedTab() { + if (source instanceof TabPane tabPane) { + return tabPane.getSelectionModel().getSelectedItem() instanceof FollowedTab; + } + return false; + } + + private void addSwitchStreamSource(ContextMenu menu, List selectedModels) { + var model = selectedModels.get(0); + if (!recorder.isTracked(model)) { + return; + } + var switchStreamSource = new MenuItem("Switch resolution"); + switchStreamSource.setOnAction(e -> switchStreamSource(selectedModels.get(0))); + menu.getItems().add(switchStreamSource); + switchStreamSource.setDisable(selectedModels.size() != 1); + } + + private void switchStreamSource(Model selectedModel) { + new SwitchStreamResolutionAction(source, selectedModel, recorder).execute(); + } + + private void addGroupMenu(ContextMenu menu, List selectedModels) { + var model = selectedModels.get(0); + var addToGroup = new MenuItem("Add to group"); + addToGroup.setOnAction(e -> new AddToGroupAction(source, recorder, selectedModels).execute(callback)); + var groupSubMenu = new ModelGroupMenuBuilder() // @formatter:off + .model(model) + .recorder(recorder) + .node(source) + .callback(m -> callback.run()) + .build(); // @formatter:on + Optional modelGroup = recorder.getModelGroup(model); + menu.getItems().add(modelGroup.isEmpty() ? addToGroup : groupSubMenu); + } + + private void addPauseResume(ContextMenu menu, List selectedModels) { + var first = selectedModels.get(0); + if (recorder.isTracked(first)) { + var pause = new MenuItem("Pause Recording"); + pause.setOnAction(e -> new PauseResumeHandler(source, recorder, callback).pause(selectedModels)); + var resume = new MenuItem("Resume Recording"); + resume.setOnAction(e -> new PauseResumeHandler(source, recorder, callback).resume(selectedModels)); + var pauseResume = recorder.isSuspended(first) ? resume : pause; + menu.getItems().add(pauseResume); + } + } + + private void addForceRecord(ContextMenu menu, List selectedModels) { + var forcePriority = new MenuItem("Enable Force Recording"); + forcePriority.setOnAction(e -> { + for (Model model : selectedModels) { + model.setMarkedForLaterRecording(false); + model.setSuspended(false); + } + if (!recorder.isTracked(selectedModels.get(0))) { + startStopAction(selectedModels, true); + } + new ForcePriorityHandler(source, recorder, callback).forcePriority(selectedModels); + }); + var resumePriority = new MenuItem("Disable Force Recording"); + resumePriority.setOnAction(e -> new ForcePriorityHandler(source, recorder, callback).resumePriority(selectedModels)); + var forceResumePriority = recorder.isForcePriority(selectedModels.get(0)) ? resumePriority : forcePriority; + menu.getItems().add(forceResumePriority); + } + + private void addRecordLater(ContextMenu menu, List selectedModels) { + var first = selectedModels.get(0); + var recordLater = new MenuItem("Record Later"); + recordLater.setOnAction(e -> recordLater(selectedModels, true)); + var removeRecordLater = new MenuItem("Forget Model"); + removeRecordLater.setOnAction(e -> recordLater(selectedModels, false)); + var addRemoveBookmark = recorder.isMarkedForLaterRecording(first) ? removeRecordLater : recordLater; + if (recorder.isTracked(first)) { + menu.getItems().add(recordLater); + } else { + menu.getItems().add(addRemoveBookmark); + } + } + + private void recordLater(List selectedModels, boolean recordLater) { + var confirmed = true; + if (!recordLater) { + confirmed = showRemoveConfirmationDialog(selectedModels); + } + if (confirmed) { + selectedModels.forEach(m -> m.setMarkedForLaterRecording(recordLater)); + new MarkForLaterRecordingAction(source, selectedModels, recordLater, recorder).execute(m -> executeCallback()); + } + } + + private void addStartPaused(ContextMenu menu, List selectedModels) { + if (!recorder.isTracked(selectedModels.get(0))) { + var addPaused = new MenuItem("Add in paused state"); + menu.getItems().add(addPaused); + addPaused.setOnAction(e -> { + for (Model model : selectedModels) { + model.setMarkedForLaterRecording(false); + model.setSuspended(true); + } + startStopAction(selectedModels, true); + }); + } + } + + private void addStartRecordingWithTimeLimit(ContextMenu menu, List selectedModels) { + var model = selectedModels.get(0); + String text; + EventHandler eventHandler; + if (recorder.isTracked(model)) { + text = "Record Until"; + eventHandler = e -> { + for (Model selectedModel : selectedModels) { + new SetStopDateAction(source, selectedModel, recorder) + .execute() + .thenAccept(r -> executeCallback()); + } + }; + } else { + text = "Start Recording Until"; + eventHandler = e -> new StartRecordingAction(source, selectedModels, recorder) + .showRecordUntilDialog() + .execute() + .thenAccept(r -> executeCallback()); + } + + var start = new MenuItem(text); + start.setOnAction(eventHandler); + menu.getItems().add(start); + } + + private void addRemoveTimeLimit(ContextMenu menu, List selectedModels) { + var model = selectedModels.get(0); + if (!model.isRecordingTimeLimited()) { + return; + } + var removeTimeLimit = new MenuItem("Remove Time Limit"); + removeTimeLimit.setOnAction(e -> removeTimeLimit(model)); + menu.getItems().add(removeTimeLimit); + } + + private void removeTimeLimit(Model selectedModel) { + new RemoveTimeLimitAction(source, selectedModel, recorder) // + .execute() // + .whenComplete((result, exception) -> executeCallback()); + } + + private void addOpenInPlayer(ContextMenu menu, List selectedModels) { + var openInPlayer = new MenuItem("Open in Player"); + openInPlayer.setOnAction(e -> selectedModels.forEach(m -> new PlayAction(source, m).execute())); + menu.getItems().add(openInPlayer); + + if (config.getSettings().singlePlayer && selectedModels.size() > 1) { + openInPlayer.setDisable(true); + } + } + + private void addStartOrStop(ContextMenu menu, List selectedModels) { + var start = new MenuItem("Start Recording"); + start.setOnAction(e -> { + for (Model model : selectedModels) { + model.setMarkedForLaterRecording(false); + model.setSuspended(false); + } + startStopAction(selectedModels, true); + }); + var stop = new MenuItem("Stop Recording"); + stop.setOnAction(e -> startStopAction(selectedModels, false)); + var startStop = recorder.isTracked(selectedModels.get(0)) ? stop : start; + menu.getItems().add(startStop); + } + + private void startStopAction(List selection, boolean start) { + if (start) { + startRecording(selection); + } else { + stopRecording(selection); + } + } + + private void startRecording(List models) { + new StartRecordingAction(source, models, recorder).execute() + .whenComplete((r, ex) -> { + if (ex != null) { + LOG.error("Error while starting recordings", ex); + } + r.stream().map(Result::getModel).forEach(startStopCallback); + }); + } + + private void stopRecording(List models) { + var confirmed = showRemoveConfirmationDialog(models); + if (confirmed) { + new StopRecordingAction(source, models, recorder).execute() + .whenComplete((r, ex) -> { + if (ex != null) { + LOG.error("Error while stopping recordings", ex); + } + r.stream().map(Result::getModel).forEach(startStopCallback); + }); + } + } + + private boolean showRemoveConfirmationDialog(List models) { + if (Config.getInstance().getSettings().confirmationForDangerousActions) { + int n = models.size(); + String plural = n > 1 ? "s" : ""; + String header = "This will remove " + n + " model" + plural; + return Dialogs.showConfirmDialog("Stop Recording", "Continue?", header, source.getScene()); + } else { + return true; + } + } + + private void executeCallback() { + try { + callback.run(); + } catch (Exception e) { + LOG.error("Error while executing menu callback", e); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/menu/PauseResumeHandler.java b/client/src/main/java/ctbrec/ui/menu/PauseResumeHandler.java new file mode 100644 index 00000000..d6aae631 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/menu/PauseResumeHandler.java @@ -0,0 +1,51 @@ +package ctbrec.ui.menu; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Model; +import ctbrec.recorder.Recorder; +import ctbrec.ui.action.PauseAction; +import ctbrec.ui.action.ResumeAction; +import javafx.scene.Node; + +public class PauseResumeHandler { + + private static final Logger LOG = LoggerFactory.getLogger(PauseResumeHandler.class); + + private Node source; + private Recorder recorder; + private Runnable callback; + + public PauseResumeHandler(Node source, Recorder recorder, Runnable callback) { + this.source = source; + this.recorder = recorder; + this.callback = callback; + } + + protected void pause(List selectedModels) { + new PauseAction(source, selectedModels, recorder).execute() + .exceptionally(ex -> { + LOG.error("Error while pausing recordings", ex); + return null; + }).whenComplete((r, ex) -> executeCallback()); + } + + protected void resume(List selectedModels) { + new ResumeAction(source, selectedModels, recorder).execute() + .exceptionally(ex -> { + LOG.error("Error while resuming recordings", ex); + return null; + }).whenComplete((r, ex) -> executeCallback()); + } + + private void executeCallback() { + try { + callback.run(); + } catch (Exception e) { + LOG.error("Error while executing menu callback", e); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/news/Account.java b/client/src/main/java/ctbrec/ui/news/Account.java new file mode 100644 index 00000000..8b5db643 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/news/Account.java @@ -0,0 +1,49 @@ +package ctbrec.ui.news; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class Account { + + @JsonProperty("emojis") + private List emojis = null; + @JsonProperty("note") + private String note; + @JsonProperty("bot") + private Boolean bot; + @JsonProperty("created_at") + private String createdAt; + @JsonProperty("avatar") + private String avatar; + @JsonProperty("display_name") + private String displayName; + @JsonProperty("header_static") + private String headerStatic; + @JsonProperty("url") + private String url; + @JsonProperty("following_count") + private Integer followingCount; + @JsonProperty("statuses_count") + private Integer statusesCount; + @JsonProperty("followers_count") + private Integer followersCount; + @JsonProperty("header") + private String header; + @JsonProperty("id") + private String id; + @JsonProperty("locked") + private Boolean locked; + @JsonProperty("avatar_static") + private String avatarStatic; + @JsonProperty("fields") + private List fields = null; + @JsonProperty("acct") + private String acct; + @JsonProperty("username") + private String username; +} diff --git a/client/src/main/java/ctbrec/ui/news/NewsTab.java b/client/src/main/java/ctbrec/ui/news/NewsTab.java new file mode 100644 index 00000000..152cd693 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/news/NewsTab.java @@ -0,0 +1,105 @@ +package ctbrec.ui.news; + +import com.fasterxml.jackson.databind.ObjectMapper; +import ctbrec.Config; +import ctbrec.GlobalThreadPool; +import ctbrec.Version; +import ctbrec.io.HttpException; +import ctbrec.io.json.ObjectMapperFactory; +import ctbrec.ui.CamrecApplication; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.tabs.TabSelectionListener; +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.Tab; +import javafx.scene.layout.VBox; +import lombok.extern.slf4j.Slf4j; +import okhttp3.Request; +import org.json.JSONObject; + +import java.io.IOException; +import java.util.Objects; + +import static ctbrec.ErrorMessages.HTTP_RESPONSE_BODY_IS_NULL; +import static ctbrec.io.HttpConstants.USER_AGENT; + +@Slf4j +public class NewsTab extends Tab implements TabSelectionListener { + private static final String ACCESS_TOKEN = "a2804d73a89951a22e0f8483a6fcec8943afd88b7ba17c459c095aa9e6f94fd0"; + private static final String URL = "https://mastodon.cloud/api/v1/accounts/480960/statuses?limit=20&exclude_replies=true"; + private final Config config; + private final VBox layout = new VBox(); + + private final ObjectMapper mapper = ObjectMapperFactory.getMapper(); + + public NewsTab(Config config) { + this.config = config; + setText("News"); + layout.setMaxWidth(800); + layout.setAlignment(Pos.CENTER); + setContent(new ScrollPane(layout)); + } + + @Override + public void selected() { + GlobalThreadPool.submit(this::loadToots); + } + + private void loadToots() { + try { + var request = new Request.Builder() + .url(URL) + .header("Authorization", "Bearer " + ACCESS_TOKEN) + .header(USER_AGENT, "ctbrec " + Version.getVersion()) + .build(); + try (var response = CamrecApplication.httpClient.execute(request)) { + if (response.isSuccessful()) { + var body = Objects.requireNonNull(response.body(), HTTP_RESPONSE_BODY_IS_NULL).string(); + log.debug(body); + if (body.startsWith("[")) { + onSuccess(body); + } else if (body.startsWith("{")) { + onError(body); + } else { + throw new IOException("Unexpected response: " + body); + } + } else { + throw new HttpException(response.code(), response.message()); + } + } + } catch (IOException e) { + log.info("Error while loading news", e); + Dialogs.showError(getTabPane().getScene(), "News", "Couldn't load news from mastodon", e); + } + } + + private void onError(String body) throws IOException { + var json = new JSONObject(body); + if (json.has("error")) { + throw new IOException("Request not successful: " + json.getString("error")); + } else { + throw new IOException("Unexpected response: " + body); + } + } + + private void onSuccess(String body) throws IOException { + Status[] statusArray = mapper.readValue(body, Status[].class); + Platform.runLater(() -> { + layout.getChildren().clear(); + for (Status status : statusArray) { + if (status.getInReplyToId() == null && !Objects.equals("direct", status.getVisibility())) { + var stp = new StatusPane(status, config.getDateTimeFormatter()); + layout.getChildren().add(stp); + VBox.setMargin(stp, new Insets(10)); + } + } + }); + } + + @Override + public void deselected() { + // nothing to do + } +} diff --git a/client/src/main/java/ctbrec/ui/news/Status.java b/client/src/main/java/ctbrec/ui/news/Status.java new file mode 100644 index 00000000..8bf4c97d --- /dev/null +++ b/client/src/main/java/ctbrec/ui/news/Status.java @@ -0,0 +1,75 @@ +package ctbrec.ui.news; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class Status { + + @JsonProperty("pinned") + private Boolean pinned; + @JsonProperty("in_reply_to_id") + private Object inReplyToId; + @JsonProperty("favourites_count") + private Integer favouritesCount; + @JsonProperty("media_attachments") + private List mediaAttachments = null; + @JsonProperty("created_at") + private String createdAt; + @JsonProperty("replies_count") + private Integer repliesCount; + @JsonProperty("language") + private String language; + @JsonProperty("in_reply_to_account_id") + private Object inReplyToAccountId; + @JsonProperty("content") + private String content; + @JsonProperty("reblog") + private Object reblog; + @JsonProperty("spoiler_text") + private String spoilerText; + @JsonProperty("id") + private String id; + @JsonProperty("reblogged") + private Boolean reblogged; + @JsonProperty("muted") + private Boolean muted; + @JsonProperty("emojis") + private List emojis = null; + @JsonProperty("reblogs_count") + private Integer reblogsCount; + @JsonProperty("visibility") + private String visibility; + @JsonProperty("sensitive") + private Boolean sensitive; + @JsonProperty("uri") + private String uri; + @JsonProperty("url") + private String url; + @JsonProperty("tags") + private List tags = null; + @JsonProperty("application") + private Object application; + @JsonProperty("favourited") + private Boolean favourited; + @JsonProperty("mentions") + private List mentions = null; + @JsonProperty("account") + private Account account; + @JsonProperty("card") + private Object card; + + public ZonedDateTime getCreationTime() { + String timestamp = getCreatedAt(); + var instant = Instant.parse(timestamp); + var time = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault()); + return time; + } +} diff --git a/client/src/main/java/ctbrec/ui/news/StatusPane.java b/client/src/main/java/ctbrec/ui/news/StatusPane.java new file mode 100644 index 00000000..afb4b763 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/news/StatusPane.java @@ -0,0 +1,76 @@ +package ctbrec.ui.news; + +import ctbrec.io.HtmlParser; +import ctbrec.ui.DesktopIntegration; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.geometry.Orientation; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.layout.StackPane; +import javafx.scene.shape.Rectangle; + +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Set; + +public class StatusPane extends StackPane { + + TextArea content; + Button reply; + + public StatusPane(Status status, DateTimeFormatter formatter) { + String text = HtmlParser.getText("
" + status.getContent() + "
", "div"); + + content = new TextArea(text); + content.setMaxHeight(130); + content.setEditable(false); + content.setWrapText(true); + getChildren().add(content); + + ZonedDateTime createdAt = status.getCreationTime(); + String creationTime = formatter.format(createdAt); + var time = new Label(creationTime); + time.setStyle("-fx-background-color: -fx-base"); + time.setOpacity(.7); + time.setPadding(new Insets(3)); + time.setOnMouseEntered(evt -> time.setOpacity(1)); + time.setOnMouseExited(evt -> time.setOpacity(.7)); + getChildren().add(time); + StackPane.setMargin(time, new Insets(5, 5, 5, 10)); + StackPane.setAlignment(time, Pos.BOTTOM_LEFT); + var clip = new Rectangle(time.getWidth(), time.getHeight()); + clip.heightProperty().bind(time.heightProperty()); + clip.widthProperty().bind(time.widthProperty()); + clip.setArcHeight(5); + clip.arcWidthProperty().bind(clip.arcHeightProperty()); + time.setClip(clip); + + reply = new Button("Reply"); + reply.setOnAction(evt -> DesktopIntegration.open(status.getUrl())); + getChildren().add(reply); + StackPane.setMargin(reply, new Insets(5, 5, 5, 5)); + StackPane.setAlignment(reply, Pos.BOTTOM_RIGHT); + } + + @Override + protected void layoutChildren() { + ObservableList childrenUnmodifiable = content.getChildrenUnmodifiable(); + for (Node node : childrenUnmodifiable) { + if (node instanceof ScrollPane scrollPane) { + Set nodes = scrollPane.lookupAll(".scroll-bar"); + for (final Node child : nodes) { + if (child instanceof ScrollBar sb && sb.getOrientation() == Orientation.VERTICAL) { + if (sb.isVisible()) { + StackPane.setMargin(reply, new Insets(5, 22, 5, 5)); + } else { + StackPane.setMargin(reply, new Insets(5, 5, 5, 5)); + } + } + } + } + } + super.layoutChildren(); + } +} diff --git a/client/src/main/java/ctbrec/ui/settings/AbstractPostProcessingPaneFactory.java b/client/src/main/java/ctbrec/ui/settings/AbstractPostProcessingPaneFactory.java new file mode 100644 index 00000000..edd2842f --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/AbstractPostProcessingPaneFactory.java @@ -0,0 +1,142 @@ +package ctbrec.ui.settings; + +import ctbrec.recorder.postprocessing.PostProcessor; +import ctbrec.ui.controls.DirectorySelectionBox; +import ctbrec.ui.controls.ProgramSelectionBox; +import ctbrec.ui.settings.api.*; +import javafx.beans.property.*; +import javafx.geometry.Insets; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.layout.HBox; +import javafx.util.converter.NumberStringConverter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; + +public abstract class AbstractPostProcessingPaneFactory { + + private static final Logger LOG = LoggerFactory.getLogger(AbstractPostProcessingPaneFactory.class); + private PostProcessor pp; + Set> properties = new HashSet<>(); + + public abstract Preferences doCreatePostProcessorPane(PostProcessor pp); + + public Preferences createPostProcessorPane(PostProcessor pp) { + this.pp = pp; + return doCreatePostProcessorPane(pp); + } + + class MapPreferencesStorage implements PreferencesStorage { + @Override + public void save(Preferences preferences) throws IOException { + for (Property property : properties) { + String key = property.getName(); + Object value = preferences.getSetting(key).get().getProperty().getValue(); + LOG.debug("{}={}", key, value); + pp.getConfig().put(key, value.toString()); + } + } + + @Override + public void load(Preferences preferences) { + // no op + } + + @Override + public Node createGui(Setting setting) throws NoSuchFieldException, IllegalAccessException { + Property prop = setting.getProperty(); + if (prop instanceof ExclusiveSelectionProperty) { + return createRadioGroup(setting); + } else if (prop instanceof SimpleDirectoryProperty) { + return createDirectorySelector(setting); + } else if (prop instanceof SimpleFileProperty) { + return createFileSelector(setting); + } else if (prop instanceof IntegerProperty) { + return createIntegerProperty(setting); + } else if (prop instanceof LongProperty) { + return createLongProperty(setting); + } else if (prop instanceof BooleanProperty) { + return createBooleanProperty(setting); + } else if (prop instanceof ListProperty) { + return createComboBox(setting); + } else if (prop instanceof StringProperty) { + return createStringProperty(setting); + } else { + return new Label("Unsupported Type for key " + setting.getKey() + ": " + setting.getProperty()); + } + } + + private Node createRadioGroup(Setting setting) { + ExclusiveSelectionProperty prop = (ExclusiveSelectionProperty) setting.getProperty(); + var toggleGroup = new ToggleGroup(); + var optionA = new RadioButton(prop.getOptionA()); + optionA.setSelected(prop.getValue()); + optionA.setToggleGroup(toggleGroup); + var optionB = new RadioButton(prop.getOptionB()); + optionB.setSelected(!optionA.isSelected()); + optionB.setToggleGroup(toggleGroup); + optionA.selectedProperty().bindBidirectional(prop); + var row = new HBox(); + row.getChildren().addAll(optionA, optionB); + HBox.setMargin(optionA, new Insets(5)); + HBox.setMargin(optionB, new Insets(5)); + return row; + } + + private Node createFileSelector(Setting setting) { + var programSelector = new ProgramSelectionBox(""); + StringProperty property = (StringProperty) setting.getProperty(); + programSelector.fileProperty().bindBidirectional(property); + return programSelector; + } + + private Node createDirectorySelector(Setting setting) { + var directorySelector = new DirectorySelectionBox(""); + directorySelector.prefWidth(400); + StringProperty property = (StringProperty) setting.getProperty(); + directorySelector.fileProperty().bindBidirectional(property); + return directorySelector; + } + + @SuppressWarnings("unchecked") + private Node createStringProperty(Setting setting) { + var ctrl = new TextField(); + ctrl.textProperty().bindBidirectional(setting.getProperty()); + return ctrl; + } + + @SuppressWarnings("unchecked") + private Node createIntegerProperty(Setting setting) { + var ctrl = new TextField(); + Property prop = setting.getProperty(); + ctrl.textProperty().bindBidirectional(prop, new NumberStringConverter()); + return ctrl; + } + + @SuppressWarnings("unchecked") + private Node createLongProperty(Setting setting) { + var ctrl = new TextField(); + Property prop = setting.getProperty(); + ctrl.textProperty().bindBidirectional(prop, new NumberStringConverter()); + return ctrl; + } + + private Node createBooleanProperty(Setting setting) { + var ctrl = new CheckBox(); + BooleanProperty prop = (BooleanProperty) setting.getProperty(); + ctrl.selectedProperty().bindBidirectional(prop); + return ctrl; + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private Node createComboBox(Setting setting) { + ListProperty listProp = (ListProperty) setting.getProperty(); + ComboBox comboBox = new ComboBox(listProp); + return comboBox; + } + } +} 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..d751e6cf --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/ActionSettingsPanel.java @@ -0,0 +1,360 @@ +package ctbrec.ui.settings; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.Recording; +import ctbrec.StringUtil; +import ctbrec.event.*; +import ctbrec.event.EventHandlerConfiguration.ActionConfiguration; +import ctbrec.event.EventHandlerConfiguration.PredicateConfiguration; +import ctbrec.io.json.mapper.ModelMapper; +import ctbrec.recorder.Recorder; +import ctbrec.ui.CamrecApplication; +import ctbrec.ui.DesktopIntegration; +import ctbrec.ui.controls.Dialogs; +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.Pos; +import javafx.geometry.VPos; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.*; +import javafx.scene.image.Image; +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; +import org.mapstruct.factory.Mappers; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; +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 java.util.Objects; +import java.util.stream.Collectors; + +public class ActionSettingsPanel extends GridPane { + private static final Logger LOG = LoggerFactory.getLogger(ActionSettingsPanel.class); + private ListView actionTable; + + private final TextField name = new TextField(); + private final ComboBox event = new ComboBox<>(); + private final ComboBox modelState = new ComboBox<>(); + private final ComboBox recordingState = new ComboBox<>(); + + private final CheckBox playSound = new CheckBox("Play sound"); + private final FileSelectionBox sound = new FileSelectionBox(); + private final Slider soundVolume = new Slider(0, 100, 100); + private final CheckBox showNotification = new CheckBox("Notify me"); + private final Button testNotification = new Button("Test"); + private final ToggleButton toggleEvents = new ToggleButton(); + private final CheckBox executeProgram = new CheckBox("Execute program"); + private final ProgramSelectionBox program = new ProgramSelectionBox(); + private ListSelectionPane modelSelectionPane; + + private final Recorder recorder; + + public ActionSettingsPanel(Recorder recorder) { + this.recorder = recorder; + createGui(); + loadEventHandlers(); + } + + private void loadEventHandlers() { + actionTable.getItems().addAll(Config.getInstance().getSettings().eventHandlers); + } + + private void createGui() { + setHgap(10); + setVgap(10); + setPadding(new Insets(20, 10, 10, 10)); + + var headline = new Label("Events & Actions"); + headline.getStyleClass().add("settings-group-label"); + add(headline, 0, 0); + + actionTable = createActionTable(); + var scrollPane = new ScrollPane(actionTable); + scrollPane.setFitToHeight(true); + scrollPane.setFitToWidth(true); + scrollPane.setStyle("-fx-background-color: -fx-background"); + add(scrollPane, 0, 1); + GridPane.setHgrow(scrollPane, Priority.ALWAYS); + + var add = new Button("Add"); + add.setOnAction(this::add); + var delete = new Button("Delete"); + delete.setOnAction(this::delete); + delete.setDisable(true); + toggleEvents.setOnAction(this::toggleEvents); + toggleEvents.setSelected(Config.getInstance().getSettings().eventsSuspended); + toggleEvents.setText(toggleEvents.isSelected() ? "Resume Events" : "Suspend Events"); + toggleEvents.setTooltip(new Tooltip(toggleEvents.isSelected() ? "Events are currently suspended" : "Events are currently active")); + var buttons = new HBox(5, add, delete, toggleEvents); + buttons.setStyle("-fx-background-color: -fx-background"); // workaround so that the buttons don't shrink + add(buttons, 0, 2); + + actionTable.getSelectionModel().getSelectedItems().addListener((ListChangeListener) change -> delete.setDisable(change.getList().isEmpty())); + } + + private void toggleEvents(ActionEvent actionEvent) { + Config.getInstance().getSettings().eventsSuspended = toggleEvents.isSelected(); + toggleEvents.setText(toggleEvents.isSelected() ? "Resume Events" : "Suspend Events"); + toggleEvents.setTooltip(new Tooltip(toggleEvents.isSelected() ? "Events are currently suspended" : "Events are currently active")); + try { + Config.getInstance().save(); + } catch (IOException e) { + LOG.error("Couldn't save config", e); + } + } + + private void add(ActionEvent evt) { + var actionPane = createActionPane(); + var dialog = new Stage(); + dialog.initModality(Modality.APPLICATION_MODAL); + dialog.initOwner(getScene().getWindow()); + dialog.setTitle("New Action"); + InputStream icon = Objects.requireNonNull(getClass().getResourceAsStream("/icon.png"), "/icon.png not found in classpath"); + dialog.getIcons().add(new Image(icon)); + var root = new Wizard(dialog, this::validateSettings, actionPane); + var 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() { + var config = new EventHandlerConfiguration(); + config.setName(name.getText()); + config.setEvent(event.getValue()); + if (event.getValue() == Event.Type.MODEL_STATUS_CHANGED) { + var 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) { + var 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); + } else if (event.getValue() == Event.Type.NO_SPACE_LEFT) { + var pc = new PredicateConfiguration(); + pc.setType(MatchAllPredicate.class.getName()); + pc.setName("no space left"); + config.getPredicates().add(pc); + } + if (!modelSelectionPane.isAllSelected()) { + var pc = new PredicateConfiguration(); + pc.setType(ModelPredicate.class.getName()); + pc.setModels(modelSelectionPane.getSelectedItems().stream().map(Mappers.getMapper(ModelMapper.class)::toDto).collect(Collectors.toList())); // NOSONAR + pc.setName("model is one of:" + modelSelectionPane.getSelectedItems()); + config.getPredicates().add(pc); + } + if (showNotification.isSelected()) { + var ac = new ActionConfiguration(); + ac.setType(ShowNotification.class.getName()); + ac.setName("show notification"); + config.getActions().add(ac); + } + if (playSound.isSelected()) { + var ac = new ActionConfiguration(); + ac.setType(PlaySound.class.getName()); + var file = new File(sound.fileProperty().get()); + ac.getConfiguration().put("file", file.getAbsolutePath()); + ac.getConfiguration().put("volume", soundVolume.getValue() / 100); + ac.setName("play " + file.getName()); + config.getActions().add(ac); + } + if (executeProgram.isSelected()) { + var ac = new ActionConfiguration(); + ac.setType(ExecuteProgram.class.getName()); + var file = new File(program.fileProperty().get()); + ac.getConfiguration().put("file", file.getAbsolutePath()); + ac.setName("execute " + file.getName()); + config.getActions().add(ac); + } + + var 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 (event.getValue() != Event.Type.NO_SPACE_LEFT && 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()); + + var row = 0; + layout.add(new Label("Name"), 0, row); + layout.add(name, 1, row++); + + layout.add(new Label("Event"), 0, row); + event.getItems().clear(); + event.getItems().add(Event.Type.MODEL_STATUS_CHANGED); + event.getItems().add(Event.Type.RECORDING_STATUS_CHANGED); + event.getItems().add(Event.Type.NO_SPACE_LEFT); + 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++); + + event.getSelectionModel().selectedItemProperty().addListener((obs, oldV, newV) -> { + var modelRelatedStuffDisabled = false; + if (newV == Event.Type.NO_SPACE_LEFT) { + modelRelatedStuffDisabled = true; + modelSelectionPane.selectAll(); + } + + modelState.setDisable(modelRelatedStuffDisabled); + recordingState.setDisable(modelRelatedStuffDisabled); + modelSelectionPane.setDisable(modelRelatedStuffDisabled); + }); + + 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++); + + var l = new Label("Models"); + layout.add(l, 0, row); + modelSelectionPane = new ListSelectionPane<>(recorder.getModels(), 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 -> { + var format = DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM); + var time = ZonedDateTime.now(); + DesktopIntegration.notification(CamrecApplication.title, "Test Notification", "Oi, what's up! " + format.format(time)); + }); + testNotification.disableProperty().bind(showNotification.selectedProperty().not()); + + HBox soundContainer = new HBox(); + soundContainer.setAlignment(Pos.CENTER_LEFT); + soundContainer.setSpacing(5); + layout.add(playSound, 0, row); + layout.add(soundContainer, 1, row++); + soundContainer.getChildren().add(soundVolume); + soundContainer.getChildren().add(sound); + sound.disableProperty().bind(playSound.selectedProperty().not()); + soundVolume.setTooltip(new Tooltip("Volume")); + soundVolume.disableProperty().bind(playSound.selectedProperty().not()); + HBox.setHgrow(sound, Priority.ALWAYS); + Button soundTest = new Button("Test"); + soundTest.setOnAction(this::testSound); + soundContainer.getChildren().add(soundTest); + + 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); + return layout; + } + + private void testSound(ActionEvent actionEvent) { + try { + URL soundFileUrl = new File(sound.fileProperty().getValue()).toURI().toURL(); + new PlaySound(soundFileUrl, soundVolume.getValue() / 100).accept(null); + } catch (MalformedURLException e) { + Dialogs.showError(getScene(), "Error", e.getLocalizedMessage(), e); + } + } + + private ListView createActionTable() { + ListView view = new ListView<>(); + view.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); + return view; + } + + private Node createSeparator() { + var divider = new Separator(Orientation.HORIZONTAL); + GridPane.setHgrow(divider, Priority.ALWAYS); + GridPane.setFillWidth(divider, true); + GridPane.setColumnSpan(divider, 2); + var tb = 20; + var 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/CacheSettingsPane.java b/client/src/main/java/ctbrec/ui/settings/CacheSettingsPane.java new file mode 100644 index 00000000..9295b374 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/CacheSettingsPane.java @@ -0,0 +1,53 @@ +package ctbrec.ui.settings; + +import ctbrec.Config; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.scene.control.ComboBox; +import javafx.scene.layout.HBox; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.util.List; + +@Slf4j +public class CacheSettingsPane extends HBox { + + private ComboBox cacheSizeCombo; + private final SettingsTab settingsTab; + private final Config config; + private static final List names = List.of("disabled", "16 MiB", "64 MiB", "128 MiB", "256 MiB", "512 MiB"); + private static final List values = List.of(0, 16, 64, 128, 256, 512); + + public CacheSettingsPane(SettingsTab settingsTab, Config config) { + this.settingsTab = settingsTab; + this.config = config; + setSpacing(5); + getChildren().addAll(buildCacheSizeCombo()); + } + + private ComboBox buildCacheSizeCombo() { + ObservableList lst = FXCollections.observableList(names); + cacheSizeCombo = new ComboBox<>(lst); + cacheSizeCombo.setOnAction(evt -> saveCacheConfig()); + int size = config.getSettings().thumbCacheSize; + int selectedIndex = values.indexOf(size); + if (selectedIndex < 0) { + selectedIndex = 1; + } + cacheSizeCombo.getSelectionModel().select(selectedIndex); + return cacheSizeCombo; + } + + private void saveCacheConfig() { + int index = cacheSizeCombo.getSelectionModel().getSelectedIndex(); + int size = values.get(index); + config.getSettings().thumbCacheSize = size; + try { + config.save(); + settingsTab.showRestartRequired(); + } catch (IOException e) { + log.error("Can't save config", e); + } + } +} 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..db441d7e --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/ColorSettingsPane.java @@ -0,0 +1,80 @@ +package ctbrec.ui.settings; + +import java.io.IOException; + +import ctbrec.Config; +import ctbrec.ui.controls.Dialogs; +import javafx.scene.control.Button; +import javafx.scene.control.ColorPicker; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Pane; +import javafx.scene.paint.Color; + +public class ColorSettingsPane extends HBox { + + ColorPicker baseColor = new ColorPicker(); + ColorPicker accentColor = new ColorPicker(); + Button reset = new Button("Reset"); + Pane foobar = new Pane(); + private Config config; + + public ColorSettingsPane(Config config) { + super(5); + this.config = config; + getChildren().add(baseColor); + getChildren().add(accentColor); + getChildren().add(reset); + + baseColor.setValue(Color.web(config.getSettings().colorBase)); + baseColor.setTooltip(new Tooltip("Base Color")); + baseColor.setPrefWidth(100); + accentColor.setValue(Color.web(config.getSettings().colorAccent)); + accentColor.setTooltip(new Tooltip("Accent Color")); + accentColor.setPrefWidth(100); + + baseColor.setOnAction(evt -> { + config.getSettings().colorBase = toWeb(baseColor.getValue()); + save(); + }); + accentColor.setOnAction(evt -> { + config.getSettings().colorAccent = toWeb(accentColor.getValue()); + save(); + }); + reset.setOnAction(evt -> { + baseColor.setValue(Color.WHITE); + config.getSettings().colorBase = toWeb(Color.WHITE); + accentColor.setValue(Color.WHITE); + config.getSettings().colorAccent = toWeb(Color.WHITE); + save(); + }); + } + + private void save() { + try { + config.save(); + } catch (IOException e) { + Dialogs.showError(getScene(), "Save Settings", "Couldn't save color settings", e); + } + } + + private String toWeb(Color value) { + var 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) { + var sb = new StringBuilder(); + if(v < 16) { + sb.append('0'); + } + sb.append(Integer.toHexString(v)); + return sb; + } +} diff --git a/client/src/main/java/ctbrec/ui/settings/CreateContactSheetPaneFactory.java b/client/src/main/java/ctbrec/ui/settings/CreateContactSheetPaneFactory.java new file mode 100644 index 00000000..e5eaee8e --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/CreateContactSheetPaneFactory.java @@ -0,0 +1,67 @@ +package ctbrec.ui.settings; + +import static ctbrec.recorder.postprocessing.CreateContactSheet.*; +import static java.lang.Boolean.*; + +import ctbrec.recorder.postprocessing.PostProcessor; +import ctbrec.ui.settings.api.Category; +import ctbrec.ui.settings.api.Preferences; +import ctbrec.ui.settings.api.Setting; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.scene.control.ColorPicker; +import javafx.scene.paint.Color; + +public class CreateContactSheetPaneFactory extends AbstractPostProcessingPaneFactory { + + private SimpleStringProperty background; + + @Override + public Preferences doCreatePostProcessorPane(PostProcessor pp) { + var totalSize = new SimpleStringProperty(null, TOTAL_SIZE, pp.getConfig().getOrDefault(TOTAL_SIZE, "1920")); + var padding = new SimpleStringProperty(null, PADDING, pp.getConfig().getOrDefault(PADDING, "4")); + var cols = new SimpleStringProperty(null, COLS, pp.getConfig().getOrDefault(COLS, "8")); + var rows = new SimpleStringProperty(null, ROWS, pp.getConfig().getOrDefault(ROWS, "7")); + var filename = new SimpleStringProperty(null, FILENAME, pp.getConfig().getOrDefault(FILENAME, "contactsheet.jpg")); + background = new SimpleStringProperty(null, BACKGROUND, pp.getConfig().getOrDefault(BACKGROUND, "0x333333")); + var burnTimestamp = new SimpleBooleanProperty(null, BURN_IN_TIMESTAMP, + Boolean.valueOf(pp.getConfig().getOrDefault(BURN_IN_TIMESTAMP, TRUE.toString()))); + properties.add(totalSize); + properties.add(padding); + properties.add(cols); + properties.add(rows); + properties.add(filename); + properties.add(background); + properties.add(burnTimestamp); + + var backgroundSetting = Setting.of("", background, "Hexadecimal value of the background color for the space between the thumbnails"); + var prefs = Preferences.of(new MapPreferencesStorage(), + Category.of(pp.getName(), + Setting.of("Total Width", totalSize, "Total width of the generated contact sheet"), + Setting.of("Padding", padding, "Padding between the thumbnails"), + Setting.of("Columns", cols ), + Setting.of("Rows", rows), + Setting.of("File Name", filename), + Setting.of("Background", createColorPicker(background.get())), + Setting.of("Timestamp (experimental)", burnTimestamp, "Burn in a timestamp on each thumb. Can be very slow on some systems."), + backgroundSetting + ) + ); + + try { + // hide the background color input field, because we use a color picker instead + backgroundSetting.getGui().setVisible(false); + } catch (Exception e) { + // hiding the background color input field didn't work, that's ok + } + + return prefs; + } + + private ColorPicker createColorPicker(String hexColor) { + var preselection = Color.web(hexColor); + var colorPicker = new ColorPicker(preselection); + colorPicker.setOnAction(e -> background.set(colorPicker.getValue().toString())); + return colorPicker; + } +} diff --git a/client/src/main/java/ctbrec/ui/settings/CtbrecPreferencesStorage.java b/client/src/main/java/ctbrec/ui/settings/CtbrecPreferencesStorage.java new file mode 100644 index 00000000..30f17451 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/CtbrecPreferencesStorage.java @@ -0,0 +1,390 @@ +package ctbrec.ui.settings; + +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.time.LocalTime; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.Settings; +import ctbrec.StringUtil; +import ctbrec.ui.controls.DirectorySelectionBox; +import ctbrec.ui.controls.ProgramSelectionBox; +import ctbrec.ui.controls.TimePicker; +import ctbrec.ui.controls.range.DiscreteRange; +import ctbrec.ui.controls.range.RangeSlider; +import ctbrec.ui.settings.api.ExclusiveSelectionProperty; +import ctbrec.ui.settings.api.LocalTimeProperty; +import ctbrec.ui.settings.api.Preferences; +import ctbrec.ui.settings.api.PreferencesStorage; +import ctbrec.ui.settings.api.Setting; +import ctbrec.ui.settings.api.SimpleDirectoryProperty; +import ctbrec.ui.settings.api.SimpleFileProperty; +import ctbrec.ui.settings.api.SimpleJoinedStringListProperty; +import ctbrec.ui.settings.api.SimpleRangeProperty; +import ctbrec.io.BoundField; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.ListProperty; +import javafx.beans.property.LongProperty; +import javafx.beans.property.Property; +import javafx.beans.property.StringProperty; +import javafx.beans.value.ChangeListener; +import javafx.geometry.Insets; +import javafx.scene.Node; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.RadioButton; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import javafx.scene.control.ToggleGroup; +import javafx.scene.layout.HBox; +import javafx.util.converter.NumberStringConverter; + +public class CtbrecPreferencesStorage implements PreferencesStorage { + + private static final Logger LOG = LoggerFactory.getLogger(CtbrecPreferencesStorage.class); + public static final String PATTERN_NOT_A_DIGIT = "[^\\d]"; + public static final String COULDNT_SAVE_MSG = "Couldn't save config setting"; + + private Config config; + private Settings settings; + private Preferences prefs; + + public CtbrecPreferencesStorage(Config config) { + this.config = config; + this.settings = config.getSettings(); + } + + public void setPreferences(Preferences prefs) { + this.prefs = prefs; + } + + @Override + public void save(Preferences preferences) throws IOException { + throw new RuntimeException("not implemented"); + } + + @Override + public void load(Preferences preferences) { + throw new RuntimeException("not implemented"); + } + + @Override + public Node createGui(Setting setting) throws NoSuchFieldException, IllegalAccessException { + config.disableSaving(); + try { + Property prop = setting.getProperty(); + if (prop instanceof ExclusiveSelectionProperty) { + return createRadioGroup(setting); + } else if (prop instanceof SimpleRangeProperty) { + return createRangeSlider(setting); + } else if (prop instanceof SimpleDirectoryProperty) { + return createDirectorySelector(setting); + } else if (prop instanceof SimpleFileProperty) { + return createFileSelector(setting); + } else if (prop instanceof LocalTimeProperty) { + return createTimeSelector(setting); + } else if (prop instanceof IntegerProperty) { + return createIntegerProperty(setting); + } else if (prop instanceof LongProperty) { + return createLongProperty(setting); + } else if (prop instanceof BooleanProperty) { + return createBooleanProperty(setting); + } else if (prop instanceof ListProperty) { + return createComboBox(setting); + } else if (prop instanceof SimpleJoinedStringListProperty) { + return createStringListProperty(setting); + } else if (prop instanceof StringProperty) { + return createStringProperty(setting); + } else { + return new Label("Unsupported Type for key " + setting.getKey() + ": " + setting.getProperty()); + } + } finally { + config.enableSaving(); + } + } + + private Node createRadioGroup(Setting setting) { + ExclusiveSelectionProperty prop = (ExclusiveSelectionProperty) setting.getProperty(); + var toggleGroup = new ToggleGroup(); + var optionA = new RadioButton(prop.getOptionA()); + optionA.setSelected(prop.getValue()); + optionA.setToggleGroup(toggleGroup); + var optionB = new RadioButton(prop.getOptionB()); + optionB.setSelected(!optionA.isSelected()); + optionB.setToggleGroup(toggleGroup); + optionA.selectedProperty().bindBidirectional(prop); + prop.addListener((obs, oldV, newV) -> saveValue(() -> { + if (setIfChanged(setting.getKey(), newV)) { + if (setting.doesNeedRestart()) { + runRestartRequiredCallback(); + } + config.save(); + } + })); + var row = new HBox(); + row.getChildren().addAll(optionA, optionB); + HBox.setMargin(optionA, new Insets(5)); + HBox.setMargin(optionB, new Insets(5)); + return row; + } + + private void runRestartRequiredCallback() { + Optional.ofNullable(prefs).map(Preferences::getRestartRequiredCallback).ifPresent(r -> { + try { + r.run(); + } catch (RuntimeException e) { + LOG.warn("Error while calling \"restart required\" callback", e); + } + }); + } + + @SuppressWarnings("unchecked") + private Node createRangeSlider(Setting setting) { + SimpleRangeProperty rangeProperty = (SimpleRangeProperty) setting.getProperty(); + DiscreteRange range = (DiscreteRange) rangeProperty.getRange(); + List labels = (List) range.getLabels(); + List values = range.getTicks(); + RangeSlider resolutionRange = new RangeSlider<>(rangeProperty.getRange()); + resolutionRange.setShowTickMarks(true); + resolutionRange.setShowTickLabels(true); + int lowValue = getRangeSliderValue(values, labels, Config.getInstance().getSettings().minimumResolution); + resolutionRange.setLow(lowValue >= 0 ? lowValue : values.get(0)); + int highValue = getRangeSliderValue(values, labels, Config.getInstance().getSettings().maximumResolution); + resolutionRange.setHigh(highValue >= 0 ? highValue : values.get(values.size() - 1)); + resolutionRange.getLow().addListener((obs, o, n) -> saveValue(() -> { + int newV = labels.get(n.intValue()); + if (setIfChanged(rangeProperty.getLowKey(), newV)) { + config.save(); + } + })); + resolutionRange.getHigh().addListener((obs, o, n) -> saveValue(() -> { + int newV = labels.get(n.intValue()); + if (setIfChanged(rangeProperty.getHighKey(), newV)) { + config.save(); + } + })); + return resolutionRange; + } + + private int getRangeSliderValue(List values, List labels, int value) { + for (var i = 0; i < labels.size(); i++) { + var label = labels.get(i).intValue(); + if (label == value) { + return values.get(i); + } + } + return -1; + } + + private Node createFileSelector(Setting setting) { + var programSelector = new ProgramSelectionBox(""); + programSelector.fileProperty().addListener((obs, o, n) -> saveValue(() -> { + if (setIfChanged(setting.getKey(), n)) { + if (setting.doesNeedRestart()) { + runRestartRequiredCallback(); + } + config.save(); + } + })); + StringProperty property = (StringProperty) setting.getProperty(); + programSelector.fileProperty().bindBidirectional(property); + return programSelector; + } + + private Node createDirectorySelector(Setting setting) { + var directorySelector = new DirectorySelectionBox(""); + directorySelector.prefWidth(400); + directorySelector.fileProperty().addListener((obs, o, n) -> saveValue(() -> { + if (setIfChanged(setting.getKey(), n)) { + if (setting.doesNeedRestart()) { + runRestartRequiredCallback(); + } + config.save(); + } + })); + StringProperty property = (StringProperty) setting.getProperty(); + directorySelector.fileProperty().bindBidirectional(property); + return directorySelector; + } + + private Node createTimeSelector(Setting setting) { + LocalTime time = (LocalTime) setting.getProperty().getValue(); + var timePicker = new TimePicker(time); + timePicker.valueProperty().addListener((obs, o, n) -> saveValue(() -> { + if (setIfChanged(setting.getKey(), n)) { + if (setting.doesNeedRestart()) { + runRestartRequiredCallback(); + } + config.save(); + } + })); + return timePicker; + } + + private Node createStringProperty(Setting setting) { + var ctrl = new TextField(); + ctrl.textProperty().addListener((obs, oldV, newV) -> saveValue(() -> { + if (setIfChanged(setting.getKey(), newV)) { + if (setting.doesNeedRestart()) { + runRestartRequiredCallback(); + } + config.save(); + } + })); + StringProperty prop = (StringProperty) setting.getProperty(); + ctrl.textProperty().bindBidirectional(prop); + return ctrl; + } + + private Node createStringListProperty(Setting setting) { + var ctrl = new TextArea(); + StringProperty prop = (StringProperty) setting.getProperty(); + ctrl.textProperty().bindBidirectional(prop); + prop.addListener((obs, oldV, newV) -> saveValue(() -> { + //setUnchecked(setting.getKey(), Arrays.asList(newV.split("\n"))); + if (setting.doesNeedRestart()) { + runRestartRequiredCallback(); + } + config.save(); + })); + return ctrl; + } + + @SuppressWarnings("unchecked") + private Node createIntegerProperty(Setting setting) { + var ctrl = new TextField(); + ctrl.textProperty().addListener((obs, oldV, newV) -> saveValue(() -> { + if (!newV.matches("\\d*")) { + ctrl.setText(newV.replaceAll(PATTERN_NOT_A_DIGIT, "")); + } + if (!ctrl.getText().isEmpty() && setIfChanged(setting.getKey(), Integer.parseInt(ctrl.getText()))) { + if (setting.doesNeedRestart() && prefs != null) { + runRestartRequiredCallback(); + } + config.save(); + } + })); + Property prop = setting.getProperty(); + ctrl.textProperty().bindBidirectional(prop, new NumberStringConverter()); + return ctrl; + } + + @SuppressWarnings("unchecked") + private Node createLongProperty(Setting setting) { + var ctrl = new TextField(); + ctrl.textProperty().addListener((obs, oldV, newV) -> saveValue(() -> { + if (!newV.matches("\\d*")) { + ctrl.setText(newV.replaceAll(PATTERN_NOT_A_DIGIT, "")); + } + if (!ctrl.getText().isEmpty()) { + var value = Long.parseLong(ctrl.getText()); + if (setting.getConverter() != null) { + value = (long) setting.getConverter().convertFrom(value); + } + if (setIfChanged(setting.getKey(), value)) { + if (setting.doesNeedRestart() && !Objects.equals(oldV, newV)) { + runRestartRequiredCallback(); + } + config.save(); + } + } + })); + Property prop = setting.getProperty(); + ctrl.textProperty().bindBidirectional(prop, new NumberStringConverter()); + return ctrl; + } + + private Node createBooleanProperty(Setting setting) { + var ctrl = new CheckBox(); + ctrl.selectedProperty().addListener((obs, oldV, newV) -> saveValue(() -> { + if (setIfChanged(setting.getKey(), newV)) { + if (setting.doesNeedRestart()) { + runRestartRequiredCallback(); + } + config.save(); + } + })); + BooleanProperty prop = (BooleanProperty) setting.getProperty(); + ctrl.selectedProperty().bindBidirectional(prop); + return ctrl; + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private Node createComboBox(Setting setting) throws IllegalAccessException, NoSuchFieldException { + ListProperty listProp = (ListProperty) setting.getProperty(); + ComboBox comboBox = new ComboBox(listProp); + Object value = BoundField.of(settings, setting.getKey()).get(); + if (StringUtil.isNotBlank(value.toString())) { + if (setting.getConverter() != null) { + comboBox.getSelectionModel().select(setting.getConverter().convertTo(value)); + } else { + comboBox.getSelectionModel().select(value); + } + } + comboBox.valueProperty().addListener((obs, oldV, newV) -> saveValue(() -> { + LOG.debug("Saving setting {}", setting.getKey()); + if (setIfChanged(setting.getKey(), setting.getConverter() != null ? setting.getConverter().convertFrom(newV) : newV)) { + if (setting.doesNeedRestart()) { + runRestartRequiredCallback(); + } + config.save(); + } + })); + if (setting.getChangeListener() != null) { + comboBox.valueProperty().addListener((ChangeListener) setting.getChangeListener()); + } + return comboBox; + } + + + private boolean setIfChanged(String key, Object n) throws IllegalAccessException, NoSuchFieldException, InvocationTargetException { + var field = BoundField.of(settings, key); + var o = field.get(); + if (!Objects.equals(n, o)) { + if (n instanceof List && o instanceof List) { + var list = (List)o; + list.clear(); + list.addAll((List)n); + } else { + field.set(n); // NOSONAR + } + return true; + } + return false; + } + + private boolean setUnchecked(String key, Object n) throws IllegalAccessException, NoSuchFieldException, InvocationTargetException { + var field = BoundField.of(settings, key); + var o = field.get(); + if (n instanceof List && o instanceof List) { + var list = (List)o; + list.clear(); + list.addAll((List)n); + } else { + field.set(n); // NOSONAR + } + return true; + } + + private void saveValue(Exec exe) { + try { + exe.run(); + } catch (Exception e) { + LOG.error(COULDNT_SAVE_MSG, e); + } + } + + @FunctionalInterface + private interface Exec { + public void run() throws IllegalAccessException, IOException, NoSuchFieldException, NoSuchMethodException, InvocationTargetException; + } +} diff --git a/client/src/main/java/ctbrec/ui/settings/DeleteTooShortPaneFactory.java b/client/src/main/java/ctbrec/ui/settings/DeleteTooShortPaneFactory.java new file mode 100644 index 00000000..29ad0928 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/DeleteTooShortPaneFactory.java @@ -0,0 +1,24 @@ +package ctbrec.ui.settings; + +import ctbrec.recorder.postprocessing.DeleteTooShort; +import ctbrec.recorder.postprocessing.PostProcessor; +import ctbrec.ui.settings.api.Category; +import ctbrec.ui.settings.api.Preferences; +import ctbrec.ui.settings.api.Setting; +import javafx.beans.property.SimpleStringProperty; + +public class DeleteTooShortPaneFactory extends AbstractPostProcessingPaneFactory { + + @Override + public Preferences doCreatePostProcessorPane(PostProcessor pp) { + var minimumLengthInSeconds = new SimpleStringProperty(null, DeleteTooShort.MIN_LEN_IN_SECS, pp.getConfig().getOrDefault(DeleteTooShort.MIN_LEN_IN_SECS, "10")); + properties.add(minimumLengthInSeconds); + + return Preferences.of(new MapPreferencesStorage(), + Category.of(pp.getName(), + Setting.of("Minimum length in seconds", minimumLengthInSeconds) + ) + ); + } + +} diff --git a/client/src/main/java/ctbrec/ui/settings/FontSettingsPane.java b/client/src/main/java/ctbrec/ui/settings/FontSettingsPane.java new file mode 100644 index 00000000..03b76bbd --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/FontSettingsPane.java @@ -0,0 +1,103 @@ +package ctbrec.ui.settings; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.ui.controls.Dialogs; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.ListCell; +import javafx.scene.layout.HBox; +import javafx.scene.text.Font; + +public class FontSettingsPane extends HBox { + + private static final Logger LOG = LoggerFactory.getLogger(FontSettingsPane.class); + + private ComboBox fontFaceCombo; + private ComboBox fontSizeCombo; + + private SettingsTab settingsTab; + private Config config; + + public FontSettingsPane(SettingsTab settingsTab, Config config) { + this.settingsTab = settingsTab; + this.config = config; + setSpacing(5); + getChildren().addAll(buildFontFaceCombo(), buildFontSizeCombo(), buildFontResetButton()); + } + + private ComboBox buildFontFaceCombo() { + ObservableList lst = FXCollections.observableList(javafx.scene.text.Font.getFamilies()); + fontFaceCombo = new ComboBox<>(lst); + fontFaceCombo.getSelectionModel().select(0); + fontFaceCombo.setCellFactory((listview -> new ListCell() { + @Override + protected void updateItem(String family, boolean empty) { + super.updateItem(family, empty); + if (empty) { + setText(null); + } else { + setFont(Font.font(family)); + setText(family); + } + } + })); + fontFaceCombo.getSelectionModel().select(config.getSettings().fontFamily); + fontFaceCombo.setOnAction(evt -> saveFontConfig()); + return fontFaceCombo; + } + + private ComboBox buildFontSizeCombo() { + ObservableList lst = FXCollections + .observableList(List.of(4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 22, 24, 26, 28, 32, 48, 64, 72, 80, 96, 128)); + fontSizeCombo = new ComboBox<>(lst); + fontSizeCombo.setOnAction(evt -> saveFontConfig()); + int size = config.getSettings().fontSize; + int selectedIndex = Math.max(0, lst.indexOf(size)); + fontSizeCombo.getSelectionModel().select(selectedIndex); + return fontSizeCombo; + } + + private void saveFontConfig() { + Font font = Font.font(fontFaceCombo.getSelectionModel().getSelectedItem()); + int size = fontSizeCombo.getSelectionModel().getSelectedItem(); + String css = ".root {\n -fx-font: " + size + " '" + font.getFamily() + "';\n}"; + config.getSettings().fontFamily = font.getFamily(); + config.getSettings().fontSize = size; + try { + config.save(); + Files.writeString(getFontCssFile().toPath(), css); + settingsTab.showRestartRequired(); + } catch (IOException e) { + LOG.error("Couldn't write font file", e); + Dialogs.showError(getScene(), "Error saving configuration", "The font stylesheet file couldn't be written", e); + } + } + + private File getFontCssFile() { + return new File(Config.getInstance().getConfigDir(), "font.css"); + } + + private Button buildFontResetButton() { + var button = new Button("Reset"); + button.setOnAction(evt -> { + try { + Files.delete(getFontCssFile().toPath()); + settingsTab.showRestartRequired(); + } catch (IOException e) { + LOG.error("Couldn't delete font file", e); + Dialogs.showError(getScene(), "Error resetting font configuration", "The font stylesheet file couldn't be deleted", e); + } + }); + return button; + } +} diff --git a/client/src/main/java/ctbrec/ui/settings/IgnoreList.java b/client/src/main/java/ctbrec/ui/settings/IgnoreList.java new file mode 100644 index 00000000..e8445c26 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/IgnoreList.java @@ -0,0 +1,135 @@ +package ctbrec.ui.settings; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import ctbrec.Config; +import ctbrec.io.json.ObjectMapperFactory; +import ctbrec.ui.AutosizeAlert; +import ctbrec.ui.controls.Dialogs; +import javafx.geometry.Insets; +import javafx.scene.control.Alert.AlertType; +import javafx.scene.control.*; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.stage.FileChooser; +import lombok.extern.slf4j.Slf4j; + +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Collections; +import java.util.List; + +import static javafx.scene.control.ButtonType.NO; +import static javafx.scene.control.ButtonType.YES; + +@Slf4j +public class IgnoreList extends GridPane { + + private ListView ignoreListView; + + private final ObjectMapper mapper = ObjectMapperFactory.getMapper(); + + public IgnoreList() { + createGui(); + loadIgnoredModels(); + } + + private void createGui() { + setHgap(10); + setVgap(10); + setPadding(new Insets(20, 10, 10, 10)); + + ignoreListView = new ListView<>(); + ignoreListView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); + ignoreListView.addEventHandler(KeyEvent.KEY_PRESSED, event -> { + if (event.getCode() == KeyCode.DELETE) { + removeSelectedModels(); + } + }); + add(ignoreListView, 0, 0); + GridPane.setHgrow(ignoreListView, Priority.ALWAYS); + + var remove = new Button("Remove"); + remove.setOnAction(evt -> removeSelectedModels()); + var exportIgnoreList = new Button("Export"); + exportIgnoreList.setOnAction(e -> exportIgnoreList()); + var importIgnoreList = new Button("Import"); + importIgnoreList.setOnAction(e -> importIgnoreList()); + var buttons = new HBox(10, remove, exportIgnoreList, importIgnoreList); + add(buttons, 0, 2); + buttons.setStyle("-fx-background-color: -fx-background"); // workaround so that the buttons don't shrink + } + + private void removeSelectedModels() { + List selectedModels = ignoreListView.getSelectionModel().getSelectedItems(); + if (!selectedModels.isEmpty()) { + Config.getInstance().getSettings().ignoredModels.removeAll(selectedModels); + ignoreListView.getItems().removeAll(selectedModels); + log.debug(Config.getInstance().getSettings().ignoredModels.toString()); + try { + Config.getInstance().save(); + } catch (IOException e) { + log.warn("Couldn't save config", e); + } + } + } + + private void loadIgnoredModels() { + List ignored = Config.getInstance().getSettings().ignoredModels; + ignoreListView.getItems().clear(); + ignoreListView.getItems().addAll(ignored); + Collections.sort(ignoreListView.getItems()); + } + + public void refresh() { + loadIgnoredModels(); + } + + private void exportIgnoreList() { + var chooser = new FileChooser(); + chooser.setTitle("Export ignore list"); + chooser.setInitialFileName("ctbrec-ignorelist.json"); + var file = chooser.showSaveDialog(null); + if (file != null) { + try (var out = new FileOutputStream(file)) { + String json = mapper.writeValueAsString(Config.getInstance().getSettings().ignoredModels); + out.write(json.getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + Dialogs.showError(getScene(), "Couldn't export ignore list", e.getLocalizedMessage(), e); + } + } + } + + private void importIgnoreList() { + var chooser = new FileChooser(); + chooser.setTitle("Import ignore list"); + var file = chooser.showOpenDialog(null); + if (file != null) { + try { + String fileContent = Files.readString(file.toPath()); + List ignoredModels = mapper.readValue(fileContent, new TypeReference<>() { + }); + var confirmed = true; + if (!Config.getInstance().getSettings().ignoredModels.isEmpty()) { + var msg = "This will replace the existing ignore list! Continue?"; + var confirm = new AutosizeAlert(AlertType.CONFIRMATION, msg, getScene(), YES, NO); + confirm.setTitle("Import ignore list"); + confirm.setHeaderText("Overwrite ignore list"); + confirm.showAndWait(); + confirmed = confirm.getResult() == ButtonType.YES; + } + if (confirmed) { + Config.getInstance().getSettings().ignoredModels = ignoredModels; + refresh(); + } + } catch (IOException e) { + Dialogs.showError(getScene(), "Couldn't import ignore list", e.getLocalizedMessage(), e); + } + } + } +} 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..80535328 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/ListSelectionPane.java @@ -0,0 +1,125 @@ +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();) { + var 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() { + var labelAvailable = new Label("Available"); + var labelSelected = new Label("Selected"); + + add(labelAvailable, 0, 0); + add(availableListView, 0, 1); + + var 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(); + } + + public void selectAll() { + selectAll.setSelected(true); + } +} diff --git a/client/src/main/java/ctbrec/ui/settings/MoverPaneFactory.java b/client/src/main/java/ctbrec/ui/settings/MoverPaneFactory.java new file mode 100644 index 00000000..ccd7801f --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/MoverPaneFactory.java @@ -0,0 +1,24 @@ +package ctbrec.ui.settings; + +import ctbrec.recorder.postprocessing.Move; +import ctbrec.recorder.postprocessing.PostProcessor; +import ctbrec.ui.settings.api.Category; +import ctbrec.ui.settings.api.Preferences; +import ctbrec.ui.settings.api.Setting; +import javafx.beans.property.SimpleStringProperty; + +public class MoverPaneFactory extends AbstractPostProcessingPaneFactory { + + @Override + public Preferences doCreatePostProcessorPane(PostProcessor pp) { + var pathTemplate = new SimpleStringProperty(null, Move.PATH_TEMPLATE, pp.getConfig().getOrDefault(Move.PATH_TEMPLATE, Move.DEFAULT)); + properties.add(pathTemplate); + + return Preferences.of(new MapPreferencesStorage(), + Category.of(pp.getName(), + Setting.of("Directory", pathTemplate) + ) + ); + } + +} diff --git a/client/src/main/java/ctbrec/ui/settings/PostProcessingDialogFactory.java b/client/src/main/java/ctbrec/ui/settings/PostProcessingDialogFactory.java new file mode 100644 index 00000000..95f3c52c --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/PostProcessingDialogFactory.java @@ -0,0 +1,87 @@ +package ctbrec.ui.settings; + +import ctbrec.recorder.postprocessing.*; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.settings.api.Preferences; +import javafx.collections.ObservableList; +import javafx.geometry.HPos; +import javafx.geometry.Insets; +import javafx.geometry.VPos; +import javafx.scene.Scene; +import javafx.scene.control.CheckBox; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Region; + +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +public class PostProcessingDialogFactory { + + static Map, Class> ppToDialogMap = new HashMap<>(); + + static { + ppToDialogMap.put(Remux.class, RemuxerPaneFactory.class); + ppToDialogMap.put(Script.class, ScriptPaneFactory.class); + ppToDialogMap.put(Rename.class, RenamerPaneFactory.class); + ppToDialogMap.put(Move.class, MoverPaneFactory.class); + ppToDialogMap.put(DeleteTooShort.class, DeleteTooShortPaneFactory.class); + ppToDialogMap.put(CreateContactSheet.class, CreateContactSheetPaneFactory.class); + } + + private PostProcessingDialogFactory() { + } + + public static void openNewDialog(PostProcessor pp, Scene scene, ObservableList stepList) { + openDialog(pp, scene, stepList, true); + } + + public static void openEditDialog(PostProcessor pp, Scene scene, ObservableList stepList) { + openDialog(pp, scene, stepList, false); + } + + private static void openDialog(PostProcessor pp, Scene scene, ObservableList stepList, boolean newEntry) { + boolean ok; + try { + Optional preferences = createPreferences(pp); + if (preferences.isPresent()) { + Region view = preferences.get().getView(false); + view.setMinWidth(600); + CheckBox enabledCheckbox = new CheckBox("Enabled"); + enabledCheckbox.setSelected(pp.isEnabled()); + enabledCheckbox.selectedProperty().addListener((src, oldV, newV) -> pp.setEnabled(newV)); + GridPane container = new GridPane(); + container.add(enabledCheckbox, 0, 0); + container.add(view, 0, 1); + GridPane.setMargin(enabledCheckbox, new Insets(0, 0, 10, 0)); + GridPane.setValignment(view, VPos.CENTER); + GridPane.setHalignment(view, HPos.CENTER); + ok = Dialogs.showCustomInput(scene, "Configure " + pp.getName(), container); + if (ok) { + preferences.get().save(); + if (newEntry) { + stepList.add(pp); + } + } + } else if (newEntry) { + stepList.add(pp); + } + } catch (NoSuchMethodException | SecurityException | IllegalAccessException | IllegalArgumentException | InvocationTargetException + | InstantiationException | IOException e) { + Dialogs.showError(scene, "New post-processing step", "Couldn't create dialog for " + pp.getName(), e); + } + } + + private static Optional createPreferences(PostProcessor pp) throws InstantiationException, IllegalAccessException, + InvocationTargetException, NoSuchMethodException { + Class paneFactoryClass = ppToDialogMap.get(pp.getClass()); + if (paneFactoryClass != null) { + AbstractPostProcessingPaneFactory factory = (AbstractPostProcessingPaneFactory) paneFactoryClass.getDeclaredConstructor().newInstance(); + return Optional.of(factory.createPostProcessorPane(pp)); + } else { + return Optional.empty(); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/settings/PostProcessingStepPanel.java b/client/src/main/java/ctbrec/ui/settings/PostProcessingStepPanel.java new file mode 100644 index 00000000..02a395ca --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/PostProcessingStepPanel.java @@ -0,0 +1,220 @@ +package ctbrec.ui.settings; + +import ctbrec.Config; +import ctbrec.io.json.mapper.PostProcessorMapper; +import ctbrec.recorder.postprocessing.*; +import ctbrec.ui.controls.Dialogs; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.scene.control.*; +import javafx.scene.control.cell.CheckBoxTableCell; +import javafx.scene.image.Image; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; +import org.mapstruct.factory.Mappers; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.InvocationTargetException; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +public class PostProcessingStepPanel extends GridPane { + + private final Config config; + + private static final Class[] POST_PROCESSOR_CLASSES = new Class[]{ // @formatter: off + Copy.class, + Rename.class, + Move.class, + Remux.class, + Script.class, + DeleteOriginal.class, + DeleteTooShort.class, + RemoveKeepFile.class, + CreateContactSheet.class + }; // @formatter: on + + TableView stepView; + ObservableList stepList; + + Button up; + Button down; + + Button add; + Button remove; + Button edit; + + public PostProcessingStepPanel(Config config) { + this.config = config; + initGui(); + } + + private void initGui() { + setHgap(5); + vgapProperty().bind(hgapProperty()); + + up = createUpButton(); + down = createDownButton(); + add = createAddButton(); + remove = createRemoveButton(); + edit = createEditButton(); + var buttons = new VBox(5, add, edit, up, down, remove); + + List postProcessors = config.getSettings().postProcessors + .stream() + .map(Mappers.getMapper(PostProcessorMapper.class)::toPostProcessor) + .collect(Collectors.toList()); // NOSONAR - toList returns an unmodifiable list + stepList = FXCollections.observableList(postProcessors); + stepList.addListener((ListChangeListener) change -> safelySaveConfig()); + stepView = new TableView<>(stepList); + stepView.setEditable(true); + stepView.setStyle("-fx-table-cell-border-color: transparent;"); + var postProcessorColumn = new TableColumn("Step"); + postProcessorColumn.setCellValueFactory(cdf -> new SimpleStringProperty(cdf.getValue().getName())); + postProcessorColumn.setStyle("-fx-alignment: CENTER-LEFT;"); + var enabledColumn = new TableColumn("Enabled"); + enabledColumn.setCellValueFactory(cdf -> { + var prop = new SimpleBooleanProperty(cdf.getValue().isEnabled()); + prop.addListener((src, oldV, newV) -> { + cdf.getValue().setEnabled(newV); + safelySaveConfig(); + }); + return prop; + }); + enabledColumn.setCellFactory(CheckBoxTableCell.forTableColumn(enabledColumn)); + enabledColumn.setEditable(true); + enabledColumn.setStyle("-fx-alignment: CENTER;"); + postProcessorColumn.prefWidthProperty().bind(stepView.widthProperty().subtract(enabledColumn.widthProperty()).subtract(5)); + stepView.getColumns().add(enabledColumn); + stepView.getColumns().add(postProcessorColumn); + GridPane.setHgrow(stepView, Priority.ALWAYS); + GridPane.setFillWidth(stepView, true); + + add(stepView, 0, 0); + add(buttons, 1, 0); + + stepView.getSelectionModel().selectedIndexProperty().addListener((obs, oldV, newV) -> { + var idx = newV.intValue(); + boolean noSelection = idx == -1; + up.setDisable(noSelection || idx == 0); + down.setDisable(noSelection || idx == stepList.size() - 1); + edit.setDisable(noSelection); + remove.setDisable(noSelection); + }); + + setPadding(new Insets(10)); + } + + private void safelySaveConfig() { + try { + config.getSettings().postProcessors = stepList.stream() + .map(Mappers.getMapper(PostProcessorMapper.class)::toDto) + .collect(Collectors.toList()); // NOSONAR - toList returns an unmodifiable list + config.save(); + } catch (IOException e) { + Dialogs.showError(getScene(), "Couldn't save configuration", "An error occurred while saving the configuration", e); + } + } + + private Button createUpButton() { + var button = createButton("▴", "Move step up"); + button.setOnAction(evt -> { + int idx = stepView.getSelectionModel().getSelectedIndex(); + PostProcessor selectedItem = stepView.getSelectionModel().getSelectedItem(); + stepList.remove(idx); + stepList.add(idx - 1, selectedItem); + stepView.getSelectionModel().select(idx - 1); + }); + return button; + } + + private Button createDownButton() { + var button = createButton("▾", "Move step down"); + button.setOnAction(evt -> { + int idx = stepView.getSelectionModel().getSelectedIndex(); + PostProcessor selectedItem = stepView.getSelectionModel().getSelectedItem(); + stepList.remove(idx); + stepList.add(idx + 1, selectedItem); + stepView.getSelectionModel().select(idx + 1); + }); + return button; + } + + private Button createAddButton() { + var button = createButton("+", "Add a new step"); + button.setDisable(false); + button.setOnAction(evt -> { + PostProcessor[] options = createOptions(); + ChoiceDialog choice = new ChoiceDialog<>(options[0], options); + choice.setTitle("New Post-Processing Step"); + choice.setHeaderText("Select the new step type"); + choice.setResizable(true); + choice.setWidth(600); + choice.getDialogPane().setMinWidth(400); + var stage = (Stage) choice.getDialogPane().getScene().getWindow(); + stage.getScene().getStylesheets().addAll(getScene().getStylesheets()); + InputStream icon = Dialogs.class.getResourceAsStream("/icon.png"); + stage.getIcons().add(new Image(icon)); + + Optional result = choice.showAndWait(); + result.ifPresent(pp -> PostProcessingDialogFactory.openNewDialog(pp, getScene(), stepList)); + safelySaveConfig(); + }); + return button; + } + + private PostProcessor[] createOptions() { + try { + var options = new PostProcessor[POST_PROCESSOR_CLASSES.length]; + for (var i = 0; i < POST_PROCESSOR_CLASSES.length; i++) { + Class cls = POST_PROCESSOR_CLASSES[i]; + PostProcessor pp; + pp = (PostProcessor) cls.getDeclaredConstructor().newInstance(); + options[i] = pp; + } + return options; + } catch (InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException | NoSuchMethodException + | SecurityException e) { + Dialogs.showError(getScene(), "Create post-processor selection", "Error while reaing in post-processing options", e); + return new PostProcessor[0]; + } + } + + private Button createRemoveButton() { + var button = createButton("-", "Remove selected step"); + button.setOnAction(evt -> { + PostProcessor selectedItem = stepView.getSelectionModel().getSelectedItem(); + if (selectedItem != null) { + stepList.remove(selectedItem); + } + }); + return button; + } + + private Button createEditButton() { + var button = createButton("✎", "Edit selected step"); + button.setOnAction(evt -> { + PostProcessor selectedItem = stepView.getSelectionModel().getSelectedItem(); + PostProcessingDialogFactory.openEditDialog(selectedItem, getScene(), stepList); + stepView.refresh(); + safelySaveConfig(); + }); + return button; + } + + private Button createButton(String text, String tooltip) { + var b = new Button(text); + b.setTooltip(new Tooltip(tooltip)); + b.setDisable(true); + b.setPrefSize(32, 32); + return b; + } +} diff --git a/client/src/main/java/ctbrec/ui/settings/RemuxerPaneFactory.java b/client/src/main/java/ctbrec/ui/settings/RemuxerPaneFactory.java new file mode 100644 index 00000000..6c016986 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/RemuxerPaneFactory.java @@ -0,0 +1,27 @@ +package ctbrec.ui.settings; + +import ctbrec.recorder.postprocessing.PostProcessor; +import ctbrec.recorder.postprocessing.Remux; +import ctbrec.ui.settings.api.Category; +import ctbrec.ui.settings.api.Preferences; +import ctbrec.ui.settings.api.Setting; +import javafx.beans.property.SimpleStringProperty; + +public class RemuxerPaneFactory extends AbstractPostProcessingPaneFactory { + + @Override + public Preferences doCreatePostProcessorPane(PostProcessor pp) { + var ffmpegParams = new SimpleStringProperty(null, Remux.FFMPEG_ARGS, pp.getConfig().getOrDefault(Remux.FFMPEG_ARGS, "-c:v copy -c:a copy -movflags faststart -y -f mp4")); + var fileExt = new SimpleStringProperty(null, Remux.FILE_EXT, pp.getConfig().getOrDefault(Remux.FILE_EXT, "mp4")); + properties.add(ffmpegParams); + properties.add(fileExt); + + return Preferences.of(new MapPreferencesStorage(), + Category.of(pp.getName(), + Setting.of("FFmpeg parameters", ffmpegParams), + Setting.of("File extension", fileExt) + ) + ); + } + +} diff --git a/client/src/main/java/ctbrec/ui/settings/RenamerPaneFactory.java b/client/src/main/java/ctbrec/ui/settings/RenamerPaneFactory.java new file mode 100644 index 00000000..922420e4 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/RenamerPaneFactory.java @@ -0,0 +1,24 @@ +package ctbrec.ui.settings; + +import ctbrec.recorder.postprocessing.PostProcessor; +import ctbrec.recorder.postprocessing.Rename; +import ctbrec.ui.settings.api.Category; +import ctbrec.ui.settings.api.Preferences; +import ctbrec.ui.settings.api.Setting; +import javafx.beans.property.SimpleStringProperty; + +public class RenamerPaneFactory extends AbstractPostProcessingPaneFactory { + + @Override + public Preferences doCreatePostProcessorPane(PostProcessor pp) { + var fileTemplate = new SimpleStringProperty(null, Rename.FILE_NAME_TEMPLATE, pp.getConfig().getOrDefault(Rename.FILE_NAME_TEMPLATE, Rename.DEFAULT)); + properties.add(fileTemplate); + + return Preferences.of(new MapPreferencesStorage(), + Category.of(pp.getName(), + Setting.of("File name", fileTemplate) + ) + ); + } + +} diff --git a/client/src/main/java/ctbrec/ui/settings/RestrictResolutionSidePane.java b/client/src/main/java/ctbrec/ui/settings/RestrictResolutionSidePane.java new file mode 100644 index 00000000..e764ccaf --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/RestrictResolutionSidePane.java @@ -0,0 +1,44 @@ +package ctbrec.ui.settings; + +import ctbrec.Config; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.scene.control.ComboBox; +import javafx.scene.layout.HBox; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.util.List; + +@Slf4j +public class RestrictResolutionSidePane extends HBox { + + private ComboBox combo; + private final Config config; + private static final List names = List.of("by video height", "by shortest side"); + + public RestrictResolutionSidePane(Config config) { + this.config = config; + setSpacing(5); + getChildren().addAll(buildCombo()); + } + + private ComboBox buildCombo() { + ObservableList lst = FXCollections.observableList(names); + combo = new ComboBox<>(lst); + combo.setOnAction(evt -> saveConfig()); + int index = config.getSettings().checkResolutionByMinSide ? 1 : 0; + combo.getSelectionModel().select(index); + return combo; + } + + private void saveConfig() { + int index = combo.getSelectionModel().getSelectedIndex(); + config.getSettings().checkResolutionByMinSide = (index == 1); + try { + config.save(); + } catch (IOException e) { + log.error("Can't save config", e); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/settings/ScriptPaneFactory.java b/client/src/main/java/ctbrec/ui/settings/ScriptPaneFactory.java new file mode 100644 index 00000000..53448f14 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/ScriptPaneFactory.java @@ -0,0 +1,27 @@ +package ctbrec.ui.settings; + +import ctbrec.recorder.postprocessing.PostProcessor; +import ctbrec.recorder.postprocessing.Script; +import ctbrec.ui.settings.api.Category; +import ctbrec.ui.settings.api.Preferences; +import ctbrec.ui.settings.api.Setting; +import javafx.beans.property.SimpleStringProperty; + +public class ScriptPaneFactory extends AbstractPostProcessingPaneFactory { + + @Override + public Preferences doCreatePostProcessorPane(PostProcessor pp) { + var script = new SimpleStringProperty(null, Script.SCRIPT_EXECUTABLE, pp.getConfig().getOrDefault(Script.SCRIPT_EXECUTABLE, "c:\\users\\johndoe\\somescript")); + var params = new SimpleStringProperty(null, Script.SCRIPT_PARAMS, pp.getConfig().getOrDefault(Script.SCRIPT_PARAMS, "${absolutePath}")); + properties.add(script); + properties.add(params); + + return Preferences.of(new MapPreferencesStorage(), + Category.of(pp.getName(), + Setting.of("Script", script), + Setting.of("Parameters", params) + ) + ); + } + +} diff --git a/client/src/main/java/ctbrec/ui/settings/SettingsTab.java b/client/src/main/java/ctbrec/ui/settings/SettingsTab.java new file mode 100644 index 00000000..c7c13d28 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/SettingsTab.java @@ -0,0 +1,718 @@ +package ctbrec.ui.settings; + +import ctbrec.Config; +import ctbrec.GlobalThreadPool; +import ctbrec.Hmac; +import ctbrec.Settings; +import ctbrec.Settings.DirectoryStructure; +import ctbrec.Settings.ProxyType; +import ctbrec.docs.DocServer; +import ctbrec.recorder.Recorder; +import ctbrec.sites.Site; +import ctbrec.ui.DesktopIntegration; +import ctbrec.ui.SiteUI; +import ctbrec.ui.SiteUiFactory; +import ctbrec.ui.controls.range.DiscreteRange; +import ctbrec.ui.settings.api.*; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabSelectionListener; +import javafx.animation.FadeTransition; +import javafx.animation.PauseTransition; +import javafx.animation.Transition; +import javafx.beans.binding.BooleanExpression; +import javafx.beans.property.*; +import javafx.beans.value.ObservableValue; +import javafx.collections.FXCollections; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.Tab; +import javafx.scene.control.TextInputDialog; +import javafx.scene.layout.*; +import javafx.scene.paint.Color; +import javafx.util.Duration; +import lombok.Getter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static ctbrec.Settings.DirectoryStructure.*; +import static ctbrec.Settings.ProxyType.*; +import static ctbrec.Settings.SplitStrategy.*; +import static java.util.Optional.ofNullable; + +public class SettingsTab extends Tab implements TabSelectionListener { + + private static final Logger LOG = LoggerFactory.getLogger(SettingsTab.class); + public static final int CHECKBOX_MARGIN = 6; + private static final long MiB = 1024 * 1024L; // NOSONAR + private static final long GiB = 1024 * MiB; // NOSONAR + + private final List sites; + private final Recorder recorder; + private final Config config; + private final Settings settings; + private boolean initialized = false; + + private SimpleStringProperty flaresolverrApiUrl; + private SimpleIntegerProperty flaresolverrTimeoutInMillis; + private SimpleJoinedStringListProperty flaresolverrUseForDomains; + private SimpleStringProperty httpUserAgent; + private SimpleStringProperty httpUserAgentMobile; + private SimpleIntegerProperty overviewUpdateIntervalInSecs; + private SimpleBooleanProperty updateThumbnails; + private SimpleBooleanProperty determineResolution; + private SimpleBooleanProperty chooseStreamQuality; + private SimpleBooleanProperty confirmationDialogs; + private SimpleBooleanProperty livePreviews; + private SimpleBooleanProperty monitorClipboard; + private SimpleListProperty startTab; + private SimpleFileProperty mediaPlayer; + private SimpleStringProperty mediaPlayerParams; + private SimpleFileProperty browserOverride; + private SimpleStringProperty browserParams; + private SimpleBooleanProperty forceBrowserOverride; + private SimpleIntegerProperty maximumResolutionPlayer; + private SimpleBooleanProperty showPlayerStarting; + private SimpleBooleanProperty singlePlayer; + private SimpleListProperty proxyType; + private SimpleStringProperty proxyHost; + private SimpleStringProperty proxyPort; + private SimpleStringProperty proxyUser; + private SimpleStringProperty proxyPassword; + private SimpleDirectoryProperty recordingsDir; + private SimpleListProperty directoryStructure; + private SimpleListProperty splitAfter; + private SimpleListProperty splitBiggerThan; + private SimpleRangeProperty resolutionRange; + private final List labels = Arrays.asList(0, 240, 360, 480, 540, 600, 720, 960, 1080, 1440, 2160, 4320, 8640); + private final List values = Arrays.asList(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12); + private final DiscreteRange rangeValues = new DiscreteRange<>(values, labels); + private SimpleIntegerProperty concurrentRecordings; + private SimpleIntegerProperty onlineCheckIntervalInSecs; + private SimpleBooleanProperty onlineCheckSkipsPausedModels; + private SimpleLongProperty leaveSpaceOnDevice; + private SimpleStringProperty ffmpegParameters; + private SimpleBooleanProperty logFFmpegOutput; + private SimpleBooleanProperty loghlsdlOutput; + private SimpleBooleanProperty logMissedSegments; + private SimpleStringProperty fileExtension; + private SimpleStringProperty server; + private SimpleIntegerProperty port; + private SimpleStringProperty path; + private SimpleStringProperty downloadFilename; + private SimpleBooleanProperty recordedModelsPerSite; + private SimpleBooleanProperty requireAuthentication; + private SimpleBooleanProperty totalModelCountInTitle; + private SimpleBooleanProperty showActiveRecordingsInTray; + private SimpleBooleanProperty transportLayerSecurity; + private SimpleBooleanProperty fastScrollSpeed; + private SimpleBooleanProperty useHlsdl; + private SimpleBooleanProperty recentlyWatched; + private SimpleFileProperty hlsdlExecutable; + private ExclusiveSelectionProperty recordLocal; + private SimpleIntegerProperty postProcessingThreads; + private IgnoreList ignoreList; + private Label restartNotification; + private SimpleIntegerProperty playlistRequestTimeout; + private SimpleBooleanProperty minimizeToTray; + private SimpleBooleanProperty startMinimized; + private SimpleBooleanProperty showGridLinesInTables; + private SimpleBooleanProperty tabsSortable; + private SimpleIntegerProperty defaultPriority; + private LocalTimeProperty timeoutRecordingStartingAt; + private LocalTimeProperty timeoutRecordingEndingAt; + private SimpleLongProperty recordUntilDefaultDurationInMinutes; + private SimpleStringProperty dateTimeFormat; + private final VariablePlayGroundDialogFactory variablePlayGroundDialogFactory = new VariablePlayGroundDialogFactory(); + private SimpleBooleanProperty checkForUpdates; + private SimpleStringProperty filterBlacklist; + private SimpleStringProperty filterWhitelist; + private SimpleBooleanProperty deleteOrphanedRecordingMetadata; + private SimpleIntegerProperty restrictBitrate; + private SimpleIntegerProperty configSavingDelayMs; + private SimpleIntegerProperty httpClientMaxRequests; + private SimpleIntegerProperty httpClientMaxRequestsPerHost; + + public SettingsTab(List sites, Recorder recorder) { + this.sites = sites; + this.recorder = recorder; + setText("Settings"); + setClosable(false); + config = Config.getInstance(); + settings = config.getSettings(); + } + + private void initializeProperties() { + flaresolverrApiUrl = new SimpleStringProperty(null, "flaresolverr.apiUrl", settings.flaresolverr.apiUrl); + flaresolverrTimeoutInMillis = new SimpleIntegerProperty(null, "flaresolverr.timeoutInMillis", settings.flaresolverr.timeoutInMillis); + flaresolverrUseForDomains = new SimpleJoinedStringListProperty(null, "flaresolverr.useForDomains", "\n", + FXCollections.observableList(settings.flaresolverr.useForDomains)); + httpUserAgent = new SimpleStringProperty(null, "httpUserAgent", settings.httpUserAgent); + httpUserAgentMobile = new SimpleStringProperty(null, "httpUserAgentMobile", settings.httpUserAgentMobile); + overviewUpdateIntervalInSecs = new SimpleIntegerProperty(null, "overviewUpdateIntervalInSecs", settings.overviewUpdateIntervalInSecs); + updateThumbnails = new SimpleBooleanProperty(null, "updateThumbnails", settings.updateThumbnails); + determineResolution = new SimpleBooleanProperty(null, "determineResolution", settings.determineResolution); + chooseStreamQuality = new SimpleBooleanProperty(null, "chooseStreamQuality", settings.chooseStreamQuality); + livePreviews = new SimpleBooleanProperty(null, "livePreviews", settings.livePreviews); + monitorClipboard = new SimpleBooleanProperty(null, "monitorClipboard", settings.monitorClipboard); + startTab = new SimpleListProperty<>(null, "startTab", FXCollections.observableList(getTabNames())); + mediaPlayer = new SimpleFileProperty(null, "mediaPlayer", settings.mediaPlayer); + mediaPlayerParams = new SimpleStringProperty(null, "mediaPlayerParams", settings.mediaPlayerParams); + browserOverride = new SimpleFileProperty(null, "browserOverride", settings.browserOverride); + browserParams = new SimpleStringProperty(null, "browserParams", settings.browserParams); + forceBrowserOverride = new SimpleBooleanProperty(null, "forceBrowserOverride", settings.forceBrowserOverride); + maximumResolutionPlayer = new SimpleIntegerProperty(null, "maximumResolutionPlayer", settings.maximumResolutionPlayer); + showPlayerStarting = new SimpleBooleanProperty(null, "showPlayerStarting", settings.showPlayerStarting); + singlePlayer = new SimpleBooleanProperty(null, "singlePlayer", settings.singlePlayer); + proxyType = new SimpleListProperty<>(null, "proxyType", FXCollections.observableList(List.of(DIRECT, HTTP, SOCKS4, SOCKS5))); + proxyHost = new SimpleStringProperty(null, "proxyHost", settings.proxyHost); + proxyPort = new SimpleStringProperty(null, "proxyPort", settings.proxyPort); + proxyUser = new SimpleStringProperty(null, "proxyUser", settings.proxyUser); + proxyPassword = new SimpleStringProperty(null, "proxyPassword", settings.proxyPassword); + recordingsDir = new SimpleDirectoryProperty(null, "recordingsDir", settings.recordingsDir); + directoryStructure = new SimpleListProperty<>(null, "recordingsDirStructure", + FXCollections.observableList(List.of(FLAT, ONE_PER_MODEL, ONE_PER_GROUP, ONE_PER_RECORDING))); + splitAfter = new SimpleListProperty<>(null, "splitRecordingsAfterSecs", FXCollections.observableList(getSplitAfterSecsOptions())); + splitBiggerThan = new SimpleListProperty<>(null, "splitRecordingsBiggerThanBytes", FXCollections.observableList(getSplitBiggerThanOptions())); + resolutionRange = new SimpleRangeProperty<>(rangeValues, "minimumResolution", "maximumResolution", settings.minimumResolution, + settings.maximumResolution); + concurrentRecordings = new SimpleIntegerProperty(null, "concurrentRecordings", settings.concurrentRecordings); + onlineCheckIntervalInSecs = new SimpleIntegerProperty(null, "onlineCheckIntervalInSecs", settings.onlineCheckIntervalInSecs); + leaveSpaceOnDevice = new SimpleLongProperty(null, "minimumSpaceLeftInBytes", + (long) new GigabytesConverter().convertTo(settings.minimumSpaceLeftInBytes)); + ffmpegParameters = new SimpleStringProperty(null, "ffmpegMergedDownloadArgs", settings.ffmpegMergedDownloadArgs); + logFFmpegOutput = new SimpleBooleanProperty(null, "logFFmpegOutput", settings.logFFmpegOutput); + loghlsdlOutput = new SimpleBooleanProperty(null, "loghlsdlOutput", settings.loghlsdlOutput); + logMissedSegments = new SimpleBooleanProperty(null, "logMissedSegments", settings.logMissedSegments); + fileExtension = new SimpleStringProperty(null, "ffmpegFileSuffix", settings.ffmpegFileSuffix); + server = new SimpleStringProperty(null, "httpServer", settings.httpServer); + port = new SimpleIntegerProperty(null, "httpPort", settings.httpPort); + path = new SimpleStringProperty(null, "servletContext", settings.servletContext); + downloadFilename = new SimpleStringProperty(null, "downloadFilename", settings.downloadFilename); + recordedModelsPerSite = new SimpleBooleanProperty(null, "recordedModelsPerSite", settings.recordedModelsPerSite); + requireAuthentication = new SimpleBooleanProperty(null, "requireAuthentication", settings.requireAuthentication); + requireAuthentication.addListener(this::requireAuthenticationChanged); + totalModelCountInTitle = new SimpleBooleanProperty(null, "totalModelCountInTitle", settings.totalModelCountInTitle); + showActiveRecordingsInTray = new SimpleBooleanProperty(null, "showActiveRecordingsInTray", settings.showActiveRecordingsInTray); + transportLayerSecurity = new SimpleBooleanProperty(null, "transportLayerSecurity", settings.transportLayerSecurity); + recordLocal = new ExclusiveSelectionProperty(null, "localRecording", settings.localRecording, "Local", "Remote"); + postProcessingThreads = new SimpleIntegerProperty(null, "postProcessingThreads", settings.postProcessingThreads); + onlineCheckSkipsPausedModels = new SimpleBooleanProperty(null, "onlineCheckSkipsPausedModels", settings.onlineCheckSkipsPausedModels); + fastScrollSpeed = new SimpleBooleanProperty(null, "fastScrollSpeed", settings.fastScrollSpeed); + confirmationDialogs = new SimpleBooleanProperty(null, "confirmationForDangerousActions", settings.confirmationForDangerousActions); + useHlsdl = new SimpleBooleanProperty(null, "useHlsdl", settings.useHlsdl); + hlsdlExecutable = new SimpleFileProperty(null, "hlsdlExecutable", settings.hlsdlExecutable); + recentlyWatched = new SimpleBooleanProperty(null, "recentlyWatched", settings.recentlyWatched); + playlistRequestTimeout = new SimpleIntegerProperty(null, "playlistRequestTimeout", settings.playlistRequestTimeout); + minimizeToTray = new SimpleBooleanProperty(null, "minimizeToTray", settings.minimizeToTray); + startMinimized = new SimpleBooleanProperty(null, "startMinimized", settings.startMinimized); + showGridLinesInTables = new SimpleBooleanProperty(null, "showGridLinesInTables", settings.showGridLinesInTables); + defaultPriority = new SimpleIntegerProperty(null, "defaultPriority", settings.defaultPriority); + timeoutRecordingStartingAt = new LocalTimeProperty(null, "timeoutRecordingStartingAt", settings.timeoutRecordingStartingAt); + timeoutRecordingEndingAt = new LocalTimeProperty(null, "timeoutRecordingEndingAt", settings.timeoutRecordingEndingAt); + recordUntilDefaultDurationInMinutes = new SimpleLongProperty(null, "recordUntilDefaultDurationInMinutes", settings.recordUntilDefaultDurationInMinutes); + dateTimeFormat = new SimpleStringProperty(null, "dateTimeFormat", settings.dateTimeFormat); + tabsSortable = new SimpleBooleanProperty(null, "tabsSortable", settings.tabsSortable); + checkForUpdates = new SimpleBooleanProperty(null, "checkForUpdates", settings.checkForUpdates); + filterBlacklist = new SimpleStringProperty(null, "filterBlacklist", settings.filterBlacklist); + filterWhitelist = new SimpleStringProperty(null, "filterWhitelist", settings.filterWhitelist); + deleteOrphanedRecordingMetadata = new SimpleBooleanProperty(null, "deleteOrphanedRecordingMetadata", settings.deleteOrphanedRecordingMetadata); + restrictBitrate = new SimpleIntegerProperty(null, "restrictBitrate", settings.restrictBitrate); + configSavingDelayMs = new SimpleIntegerProperty(null, "configSavingDelayMs", settings.configSavingDelayMs); + httpClientMaxRequests = new SimpleIntegerProperty(null, "httpClientMaxRequests", settings.httpClientMaxRequests); + httpClientMaxRequestsPerHost = new SimpleIntegerProperty(null, "httpClientMaxRequestsPerHost", settings.httpClientMaxRequestsPerHost); + } + + private void createGui() { + var postProcessingStepPanel = new PostProcessingStepPanel(config); + var variablesHelpButton = createHelpButton("Variables", "http://localhost:5689/docs/PostProcessing.md#variables"); + ignoreList = new IgnoreList(); + List siteCategories = new ArrayList<>(); + for (Site site : sites) { + ofNullable(SiteUiFactory.getUi(site)).map(SiteUI::getConfigUI).map(ConfigUI::createConfigPanel) + .ifPresent(configPanel -> siteCategories.add(Category.of(site.getName(), configPanel))); + } + + var storage = new CtbrecPreferencesStorage(config); + var prefs = Preferences.of(storage, + Category.of("General", + Group.of("General", + Setting.of("User-Agent", httpUserAgent), + Setting.of("User-Agent mobile", httpUserAgentMobile), + Setting.of("Update overview interval (seconds)", overviewUpdateIntervalInSecs, "Update the thumbnail overviews every x seconds").needsRestart(), + Setting.of("Update thumbnails", updateThumbnails, + "The overviews will still be updated, but the thumbnails won't be changed. This is useful for less powerful systems."), + Setting.of("Thumbnails cache size", new CacheSettingsPane(this, config)).needsRestart(), + Setting.of("Manually select stream quality", chooseStreamQuality, "Opens a dialog to select the video resolution before recording"), + Setting.of("Enable live previews (experimental)", livePreviews), + Setting.of("Enable recently watched tab", recentlyWatched).needsRestart(), + Setting.of("Minimize to tray", minimizeToTray, "Removes the app from the task bar, if minimized"), + Setting.of("Start minimized", startMinimized, "Start the app minimized to the tray, automatically activates \"Minimize to tray\""), + Setting.of("Add models from clipboard", monitorClipboard, "Monitor clipboard for model URLs and automatically add them to the recorder").needsRestart(), + Setting.of("Show confirmation dialogs", confirmationDialogs, "Show confirmation dialogs for irreversible actions"), + Setting.of("Recording tab per site", recordedModelsPerSite, "Add a Recording tab for each site").needsRestart(), + Setting.of("Check for new versions at startup", checkForUpdates, "Search for updates every startup"), + Setting.of("Start Tab", startTab)), + + Group.of("Player", + Setting.of("Player", mediaPlayer), + Setting.of("Start parameters", mediaPlayerParams), + Setting.of("Maximum resolution (0 = unlimited)", maximumResolutionPlayer, "video height, e.g. 720 or 1080"), + Setting.of("Show \"Player Starting\" Message", showPlayerStarting), + Setting.of("Start only one player at a time", singlePlayer)), + + Group.of("Browser", + Setting.of("Browser", browserOverride), + Setting.of("Start parameters", browserParams), + Setting.of("Force use (ignore default browser)", forceBrowserOverride, "Default behaviour will fallback to OS default if the above browser fails")), + + Group.of("Flaresolverr", + Setting.of("API URL", flaresolverrApiUrl), + Setting.of("Request timeout", flaresolverrTimeoutInMillis), + Setting.of("Use for domains (one per line)", flaresolverrUseForDomains))), + Category.of("Look & Feel", + Group.of("Look & Feel", + Setting.of("Colors (Base / Accent)", new ColorSettingsPane(Config.getInstance())).needsRestart(), + Setting.of("Font", new FontSettingsPane(this, config)).needsRestart(), + Setting.of("Date format (empty = system default)", dateTimeFormat, DATE_FORMATTER_TOOLTIP).needsRestart(), + Setting.of("Display stream resolution in overview", determineResolution), + Setting.of("Total model count in title", totalModelCountInTitle, "Show the total number of models in the title bar"), + Setting.of("Show active recordings counter in tray", showActiveRecordingsInTray, "Show the number of running recorings in the tray icon"), + Setting.of("Show grid lines in tables", showGridLinesInTables, "Show grid lines in tables").needsRestart(), + Setting.of("Fast scroll speed", fastScrollSpeed, "Makes the thumbnail overviews scroll faster with the mouse wheel").needsRestart(), + Setting.of("Draggable tabs", tabsSortable, "Main tabs can be reordered").needsRestart())), + Category.of("Recorder", + Group.of("Recorder", + Setting.of("Recordings Directory", recordingsDir), + Setting.of("Directory Structure", directoryStructure), + Setting.of("Split recordings after", splitAfter).converter(SplitAfterOption.converter()).onChange(this::splitValuesChanged), + Setting.of("Split recordings bigger than", splitBiggerThan).converter(SplitBiggerThanOption.converter()).onChange(this::splitValuesChanged), + Setting.of("Restrict Resolution", resolutionRange, "Only record streams with resolution within the given range"), + Setting.of("Restrict Resolution", new RestrictResolutionSidePane(config)), + Setting.of("Restrict Video Bitrate (kbps, 0 = unlimited)", restrictBitrate, "Only record streams with a video bitrate below this limit (kbps)"), + Setting.of("Concurrent Recordings (0 = unlimited)", concurrentRecordings), + Setting.of("Default Priority", defaultPriority, "lowest 0 - 10000 highest"), + Setting.of("Default duration for \"Record until\" (minutes)", recordUntilDefaultDurationInMinutes), + Setting.of("Leave space on device (GiB)", leaveSpaceOnDevice, + "Stop recording, if the free space on the device gets below this threshold").converter(new GigabytesConverter()), + Setting.of("FFmpeg parameters", ffmpegParameters, "FFmpeg parameters to use when merging stream segments"), + Setting.of("File Extension", fileExtension, "File extension to use for recordings"), + Setting.of("Check online state every (seconds)", onlineCheckIntervalInSecs, "Check every x seconds, if a model came online"), + Setting.of("Skip online check for paused models", onlineCheckSkipsPausedModels, "Skip online check for paused models"), + Setting.of("Delete orphaned recording metadata", deleteOrphanedRecordingMetadata, "Delete recordings for which the video files are missing on start")), + Group.of("Timeout", + Setting.of("Don't record from", timeoutRecordingStartingAt), + Setting.of("Until", timeoutRecordingEndingAt) + ), + Group.of("Location", + Setting.of("Record Location", recordLocal).needsRestart(), + Setting.of("Server", server), + Setting.of("Port", port), + Setting.of("Path", path, "Leave empty, if you didn't change the servletContext in the server config"), + Setting.of("Download Filename", downloadFilename, "File name pattern for downloads"), + Setting.of("", variablesHelpButton), + Setting.of("Require authentication", requireAuthentication), + Setting.of("Use Secure Communication (TLS)", transportLayerSecurity))), + Category.of("Post-Processing", + Group.of("Post-Processing", + Setting.of("Threads", postProcessingThreads), + Setting.of("Steps", postProcessingStepPanel), + Setting.of("", createHelpButton("Post-Processing Help", "http://localhost:5689/docs/PostProcessing.md")), + Setting.of("", createVariablePlayGroundButton()))), + Category.of("Events & Actions", new ActionSettingsPanel(recorder)), + Category.of("Filtering", + Group.of("Ignore List", + Setting.of("", ignoreList)), + Group.of("Text Filters", + Setting.of("Blacklist", filterBlacklist, "Default list of blacklist filters for site views, space seperated"), + Setting.of("Whitelist", filterWhitelist, "Default list of whitelist filters for site views, space seperated"))), Category.of("Sites", siteCategories.toArray(new Category[0])), + Category.of("Proxy", + Group.of("Proxy", + Setting.of("Type", proxyType).needsRestart(), + Setting.of("Host", proxyHost).needsRestart(), + Setting.of("Port", proxyPort).needsRestart(), + Setting.of("Username", proxyUser).needsRestart(), + Setting.of("Password", proxyPassword).needsRestart())), + Category.of("Advanced / Devtools", + Group.of("Networking", + Setting.of("Playlist request timeout (ms)", playlistRequestTimeout, "Timeout in ms for playlist requests"), + Setting.of("Max requests", httpClientMaxRequests, + "The maximum number of requests to execute concurrently. Above this requests queue in memory,\n" + // + "waiting for the running calls to complete.\n\n" + // + "If more than [maxRequests] requests are in flight when this is invoked, those requests will remain in flight."), + Setting.of("Max requests per host", httpClientMaxRequestsPerHost, + "The maximum number of requests for each host to execute concurrently. This limits requests by\n" + // + "the URL's host name. Note that concurrent requests to a single IP address may still exceed this\n" + // + "limit: multiple hostnames may share an IP address or be routed through the same HTTP proxy.\n\n" + // + "If more than [maxRequestsPerHost] requests are in flight when this is invoked, those requests will remain in flight.\n\n" + // + "WebSocket connections to hosts **do not** count against this limit.")), + Group.of("Logging", + Setting.of("Log FFmpeg output", logFFmpegOutput, "Log FFmpeg output to files in the system's temp directory"), + Setting.of("Log missed segments", logMissedSegments, + "Write a log files in the system's temp directory to analyze missed segments")), + Group.of("hlsdl (experimental)", + Setting.of("Use hlsdl (if possible)", useHlsdl, + "Use hlsdl to record the live streams. Some features might not work correctly."), + Setting.of("hlsdl executable", hlsdlExecutable, "Path to the hlsdl executable"), + Setting.of("Log hlsdl output", loghlsdlOutput, "Log hlsdl output to files in the system's temp directory")), + Group.of("Miscelaneous", + Setting.of("Config file saving delay (ms)", configSavingDelayMs, + "Wait specified number of milliseconds before actually writing config to disk")))); + Region preferencesView = prefs.getView(); + prefs.onRestartRequired(this::showRestartRequired); + storage.setPreferences(prefs); + + var stackPane = new StackPane(); + stackPane.getChildren().add(preferencesView); + restartNotification = new Label("Restart Required"); + restartNotification.setVisible(false); + restartNotification.setOpacity(0); + restartNotification.setStyle("-fx-font-size: 28; -fx-padding: .3em"); + restartNotification + .setBorder(new Border(new BorderStroke(Color.web(settings.colorAccent), BorderStrokeStyle.SOLID, new CornerRadii(5), new BorderWidths(2)))); + restartNotification.setBackground(new Background(new BackgroundFill(Color.web(settings.colorBase), new CornerRadii(5), Insets.EMPTY))); + stackPane.getChildren().add(restartNotification); + StackPane.setAlignment(restartNotification, Pos.TOP_RIGHT); + StackPane.setMargin(restartNotification, new Insets(10, 40, 0, 0)); + + setContent(stackPane); + prefs.expandTree(); + + prefs.getSetting("flaresolverr.apiUrl").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); + prefs.getSetting("flaresolverr.timeoutInMillis").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); + prefs.getSetting("httpServer").ifPresent(s -> bindEnabledProperty(s, recordLocal)); + prefs.getSetting("httpPort").ifPresent(s -> bindEnabledProperty(s, recordLocal)); + prefs.getSetting("servletContext").ifPresent(s -> bindEnabledProperty(s, recordLocal)); + prefs.getSetting("requireAuthentication").ifPresent(s -> bindEnabledProperty(s, recordLocal)); + prefs.getSetting("transportLayerSecurity").ifPresent(s -> bindEnabledProperty(s, recordLocal)); + prefs.getSetting("recordingsDir").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); + prefs.getSetting("splitRecordingsAfterSecs").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); + prefs.getSetting("splitRecordingsBiggerThanBytes").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); + prefs.getSetting("minimumResolution").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); + prefs.getSetting("recordingsDirStructure").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); + prefs.getSetting("onlineCheckIntervalInSecs").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); + prefs.getSetting("onlineCheckSkipsPausedModels").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); + prefs.getSetting("minimumSpaceLeftInBytes").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); + prefs.getSetting("postProcessing").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); + prefs.getSetting("postProcessingThreads").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); + prefs.getSetting("removeRecordingAfterPostProcessing").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); + prefs.getSetting("minimumLengthInSeconds").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); + prefs.getSetting("concurrentRecordings").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); + prefs.getSetting("timeoutRecordingStartingAt").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); + prefs.getSetting("timeoutRecordingEndingAt").ifPresent(s -> bindEnabledProperty(s, recordLocal.not())); + prefs.getSetting("downloadFilename").ifPresent(s -> bindEnabledProperty(s, recordLocal)); + prefs.getSetting("hlsdlExecutable").ifPresent(s -> bindEnabledProperty(s, useHlsdl.not())); + prefs.getSetting("loghlsdlOutput").ifPresent(s -> bindEnabledProperty(s, useHlsdl.not())); + postProcessingStepPanel.disableProperty().bind(recordLocal.not()); + variablesHelpButton.disableProperty().bind(recordLocal); + } + + private void splitValuesChanged(ObservableValue value, Object oldV, Object newV) { + boolean splitAfterSet = settings.splitRecordingsAfterSecs > 0; + boolean splitBiggerThanSet = settings.splitRecordingsBiggerThanBytes > 0; + if (splitAfterSet && splitBiggerThanSet) { + settings.splitStrategy = TIME_OR_SIZE; + } else if (splitAfterSet) { + settings.splitStrategy = TIME; + } else if (splitBiggerThanSet) { + settings.splitStrategy = SIZE; + } else { + settings.splitStrategy = DONT; + } + saveConfig(); + } + + private Button createHelpButton(String text, String url) { + var postProcessingHelpButton = new Button(text); + postProcessingHelpButton.setOnAction(e -> { + new Thread(() -> { + try { + DocServer.start(); + } catch (Exception ex) { + LOG.error("Couldn't start documentation server", ex); + } + }).start(); + DesktopIntegration.open(url); + }); + return postProcessingHelpButton; + } + + private Button createVariablePlayGroundButton() { + var button = new Button("Variable Playground"); + button.setOnAction(e -> variablePlayGroundDialogFactory.openDialog(this.getTabPane().getScene(), config, recorder)); + return button; + } + + private void bindEnabledProperty(Setting s, BooleanExpression bindTo) { + try { + s.getGui().disableProperty().bind(bindTo); + } catch (Exception e) { + LOG.error("Couldn't bind disableProperty of {}", s.getName(), e); + } + } + + private List getTabNames() { + return getTabPane().getTabs().stream().map(Tab::getText).toList(); + } + + private List getSplitAfterSecsOptions() { + List splitOptions = new ArrayList<>(); + splitOptions.add(new SplitAfterOption("disabled", 0)); + if (Config.isDevMode()) { + splitOptions.add(new SplitAfterOption("1 min", 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)); + return splitOptions; + } + + private List getSplitBiggerThanOptions() { + List splitOptions = new ArrayList<>(); + splitOptions.add(new SplitBiggerThanOption("disabled", 0)); + if (Config.isDevMode()) { + splitOptions.add(new SplitBiggerThanOption("10 MiB", 10 * MiB)); + splitOptions.add(new SplitBiggerThanOption("20 MiB", 20 * MiB)); + } + splitOptions.add(new SplitBiggerThanOption("100 MiB", 100 * MiB)); + splitOptions.add(new SplitBiggerThanOption("250 MiB", 250 * MiB)); + splitOptions.add(new SplitBiggerThanOption("500 MiB", 500 * MiB)); + splitOptions.add(new SplitBiggerThanOption("1 GiB", GiB)); + splitOptions.add(new SplitBiggerThanOption("2 GiB", 2 * GiB)); + splitOptions.add(new SplitBiggerThanOption("3 GiB", 3 * GiB)); + splitOptions.add(new SplitBiggerThanOption("4 GiB", 4 * GiB)); + splitOptions.add(new SplitBiggerThanOption("5 GiB", 5 * GiB)); + splitOptions.add(new SplitBiggerThanOption("6 GiB", 6 * GiB)); + splitOptions.add(new SplitBiggerThanOption("7 GiB", 7 * GiB)); + splitOptions.add(new SplitBiggerThanOption("8 GiB", 8 * GiB)); + splitOptions.add(new SplitBiggerThanOption("9 GiB", 9 * GiB)); + splitOptions.add(new SplitBiggerThanOption("10 GiB", 10 * GiB)); + return splitOptions; + } + + private void requireAuthenticationChanged(ObservableValue obs, Boolean oldV, Boolean newV) { // NOSONAR + boolean requiresAuthentication = newV; + Config.getInstance().getSettings().requireAuthentication = requiresAuthentication; + if (requiresAuthentication) { + byte[] key = Config.getInstance().getSettings().key; + if (key == null) { + key = Hmac.generateKey(); + Config.getInstance().getSettings().key = key; + saveConfig(); + } + var 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(); + } + } + + public void saveConfig() { + GlobalThreadPool.submit(() -> { + try { + Config.getInstance().save(); + } catch (IOException e) { + LOG.error("Couldn't save config", e); + } + }); + } + + @Override + public void selected() { + if (!initialized) { + initializeProperties(); + createGui(); + initialized = true; + } + ignoreList.refresh(); + } + + @Override + public void deselected() { + saveConfig(); + } + + public static GridPane createGridLayout() { + var layout = new GridPane(); + layout.setPadding(new Insets(10)); + layout.setHgap(5); + layout.setVgap(5); + return layout; + } + + void showRestartRequired() { + if (!restartNotification.isVisible()) { + restartNotification.setVisible(true); + Transition fadeIn = changeOpacity(restartNotification, 1); + fadeIn.play(); + fadeIn.setOnFinished(e -> { + Transition fadeOut = changeOpacity(restartNotification, 0); + fadeOut.setOnFinished(e2 -> restartNotification.setVisible(false)); + var pauseTransition = new PauseTransition(Duration.seconds(5)); + pauseTransition.setOnFinished(evt -> fadeOut.play()); + pauseTransition.play(); + }); + } + } + + private static final Duration ANIMATION_DURATION = new Duration(500); + + private Transition changeOpacity(Node node, double opacity) { + var transition = new FadeTransition(ANIMATION_DURATION, node); + transition.setFromValue(node.getOpacity()); + transition.setToValue(opacity); + return transition; + } + + public record SplitAfterOption(String label, @Getter int value) { + + @Override + public String toString() { + return label; + } + + @Override + public int hashCode() { + final var prime = 31; + var result = 1; + result = prime * result + value; + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + SplitAfterOption other = (SplitAfterOption) obj; + return value == other.value; + } + + public static ValueConverter converter() { + return new ValueConverter() { + @Override + public Integer convertFrom(Object splitAfterOption) { + return ((SplitAfterOption) splitAfterOption).getValue(); + } + + @Override + public SplitAfterOption convertTo(Object integer) { + return new SplitAfterOption(integer.toString(), (Integer) integer); + } + }; + } + } + + public record SplitBiggerThanOption(String label, @Getter long value) { + + @Override + public String toString() { + return label; + } + + @Override + public int hashCode() { + final var prime = 31; + var result = 1; + result = prime * result + (int) (value ^ (value >>> 32)); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + SplitBiggerThanOption other = (SplitBiggerThanOption) obj; + return value == other.value; + } + + public static ValueConverter converter() { + return new ValueConverter() { + @Override + public Long convertFrom(Object splitBiggerThanOption) { + return ((SplitBiggerThanOption) splitBiggerThanOption).getValue(); + } + + @Override + public SplitBiggerThanOption convertTo(Object value) { + return new SplitBiggerThanOption(value.toString(), (Long) value); + } + }; + } + } + + private static final String DATE_FORMATTER_TOOLTIP = """ + Leave empty for system default + + Symbol Meaning Presentation Examples + ------ ------- ------------ ------- + G era text AD; Anno Domini; A + u year year 2004; 04 + y year-of-era year 2004; 04 + D day-of-year number 189 + M/L month-of-year number/text 7; 07; Jul; July; J + d day-of-month number 10 + + Q/q quarter-of-year number/text 3; 03; Q3; 3rd quarter + Y week-based-year year 1996; 96 + w week-of-week-based-year number 27 + W week-of-month number 4 + E day-of-week text Tue; Tuesday; T + e/c localized day-of-week number/text 2; 02; Tue; Tuesday; T + F week-of-month number 3 + + a am-pm-of-day text PM + h clock-hour-of-am-pm (1-12) number 12 + K hour-of-am-pm (0-11) number 0 + k clock-hour-of-am-pm (1-24) number 0 + + H hour-of-day (0-23) number 0 + m minute-of-hour number 30 + s second-of-minute number 55 + S fraction-of-second fraction 978 + A milli-of-day number 1234 + n nano-of-second number 987654321 + N nano-of-day number 1234000000 + + V time-zone ID zone-id America/Los_Angeles; Z; -08:30 + z time-zone name zone-name Pacific Standard Time; PST + O localized zone-offset offset-O GMT+8; GMT+08:00; UTC-08:00; + X zone-offset 'Z' for zero offset-X Z; -08; -0830; -08:30; -083015; -08:30:15; + x zone-offset offset-x +0000; -08; -0830; -08:30; -083015; -08:30:15; + Z zone-offset offset-Z +0000; -0800; -08:00; + + p pad next pad modifier 1 + + ' escape for text delimiter + '' single quote literal ' + [ optional section start + ] optional section end + # reserved for future use + { reserved for future use + } reserved for future use + """; +} diff --git a/client/src/main/java/ctbrec/ui/settings/VariablePlayGroundDialogFactory.java b/client/src/main/java/ctbrec/ui/settings/VariablePlayGroundDialogFactory.java new file mode 100644 index 00000000..80d534b2 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/VariablePlayGroundDialogFactory.java @@ -0,0 +1,100 @@ +package ctbrec.ui.settings; + +import ctbrec.Config; +import ctbrec.Recording; +import ctbrec.StringUtil; +import ctbrec.UnknownModel; +import ctbrec.recorder.Recorder; +import ctbrec.sites.chaturbate.Chaturbate; +import ctbrec.ui.CamrecApplication; +import ctbrec.ui.controls.Dialogs; +import ctbrec.variableexpansion.ConfigVariableExpander; +import ctbrec.variableexpansion.ModelVariableExpander; +import ctbrec.variableexpansion.RecordingVariableExpander; +import ctbrec.variableexpansion.functions.AntlrSyntacErrorAdapter; +import javafx.scene.Scene; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; +import lombok.extern.slf4j.Slf4j; +import org.antlr.v4.runtime.RecognitionException; +import org.antlr.v4.runtime.Recognizer; + +import java.nio.file.Paths; +import java.time.Instant; + +@Slf4j +public class VariablePlayGroundDialogFactory { + + private GridPane pane; + + public void openDialog(Scene parent, Config config, Recorder recorder) { + if (pane == null) { + initGui(config, recorder); + } + + Dialogs.showCustomInput(parent, "Playground", pane, (obs, oldV, newV) -> { + }); + } + + private void initGui(Config config, Recorder recorder) { + Chaturbate chaturbate = new Chaturbate(); + UnknownModel unknownModel = new UnknownModel(); + unknownModel.setName("Pussy_Galore"); + unknownModel.setDisplayName("Pussy Galore"); + unknownModel.setSite(chaturbate); + unknownModel.setUrl("http://camsite.example/pussy_galore"); + Recording recording = new Recording(); + recording.setAbsoluteFile(Paths.get("ctbrec", "recs", "pussy_galore", "2023-02-26_14-05-56").toFile()); + recording.setStartDate(Instant.now()); + recording.setStatus(Recording.State.POST_PROCESSING); + recording.setNote("notes about the recording"); + recording.setModel(unknownModel); + + pane = new GridPane(); + Label result = new Label(); + Label error = new Label(); + + pane.add(new Label("Expression"), 0, 0); + TextField input = new TextField(); + input.setMinWidth(600); + pane.add(input, 1, 0); + GridPane.setHgrow(input, Priority.ALWAYS); + + pane.add(error, 0, 1); + GridPane.setColumnSpan(error, 2); + + pane.add(result, 0, 2); + GridPane.setColumnSpan(result, 2); + + pane.setHgap(5); + pane.vgapProperty().bind(pane.hgapProperty()); + + AntlrSyntacErrorAdapter errorHandler = new AntlrSyntacErrorAdapter() { + @Override + public void syntaxError(Recognizer recognizer, Object o, int line, int pos, String s, RecognitionException e) { + error.setText(String.format("Syntax error at %d:%d %s", line, pos, s)); + } + }; + + ModelVariableExpander modelVariableExpander = new ModelVariableExpander(unknownModel, CamrecApplication.modelNotesService, recorder, errorHandler); + RecordingVariableExpander recordingVariableExpander = new RecordingVariableExpander(recording, errorHandler); + ConfigVariableExpander configVariableExpander = new ConfigVariableExpander(config, errorHandler); + + input.setOnKeyTyped(evt -> { + try { + String r = input.getText(); + modelVariableExpander.getPlaceholderValueSuppliers().putAll(recordingVariableExpander.getPlaceholderValueSuppliers()); + modelVariableExpander.getPlaceholderValueSuppliers().putAll(configVariableExpander.getPlaceholderValueSuppliers()); + r = modelVariableExpander.expand(r); + result.setText(r); + if (StringUtil.isNotBlank(r)) { + error.setText(""); + } + } catch (Exception e) { + log.error("Error", e); + } + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/settings/api/Category.java b/client/src/main/java/ctbrec/ui/settings/api/Category.java new file mode 100644 index 00000000..ba658cce --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/api/Category.java @@ -0,0 +1,153 @@ +package ctbrec.ui.settings.api; + +import static java.util.Optional.*; + +import java.util.function.Supplier; + +import ctbrec.StringUtil; +import javafx.scene.Node; +import javafx.scene.control.Control; +import javafx.scene.control.Label; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.Pane; + +public class Category { + + protected String name; + protected Group[] groups; + protected Category[] subCategories; + protected Node gui; + + private Category(String name, Group... groups) { + this.name = name; + this.groups = groups; + } + + public Category(String name, Category[] subCategories) { + this.name = name; + this.subCategories = subCategories; + groups = new Group[0]; + } + + public Category(String name, Node gui) { + this.name = name; + this.gui = gui; + groups = new Group[0]; + } + + public static Category of(String name, Setting... settings) { + return new Category(name, Group.of(settings)); + } + + public static Category of(String name, Group... groups) { + return new Category(name, groups); + } + + public static Category of(String name, Category... subCategories) { + return new Category(name, subCategories); + } + + public static Category of(String name, Node gui) { + return new Category(name, gui); + } + + String getName() { + return name; + } + + Group[] getGroups() { + return groups; + } + + Category[] getSubCategories() { + return subCategories; + } + + boolean hasGroups() { + return groups != null && groups.length > 0 && !groups[0].isDefault(); + } + + boolean hasSubCategories() { + return subCategories != null && subCategories.length > 0; + } + + Node getGuiOrElse(Supplier guiFactory) { + if (gui == null) { + gui = guiFactory.get(); + } + return gui; + } + + @Override + public String toString() { + return name; + } + + public boolean contains(String filter) { + if (StringUtil.isBlank(filter)) { + return true; + } + String q = filter.toLowerCase().trim(); + if(hasGroups() || hasSubCategories()) { + return name.toLowerCase().contains(q) + || groupsContains(q) + || subCategoriesContains(q); + } else { + return name.toLowerCase().contains(q) + || guiContains(q); + } + + } + + private boolean subCategoriesContains(String filter) { + var contains = false; + if (subCategories != null) { + for (Category category : subCategories) { + if (category.contains(filter)) { + contains = true; + } + } + } + return contains; + } + + private boolean groupsContains(String filter) { + var contains = false; + if (groups != null) { + for (Group group : groups) { + if (group.contains(filter)) { + contains = true; + } + } + } + return contains; + } + + private boolean guiContains(String filter) { + if (gui != null) { + return nodeContains(gui, filter); + } + return false; + } + + private boolean nodeContains(Node node, String filter) { + var contains = false; + if (node instanceof Pane) { + var pane = (Pane) node; + for (Node child : pane.getChildren()) { + contains |= nodeContains(child, filter); + } + } + + if (node instanceof Label) { + Label lbl = (Label) node; + contains |= lbl.getText().toLowerCase().contains(filter); + contains |= ofNullable(lbl.getTooltip()).map(Tooltip::getText).orElse("").toLowerCase().contains(filter); + } + if (node instanceof Control) { + contains |= ofNullable(((Control) node).getTooltip()).map(Tooltip::getText).orElse("").toLowerCase().contains(filter); + } + contains |= node.toString().toLowerCase().contains(filter); + return contains; + } +} diff --git a/client/src/main/java/ctbrec/ui/settings/api/ExclusiveSelectionProperty.java b/client/src/main/java/ctbrec/ui/settings/api/ExclusiveSelectionProperty.java new file mode 100644 index 00000000..fd367819 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/api/ExclusiveSelectionProperty.java @@ -0,0 +1,23 @@ +package ctbrec.ui.settings.api; + +import javafx.beans.property.SimpleBooleanProperty; + +public class ExclusiveSelectionProperty extends SimpleBooleanProperty { + + private String optionA; + private String optionB; + + public ExclusiveSelectionProperty(Object bean, String name, boolean value, String optionA, String optionB) { + super(bean, name, value); + this.optionA = optionA; + this.optionB = optionB; + } + + public String getOptionA() { + return optionA; + } + + public String getOptionB() { + return optionB; + } +} diff --git a/client/src/main/java/ctbrec/ui/settings/api/GigabytesConverter.java b/client/src/main/java/ctbrec/ui/settings/api/GigabytesConverter.java new file mode 100644 index 00000000..52b87310 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/api/GigabytesConverter.java @@ -0,0 +1,19 @@ +package ctbrec.ui.settings.api; + +public class GigabytesConverter implements ValueConverter { + + private static final int ONE_GIB_IN_BYTES = 1024 * 1024 * 1024; + + @Override + public Object convertTo(Object a) { + long input = (long) a; + return input / ONE_GIB_IN_BYTES; + } + + @Override + public Object convertFrom(Object b) { + long gibiBytes = (long) b; + return gibiBytes * ONE_GIB_IN_BYTES; + } + +} diff --git a/client/src/main/java/ctbrec/ui/settings/api/Group.java b/client/src/main/java/ctbrec/ui/settings/api/Group.java new file mode 100644 index 00000000..465ff243 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/api/Group.java @@ -0,0 +1,51 @@ +package ctbrec.ui.settings.api; + +import java.util.Objects; + +public class Group { + + public static final String DEFAULT = "default"; + private String name; + private Setting[] settings; + + private Group(String name, Setting...settings) { + this.name = name; + this.settings = settings; + } + + public static Group of(Setting...settings) { + return new Group(DEFAULT, settings); + } + + public static Group of(String name, Setting...settings) { + return new Group(name, settings); + } + + String getName() { + return name; + } + + Setting[] getSettings() { + return settings; + } + + boolean isDefault() { + return Objects.equals(name, DEFAULT); + } + + public boolean contains(String filter) { + return name.toLowerCase().contains(filter) || settingsContain(filter); + } + + private boolean settingsContain(String filter) { + var contains = false; + if (settings != null) { + for (Setting setting : settings) { + if (setting.contains(filter)) { + contains = true; + } + } + } + return contains; + } +} diff --git a/client/src/main/java/ctbrec/ui/settings/api/HighlightingSupport.java b/client/src/main/java/ctbrec/ui/settings/api/HighlightingSupport.java new file mode 100644 index 00000000..52e088b7 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/api/HighlightingSupport.java @@ -0,0 +1,89 @@ +package ctbrec.ui.settings.api; + +import javafx.scene.Node; +import javafx.scene.control.Control; +import javafx.scene.control.Label; +import javafx.scene.control.TextInputControl; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.Pane; + +import static java.util.Optional.ofNullable; + +class HighlightingSupport { + + private static final String CSS_HIGHLIGHT_CLASS = "setting-highlighted"; + + private HighlightingSupport() { + } + + static void highlightMatches(Category cat, String filter) { + var node = cat.getGuiOrElse(() -> { + throw new IllegalStateException(cat + " has not been loaded"); + }); + highlightMatches(node, filter); + if (cat.hasSubCategories()) { + for (Category sub : cat.getSubCategories()) { + highlightMatches(sub, filter); + } + } + } + + static void highlightMatches(Node node, String filter) { + var contains = false; + if (node instanceof Pane pane) { + for (Node child : pane.getChildren()) { + highlightMatches(child, filter); + } + } + + if (node instanceof Label lbl) { + contains |= lbl.getText().toLowerCase().contains(filter); + contains |= ofNullable(lbl.getTooltip()).map(Tooltip::getText).orElse("").toLowerCase().contains(filter); + contains |= labelControlContains(lbl, filter); + + if (contains) { + if (!node.getStyleClass().contains(CSS_HIGHLIGHT_CLASS)) { + node.getStyleClass().add(CSS_HIGHLIGHT_CLASS); + } + } else { + node.getStyleClass().remove(CSS_HIGHLIGHT_CLASS); + } + } + } + + private static boolean labelControlContains(Label lbl, String filter) { + var contains = false; + if (lbl.labelForProperty().get() != null) { + var labeledNode = lbl.labelForProperty().get(); + contains |= labeledNode.toString().toLowerCase().contains(filter); + if (labeledNode instanceof Control control) { + contains |= ofNullable(control.getTooltip()).map(Tooltip::getText).orElse("").toLowerCase().contains(filter); + } + if (labeledNode instanceof TextInputControl textInput) { + contains |= ofNullable(textInput.getText()).orElse("").toLowerCase().contains(filter); + } + } + return contains; + } + + static void removeHighlights(Category cat) { + var node = cat.getGuiOrElse(Label::new); + removeHighlights(node); + if (cat.hasSubCategories()) { + for (Category sub : cat.getSubCategories()) { + removeHighlights(sub); + } + } + } + + static void removeHighlights(Node gui) { + if (gui != null) { + if (gui instanceof Pane p) { + for (Node n : p.getChildren()) { + removeHighlights(n); + } + } + gui.getStyleClass().remove(CSS_HIGHLIGHT_CLASS); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/settings/api/LocalTimeProperty.java b/client/src/main/java/ctbrec/ui/settings/api/LocalTimeProperty.java new file mode 100644 index 00000000..ebd64b5d --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/api/LocalTimeProperty.java @@ -0,0 +1,13 @@ +package ctbrec.ui.settings.api; + +import java.time.LocalTime; + +import javafx.beans.property.SimpleObjectProperty; + +public class LocalTimeProperty extends SimpleObjectProperty { + + public LocalTimeProperty(Object bean, String name, LocalTime initialValue) { + super(bean, name, initialValue); + } + +} diff --git a/client/src/main/java/ctbrec/ui/settings/api/Preferences.css b/client/src/main/java/ctbrec/ui/settings/api/Preferences.css new file mode 100644 index 00000000..e3d88a47 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/api/Preferences.css @@ -0,0 +1,8 @@ +.settings-group-label { + -fx-font-size: 1.6em; +} + +.setting-highlighted { + -fx-border-color: -fx-accent; + -fx-border-width: 3; +} \ No newline at end of file diff --git a/client/src/main/java/ctbrec/ui/settings/api/Preferences.java b/client/src/main/java/ctbrec/ui/settings/api/Preferences.java new file mode 100644 index 00000000..92c2409a --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/api/Preferences.java @@ -0,0 +1,260 @@ +package ctbrec.ui.settings.api; + +import ctbrec.ui.controls.SearchBox; +import javafx.beans.value.ObservableValue; +import javafx.geometry.Insets; +import javafx.geometry.VPos; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.control.ScrollPane.ScrollBarPolicy; +import javafx.scene.layout.*; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Consumer; + +import static java.util.Optional.ofNullable; + +@Slf4j +public class Preferences { + + private final Category[] categories; + private final PreferencesStorage preferencesStorage; + + private TreeView categoryTree; + + private Runnable restartRequiredCallback = () -> { + }; + + private Preferences(PreferencesStorage preferencesStorage, Category... categories) { + this.preferencesStorage = preferencesStorage; + this.categories = categories; + for (Category category : categories) { + assignPreferencesStorage(category, preferencesStorage); + } + } + + private void assignPreferencesStorage(Category cat, PreferencesStorage preferencesStorage) { + if (cat.hasSubCategories()) { + for (Category sub : cat.getSubCategories()) { + assignPreferencesStorage(sub, preferencesStorage); + } + } else { + for (Group group : cat.getGroups()) { + for (Setting setting : group.getSettings()) { + setting.setPreferencesStorage(preferencesStorage); + } + } + } + } + + public static Preferences of(PreferencesStorage preferencesStorage, Category... categories) { + return new Preferences(preferencesStorage, categories); + } + + public void save() throws IOException { + preferencesStorage.save(this); + } + + public Region getView(boolean withNavigation) { + for (Category category : categories) { + initializeCategory(category); + } + var search = new SearchBox(true); + search.textProperty().addListener(this::filterTree); + TreeItem categoryTreeItems = createCategoryTree(categories, new TreeItem<>(), null); + categoryTree = new TreeView<>(categoryTreeItems); + categoryTree.showRootProperty().set(false); + var leftSide = new VBox(search, categoryTree); + VBox.setVgrow(categoryTree, Priority.ALWAYS); + VBox.setMargin(search, new Insets(2)); + VBox.setMargin(categoryTree, new Insets(2)); + + var main = new BorderPane(); + if (withNavigation) { + main.setLeft(leftSide); + } + main.setCenter(new Label("Center")); + BorderPane.setMargin(leftSide, new Insets(2)); + categoryTree.getSelectionModel().selectedItemProperty().addListener((obs, oldV, newV) -> { + if (newV != null) { + Category cat = newV.getValue(); + Node gui = cat.getGuiOrElse(() -> createGui(cat)); + ScrollPane scrollPane = new ScrollPane(gui); + scrollPane.setPadding(new Insets(20)); + scrollPane.setHbarPolicy(ScrollBarPolicy.NEVER); + scrollPane.setFitToWidth(true); + scrollPane.setFitToHeight(true); + main.setCenter(scrollPane); + } + }); + categoryTree.getSelectionModel().select(0); + main.setBorder(Border.EMPTY); + return main; + } + + public Region getView() { + return getView(true); + } + + private void filterTree(ObservableValue obs, String oldV, String newV) { + String q = ofNullable(newV).orElse("").toLowerCase().trim(); + TreeItem filteredCategoryTree = createCategoryTree(categories, new TreeItem<>(), q); + categoryTree.setRoot(filteredCategoryTree); + expandAll(categoryTree.getRoot()); + + TreeItem parent = categoryTree.getRoot(); + while (!parent.getChildren().isEmpty()) { + parent = parent.getChildren().get(0); + categoryTree.getSelectionModel().select(parent); + } + + for (Category category : categories) { + if (q.length() > 2) { + HighlightingSupport.highlightMatches(category, q); + } else { + HighlightingSupport.removeHighlights(category); + } + } + } + + private void initializeCategory(Category category) { + category.getGuiOrElse(() -> createGui(category)); + } + + private void expandAll(TreeItem treeItem) { + treeItem.setExpanded(true); + for (TreeItem child : treeItem.getChildren()) { + expandAll(child); + } + } + + private Node createGui(Category cat) { + try { + if (cat.hasSubCategories()) { + return new Label(cat.getName()); + } else if (cat.hasGroups()) { + return createPaneWithGroups(cat); + } else { + return createGrid(cat.getGroups()[0].getSettings()); + } + } catch (Exception e) { + log.error("Error creating the GUI", e); + return new Label(e.getLocalizedMessage()); + } + } + + private Node createPaneWithGroups(Category cat) { + var pane = new VBox(); + for (Group grp : cat.getGroups()) { + var groupLabel = new Label(grp.getName()); + groupLabel.getStyleClass().add("settings-group-label"); + VBox.setMargin(groupLabel, new Insets(20, 0, 10, 20)); + pane.getChildren().add(groupLabel); + Node parameterGrid = createGrid(grp.getSettings()); + pane.getChildren().add(parameterGrid); + VBox.setMargin(parameterGrid, new Insets(0, 0, 0, 40)); + } + return pane; + } + + private Node createGrid(Setting[] settings) { + var pane = new GridPane(); + pane.setHgap(2); + pane.vgapProperty().bind(pane.hgapProperty()); + var row = 0; + for (Setting setting : settings) { + var node = setting.getGui(); + var label = new Label(setting.getName()); + label.setMinHeight(34); + label.labelForProperty().set(node); + label.setTooltip(new Tooltip(setting.getName())); + pane.addRow(row++, label, node); + GridPane.setVgrow(label, Priority.ALWAYS); + GridPane.setValignment(label, VPos.CENTER); + GridPane.setMargin(node, new Insets(5, 0, 5, 10)); + GridPane.setValignment(node, VPos.CENTER); + GridPane.setHgrow(node, Priority.ALWAYS); + } + return pane; + } + + /** + * Creates a tree of the given categories. Filters out categories, which don't match the filter + * + * @param filter may be null + */ + private TreeItem createCategoryTree(Category[] categories, TreeItem parent, String filter) { + for (Category category : categories) { + TreeItem child = new TreeItem<>(category); + if (category.hasSubCategories()) { + createCategoryTree(category.getSubCategories(), child, filter); + if (!child.getChildren().isEmpty()) { + parent.getChildren().add(child); + } + } else if (category.contains(filter)) { + parent.getChildren().add(child); + } + } + return parent; + } + + public void expandTree() { + expandAll(categoryTree.getRoot()); + } + + public void traverse(Consumer visitor) { + for (Category category : categories) { + visit(category, visitor); + } + } + + private void visit(Category cat, Consumer visitor) { + for (Group group : cat.getGroups()) { + for (Setting setting : group.getSettings()) { + visitor.accept(setting); + } + } + if (cat.hasSubCategories()) { + for (Category subcat : cat.getSubCategories()) { + visit(subcat, visitor); + } + } + } + + public Optional getSetting(String key) { + var search = new SettingSearchVisitor(key); + traverse(search); + return search.getResult(); + } + + private static class SettingSearchVisitor implements Consumer { + Setting result; + private final String key; + + public SettingSearchVisitor(String key) { + this.key = key; + } + + @Override + public void accept(Setting s) { + if (Objects.equals(key, ofNullable(s.getKey()).orElse(""))) { + result = s; + } + } + + public Optional getResult() { + return Optional.ofNullable(result); + } + } + + public void onRestartRequired(Runnable callback) { + this.restartRequiredCallback = callback; + } + + public Runnable getRestartRequiredCallback() { + return restartRequiredCallback; + } +} diff --git a/client/src/main/java/ctbrec/ui/settings/api/PreferencesStorage.java b/client/src/main/java/ctbrec/ui/settings/api/PreferencesStorage.java new file mode 100644 index 00000000..c3dd3ecb --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/api/PreferencesStorage.java @@ -0,0 +1,13 @@ +package ctbrec.ui.settings.api; + +import java.io.IOException; + +import javafx.scene.Node; + +public interface PreferencesStorage { + + void save(Preferences preferences) throws IOException; + void load(Preferences preferences); + + Node createGui(Setting setting) throws NoSuchFieldException, IllegalAccessException; +} diff --git a/client/src/main/java/ctbrec/ui/settings/api/Setting.java b/client/src/main/java/ctbrec/ui/settings/api/Setting.java new file mode 100644 index 00000000..dffc2c85 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/api/Setting.java @@ -0,0 +1,126 @@ +package ctbrec.ui.settings.api; + +import static java.util.Optional.*; + +import ctbrec.StringUtil; +import ctbrec.ui.controls.Dialogs; +import javafx.beans.property.Property; +import javafx.beans.value.ChangeListener; +import javafx.scene.Node; +import javafx.scene.control.Control; +import javafx.scene.control.Tooltip; + +public class Setting { + + private String name; + private String tooltip; + private Property property; + private Node gui; + private PreferencesStorage preferencesStorage; + private boolean needsRestart = false; + private ValueConverter converter; + private ChangeListener changeListener; + + protected Setting(String name, Property property) { + this.name = name; + this.property = property; + } + + protected Setting(String name, Node gui) { + this.name = name; + this.gui = gui; + } + + public static Setting of(String name, Property property) { + return new Setting(name, property); + } + + public static Setting of(String name, Property property, String tooltip) { + var setting = new Setting(name, property); + setting.tooltip = tooltip; + return setting; + } + + public static Setting of(String name, Node gui) { + var setting = new Setting(name, gui); + return setting; + } + + public String getName() { + return name; + } + + public String getKey() { + if (getProperty() == null) { + return ""; + } else { + String key = getProperty().getName(); + if (StringUtil.isBlank(key)) { + throw new IllegalStateException("Name for property of setting [" + name + "] is null"); + } + return key; + } + } + + public String getTooltip() { + return tooltip; + } + + public Setting needsRestart() { + needsRestart = true; + return this; + } + + public boolean doesNeedRestart() { + return needsRestart; + } + + @SuppressWarnings("rawtypes") + public Property getProperty() { + return property; + } + + public Node getGui() { + if (gui == null) { + try { + gui = preferencesStorage.createGui(this); + if (gui instanceof Control && StringUtil.isNotBlank(tooltip)) { + var control = (Control) gui; + control.setTooltip(new Tooltip(tooltip)); + } + } catch (NoSuchFieldException | IllegalAccessException e) { + Dialogs.showError("Error", "Error creating settings GUI", e); + } + } + return gui; + } + + public void setPreferencesStorage(PreferencesStorage preferencesStorage) { + this.preferencesStorage = preferencesStorage; + } + + public boolean contains(String filter) { + boolean contains = name.toLowerCase().contains(filter) + || ofNullable(tooltip).orElse("").toLowerCase().contains(filter) + || ofNullable(property).map(Property::getValue).map(Object::toString).orElse("").toLowerCase().contains(filter); + return contains; + } + + public Setting converter(ValueConverter converter) { + this.converter = converter; + return this; + } + + public ValueConverter getConverter() { + return converter; + } + + public Setting onChange(ChangeListener changeListener) { + this.changeListener = changeListener; + return this; + } + + public ChangeListener getChangeListener() { + return changeListener; + } +} diff --git a/client/src/main/java/ctbrec/ui/settings/api/SimpleDirectoryProperty.java b/client/src/main/java/ctbrec/ui/settings/api/SimpleDirectoryProperty.java new file mode 100644 index 00000000..0fd61c3b --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/api/SimpleDirectoryProperty.java @@ -0,0 +1,11 @@ +package ctbrec.ui.settings.api; + +import javafx.beans.property.SimpleStringProperty; + +public class SimpleDirectoryProperty extends SimpleStringProperty { + + public SimpleDirectoryProperty(Object bean, String name, String initialValue) { + super(bean, name, initialValue); + } + +} diff --git a/client/src/main/java/ctbrec/ui/settings/api/SimpleFileProperty.java b/client/src/main/java/ctbrec/ui/settings/api/SimpleFileProperty.java new file mode 100644 index 00000000..837ffd3e --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/api/SimpleFileProperty.java @@ -0,0 +1,11 @@ +package ctbrec.ui.settings.api; + +import javafx.beans.property.SimpleStringProperty; + +public class SimpleFileProperty extends SimpleStringProperty { + + public SimpleFileProperty(Object bean, String name, String initialValue) { + super(bean, name, initialValue); + } + +} diff --git a/client/src/main/java/ctbrec/ui/settings/api/SimpleJoinedStringListProperty.java b/client/src/main/java/ctbrec/ui/settings/api/SimpleJoinedStringListProperty.java new file mode 100644 index 00000000..3d91c5c3 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/api/SimpleJoinedStringListProperty.java @@ -0,0 +1,48 @@ +package ctbrec.ui.settings.api; + +import javafx.beans.binding.Bindings; +import javafx.beans.property.SimpleListProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import lombok.Getter; + +public class SimpleJoinedStringListProperty extends SimpleStringProperty implements ListChangeListener { + private ObservableList list; + @Getter + private String delimiter; + private boolean updating = false; + + public SimpleJoinedStringListProperty(Object bean, String name, String delimiter, ObservableList initialValue) { + super(bean, name, String.join(delimiter, initialValue)); + this.delimiter = delimiter; + this.list = initialValue; + initialValue.addListener(this); + } + + + @Override + public void setValue(String newValue) { + if (!updating) { + try { + updating = true; + list.setAll(newValue.split(delimiter)); + super.setValue(newValue); + } finally { + updating = false; + } + } + } + + @Override + public void onChanged(Change c) { + if (!updating) { + try { + updating = true; + super.setValue(String.join(delimiter, list)); + } finally { + updating = false; + } + } + } +} diff --git a/client/src/main/java/ctbrec/ui/settings/api/SimpleRangeProperty.java b/client/src/main/java/ctbrec/ui/settings/api/SimpleRangeProperty.java new file mode 100644 index 00000000..238243eb --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/api/SimpleRangeProperty.java @@ -0,0 +1,43 @@ +package ctbrec.ui.settings.api; + +import ctbrec.ui.controls.range.Range; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; + +public class SimpleRangeProperty extends SimpleObjectProperty { + + private Range range; + private SimpleObjectProperty lowProperty; + private SimpleObjectProperty highProperty; + private String lowKey; + private String highKey; + + public SimpleRangeProperty(Range range, String lowKey, String highKey, T low, T high) { + super(null, lowKey); + this.range = range; + this.lowKey = lowKey; + this.highKey = highKey; + lowProperty = new SimpleObjectProperty<>(low); + highProperty = new SimpleObjectProperty<>(high); + } + + public Range getRange() { + return range; + } + + public ObjectProperty lowProperty() { + return lowProperty; + } + + public ObjectProperty highProperty() { + return highProperty; + } + + public String getLowKey() { + return lowKey; + } + + public String getHighKey() { + return highKey; + } +} diff --git a/client/src/main/java/ctbrec/ui/settings/api/ValueConverter.java b/client/src/main/java/ctbrec/ui/settings/api/ValueConverter.java new file mode 100644 index 00000000..86764ffc --- /dev/null +++ b/client/src/main/java/ctbrec/ui/settings/api/ValueConverter.java @@ -0,0 +1,7 @@ +package ctbrec.ui.settings.api; + +public interface ValueConverter { + + Object convertTo(Object a); + Object convertFrom(Object b); +} 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..889f4acb --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/AbstractConfigUI.java @@ -0,0 +1,21 @@ +package ctbrec.ui.sites; + +import java.io.IOException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; + +public abstract class AbstractConfigUI implements ConfigUI { + + private static final 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/AbstractSiteUi.java b/client/src/main/java/ctbrec/ui/sites/AbstractSiteUi.java new file mode 100644 index 00000000..fb5cbfea --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/AbstractSiteUi.java @@ -0,0 +1,12 @@ +package ctbrec.ui.sites; + +import ctbrec.Model; +import ctbrec.ui.Player; +import ctbrec.ui.SiteUI; + +public abstract class AbstractSiteUi implements SiteUI { + @Override + public boolean play(Model model) { + return Player.play(model); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/AbstractTabProvider.java b/client/src/main/java/ctbrec/ui/sites/AbstractTabProvider.java new file mode 100644 index 00000000..3eb8f67e --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/AbstractTabProvider.java @@ -0,0 +1,39 @@ +package ctbrec.ui.sites; + +import ctbrec.Config; +import ctbrec.recorder.Recorder; +import ctbrec.sites.Site; +import ctbrec.ui.tabs.TabProvider; +import ctbrec.ui.tabs.recorded.RecordedModelsPerSiteTab; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +import java.util.List; + +public abstract class AbstractTabProvider implements TabProvider { + + protected Recorder recorder; + protected Site site; + + protected AbstractTabProvider(Site site) { + this.site = site; + this.recorder = site.getRecorder(); + } + + @Override + public List getTabs(Scene scene) { + var tabs = getSiteTabs(scene); + if (Config.getInstance().getSettings().recordedModelsPerSite) { + var recordingTab = new RecordedModelsPerSiteTab("Recording", recorder, site); + tabs.add(recordingTab); + } + return tabs; + } + + protected abstract List getSiteTabs(Scene scene); + + @Override + public Tab getFollowedTab() { + return null; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/ConfigUI.java b/client/src/main/java/ctbrec/ui/sites/ConfigUI.java new file mode 100644 index 00000000..6e7e4906 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/ConfigUI.java @@ -0,0 +1,7 @@ +package ctbrec.ui.sites; + +import javafx.scene.Parent; + +public interface ConfigUI { + public Parent createConfigPanel(); +} diff --git a/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvConfigUI.java b/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvConfigUI.java new file mode 100644 index 00000000..b4496f88 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvConfigUI.java @@ -0,0 +1,93 @@ +package ctbrec.ui.sites.amateurtv; + +import ctbrec.Config; +import ctbrec.sites.amateurtv.AmateurTv; +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 AmateurTvConfigUI extends AbstractConfigUI { + private AmateurTv site; + + public AmateurTvConfigUI(AmateurTv site) { + this.site = site; + } + + @Override + public Parent createConfigPanel() { + GridPane layout = SettingsTab.createGridLayout(); + var settings = Config.getInstance().getSettings(); + + var row = 0; + var l = new Label("Active"); + layout.add(l, 0, row); + var 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()); + } + save(); + }); + GridPane.setMargin(enabled, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + layout.add(enabled, 1, row++); + + layout.add(new Label("Amateur.TV User"), 0, row); + var username = new TextField(settings.amateurTvUsername); + username.setPrefWidth(300); + username.textProperty().addListener((ob, o, n) -> { + if(!n.equals(Config.getInstance().getSettings().amateurTvUsername)) { + Config.getInstance().getSettings().amateurTvUsername = username.getText(); + site.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("Amateur.TV Password"), 0, row); + var password = new PasswordField(); + password.setText(settings.amateurTvPassword); + password.textProperty().addListener((ob, o, n) -> { + if(!n.equals(Config.getInstance().getSettings().amateurTvPassword)) { + Config.getInstance().getSettings().amateurTvPassword = password.getText(); + site.getHttpClient().logout(); + save(); + } + }); + GridPane.setFillWidth(password, true); + GridPane.setHgrow(password, Priority.ALWAYS); + GridPane.setColumnSpan(password, 2); + layout.add(password, 1, row++); + + var createAccount = new Button("Create new Account"); + createAccount.setOnAction(e -> DesktopIntegration.open(site.getAffiliateLink())); + layout.add(createAccount, 1, row++); + GridPane.setColumnSpan(createAccount, 2); + + var deleteCookies = new Button("Delete Cookies"); + deleteCookies.setOnAction(e -> site.getHttpClient().clearCookies()); + layout.add(deleteCookies, 1, row); + GridPane.setColumnSpan(deleteCookies, 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)); + GridPane.setMargin(deleteCookies, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + return layout; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvElectronLoginDialog.java b/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvElectronLoginDialog.java new file mode 100644 index 00000000..4a609392 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvElectronLoginDialog.java @@ -0,0 +1,126 @@ +package ctbrec.ui.sites.amateurtv; + +import java.io.IOException; +import java.util.Collections; +import java.util.Objects; +import java.util.function.Consumer; + +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.sites.amateurtv.AmateurTv; +import ctbrec.ui.ExternalBrowser; +import okhttp3.Cookie; +import okhttp3.Cookie.Builder; +import okhttp3.CookieJar; +import okhttp3.HttpUrl; + +public class AmateurTvElectronLoginDialog { + + private static final Logger LOG = LoggerFactory.getLogger(AmateurTvElectronLoginDialog.class); + public static final String DOMAIN = "amateur.tv"; + public static final String URL = AmateurTv.BASE_URL; + private CookieJar cookieJar; + private ExternalBrowser browser; + + public AmateurTvElectronLoginDialog(CookieJar cookieJar) throws IOException { + this.cookieJar = cookieJar; + browser = ExternalBrowser.getInstance(); + try { + var config = new JSONObject(); + config.put("url", URL); + config.put("w", 640); + config.put("h", 480); + var msg = new JSONObject(); + msg.put("config", config); + browser + .onReady(this::onReady) + .run(msg, msgHandler); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Couldn't wait for login dialog", e); + } finally { + browser.close(); + } + } + + private Consumer msgHandler = line -> { + if (!line.startsWith("{")) { + LOG.error("Didn't received a JSON object {}", line); + } else { + var json = new JSONObject(line); + + var loginSuccessful = false; + if (json.has("cookies")) { + var cookiesFromBrowser = json.getJSONArray("cookies"); + for (var i = 0; i < cookiesFromBrowser.length(); i++) { + var cookie = cookiesFromBrowser.getJSONObject(i); + if (cookie.getString("domain").contains(DOMAIN)) { + Builder b = new Cookie.Builder() + .path(cookie.getString("path")) + .domain(DOMAIN) + .name(cookie.getString("name")) + .value(cookie.getString("value")) + .expiresAt((long) cookie.optDouble("expirationDate") * 1000l); + if (cookie.optBoolean("hostOnly")) { + b.hostOnlyDomain(DOMAIN); + } + if (cookie.optBoolean("httpOnly")) { + b.httpOnly(); + } + if (cookie.optBoolean("secure")) { + b.secure(); + } + Cookie c = b.build(); + cookieJar.saveFromResponse(HttpUrl.parse(AmateurTv.BASE_URL), Collections.singletonList(c)); + LOG.debug("{}={}", c.name(), c.value()); + if (Objects.equals(c.name(), "userType") && Objects.equals(c.value(), "registered")) { + loginSuccessful = true; + } + } + } + } + + if (loginSuccessful) { + try { + browser.close(); + return; + } catch (IOException e) { + LOG.error("Couldn't send shutdown request to external browser", e); + } + } + + try { + browser.executeJavaScript("document.querySelector('div[class~=\"cy_ubCoins\"]') != null") + .thenAccept(b -> { + LOG.debug("Result: {}", b); + if (Boolean.TRUE.equals(b)) { + try { + browser.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + }) + .exceptionally(ex -> {LOG.error("Error", ex); return null;}); + + browser.executeJavaScript("if (!loginDialogVisible) { document.querySelector('button').innerHTML.indexOf('I agree') >= 0 && document.querySelector('button').click(); }"); + browser.executeJavaScript("if (!loginDialogVisible) { document.querySelector('button[aria-label=\"open drawer\"]').click(); }"); // open the burger menu to get to the login button + browser.executeJavaScript("if (!loginDialogVisible) { document.querySelectorAll('button').forEach(function(b) { if (b.textContent === 'Log in') b.click(); }); }"); // click the login button to open the login dialog + browser.executeJavaScript("loginDialogVisible = document.querySelectorAll('div[class~=\"MuiDialog-container\"]').length > 1"); + browser.executeJavaScript("if (loginDialogVisible) throw new Error(\"Stop execution right here\")"); + } catch(Exception e) { + LOG.warn("Couldn't auto fill username and password for Amateur.TV", e); + } + } + }; + + private void onReady() { + try { + browser.executeJavaScript("let loginDialogVisible = document.querySelectorAll('div[class~=\"MuiDialog-container\"]').length > 1"); + } catch(Exception e) { + LOG.warn("Couldn't auto fill username and password for Amateur.TV", e); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvFollowedTab.java b/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvFollowedTab.java new file mode 100644 index 00000000..c6f187a3 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvFollowedTab.java @@ -0,0 +1,13 @@ +package ctbrec.ui.sites.amateurtv; + +import ctbrec.sites.Site; +import ctbrec.ui.tabs.FollowedTab; +import ctbrec.ui.tabs.PaginatedScheduledService; +import ctbrec.ui.tabs.ThumbOverviewTab; + +public class AmateurTvFollowedTab extends ThumbOverviewTab implements FollowedTab { + + public AmateurTvFollowedTab(String title, PaginatedScheduledService updateService, Site site) { + super(title, updateService, site); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvSiteUi.java b/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvSiteUi.java new file mode 100644 index 00000000..2196e50d --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvSiteUi.java @@ -0,0 +1,65 @@ +package ctbrec.ui.sites.amateurtv; + +import java.io.IOException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.sites.amateurtv.AmateurTv; +import ctbrec.sites.amateurtv.AmateurTvHttpClient; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.sites.AbstractSiteUi; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; + +public class AmateurTvSiteUi extends AbstractSiteUi { + + private static final Logger LOG = LoggerFactory.getLogger(AmateurTvSiteUi.class); + + private final AmateurTv site; + private AmateurTvTabProvider tabProvider; + private AmateurTvConfigUI configUi; + + public AmateurTvSiteUi(AmateurTv amateurTv) { + this.site = amateurTv; + } + + @Override + public TabProvider getTabProvider() { + if (tabProvider == null) { + tabProvider = new AmateurTvTabProvider(site); + } + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + if (configUi == null) { + configUi = new AmateurTvConfigUI(site); + } + return configUi; + } + + @Override + public synchronized boolean login() throws IOException { + if (!site.credentialsAvailable()) { + return false; + } + + boolean automaticLogin = site.login(); + if (automaticLogin) { + return true; + } else { + // login with external browser window + try { + new AmateurTvElectronLoginDialog(site.getHttpClient().getCookieJar()); + } catch (Exception e1) { + LOG.error("Error logging in with external browser", e1); + Dialogs.showError("Login error", "Couldn't login to " + site.getName(), e1); + } + + AmateurTvHttpClient httpClient = (AmateurTvHttpClient) site.getHttpClient(); + return httpClient.checkLoginSuccess(); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvTabProvider.java b/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvTabProvider.java new file mode 100644 index 00000000..5f0f6e99 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvTabProvider.java @@ -0,0 +1,73 @@ +package ctbrec.ui.sites.amateurtv; + +import ctbrec.sites.amateurtv.AmateurTv; +import ctbrec.ui.sites.AbstractTabProvider; +import ctbrec.ui.tabs.PaginatedScheduledService; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +import java.util.ArrayList; +import java.util.List; + +public class AmateurTvTabProvider extends AbstractTabProvider { + + + private AmateurTvFollowedTab followedTab; + + public AmateurTvTabProvider(AmateurTv amateurTv) { + super(amateurTv); + } + + @Override + protected List getSiteTabs(Scene scene) { + List tabs = new ArrayList<>(); + + // all + var url = AmateurTv.BASE_URL + "/v3/readmodel/cache/onlinecamlist"; + var updateService = new AmateurTvUpdateService((AmateurTv) site, url); + tabs.add(createTab("All", updateService)); + + // female + url = AmateurTv.BASE_URL + "/v3/readmodel/cache/sectioncamlist?genre=[%22w%22]"; + updateService = new AmateurTvUpdateService((AmateurTv) site, url); + tabs.add(createTab("Female", updateService)); + + // male + url = AmateurTv.BASE_URL + "/v3/readmodel/cache/sectioncamlist?genre=[%22m%22]"; + updateService = new AmateurTvUpdateService((AmateurTv) site, url); + tabs.add(createTab("Male", updateService)); + + // couples + url = AmateurTv.BASE_URL + "/v3/readmodel/cache/sectioncamlist?genre=[%22c%22]"; + updateService = new AmateurTvUpdateService((AmateurTv) site, url); + tabs.add(createTab("Couples", updateService)); + + // trans + url = AmateurTv.BASE_URL + "/v3/readmodel/cache/sectioncamlist?genre=[%22t%22]"; + updateService = new AmateurTvUpdateService((AmateurTv) site, url); + tabs.add(createTab("Trans", updateService)); + + // followed + url = AmateurTv.BASE_URL + "/v3/readmodel/cache/favorites"; + updateService = new AmateurTvUpdateService((AmateurTv) site, url); + updateService.requiresLogin(true); + followedTab = new AmateurTvFollowedTab("Followed", updateService, site); + followedTab.setRecorder(recorder); + tabs.add(followedTab); + + return tabs; + } + + private Tab createTab(String title, PaginatedScheduledService updateService) { + var tab = new ThumbOverviewTab(title, updateService, site); + tab.setRecorder(recorder); + return tab; + } + + @Override + public Tab getFollowedTab() { + return followedTab; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvUpdateService.java b/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvUpdateService.java new file mode 100644 index 00000000..d419278a --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/amateurtv/AmateurTvUpdateService.java @@ -0,0 +1,132 @@ +package ctbrec.ui.sites.amateurtv; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.sites.amateurtv.AmateurTv; +import ctbrec.sites.amateurtv.AmateurTvModel; +import ctbrec.ui.SiteUiFactory; +import ctbrec.ui.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; +import okhttp3.Request; +import okhttp3.Response; +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +import static ctbrec.io.HttpConstants.*; + +public class AmateurTvUpdateService extends PaginatedScheduledService { + + private static final Logger LOG = LoggerFactory.getLogger(AmateurTvUpdateService.class); + private static final int ITEMS_PER_PAGE = 48; + + private AmateurTv site; + private String url; + private boolean requiresLogin = false; + private List modelsList; + private Instant lastListInfoRequest = Instant.EPOCH; + + public AmateurTvUpdateService(AmateurTv site, String url) { + this.site = site; + this.url = url; + } + + @Override + protected Task> createTask() { + return new Task>() { + @Override + public List call() throws IOException { + if (requiresLogin) { + if (!SiteUiFactory.getUi(site).login()) { + throw new IOException("- Login is required"); + } + ; + } + return getModelList().stream() + .skip((page - 1) * (long) ITEMS_PER_PAGE) + .limit(ITEMS_PER_PAGE) + .collect(Collectors.toList()); // NOSONAR + } + }; + } + + private List getModelList() throws IOException { + if (Duration.between(lastListInfoRequest, Instant.now()).getSeconds() < 30) { + return modelsList; + } + lastListInfoRequest = Instant.now(); + modelsList = loadModelList(); + if (modelsList == null) { + modelsList = Collections.emptyList(); + } + return modelsList; + } + + private List loadModelList() throws IOException { + LOG.debug("Fetching page {}", url); + Request request = new Request.Builder() + .url(url) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, MIMETYPE_APPLICATION_JSON) + .header(ACCEPT, Locale.ENGLISH.getLanguage()) + .header(REFERER, site.getBaseUrl() + "/following") + .build(); + try (Response response = site.getHttpClient().execute(request)) { + if (response.isSuccessful()) { + String content = response.body().string(); + List models = new ArrayList<>(); + JSONObject json = new JSONObject(content); + if (json.has("body")) { + JSONObject body = json.getJSONObject("body"); + if (body.has("cams")) { + JSONArray cams = body.getJSONArray("cams"); + parseModels(cams, models); + } + if (body.has("list") && body.has("total")) { + if (body.optInt("total") > 0) { + JSONArray list = body.getJSONArray("list"); + parseModels(list, models); + } + } + } + if (json.has("cams")) { + JSONArray cams = json.getJSONArray("cams"); + parseModels(cams, models); + } + return models; + } else { + int code = response.code(); + throw new IOException("HTTP status " + code); + } + } + } + + private void parseModels(JSONArray jsonModels, List models) { + for (var i = 0; i < jsonModels.length(); i++) { + JSONObject m = jsonModels.getJSONObject(i); + String name = m.optString("username"); + AmateurTvModel model = (AmateurTvModel) site.createModel(name); + if (m.optBoolean("capturesEnabled", true) && m.has("capture")) { + model.setPreview(m.optString("capture")); + } else { + model.setPreview(site.getBaseUrl() + m.optString("avatar")); + } + model.setDescription(m.optString("topic")); + models.add(model); + } + } + + public void requiresLogin(boolean requiresLogin) { + this.requiresLogin = requiresLogin; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsConfigUI.java b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsConfigUI.java new file mode 100644 index 00000000..9f51d380 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsConfigUI.java @@ -0,0 +1,106 @@ +package ctbrec.ui.sites.bonga; + +import ctbrec.Config; +import ctbrec.sites.bonga.BongaCams; +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 BongaCamsConfigUI extends AbstractConfigUI { + private BongaCams bongaCams; + + public BongaCamsConfigUI(BongaCams bongaCams) { + this.bongaCams = bongaCams; + } + + @Override + public Parent createConfigPanel() { + GridPane layout = SettingsTab.createGridLayout(); + var settings = Config.getInstance().getSettings(); + + var row = 0; + var l = new Label("Active"); + layout.add(l, 0, row); + var 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); + var username = new TextField(settings.bongaUsername); + username.setPrefWidth(300); + 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, row++); + + layout.add(new Label("BongaCams Password"), 0, row); + var password = new PasswordField(); + 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, row++); + + layout.add(new Label("Bongacams Base URL"), 0, row); + var baseUrl = new TextField(); + baseUrl.setText(Config.getInstance().getSettings().bongacamsBaseUrl); + baseUrl.textProperty().addListener((ob, o, n) -> { + Config.getInstance().getSettings().bongacamsBaseUrl = baseUrl.getText(); + save(); + }); + GridPane.setFillWidth(baseUrl, true); + GridPane.setHgrow(baseUrl, Priority.ALWAYS); + GridPane.setColumnSpan(baseUrl, 2); + layout.add(baseUrl, 1, row++); + + var createAccount = new Button("Create new Account"); + createAccount.setOnAction(e -> DesktopIntegration.open(bongaCams.getAffiliateLink())); + layout.add(createAccount, 1, row++); + GridPane.setColumnSpan(createAccount, 2); + + var deleteCookies = new Button("Delete Cookies"); + deleteCookies.setOnAction(e -> bongaCams.getHttpClient().clearCookies()); + layout.add(deleteCookies, 1, row); + GridPane.setColumnSpan(deleteCookies, 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)); + GridPane.setMargin(deleteCookies, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + return layout; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsElectronLoginDialog.java b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsElectronLoginDialog.java new file mode 100644 index 00000000..f5d4a372 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsElectronLoginDialog.java @@ -0,0 +1,124 @@ +package ctbrec.ui.sites.bonga; + +import ctbrec.Config; +import ctbrec.sites.bonga.BongaCams; +import ctbrec.ui.ExternalBrowser; +import okhttp3.Cookie; +import okhttp3.Cookie.Builder; +import okhttp3.CookieJar; +import okhttp3.HttpUrl; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Collections; +import java.util.Objects; +import java.util.function.Consumer; + +public class BongaCamsElectronLoginDialog { + + private static final Logger LOG = LoggerFactory.getLogger(BongaCamsElectronLoginDialog.class); + public static final String DOMAIN = "bongacams.com"; + private BongaCams site; + private CookieJar cookieJar; + private ExternalBrowser browser; + + public BongaCamsElectronLoginDialog(BongaCams site, CookieJar cookieJar) throws IOException { + this.site = site; + this.cookieJar = cookieJar; + browser = ExternalBrowser.getInstance(); + try { + var config = new JSONObject(); + config.put("url", site.getBaseUrl() + "/login"); + config.put("w", 640); + config.put("h", 480); + var msg = new JSONObject(); + msg.put("config", config); + browser.run(msg, msgHandler); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Couldn't wait for login dialog", e); + } finally { + browser.close(); + } + } + + private final Consumer msgHandler = line -> { + if (!line.startsWith("{")) { + LOG.error("Didn't received a JSON object {}", line); + } else { + var json = new JSONObject(line); + if (json.has("url")) { + var url = json.getString("url"); + if (url.endsWith("/login")) { + try { + Thread.sleep(500); + String username = Config.getInstance().getSettings().bongaUsername; + if (username != null && !username.trim().isEmpty()) { + browser.executeJavaScript("document.getElementById('log_in_username').value = '" + username + "';"); + } + String password = Config.getInstance().getSettings().bongaPassword; + if (password != null && !password.trim().isEmpty()) { + password = password.replace("'", "\\'"); + browser.executeJavaScript("document.getElementById('log_in_password').value = '" + password + "';"); + } + var simplify = new String[]{ + "$('div[class~=\"page_header\"]').css('display','none');", + "$('div[class~=\"header_bar\"]').css('display','none')", + "$('footer').css('display','none');", + "$('div[class~=\"footer_copy\"]').css('display','none')", + "$('div[class~=\"fancybox-overlay\"]').css('display','none');" + }; + for (String js : simplify) { + browser.executeJavaScript(js); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOG.warn("Couldn't auto fill username and password for BongaCams", e); + } catch (Exception e) { + LOG.warn("Couldn't auto fill username and password for BongaCams", e); + } + } + + if (json.has("cookies")) { + var cookiesFromBrowser = json.getJSONArray("cookies"); + for (var i = 0; i < cookiesFromBrowser.length(); i++) { + var cookie = cookiesFromBrowser.getJSONObject(i); + if (cookie.getString("domain").contains(DOMAIN)) { + Builder b = new Cookie.Builder() + .path(cookie.getString("path")) + .domain(DOMAIN) + .name(cookie.getString("name")) + .value(cookie.getString("value")) + .expiresAt((long) cookie.optDouble("expirationDate")); + if (cookie.optBoolean("hostOnly")) { + b.hostOnlyDomain(DOMAIN); + } + if (cookie.optBoolean("httpOnly")) { + b.httpOnly(); + } + if (cookie.optBoolean("secure")) { + b.secure(); + } + Cookie c = b.build(); + cookieJar.saveFromResponse(HttpUrl.parse(BongaCamsElectronLoginDialog.this.site.getBaseUrl()), Collections.singletonList(c)); + } + } + } + + try { + if (Objects.equals(new URL(url).getPath(), "/")) { + browser.close(); + } + } catch (MalformedURLException e) { + LOG.error("Couldn't parse new url {}", url, e); + } catch (IOException e) { + LOG.error("Couldn't send shutdown request to external browser", e); + } + } + } + }; +} diff --git a/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsFriendsTab.java b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsFriendsTab.java new file mode 100644 index 00000000..0d7d8cb2 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsFriendsTab.java @@ -0,0 +1,14 @@ +package ctbrec.ui.sites.bonga; + +import ctbrec.sites.Site; +import ctbrec.ui.tabs.FollowedTab; +import ctbrec.ui.tabs.PaginatedScheduledService; +import ctbrec.ui.tabs.ThumbOverviewTab; + +public class BongaCamsFriendsTab extends ThumbOverviewTab implements FollowedTab { + + public BongaCamsFriendsTab(String title, PaginatedScheduledService updateService, Site site) { + super(title, updateService, site); + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsSiteUi.java b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsSiteUi.java new file mode 100644 index 00000000..82c0fff9 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsSiteUi.java @@ -0,0 +1,65 @@ +package ctbrec.ui.sites.bonga; + +import ctbrec.sites.bonga.BongaCams; +import ctbrec.sites.bonga.BongaCamsHttpClient; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.sites.AbstractSiteUi; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +public class BongaCamsSiteUi extends AbstractSiteUi { + + private static final Logger LOG = LoggerFactory.getLogger(BongaCamsSiteUi.class); + private final BongaCams bongaCams; + private BongaCamsTabProvider tabProvider; + private BongaCamsConfigUI configUi; + + public BongaCamsSiteUi(BongaCams bongaCams) { + this.bongaCams = bongaCams; + } + + @Override + public TabProvider getTabProvider() { + if (tabProvider == null) { + tabProvider = new BongaCamsTabProvider(bongaCams); + } + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + if (configUi == null) { + configUi = new BongaCamsConfigUI(bongaCams); + } + return configUi; + } + + @Override + public synchronized boolean login() throws IOException { + boolean automaticLogin = bongaCams.login(); + if (automaticLogin) { + return true; + } else { + // login with external browser window + try { + new BongaCamsElectronLoginDialog(bongaCams, bongaCams.getHttpClient().getCookieJar()); + } catch (Exception e1) { + LOG.error("Error logging in with external browser", e1); + Dialogs.showError("Login error", "Couldn't login to " + bongaCams.getName(), e1); + } + + BongaCamsHttpClient httpClient = (BongaCamsHttpClient) bongaCams.getHttpClient(); + boolean loggedIn = httpClient.checkLoginSuccess(); + if (loggedIn) { + LOG.info("Logged in. User ID is {}", httpClient.getUserId()); + } else { + LOG.info("Login failed"); + } + return loggedIn; + } + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsTabProvider.java b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsTabProvider.java new file mode 100644 index 00000000..53826a0e --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsTabProvider.java @@ -0,0 +1,71 @@ +package ctbrec.ui.sites.bonga; + +import ctbrec.sites.bonga.BongaCams; +import ctbrec.ui.sites.AbstractTabProvider; +import ctbrec.ui.tabs.PaginatedScheduledService; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +import java.util.ArrayList; +import java.util.List; + +public class BongaCamsTabProvider extends AbstractTabProvider { + + private BongaCamsFriendsTab friendsTab; + + public BongaCamsTabProvider(BongaCams bongaCams) { + super(bongaCams); + } + + @Override + protected List getSiteTabs(Scene scene) { + List tabs = new ArrayList<>(); + + // female + String url = site.getBaseUrl() + "/tools/listing_v3.php?livetab=female&online_only=true&is_mobile=true&limit=72&offset="; + var updateService = new BongaCamsUpdateService((BongaCams) site, url); + tabs.add(createTab("Female", updateService)); + + // male + url = site.getBaseUrl() + "/tools/listing_v3.php?livetab=male&online_only=true&is_mobile=true&limit=72&offset="; + updateService = new BongaCamsUpdateService((BongaCams) site, url); + tabs.add(createTab("Male", updateService)); + + // couples + url = site.getBaseUrl() + "/tools/listing_v3.php?livetab=couples&online_only=true&is_mobile=true&limit=72&offset="; + updateService = new BongaCamsUpdateService((BongaCams) site, url); + tabs.add(createTab("Couples", updateService)); + + // trans + url = site.getBaseUrl() + "/tools/listing_v3.php?livetab=transsexual&online_only=true&is_mobile=true&limit=72&offset="; + updateService = new BongaCamsUpdateService((BongaCams) site, url); + tabs.add(createTab("Transsexual", updateService)); + + // new + url = site.getBaseUrl() + "/tools/listing_v3.php?livetab=new&online_only=true&is_mobile=true&limit=72&offset="; + updateService = new BongaCamsUpdateService((BongaCams) site, url); + tabs.add(createTab("New", updateService)); + + // friends + url = site.getBaseUrl() + "/tools/listing_v3.php?livetab=friends&online_only=true&limit=72&offset="; + updateService = new BongaCamsUpdateService((BongaCams) site, url, true); + friendsTab = new BongaCamsFriendsTab("Friends", updateService, site); + friendsTab.setRecorder(recorder); + tabs.add(friendsTab); + + return tabs; + } + + @Override + public Tab getFollowedTab() { + return friendsTab; + } + + private Tab createTab(String title, PaginatedScheduledService updateService) { + var tab = new ThumbOverviewTab(title, updateService, site); + tab.setRecorder(recorder); + return tab; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsUpdateService.java b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsUpdateService.java new file mode 100644 index 00000000..f7c014a3 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/bonga/BongaCamsUpdateService.java @@ -0,0 +1,108 @@ +package ctbrec.ui.sites.bonga; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.StringUtil; +import ctbrec.sites.bonga.BongaCams; +import ctbrec.sites.bonga.BongaCamsModel; +import ctbrec.ui.SiteUiFactory; +import ctbrec.ui.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; +import okhttp3.Request; +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import static ctbrec.io.HttpConstants.*; + +public class BongaCamsUpdateService extends PaginatedScheduledService { + + private static final Logger LOG = LoggerFactory.getLogger(BongaCamsUpdateService.class); + + private final BongaCams bongaCams; + private final String url; + private final boolean requiresLogin; + + public BongaCamsUpdateService(BongaCams bongaCams, String url) { + this(bongaCams, url, false); + } + + public BongaCamsUpdateService(BongaCams bongaCams, String url, boolean requiresLogin) { + this.bongaCams = bongaCams; + this.url = url; + this.requiresLogin = requiresLogin; + } + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() throws IOException { + if (requiresLogin) { + SiteUiFactory.getUi(bongaCams).login(); + } + return loadModelList(); + } + + }; + } + + private List loadModelList() throws IOException { + String pageUrl = url + ((page - 1) * 72); + LOG.debug("Fetching page {}", pageUrl); + var request = new Request.Builder() + .url(pageUrl) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, MIMETYPE_APPLICATION_JSON) + .header(ACCEPT, Locale.ENGLISH.getLanguage()) + .header(REFERER, bongaCams.getBaseUrl()) + .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) + .build(); + try (var response = bongaCams.getHttpClient().execute(request)) { + if (response.isSuccessful()) { + var content = response.body().string(); + List models = new ArrayList<>(); + var json = new JSONObject(content); + if (json.optString("status").equals("success")) { + var jsonModels = json.getJSONArray("models"); + parseModels(jsonModels, models); + } + return models; + } else { + int code = response.code(); + throw new IOException("HTTP status " + code); + } + } + } + + private void parseModels(JSONArray jsonModels, List models) { + for (var i = 0; i < jsonModels.length(); i++) { + var m = jsonModels.getJSONObject(i); + var name = m.optString("username"); + if (StringUtil.isBlank(name)) { + continue; + } + BongaCamsModel model = (BongaCamsModel) bongaCams.createModel(name); + model.mapOnlineState(m.optString("room")); + model.setOnline(m.optInt("viewers") > 0); + model.setPreview("https://en.bongacams.com/images/default/thumb_m_female.png"); + if (m.has("thumb_image")) { + String thumb = m.optString("thumb_image"); + if (StringUtil.isNotBlank(thumb)) { + model.setPreview("https:" + thumb.replace("{ext}", "jpg")); + } + } + if (m.has("display_name")) { + model.setDisplayName(m.getString("display_name")); + } + model.setDescription(m.optString("topic")); + 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 new file mode 100644 index 00000000..02dfed60 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/cam4/Cam4ConfigUI.java @@ -0,0 +1,92 @@ +package ctbrec.ui.sites.cam4; + +import ctbrec.Config; +import ctbrec.sites.cam4.Cam4; +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 Cam4ConfigUI extends AbstractConfigUI { + private Cam4 cam4; + + public Cam4ConfigUI(Cam4 cam4) { + this.cam4 = cam4; + } + + @Override + public Parent createConfigPanel() { + var layout = SettingsTab.createGridLayout(); + var settings = Config.getInstance().getSettings(); + + var row = 0; + var l = new Label("Active"); + layout.add(l, 0, row); + var 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); + var username = new TextField(Config.getInstance().getSettings().cam4Username); + 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, row++); + + layout.add(new Label("Cam4 Password"), 0, row); + var password = new PasswordField(); + password.setText(Config.getInstance().getSettings().cam4Password); + 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, row++); + + var createAccount = new Button("Create new Account"); + createAccount.setOnAction(e -> DesktopIntegration.open(Cam4.AFFILIATE_LINK)); + layout.add(createAccount, 1, row++); + GridPane.setColumnSpan(createAccount, 2); + + var deleteCookies = new Button("Delete Cookies"); + deleteCookies.setOnAction(e -> cam4.getHttpClient().clearCookies()); + layout.add(deleteCookies, 1, row); + GridPane.setColumnSpan(deleteCookies, 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)); + GridPane.setMargin(deleteCookies, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + return layout; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/cam4/Cam4ElectronLoginDialog.java b/client/src/main/java/ctbrec/ui/sites/cam4/Cam4ElectronLoginDialog.java new file mode 100644 index 00000000..0551ba38 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/cam4/Cam4ElectronLoginDialog.java @@ -0,0 +1,193 @@ +package ctbrec.ui.sites.cam4; + +import java.io.IOException; +import java.util.Collections; +import java.util.Optional; +import java.util.function.Consumer; + +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.sites.cam4.Cam4; +import ctbrec.ui.ExternalBrowser; +import okhttp3.Cookie; +import okhttp3.Cookie.Builder; +import okhttp3.CookieJar; +import okhttp3.HttpUrl; + +public class Cam4ElectronLoginDialog { + + private static final Logger LOG = LoggerFactory.getLogger(Cam4ElectronLoginDialog.class); + public static final String DOMAIN = "cam4.com"; + public static final String URL = Cam4.BASE_URI; + private CookieJar cookieJar; + private ExternalBrowser browser; + + private boolean dialogsClicked = false; + private boolean loginDialogOpened = false; + private Thread loginChecker; + + public Cam4ElectronLoginDialog(CookieJar cookieJar) throws IOException { + this.cookieJar = cookieJar; + browser = ExternalBrowser.getInstance(); + try { + var config = new JSONObject(); + config.put("url", URL); + config.put("w", 480); + config.put("h", 640); + var msg = new JSONObject(); + msg.put("config", config); + browser.run(msg, msgHandler); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Couldn't wait for login dialog", e); + } finally { + browser.close(); + } + } + + private Consumer msgHandler = line -> { + if(!line.startsWith("{")) { + LOG.error("Didn't received a JSON object {}", line); + } else { + var json = new JSONObject(line); + safeCookies(json); + + clickAwayCookieAndAgeAcknowlegde(); + openLoginDialog(); + //fillInCredentials(); + //clickLoginButton(); + + if (loginChecker == null) { + loginChecker = new Thread(() -> { + while(!Thread.currentThread().isInterrupted()) { + try { + checkIfLoggedIn(); + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + }); + loginChecker.setDaemon(true); + loginChecker.setName("Cam4 External browser login check"); + loginChecker.start(); + } + } + }; + + private void checkIfLoggedIn() { + try { + browser.executeJavaScript("document.querySelector('a[id*=\"mainHeader_userMenuContent-logout\"]').text").thenAccept(r -> { + LOG.debug("Result from browser is {}", r); + // found the logout button, we can close the browser, the login was successful + closeBrowser(); + }).exceptionally(ex -> null); + } catch (IOException e) { + LOG.error("Check, if logged in failed", e); + } + } + + private void clickAwayCookieAndAgeAcknowlegde() { + if (!dialogsClicked) { + try { + browser.executeJavaScript("let ageButton = document.querySelector('button[id*=\"disclaimerWithAgeVerification_badge-agreeBtn\"]');" + + "if (ageButton) { ageButton.click(); }"); + browser.executeJavaScript("let cookieButton = document.querySelector('button[id*=\"cookieConsent_consentCookieBtn\"]');" + + "if (cookieButton) { cookieButton.click(); }"); + dialogsClicked = true; + } catch (Exception e) { + LOG.warn("Couldn't click on cookie and age acknowlegde buttons for Cam4", e); + } + } + } + + private void safeCookies(JSONObject json) { + if(json.has("cookies") && json.has("url")) { + var url = json.getString("url"); + var cookiesFromBrowser = json.getJSONArray("cookies"); + for (var i = 0; i < cookiesFromBrowser.length(); i++) { + var cookie = cookiesFromBrowser.getJSONObject(i); + if (cookie.getString("domain").contains("cam4")) { + var domain = cookie.getString("domain"); + if (domain.startsWith(".")) { + domain = domain.substring(1); + } + var c = createCookie(domain, cookie); + cookieJar.saveFromResponse(HttpUrl.parse(url), Collections.singletonList(c)); + c = createCookie("cam4.com", cookie); + cookieJar.saveFromResponse(HttpUrl.parse(Cam4.BASE_URI), Collections.singletonList(c)); + } + } + } + } + + private void openLoginDialog() { + if (!loginDialogOpened) { + try { + browser.executeJavaScript("let loginButton = document.querySelector('button[id=\"loginButton\"]');" + + "console.log('loginButton', loginButton);" + + "if (loginButton) { loginButton.click(); }"); + loginDialogOpened = true; + } catch (Exception e) { + LOG.warn("Couldn't open login dialog for Cam4", e); + } + } + } + + @SuppressWarnings("unused") + private void fillInCredentials() { + try { + String username = Config.getInstance().getSettings().cam4Username; + if (username != null && !username.trim().isEmpty()) { + browser.executeJavaScript("document.querySelector('input[id*=\"loginFrom_usernameInput\"]').value = '" + username + "';"); + } + String password = Config.getInstance().getSettings().cam4Password; + if (password != null && !password.trim().isEmpty()) { + password = password.replace("'", "\\'"); + browser.executeJavaScript("document.querySelector('input[id*=\"loginFrom_passwordInput\"]').value = '" + password + "');"); + } + } catch (Exception e) { + LOG.warn("Couldn't auto fill username and password for Cam4", e); + } + } + + @SuppressWarnings("unused") + private void clickLoginButton() { + try { + browser.executeJavaScript("document.querySelector('button[id*=\"loginFrom_submitButton\"]').click();"); + } catch (Exception e) { + LOG.warn("Couldn't click on login button for Cam4", e); + } + } + + private Cookie createCookie(String domain, JSONObject cookie) { + Builder b = new Cookie.Builder() + .path(cookie.getString("path")) + .domain(domain) + .name(cookie.getString("name")) + .value(cookie.getString("value")) + .expiresAt((long) cookie.optDouble("expirationDate")); + if(cookie.optBoolean("hostOnly")) { + b.hostOnlyDomain(domain); + } + if(cookie.optBoolean("httpOnly")) { + b.httpOnly(); + } + if(cookie.optBoolean("secure")) { + b.secure(); + } + return b.build(); + } + + private void closeBrowser() { + try { + Optional.ofNullable(loginChecker).ifPresent(Thread::interrupt); + browser.close(); + } catch(IOException e) { + LOG.error("Couldn't send close request to browser", e); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/cam4/Cam4FollowedTab.java b/client/src/main/java/ctbrec/ui/sites/cam4/Cam4FollowedTab.java new file mode 100644 index 00000000..3a739a1a --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/cam4/Cam4FollowedTab.java @@ -0,0 +1,78 @@ +package ctbrec.ui.sites.cam4; + +import ctbrec.sites.cam4.Cam4; +import ctbrec.ui.tabs.FollowedTab; +import ctbrec.ui.tabs.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 Cam4FollowedTab extends ThumbOverviewTab implements FollowedTab { + private final Label status; + + public Cam4FollowedTab(Cam4 cam4) { + super("Followed", new Cam4FollowedUpdateService(cam4), cam4); + if (cam4.credentialsAvailable()) { + status = new Label("Logging in..."); + } else { + status = new Label("Credentials are missing in the settings"); + } + grid.getChildren().add(status); + } + + @Override + protected void createGui() { + super.createGui(); + addOnlineOfflineSelector(); + } + + private void addOnlineOfflineSelector() { + var group = new ToggleGroup(); + var online = new RadioButton("online"); + online.setToggleGroup(group); + var 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 -> { + ((Cam4FollowedUpdateService) updateService).setShowOnline(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() && event.getCode() == KeyCode.DELETE) { + follow(selectedThumbCells, false); + } + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/cam4/Cam4FollowedUpdateService.java b/client/src/main/java/ctbrec/ui/sites/cam4/Cam4FollowedUpdateService.java new file mode 100644 index 00000000..53ec2131 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/cam4/Cam4FollowedUpdateService.java @@ -0,0 +1,92 @@ +package ctbrec.ui.sites.cam4; + +import ctbrec.Model; +import ctbrec.io.HttpException; +import ctbrec.sites.cam4.Cam4; +import ctbrec.sites.cam4.Cam4Model; +import ctbrec.ui.SiteUiFactory; +import ctbrec.ui.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; +import okhttp3.Request; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class Cam4FollowedUpdateService extends PaginatedScheduledService { + + private static final Logger LOG = LoggerFactory.getLogger(Cam4FollowedUpdateService.class); + + private final Cam4 site; + private boolean showOnline = true; + + public Cam4FollowedUpdateService(Cam4 site) { + this.site = site; + ExecutorService executor = Executors.newSingleThreadExecutor(r -> { + var t = new Thread(r); + t.setDaemon(true); + t.setName("ThumbOverviewTab UpdateService"); + return t; + }); + setExecutor(executor); + } + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() throws IOException { + return loadModelList(); + } + }; + } + + private List loadModelList() throws IOException { + if (!site.credentialsAvailable()) { + return Collections.emptyList(); + } + + // login first + SiteUiFactory.getUi(site).login(); + String url = site.getBaseUrl() + "/directoryCams?directoryJson=true&online=" + showOnline + "&url=true&friends=true&favorites=true&resultsPerPage=90"; + LOG.debug("Fetching page {}", url); + Request req = new Request.Builder().url(url).build(); + try (var response = site.getHttpClient().execute(req)) { + if (response.isSuccessful()) { + var content = response.body().string(); + return parseModels(content); + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + private List parseModels(String content) { + List models = new ArrayList<>(); + var json = new JSONObject(content); + var users = json.getJSONArray("users"); + for (var i = 0; i < users.length(); i++) { + var modelJson = users.getJSONObject(i); + var username = modelJson.optString("username"); + Cam4Model model = site.createModel(username); + model.setPreview(modelJson.optString("snapshotImageLink")); + model.setOnlineStateByShowType(modelJson.optString("showType")); + model.setDescription(modelJson.optString("statusMessage")); + if (modelJson.has("hlsPreviewUrl")) { + model.setPlaylistUrl(modelJson.getString("hlsPreviewUrl")); + } + models.add(model); + } + return models; + } + + public void setShowOnline(boolean online) { + this.showOnline = online; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/cam4/Cam4SiteUi.java b/client/src/main/java/ctbrec/ui/sites/cam4/Cam4SiteUi.java new file mode 100644 index 00000000..24224b60 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/cam4/Cam4SiteUi.java @@ -0,0 +1,60 @@ +package ctbrec.ui.sites.cam4; + +import java.io.IOException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.sites.cam4.Cam4; +import ctbrec.sites.cam4.Cam4HttpClient; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.sites.AbstractSiteUi; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; + +public class Cam4SiteUi extends AbstractSiteUi { + private static final Logger LOG = LoggerFactory.getLogger(Cam4SiteUi.class); + + private final Cam4 cam4; + private Cam4TabProvider tabProvider; + private Cam4ConfigUI configUi; + + public Cam4SiteUi(Cam4 cam4) { + this.cam4 = cam4; + } + + @Override + public TabProvider getTabProvider() { + if (tabProvider == null) { + tabProvider = new Cam4TabProvider(cam4); + } + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + if (configUi == null) { + configUi = new Cam4ConfigUI(cam4); + } + return configUi; + } + + @Override + public synchronized boolean login() throws IOException { + boolean automaticLogin = cam4.login(); + if (automaticLogin) { + return true; + } else { + // login with external browser + try { + new Cam4ElectronLoginDialog(cam4.getHttpClient().getCookieJar()); + } catch (Exception e1) { + LOG.error("Error logging in with external browser", e1); + Dialogs.showError("Login error", "Couldn't login to " + cam4.getName(), e1); + } + Cam4HttpClient httpClient = (Cam4HttpClient) cam4.getHttpClient(); + boolean loggedIn = httpClient.checkLoginSuccess(); + return loggedIn; + } + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/cam4/Cam4TabProvider.java b/client/src/main/java/ctbrec/ui/sites/cam4/Cam4TabProvider.java new file mode 100644 index 00000000..11119733 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/cam4/Cam4TabProvider.java @@ -0,0 +1,52 @@ +package ctbrec.ui.sites.cam4; + +import ctbrec.sites.cam4.Cam4; +import ctbrec.ui.sites.AbstractTabProvider; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +import java.util.ArrayList; +import java.util.List; + +public class Cam4TabProvider extends AbstractTabProvider { + + private Cam4FollowedTab followed; + + public Cam4TabProvider(Cam4 cam4) { + super(cam4); + } + + @Override + protected List getSiteTabs(Scene scene) { + List tabs = new ArrayList<>(); + + tabs.add(createTab("Female", site.getBaseUrl() + "/directoryCams?directoryJson=true&online=true&url=true&orderBy=MOST_VIEWERS&gender=female")); + tabs.add(createTab("Male", site.getBaseUrl() + "/directoryCams?directoryJson=true&online=true&url=true&orderBy=MOST_VIEWERS&gender=male")); + tabs.add(createTab("Trans", site.getBaseUrl() + "/directoryCams?directoryJson=true&online=true&url=true&orderBy=MOST_VIEWERS&gender=shemale")); + tabs.add(createTab("Couples", site.getBaseUrl() + "/directoryCams?directoryJson=true&online=true&url=true&orderBy=MOST_VIEWERS&broadcastType=male_group&broadcastType=female_group&broadcastType=male_female_group")); + tabs.add(createTab("HD", site.getBaseUrl() + "/directoryCams?directoryJson=true&online=true&url=true&orderBy=VIDEO_QUALITY&gender=female&broadcastType=female_group&broadcastType=solo&broadcastType=male_female_group&hd=true")); + tabs.add(createTab("Mobile", site.getBaseUrl() + "/directoryCams?directoryJson=true&online=true&url=true&orderBy=MOST_VIEWERS&gender=female&broadcastType=female_group&broadcastType=solo&broadcastType=male_female_group&source=mobile")); + tabs.add(createTab("New", site.getBaseUrl() + "/directoryCams?directoryJson=true&online=true&url=true&orderBy=MOST_VIEWERS&gender=female&broadcastType=female_group&broadcastType=solo&broadcastType=male_female_group&newPerformer=true")); + + followed = new Cam4FollowedTab((Cam4) site); + followed.setRecorder(recorder); + tabs.add(followed); + + return tabs; + } + + @Override + public Tab getFollowedTab() { + return followed; + } + + private Tab createTab(String name, String url) { + var updateService = new Cam4UpdateService(url, false, (Cam4) site); + var tab = new ThumbOverviewTab(name, updateService, site); + tab.setImageAspectRatio(9.0 / 16.0); + tab.setRecorder(recorder); + return tab; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/cam4/Cam4UpdateService.java b/client/src/main/java/ctbrec/ui/sites/cam4/Cam4UpdateService.java new file mode 100644 index 00000000..c45836de --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/cam4/Cam4UpdateService.java @@ -0,0 +1,102 @@ +package ctbrec.ui.sites.cam4; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.StringUtil; +import ctbrec.io.HttpException; +import ctbrec.sites.cam4.Cam4; +import ctbrec.sites.cam4.Cam4Model; +import ctbrec.ui.SiteUiFactory; +import ctbrec.ui.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; +import okhttp3.Request; +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static ctbrec.Model.State.ONLINE; +import static ctbrec.io.HttpConstants.ACCEPT_LANGUAGE; +import static ctbrec.io.HttpConstants.USER_AGENT; + +public class Cam4UpdateService extends PaginatedScheduledService { + + private static final Logger LOG = LoggerFactory.getLogger(Cam4UpdateService.class); + private String url; + private final Cam4 site; + private final boolean loginRequired; + + public Cam4UpdateService(String url, boolean loginRequired, Cam4 site) { + this.site = site; + this.url = url; + this.loginRequired = loginRequired; + + ExecutorService executor = Executors.newSingleThreadExecutor(r -> { + var t = new Thread(r); + t.setDaemon(true); + t.setName("ThumbOverviewTab UpdateService"); + return t; + }); + setExecutor(executor); + } + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() throws IOException { + if (loginRequired && StringUtil.isBlank(Config.getInstance().getSettings().cam4Username)) { + return Collections.emptyList(); + } else { + String pageUrl = Cam4UpdateService.this.url + "&page=" + page; + LOG.debug("Fetching page {}", pageUrl); + if (loginRequired) { + SiteUiFactory.getUi(site).login(); + } + var request = new Request.Builder() + .url(pageUrl) + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .build(); + try (var response = site.getHttpClient().execute(request)) { + if (response.isSuccessful()) { + return parseModels(response.body().string()); + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + } + + }; + } + + private List parseModels(String body) { + var json = new JSONObject(body); + JSONArray users = json.getJSONArray("users"); + List models = new ArrayList<>(users.length()); + for (int i = 0; i < users.length(); i++) { + JSONObject user = users.getJSONObject(i); + Cam4Model model = site.createModel(user.getString("username")); + model.setPlaylistUrl(user.getString("hlsPreviewUrl")); + model.setPreview(user.getString("snapshotImageLink")); + model.setDescription(user.getString("statusMessage")); + model.setOnlineState(ONLINE); + models.add(model); + } + return models; + } + + public void setUrl(String url) { + this.url = url; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaConfigUI.java b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaConfigUI.java new file mode 100644 index 00000000..eea13b4d --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaConfigUI.java @@ -0,0 +1,92 @@ +package ctbrec.ui.sites.camsoda; + +import ctbrec.Config; +import ctbrec.sites.camsoda.Camsoda; +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 CamsodaConfigUI extends AbstractConfigUI { + private Camsoda camsoda; + + public CamsodaConfigUI(Camsoda camsoda) { + this.camsoda = camsoda; + } + + @Override + public Parent createConfigPanel() { + var layout = SettingsTab.createGridLayout(); + var settings = Config.getInstance().getSettings(); + + var row = 0; + var l = new Label("Active"); + layout.add(l, 0, row); + var 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); + var username = new TextField(Config.getInstance().getSettings().camsodaUsername); + 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, row++); + + layout.add(new Label("CamSoda Password"), 0, row); + var password = new PasswordField(); + password.setText(Config.getInstance().getSettings().camsodaPassword); + 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, row++); + + var createAccount = new Button("Create new Account"); + createAccount.setOnAction(e -> DesktopIntegration.open(camsoda.getAffiliateLink())); + layout.add(createAccount, 1, row++); + GridPane.setColumnSpan(createAccount, 2); + + var deleteCookies = new Button("Delete Cookies"); + deleteCookies.setOnAction(e -> camsoda.getHttpClient().clearCookies()); + layout.add(deleteCookies, 1, row); + GridPane.setColumnSpan(deleteCookies, 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)); + GridPane.setMargin(deleteCookies, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + return layout; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaFollowedTab.java b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaFollowedTab.java new file mode 100644 index 00000000..3bc3475e --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaFollowedTab.java @@ -0,0 +1,97 @@ +package ctbrec.ui.sites.camsoda; + +import ctbrec.sites.camsoda.Camsoda; +import ctbrec.sites.camsoda.CamsodaModel; +import ctbrec.ui.tabs.FollowedTab; +import ctbrec.ui.tabs.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; + +import java.util.function.Predicate; + +public class CamsodaFollowedTab extends ThumbOverviewTab implements FollowedTab { + private final Label status; + boolean showOnline = true; + + public CamsodaFollowedTab(String title, Camsoda camsoda) { + super(title, new CamsodaFollowedUpdateService(camsoda.getBaseUrl() + "/api/v1/browse/react/followed", true, camsoda, m -> true), camsoda); + status = new Label("Logging in..."); + grid.getChildren().add(status); + ((CamsodaUpdateService) updateService).setFilter(createFilter(this)); + } + + @Override + protected void createGui() { + super.createGui(); + addOnlineOfflineSelector(); + } + + private void addOnlineOfflineSelector() { + var group = new ToggleGroup(); + var online = new RadioButton("online"); + online.setToggleGroup(group); + var 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 -> { + showOnline = online.isSelected(); + queue.clear(); + updateService.restart(); + }); + } + + @Override + protected void onSuccess() { + grid.getChildren().remove(status); + super.onSuccess(); + } + + @Override + protected void onFail(WorkerStateEvent event) { + var msg = ""; + if (event.getSource().getException() != null) { + msg = ": " + event.getSource().getException().getMessage(); + } + status.setText("Login failed" + msg); + 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() && event.getCode() == KeyCode.DELETE) { + follow(selectedThumbCells, false); + } + }); + } + + private static Predicate createFilter(CamsodaFollowedTab tab) { + return m -> { + try { + return m.isOnline() == tab.showOnline; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } catch (Exception e) { + return false; + } + }; + + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaFollowedUpdateService.java b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaFollowedUpdateService.java new file mode 100644 index 00000000..da74e211 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaFollowedUpdateService.java @@ -0,0 +1,32 @@ +package ctbrec.ui.sites.camsoda; + +import ctbrec.sites.camsoda.Camsoda; +import ctbrec.sites.camsoda.CamsodaModel; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; + +public class CamsodaFollowedUpdateService extends CamsodaUpdateService { + + public CamsodaFollowedUpdateService(String url, boolean loginRequired, Camsoda camsoda, Predicate filter) { + super(url, loginRequired, camsoda, filter); + } + + protected List parseModels(String body) { + List models = new ArrayList<>(); + var json = new JSONObject(body); + var userList = json.getJSONArray("userList"); + for (var i = 0; i < userList.length(); i++) { + var jsonModel = userList.getJSONObject(i); + CamsodaModel model = (CamsodaModel) camsoda.createModel(jsonModel.getString("username")); + model.setDisplayName(jsonModel.optString("displayName")); + model.setPreview(jsonModel.optString("thumbUrl")); + model.setDescription(jsonModel.optString("subjectText")); + model.setOnlineStateByStatus(jsonModel.optString("status")); + models.add(model); + } + return models; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaSiteUi.java b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaSiteUi.java new file mode 100644 index 00000000..c8733424 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaSiteUi.java @@ -0,0 +1,41 @@ +package ctbrec.ui.sites.camsoda; + +import java.io.IOException; + +import ctbrec.sites.camsoda.Camsoda; +import ctbrec.ui.sites.AbstractSiteUi; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; + +public class CamsodaSiteUi extends AbstractSiteUi { + + private final Camsoda camsoda; + private CamsodaTabProvider tabProvider; + private CamsodaConfigUI configUi; + + public CamsodaSiteUi(Camsoda camsoda) { + this.camsoda = camsoda; + } + + @Override + public TabProvider getTabProvider() { + if (tabProvider == null) { + tabProvider = new CamsodaTabProvider(camsoda); + } + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + if (configUi == null) { + configUi = new CamsodaConfigUI(camsoda); + } + return configUi; + } + + @Override + public synchronized boolean login() throws IOException { + boolean automaticLogin = camsoda.login(); + return automaticLogin; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaTabProvider.java b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaTabProvider.java new file mode 100644 index 00000000..3fad516b --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaTabProvider.java @@ -0,0 +1,55 @@ +package ctbrec.ui.sites.camsoda; + +import ctbrec.sites.camsoda.Camsoda; +import ctbrec.sites.camsoda.CamsodaModel; +import ctbrec.ui.sites.AbstractTabProvider; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Predicate; + +import static ctbrec.sites.camsoda.Camsoda.BASE_URI; + +public class CamsodaTabProvider extends AbstractTabProvider { + + private static final String API_URL = BASE_URI + "/api/v1/browse/online"; + CamsodaFollowedTab followedTab; + + public CamsodaTabProvider(Camsoda camsoda) { + super(camsoda); + followedTab = new CamsodaFollowedTab("Followed", camsoda); + } + + @Override + protected List getSiteTabs(Scene scene) { + List tabs = new ArrayList<>(); + tabs.add(createTab("All", API_URL, m -> true)); + tabs.add(createTab("New", API_URL, CamsodaModel::isNew)); + tabs.add(createTab("Female", API_URL, m -> Objects.equals("f", m.getGender()))); + tabs.add(createTab("Male", API_URL, m -> Objects.equals("m", m.getGender()))); + tabs.add(createTab("Couples", API_URL, m -> Objects.equals("c", m.getGender()))); + tabs.add(createTab("Trans", API_URL, m -> Objects.equals("t", m.getGender()))); + tabs.add(createTab("Voyeur", API_URL, CamsodaModel::isVoyeur)); + followedTab.setRecorder(recorder); + followedTab.setScene(scene); + tabs.add(followedTab); + return tabs; + } + + @Override + public Tab getFollowedTab() { + return followedTab; + } + + private Tab createTab(String title, String url, Predicate filter) { + var updateService = new CamsodaUpdateService(url, false, (Camsoda) site, filter); + var tab = new ThumbOverviewTab(title, updateService, site); + tab.setRecorder(recorder); + return tab; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaUpdateService.java b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaUpdateService.java new file mode 100644 index 00000000..324fdd6e --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/camsoda/CamsodaUpdateService.java @@ -0,0 +1,178 @@ +package ctbrec.ui.sites.camsoda; + +import ctbrec.Model; +import ctbrec.StringUtil; +import ctbrec.io.HttpException; +import ctbrec.sites.camsoda.Camsoda; +import ctbrec.sites.camsoda.CamsodaModel; +import ctbrec.ui.SiteUiFactory; +import ctbrec.ui.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import okhttp3.Request; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.*; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import static ctbrec.ErrorMessages.HTTP_RESPONSE_BODY_IS_NULL; +import static ctbrec.Model.State.OFFLINE; +import static ctbrec.Model.State.ONLINE; + +@Slf4j +public class CamsodaUpdateService extends PaginatedScheduledService { + + protected final String url; + protected final boolean loginRequired; + protected final Camsoda camsoda; + protected int modelsPerPage = 60; + private static List modelsList; + private static Instant lastListInfoRequest = Instant.EPOCH; + @Setter + protected Predicate filter; + + public CamsodaUpdateService(String url, boolean loginRequired, Camsoda camsoda, Predicate filter) { + this.url = url; + this.loginRequired = loginRequired; + this.camsoda = camsoda; + this.filter = filter; + } + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() throws IOException { + return getModelList().stream() + .sorted((m1, m2) -> (int) (m2.getSortOrder() - m1.getSortOrder())) + .filter(filter) + .skip((page - 1) * (long) modelsPerPage) + .limit(modelsPerPage) + .collect(Collectors.toList()); // NOSONAR + } + }; + } + + private List getModelList() throws IOException { + if (Objects.nonNull(modelsList) && Duration.between(lastListInfoRequest, Instant.now()).getSeconds() < 30) { + return modelsList; + } + lastListInfoRequest = Instant.now(); + modelsList = loadOnlineModels(); + return Optional.ofNullable(modelsList).orElse(Collections.emptyList()); + } + + protected List loadOnlineModels() throws IOException { + if (loginRequired && StringUtil.isBlank(ctbrec.Config.getInstance().getSettings().camsodaUsername)) { + return Collections.emptyList(); + } else { + log.debug("Fetching page {}", url); + if (loginRequired) { + SiteUiFactory.getUi(camsoda).login(); + } + var request = new Request.Builder().url(url).build(); + try (var response = camsoda.getHttpClient().execute(request)) { + if (response.isSuccessful()) { + return parseModels(Objects.requireNonNull(response.body(), HTTP_RESPONSE_BODY_IS_NULL).string()); + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + } + + protected List parseModels(String body) { + List models = new ArrayList<>(); + var json = new JSONObject(body); + var template = json.getJSONArray("template"); + var results = json.getJSONArray("results"); + for (var i = 0; i < results.length(); i++) { + var result = results.getJSONObject(i); + var templateObject = result.get("tpl"); + try { + if (templateObject instanceof JSONObject) { + parseModelFromObject(result, templateObject, template, models); + } else if (templateObject instanceof JSONArray) { + parseModelFromArray(result, templateObject, template, models); + } + } catch (Exception e) { + log.warn("Couldn't parse one of the models: {}", result, e); + } + } + return models; + } + + private void parseModelFromArray(JSONObject result, Object templateObject, JSONArray template, List models) { + var tpl = (JSONArray) templateObject; + var name = tpl.getString(getTemplateIndex(template, "username")); + CamsodaModel model = (CamsodaModel) camsoda.createModel(name); + model.setSortOrder(tpl.getFloat(getTemplateIndex(template, "sort_value"))); + model.setDescription(tpl.getString(getTemplateIndex(template, "subject_html"))); + model.setNew(result.optBoolean("new")); + model.setVoyeur(result.optBoolean("voyeur")); + var preview = tpl.getString(getTemplateIndex(template, "thumb")); + if (preview.startsWith("//")) { + preview = "https:" + preview; + } + model.setPreview(preview); + log.trace("Preview: {}", preview); + model.setOnlineStateByStatus(tpl.getString(getTemplateIndex(template, "status"))); + var displayName = tpl.getString(getTemplateIndex(template, "display_name")); + model.setDisplayName(displayName.replaceAll("[^a-zA-Z0-9]", "")); + if (model.getDisplayName().isBlank()) { + model.setDisplayName(name); + } + models.add(model); + } + + private void parseModelFromObject(JSONObject result, Object templateObject, JSONArray template, List models) { + var tpl = (JSONObject) templateObject; + var name = tpl.getString(Integer.toString(getTemplateIndex(template, "username"))); + CamsodaModel model = (CamsodaModel) camsoda.createModel(name); + model.setDescription(tpl.getString(Integer.toString(getTemplateIndex(template, "subject_html")))); + model.setSortOrder(tpl.getFloat(Integer.toString(getTemplateIndex(template, "sort_value")))); + model.setNew(result.optBoolean("new")); + model.setVoyeur(result.optBoolean("voyeur")); + model.setGender(tpl.getString(Integer.toString(getTemplateIndex(template, "gender")))); + var preview = tpl.getString(Integer.toString(getTemplateIndex(template, "thumb"))); + if (preview.startsWith("//")) { + preview = "https:" + preview; + } + model.setPreview(preview); + var displayName = tpl.getString(Integer.toString(getTemplateIndex(template, "display_name"))); + model.setDisplayName(displayName.replaceAll("[^a-zA-Z0-9]", "")); + if (model.getDisplayName().isBlank()) { + model.setDisplayName(name); + } + model.setOnlineState(tpl.getString(Integer.toString(getTemplateIndex(template, "stream_name"))).isEmpty() ? OFFLINE : ONLINE); + parseStatus(template, tpl, model); + models.add(model); + } + + private void parseStatus(JSONArray template, JSONObject tpl, CamsodaModel model) { + try { + var statusIndex = Integer.toString(getTemplateIndex(template, "status")); + if (tpl.has(statusIndex)) { + model.setOnlineStateByStatus(tpl.getString(statusIndex)); + } + } catch (NoSuchElementException e) { + // that's ok + } + } + + private int getTemplateIndex(JSONArray template, String string) { + for (var i = 0; i < template.length(); i++) { + var s = template.getString(i); + if (Objects.equals(s, string)) { + return i; + } + } + throw new NoSuchElementException(string + " not found in template: " + template); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateApiUpdateService.java b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateApiUpdateService.java new file mode 100644 index 00000000..3902d8b5 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateApiUpdateService.java @@ -0,0 +1,70 @@ +package ctbrec.ui.sites.chaturbate; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.StringUtil; +import ctbrec.io.HtmlParser; +import ctbrec.io.HttpException; +import ctbrec.sites.chaturbate.Chaturbate; +import ctbrec.sites.chaturbate.ChaturbateModel; +import ctbrec.ui.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import okhttp3.Request; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + +import static ctbrec.io.HttpConstants.*; + + +@Slf4j +@RequiredArgsConstructor +public class ChaturbateApiUpdateService extends PaginatedScheduledService { + + private final String url; + private final Chaturbate chaturbate; + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + protected List call() throws Exception { + var request = new Request.Builder() + .url(url) + .header(USER_AGENT, chaturbate.getHttpClient().getEffectiveUserAgent()) + .header(ACCEPT, MIMETYPE_APPLICATION_JSON) + .build(); + try (var response = chaturbate.getHttpClient().execute(request)) { + if (response.isSuccessful()) { + String body = response.body().string(); + log.trace(body); + var models = new ArrayList(); + JSONObject json = new JSONObject(body); + JSONArray rooms = json.optJSONArray("rooms"); + for (int i = 0; rooms != null && i < rooms.length(); i++) { + JSONObject room = rooms.getJSONObject(i); + String name = room.getString("room"); + ChaturbateModel model = (ChaturbateModel) chaturbate.createModel(name); + model.setDescription(toText(room.optString("subject")) + " #gender:" + room.optString("gender")); + if (StringUtil.isNotBlank(room.optString("display_age"))) { + model.setDescription(model.getDescription() + " #age:" + room.optString("display_age")); + } + models.add(model); + } + return models; + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + }; + } + + protected String toText(String html) { + return HtmlParser.getText(html, "*"); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateConfigUi.java b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateConfigUi.java new file mode 100644 index 00000000..dd885821 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateConfigUi.java @@ -0,0 +1,124 @@ +package ctbrec.ui.sites.chaturbate; + +import ctbrec.Config; +import ctbrec.sites.chaturbate.Chaturbate; +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.*; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; + +public class ChaturbateConfigUi extends AbstractConfigUI { + private Chaturbate chaturbate; + + public ChaturbateConfigUi(Chaturbate chaturbate) { + this.chaturbate = chaturbate; + } + + @Override + public Parent createConfigPanel() { + var settings = Config.getInstance().getSettings(); + GridPane layout = SettingsTab.createGridLayout(); + + var row = 0; + var l = new Label("Active"); + layout.add(l, 0, row); + var 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); + var username = new TextField(Config.getInstance().getSettings().chaturbateUsername); + username.textProperty().addListener((ob, o, n) -> { + if(!n.equals(Config.getInstance().getSettings().chaturbateUsername)) { + Config.getInstance().getSettings().chaturbateUsername = n; + chaturbate.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("Chaturbate Password"), 0, row); + var password = new PasswordField(); + password.setText(Config.getInstance().getSettings().chaturbatePassword); + password.textProperty().addListener((ob, o, n) -> { + if(!n.equals(Config.getInstance().getSettings().chaturbatePassword)) { + Config.getInstance().getSettings().chaturbatePassword = n; + chaturbate.getHttpClient().logout(); + save(); + } + }); + GridPane.setFillWidth(password, true); + GridPane.setHgrow(password, Priority.ALWAYS); + GridPane.setColumnSpan(password, 2); + layout.add(password, 1, row++); + + layout.add(new Label("Chaturbate Base URL"), 0, row); + var 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++); + + layout.add(new Label("Time between requests (ms)"), 0, row); + var requestThrottle = new TextField(Integer.toString(Config.getInstance().getSettings().chaturbateMsBetweenRequests)); + requestThrottle.textProperty().addListener((ob, o, n) -> { + int newValue = -1; + try { + newValue = Integer.parseInt(n); + } catch (Exception e) { + requestThrottle.setText(o); + return; + } + if (newValue != Config.getInstance().getSettings().chaturbateMsBetweenRequests) { + Config.getInstance().getSettings().chaturbateMsBetweenRequests = newValue; + save(); + } + }); + GridPane.setFillWidth(requestThrottle, true); + GridPane.setHgrow(requestThrottle, Priority.ALWAYS); + GridPane.setColumnSpan(requestThrottle, 2); + layout.add(requestThrottle, 1, row++); + + var createAccount = new Button("Create new Account"); + createAccount.setOnAction(e -> DesktopIntegration.open(Chaturbate.REGISTRATION_LINK)); + layout.add(createAccount, 1, row++); + GridPane.setColumnSpan(createAccount, 2); + + var deleteCookies = new Button("Delete Cookies"); + deleteCookies.setOnAction(e -> chaturbate.getHttpClient().clearCookies()); + layout.add(deleteCookies, 1, row); + GridPane.setColumnSpan(deleteCookies, 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(requestThrottle, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + GridPane.setMargin(createAccount, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + GridPane.setMargin(deleteCookies, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + + username.setPrefWidth(300); + + return layout; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateElectronLoginDialog.java b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateElectronLoginDialog.java new file mode 100644 index 00000000..d9b0e673 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateElectronLoginDialog.java @@ -0,0 +1,123 @@ +package ctbrec.ui.sites.chaturbate; + +import ctbrec.Config; +import ctbrec.sites.chaturbate.Chaturbate; +import ctbrec.ui.ExternalBrowser; +import okhttp3.Cookie; +import okhttp3.Cookie.Builder; +import okhttp3.CookieJar; +import okhttp3.HttpUrl; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Collections; +import java.util.Objects; +import java.util.function.Consumer; + +public class ChaturbateElectronLoginDialog { + + private static final Logger LOG = LoggerFactory.getLogger(ChaturbateElectronLoginDialog.class); + public static final String DOMAIN = "chaturbate.com"; + private Chaturbate site; + private CookieJar cookieJar; + private ExternalBrowser browser; + + public ChaturbateElectronLoginDialog(Chaturbate site, CookieJar cookieJar) throws IOException { + this.site = site; + this.cookieJar = cookieJar; + browser = ExternalBrowser.getInstance(); + try { + var config = new JSONObject(); + config.put("url", site.getBaseUrl() + "/auth/login/"); + config.put("w", 640); + config.put("h", 480); + config.put("userAgent", site.getHttpClient().getEffectiveUserAgent()); + var msg = new JSONObject(); + msg.put("config", config); + browser.run(msg, msgHandler); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Couldn't wait for login dialog", e); + } finally { + browser.close(); + } + } + + private final Consumer msgHandler = line -> { + if (!line.startsWith("{")) { + LOG.error("Didn't received a JSON object {}", line); + } else { + var json = new JSONObject(line); + if (json.has("url")) { + var url = json.getString("url"); + if (url.endsWith("/auth/login/")) { + try { + Thread.sleep(2000); + String username = Config.getInstance().getSettings().chaturbateUsername; + if (username != null && !username.trim().isEmpty()) { + browser.executeJavaScript("document.getElementById('id_username').value = '" + username + "'"); + } + String password = Config.getInstance().getSettings().chaturbatePassword; + if (password != null && !password.trim().isEmpty()) { + password = password.replace("'", "\\'"); + browser.executeJavaScript("document.getElementById('id_password').value = '" + password + "'"); + } + var simplify = new String[]{ + "$('div#header').css('display','none');", + "$('div#footer-holder').css('display','none')", + }; + for (String js : simplify) { + browser.executeJavaScript(js); + } + browser.executeJavaScript("document.querySelector('form[action*=login]').submit()"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOG.warn("Couldn't auto fill username and password for Chaturbate", e); + } catch (Exception e) { + LOG.warn("Couldn't auto fill username and password for Chaturbate", e); + } + } + + if (json.has("cookies")) { + var cookiesFromBrowser = json.getJSONArray("cookies"); + for (var i = 0; i < cookiesFromBrowser.length(); i++) { + var cookie = cookiesFromBrowser.getJSONObject(i); + if (cookie.getString("domain").contains(DOMAIN)) { + Builder b = new Builder() + .path(cookie.getString("path")) + .domain(DOMAIN) + .name(cookie.getString("name")) + .value(cookie.getString("value")) + .expiresAt((long) cookie.optDouble("expirationDate")); + if (cookie.optBoolean("hostOnly")) { + b.hostOnlyDomain(DOMAIN); + } + if (cookie.optBoolean("httpOnly")) { + b.httpOnly(); + } + if (cookie.optBoolean("secure")) { + b.secure(); + } + Cookie c = b.build(); + cookieJar.saveFromResponse(HttpUrl.parse(ChaturbateElectronLoginDialog.this.site.getBaseUrl()), Collections.singletonList(c)); + } + } + } + + try { + if (Objects.equals(new URL(url).getPath(), "/")) { + browser.close(); + } + } catch (MalformedURLException e) { + LOG.error("Couldn't parse new url {}", url, e); + } catch (IOException e) { + LOG.error("Couldn't send shutdown request to external browser", e); + } + } + } + }; +} diff --git a/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateFollowedTab.java b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateFollowedTab.java new file mode 100644 index 00000000..a84eaebe --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateFollowedTab.java @@ -0,0 +1,83 @@ +package ctbrec.ui.sites.chaturbate; + +import ctbrec.sites.chaturbate.Chaturbate; +import ctbrec.ui.tabs.FollowedTab; +import ctbrec.ui.tabs.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 ChaturbateFollowedTab extends ThumbOverviewTab implements FollowedTab { + private final Label status; + private final String onlineUrl; + private final String offlineUrl; + + public ChaturbateFollowedTab(String title, String url, Chaturbate chaturbate) { + super(title, new ChaturbateUpdateService(url, true, chaturbate), chaturbate); + onlineUrl = url + "&offline=false"; + offlineUrl = url + "&offline=true"; + + status = new Label("Logging in..."); + grid.getChildren().add(status); + } + + @Override + protected void createGui() { + super.createGui(); + addOnlineOfflineSelector(); + } + + private void addOnlineOfflineSelector() { + var group = new ToggleGroup(); + var online = new RadioButton("online"); + online.setToggleGroup(group); + var 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 -> { + if (online.isSelected()) { + ((ChaturbateUpdateService) updateService).setUrl(onlineUrl); + } else { + ((ChaturbateUpdateService) updateService).setUrl(offlineUrl); + } + 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() && event.getCode() == KeyCode.DELETE) { + follow(selectedThumbCells, false); + } + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateSiteUi.java b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateSiteUi.java new file mode 100644 index 00000000..90d84740 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateSiteUi.java @@ -0,0 +1,66 @@ +package ctbrec.ui.sites.chaturbate; + +import ctbrec.sites.chaturbate.Chaturbate; +import ctbrec.sites.chaturbate.ChaturbateHttpClient; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.sites.AbstractSiteUi; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +public class ChaturbateSiteUi extends AbstractSiteUi { + + private static final Logger LOG = LoggerFactory.getLogger(ChaturbateSiteUi.class); + + private final Chaturbate chaturbate; + private ChaturbateTabProvider tabProvider; + private ChaturbateConfigUi configUi; + + public ChaturbateSiteUi(Chaturbate chaturbate) { + this.chaturbate = chaturbate; + } + + @Override + public TabProvider getTabProvider() { + if (tabProvider == null) { + tabProvider = new ChaturbateTabProvider(chaturbate); + } + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + if (configUi == null) { + configUi = new ChaturbateConfigUi(chaturbate); + } + return configUi; + } + + @Override + public synchronized boolean login() throws IOException { + boolean automaticLogin = false; + try { + automaticLogin = chaturbate.login(); + } catch (Exception e) { + LOG.debug("Automatic login failed", e); + } + if (automaticLogin) { + return true; + } else { + // login with external browser window + try { + new ChaturbateElectronLoginDialog(chaturbate, chaturbate.getHttpClient().getCookieJar()); + } catch (Exception e1) { + LOG.error("Error logging in with external browser", e1); + Dialogs.showError("Login error", "Couldn't login to " + chaturbate.getName(), e1); + } + + ChaturbateHttpClient httpClient = (ChaturbateHttpClient) chaturbate.getHttpClient(); + return httpClient.checkLogin(); + } + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateTabProvider.java b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateTabProvider.java new file mode 100644 index 00000000..dee19dfa --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateTabProvider.java @@ -0,0 +1,67 @@ +package ctbrec.ui.sites.chaturbate; + +import ctbrec.sites.chaturbate.Chaturbate; +import ctbrec.ui.sites.AbstractTabProvider; +import ctbrec.ui.tabs.PaginatedScheduledService; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +import java.util.ArrayList; +import java.util.List; + +public class ChaturbateTabProvider extends AbstractTabProvider { + + private final String apiUrl; + private final ChaturbateFollowedTab followedTab; + + public ChaturbateTabProvider(Chaturbate chaturbate) { + super(chaturbate); + apiUrl = site.getBaseUrl() + "/api/ts"; + this.followedTab = new ChaturbateFollowedTab("Followed", apiUrl + "/roomlist/room-list/?enable_recommendations=false&follow=true", chaturbate); + } + + @Override + protected List getSiteTabs(Scene scene) { + List tabs = new ArrayList<>(); + tabs.add(createTab("Featured", apiUrl + "/roomlist/room-list/?enable_recommendations=false")); + tabs.add(createTab("Female", apiUrl + "/roomlist/room-list/?enable_recommendations=false&genders=f")); + tabs.add(createTab("New Female", apiUrl + "/roomlist/room-list/?enable_recommendations=false&genders=f&new_cams=true")); + tabs.add(createTab("Male", apiUrl + "/roomlist/room-list/?enable_recommendations=false&genders=m")); + tabs.add(createTab("New Male", apiUrl + "/roomlist/room-list/?enable_recommendations=false&genders=m&new_cams=true")); + tabs.add(createTab("Couples", apiUrl + "/roomlist/room-list/?enable_recommendations=false&genders=c")); + tabs.add(createTab("Trans", apiUrl + "/roomlist/room-list/?enable_recommendations=false&genders=t")); + tabs.add(createTab("Private", apiUrl + "/roomlist/room-list/?enable_recommendations=false&private=true")); + tabs.add(createTab("Hidden", apiUrl + "/roomlist/room-list/?enable_recommendations=false&hidden=true")); + tabs.add(createTab("Gaming", apiUrl + "/roomlist/room-list/?enable_recommendations=false&gaming=true")); + followedTab.setScene(scene); + followedTab.setRecorder(recorder); + followedTab.setImageAspectRatio(9.0 / 16.0); + tabs.add(followedTab); + tabs.add(createApiTab("Top Rated", apiUrl + "/discover/carousels/top-rated/")); + tabs.add(createApiTab("Trending", apiUrl + "/discover/carousels/trending/")); + return tabs; + } + + @Override + public Tab getFollowedTab() { + return followedTab; + } + + private Tab createTab(String title, String url) { + var updateService = new ChaturbateUpdateService(url, false, (Chaturbate) site); + return createTab(title, updateService); + } + + private Tab createTab(String title, PaginatedScheduledService updateService) { + var tab = new ThumbOverviewTab(title, updateService, site); + tab.setRecorder(recorder); + tab.setImageAspectRatio(9.0 / 16.0); + return tab; + } + + private Tab createApiTab(String title, String apiUrl) { + var updateService = new ChaturbateApiUpdateService(apiUrl, (Chaturbate) site); + return createTab(title, updateService); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateUpdateService.java b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateUpdateService.java new file mode 100644 index 00000000..90c8e7c0 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/chaturbate/ChaturbateUpdateService.java @@ -0,0 +1,90 @@ +package ctbrec.ui.sites.chaturbate; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.sites.chaturbate.Chaturbate; +import ctbrec.sites.chaturbate.ChaturbateModel; +import ctbrec.ui.SiteUiFactory; +import ctbrec.ui.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; +import lombok.extern.slf4j.Slf4j; +import okhttp3.Request; +import org.json.JSONArray; +import org.json.JSONObject; +import org.jsoup.Jsoup; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +import static ctbrec.io.HttpConstants.*; + +@Slf4j +public class ChaturbateUpdateService extends PaginatedScheduledService { + + private String url; + private final boolean loginRequired; + private final Chaturbate chaturbate; + + private static final int PAGE_SIZE = 90; + + public ChaturbateUpdateService(String url, boolean loginRequired, Chaturbate chaturbate) { + this.url = url; + this.loginRequired = loginRequired; + this.chaturbate = chaturbate; + } + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() throws IOException { + if (loginRequired && !chaturbate.credentialsAvailable()) { + return Collections.emptyList(); + } else { + String pageUrl = ChaturbateUpdateService.this.url + "&limit=" + PAGE_SIZE + "&offset=" + (page - 1) * PAGE_SIZE; + log.debug("Fetching page {}", pageUrl); + if (loginRequired) { + SiteUiFactory.getUi(chaturbate).login(); + } + Request request = new Request.Builder() + .url(pageUrl) + .header(ACCEPT, MIMETYPE_APPLICATION_JSON) + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) + .header(USER_AGENT, chaturbate.getHttpClient().getEffectiveUserAgent()) + .build(); + try (var response = chaturbate.getHttpClient().execute(request)) { + if (response.isSuccessful()) { + String body = response.body().string(); + List models = new ArrayList<>(); + JSONObject json = new JSONObject(body); + if (json.has("rooms")) { + JSONArray rooms = json.optJSONArray("rooms"); + for (int i = 0; i < rooms.length(); i++) { + JSONObject room = rooms.getJSONObject(i); + ChaturbateModel model = (ChaturbateModel) chaturbate.createModel(room.getString("username")); + if (room.has("subject")) { + model.setDescription(Jsoup.parse(room.getString("subject")).text()); + } + models.add(model); + } + } + return models; + } else { + int code = response.code(); + throw new IOException("HTTP status " + code); + } + } + } + } + }; + } + + public void setUrl(String url) { + this.url = url; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvConfigUI.java b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvConfigUI.java new file mode 100644 index 00000000..82f0c8e4 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvConfigUI.java @@ -0,0 +1,88 @@ +package ctbrec.ui.sites.cherrytv; + +import ctbrec.Config; +import ctbrec.sites.cherrytv.CherryTv; +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.*; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; + +public class CherryTvConfigUI extends AbstractConfigUI { + private final CherryTv site; + + public CherryTvConfigUI(CherryTv cherryTv) { + this.site = cherryTv; + } + + @Override + public Parent createConfigPanel() { + var layout = SettingsTab.createGridLayout(); + var settings = Config.getInstance().getSettings(); + + var row = 0; + var l = new Label("Active"); + layout.add(l, 0, row); + var 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()); + } + save(); + }); + GridPane.setMargin(enabled, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + layout.add(enabled, 1, row++); + + layout.add(new Label(site.getName() + " User"), 0, row); + var username = new TextField(Config.getInstance().getSettings().cherryTvUsername); + username.textProperty().addListener((ob, o, n) -> { + if (!n.equals(Config.getInstance().getSettings().cherryTvUsername)) { + Config.getInstance().getSettings().cherryTvUsername = username.getText(); + site.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(site.getName() + " Password"), 0, row); + var password = new PasswordField(); + password.setText(Config.getInstance().getSettings().cherryTvPassword); + password.textProperty().addListener((ob, o, n) -> { + if (!n.equals(Config.getInstance().getSettings().cherryTvPassword)) { + Config.getInstance().getSettings().cherryTvPassword = password.getText(); + site.getHttpClient().logout(); + save(); + } + }); + GridPane.setFillWidth(password, true); + GridPane.setHgrow(password, Priority.ALWAYS); + GridPane.setColumnSpan(password, 2); + layout.add(password, 1, row++); + + var createAccount = new Button("Create new Account"); + createAccount.setOnAction(e -> DesktopIntegration.open(site.getAffiliateLink())); + layout.add(createAccount, 1, row++); + GridPane.setColumnSpan(createAccount, 2); + + var deleteCookies = new Button("Delete Cookies"); + deleteCookies.setOnAction(e -> site.getHttpClient().clearCookies()); + layout.add(deleteCookies, 1, row); + GridPane.setColumnSpan(deleteCookies, 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)); + GridPane.setMargin(deleteCookies, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + return layout; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvFollowedTab.java b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvFollowedTab.java new file mode 100644 index 00000000..07bb6684 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvFollowedTab.java @@ -0,0 +1,90 @@ + +package ctbrec.ui.sites.cherrytv; + +import ctbrec.sites.cherrytv.CherryTv; +import ctbrec.ui.tabs.FollowedTab; +import ctbrec.ui.tabs.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 CherryTvFollowedTab extends ThumbOverviewTab implements FollowedTab { + private final Label status; + private ToggleGroup group; + + public CherryTvFollowedTab(String title, CherryTv site) { + super(title, new CherryTvFollowedUpdateService(site), site); + status = new Label("Logging in..."); + grid.getChildren().add(status); + } + + @Override + protected void createGui() { + super.createGui(); + group = new ToggleGroup(); + addOnlineOfflineSelector(); + setFilter(true); + } + + private void addOnlineOfflineSelector() { + var online = new RadioButton("online"); + online.setToggleGroup(group); + var 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 -> { + setFilter(online.isSelected()); + queue.clear(); + updateService.restart(); + }); + } + + private void setFilter(boolean online) { + ((CherryTvUpdateService) updateService).setFilter(m -> { + try { + return m.isOnline(false) == online; + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return false; + } catch (Exception ex) { + return false; + } + }); + } + + @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() && event.getCode() == KeyCode.DELETE) { + follow(selectedThumbCells, false); + } + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvFollowedUpdateService.java b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvFollowedUpdateService.java new file mode 100644 index 00000000..060a3222 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvFollowedUpdateService.java @@ -0,0 +1,55 @@ +package ctbrec.ui.sites.cherrytv; + +import ctbrec.Model; +import ctbrec.sites.cherrytv.CherryTv; +import ctbrec.sites.cherrytv.CherryTvModel; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static ctbrec.Model.State.OFFLINE; +import static ctbrec.Model.State.ONLINE; + +public class CherryTvFollowedUpdateService extends CherryTvUpdateService { + + private static final Logger LOG = LoggerFactory.getLogger(CherryTvFollowedUpdateService.class); + + public CherryTvFollowedUpdateService(CherryTv site) { + super("following", site, true); + url = "https://api.cherry.tv/graphql?operationName=findFollowingBroadcastsByPage&variables={\"limit\":1000}&extensions={\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"241ae6ae3c2bd62e78432b4d51a92a1baa59d9e94d173867a2a45586704465d1\"}}"; + } + + @Override + protected List parseModels(String body) throws IOException { + var json = new JSONObject(body); + //LOG.debug(json.toString(2)); + if (json.has("errors")) { + JSONArray errors = json.getJSONArray("errors"); + JSONObject first = errors.getJSONObject(0); + throw new IOException(first.getString("message")); + } + List models = new ArrayList<>(); + try { + JSONArray followings = json.getJSONObject("data").getJSONObject("broadcasts").getJSONArray("broadcasts"); + for (int i = 0; i < followings.length(); i++) { + JSONObject following = followings.getJSONObject(i); + CherryTvModel model = site.createModel(following.optString("username")); + model.setId(following.getString("id")); + model.setPreview(following.optString("imageUrl")); + var online = following.optString("broadcastStatus").equalsIgnoreCase("Live"); + model.setOnline(online); + model.setOnlineState(online ? ONLINE : OFFLINE); + models.add(model); + } + } catch (JSONException e) { + LOG.error("Couldn't parse JSON, the structure might have changed", e); + } + return models; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvSiteUi.java b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvSiteUi.java new file mode 100644 index 00000000..33c62dcf --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvSiteUi.java @@ -0,0 +1,43 @@ +package ctbrec.ui.sites.cherrytv; + +import ctbrec.sites.cherrytv.CherryTv; +import ctbrec.ui.sites.AbstractSiteUi; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +public class CherryTvSiteUi extends AbstractSiteUi { + private static final Logger LOG = LoggerFactory.getLogger(CherryTvSiteUi.class); + + private final CherryTv cherryTv; + private CherryTvTabProvider tabProvider; + private CherryTvConfigUI configUi; + + public CherryTvSiteUi(CherryTv cherryTv) { + this.cherryTv = cherryTv; + } + + @Override + public TabProvider getTabProvider() { + if (tabProvider == null) { + tabProvider = new CherryTvTabProvider(cherryTv); + } + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + if (configUi == null) { + configUi = new CherryTvConfigUI(cherryTv); + } + return configUi; + } + + @Override + public synchronized boolean login() throws IOException { + return cherryTv.login(); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvTabProvider.java b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvTabProvider.java new file mode 100644 index 00000000..a790d5e6 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvTabProvider.java @@ -0,0 +1,50 @@ +package ctbrec.ui.sites.cherrytv; + +import ctbrec.sites.cherrytv.CherryTv; +import ctbrec.ui.sites.AbstractTabProvider; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +import java.util.ArrayList; +import java.util.List; + +public class CherryTvTabProvider extends AbstractTabProvider { + + private final CherryTvFollowedTab followedTab; + + public CherryTvTabProvider(CherryTv cherryTv) { + super(cherryTv); + + followedTab = new CherryTvFollowedTab("Following", (CherryTv) site); + followedTab.setImageAspectRatio(1); + followedTab.preserveAspectRatioProperty().set(false); + followedTab.setRecorder(recorder); + } + + @Override + protected List getSiteTabs(Scene scene) { + List tabs = new ArrayList<>(); + + tabs.add(createTab("Female", "girls")); + tabs.add(createTab("Trans", "trans")); + tabs.add(createTab("Group Show", "groupshow")); + tabs.add(followedTab); + return tabs; + } + + @Override + public Tab getFollowedTab() { + return followedTab; + } + + private Tab createTab(String name, String url) { + var updateService = new CherryTvUpdateService(url, (CherryTv) site, false); + var tab = new ThumbOverviewTab(name, updateService, site); + tab.setImageAspectRatio(9.0 / 16.0); + tab.preserveAspectRatioProperty().set(false); + tab.setRecorder(recorder); + return tab; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvUpdateService.java b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvUpdateService.java new file mode 100644 index 00000000..9297fc25 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/cherrytv/CherryTvUpdateService.java @@ -0,0 +1,151 @@ +package ctbrec.ui.sites.cherrytv; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.io.HttpException; +import ctbrec.sites.cherrytv.CherryTv; +import ctbrec.sites.cherrytv.CherryTvModel; +import ctbrec.ui.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; +import okhttp3.Request; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static ctbrec.Model.State.OFFLINE; +import static ctbrec.Model.State.ONLINE; +import static ctbrec.io.HttpConstants.ACCEPT_LANGUAGE; +import static ctbrec.io.HttpConstants.USER_AGENT; +import static java.nio.charset.StandardCharsets.UTF_8; + +public class CherryTvUpdateService extends PaginatedScheduledService { + + private static final Logger LOG = LoggerFactory.getLogger(CherryTvUpdateService.class); + protected static final long MODELS_PER_PAGE = 50; + + protected String url; + private final boolean loginRequired; + protected final CherryTv site; + private Predicate filter; + + public CherryTvUpdateService(String slug, CherryTv site, boolean loginRequired) { + this.site = site; + this.url = "https://api.cherry.tv/graphql?query=" + URLEncoder.encode(BROADCASTS_QUERY + .replace(" ", "") + .replace("${slug}", slug), UTF_8); + this.loginRequired = loginRequired; + + ExecutorService executor = Executors.newSingleThreadExecutor(r -> { + var t = new Thread(r); + t.setDaemon(true); + t.setName("CherryTvUpdateService"); + return t; + }); + setExecutor(executor); + } + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() throws IOException { + if (loginRequired && !site.getHttpClient().login()) { + throw new IOException("Login failed"); + } + + LOG.debug("Fetching page {}", url); + + var request = new Request.Builder() + .url(url) + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .build(); + try (var response = site.getHttpClient().execute(request)) { + if (response.isSuccessful()) { + String body = Objects.requireNonNull(response.body()).string(); + Stream stream = parseModels(body).stream(); + if (filter != null) { + stream = stream.filter(filter); + } + return stream.skip((page - 1) * MODELS_PER_PAGE) + .limit(MODELS_PER_PAGE) + .collect(Collectors.toList()); + } else { + LOG.debug(Objects.requireNonNull(response.body()).string()); + throw new HttpException(response.code(), response.message()); + } + } + } + }; + } + + protected List parseModels(String body) throws IOException { + var json = new JSONObject(body); + if (json.has("errors")) { + JSONArray errors = json.getJSONArray("errors"); + JSONObject first = errors.getJSONObject(0); + throw new IOException(first.getString("message")); + } + List models = new ArrayList<>(); + try { + JSONArray broadcasts = json.getJSONObject("data").getJSONObject("broadcastsPaged").getJSONArray("broadcasts"); + for (int i = 0; i < broadcasts.length(); i++) { + JSONObject broadcast = broadcasts.getJSONObject(i); + CherryTvModel model = site.createModel(broadcast.optString("username")); + model.setDisplayName(broadcast.optString("title")); + model.setDescription(broadcast.optString("description")); + model.setPreview(broadcast.optString("thumbnailUrl")); + var online = broadcast.optString("showStatus").equalsIgnoreCase("Public") + && broadcast.optString("broadcastStatus").equalsIgnoreCase("Live"); + model.setOnline(online); + model.setOnlineState(online ? ONLINE : OFFLINE); + JSONArray tags = broadcast.optJSONArray("tags"); + if (tags != null) { + for (int j = 0; j < tags.length(); j++) { + model.getTags().add(tags.getString(j)); + } + } + models.add(model); + } + } catch (JSONException e) { + LOG.error("Couldn't parse JSON, the structure might have changed", e); + } + return models; + } + + public void setFilter(Predicate filter) { + this.filter = filter; + } + + private static final String BROADCASTS_QUERY = """ + { + broadcastsPaged(query: {limit:1000,slug:"${slug}"}) { + broadcasts { + id + title + username + description + thumbnailUrl + tags + broadcastStatus + showStatus + } + totalCount + } + } + """; +} diff --git a/client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamConfigUI.java b/client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamConfigUI.java new file mode 100644 index 00000000..44fcf407 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamConfigUI.java @@ -0,0 +1,66 @@ +package ctbrec.ui.sites.dreamcam; + +import ctbrec.Config; +import ctbrec.sites.dreamcam.Dreamcam; +import ctbrec.ui.settings.SettingsTab; +import ctbrec.ui.sites.AbstractConfigUI; +import javafx.geometry.Insets; +import javafx.scene.Parent; +import javafx.scene.control.ToggleGroup; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.RadioButton; +import javafx.scene.control.Label; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; + +public class DreamcamConfigUI extends AbstractConfigUI { + private final Dreamcam site; + + public DreamcamConfigUI(Dreamcam site) { + this.site = site; + } + + @Override + public Parent createConfigPanel() { + GridPane layout = SettingsTab.createGridLayout(); + var settings = Config.getInstance().getSettings(); + + var row = 0; + var l = new Label("Active"); + layout.add(l, 0, row); + var 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()); + } + save(); + }); + GridPane.setMargin(enabled, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + layout.add(enabled, 1, row++); + + row++; + l = new Label("VR Mode"); + layout.add(l, 0, row); + var vr = new CheckBox(); + vr.setSelected(settings.dreamcamVR); + vr.setOnAction(e -> { + settings.dreamcamVR = vr.isSelected(); + save(); + }); + GridPane.setMargin(vr, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + layout.add(vr, 1, row++); + + var deleteCookies = new Button("Delete Cookies"); + deleteCookies.setOnAction(e -> site.getHttpClient().clearCookies()); + layout.add(deleteCookies, 1, row); + GridPane.setColumnSpan(deleteCookies, 2); + + GridPane.setMargin(deleteCookies, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + return layout; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamSiteUi.java b/client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamSiteUi.java new file mode 100644 index 00000000..cde8f98a --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamSiteUi.java @@ -0,0 +1,40 @@ +package ctbrec.ui.sites.dreamcam; + +import ctbrec.sites.dreamcam.Dreamcam; +import ctbrec.ui.sites.AbstractSiteUi; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; + +import java.io.IOException; + +public class DreamcamSiteUi extends AbstractSiteUi { + + private DreamcamTabProvider tabProvider; + private DreamcamConfigUI configUi; + private final Dreamcam site; + + public DreamcamSiteUi(Dreamcam site) { + this.site = site; + } + + @Override + public TabProvider getTabProvider() { + if (tabProvider == null) { + tabProvider = new DreamcamTabProvider(site); + } + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + if (configUi == null) { + configUi = new DreamcamConfigUI(site); + } + return configUi; + } + + @Override + public synchronized boolean login() throws IOException { + return site.login(); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamTabProvider.java b/client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamTabProvider.java new file mode 100644 index 00000000..17226cc0 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamTabProvider.java @@ -0,0 +1,37 @@ +package ctbrec.ui.sites.dreamcam; + +import ctbrec.sites.dreamcam.Dreamcam; +import ctbrec.sites.dreamcam.DreamcamModel; +import ctbrec.ui.sites.AbstractTabProvider; +import ctbrec.ui.tabs.ThumbOverviewTab; +import java.util.ArrayList; +import java.util.List; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +public class DreamcamTabProvider extends AbstractTabProvider { + private final static String API_URL = "https://bss.dreamcamtrue.com/api/clients/v1/broadcasts?partnerId=dreamcam_oauth2&show-offline=false&stream-types=video2D&include-tags=false&include-tip-menu=false"; + + public DreamcamTabProvider(Dreamcam site) { + super(site); + } + + @Override + protected List getSiteTabs(Scene scene) { + List tabs = new ArrayList<>(); + tabs.add(createTab("Girls", API_URL + "&tag-categories=girls")); + tabs.add(createTab("Boys", API_URL + "&tag-categories=men")); + tabs.add(createTab("Couples", API_URL + "&tag-categories=couples")); + tabs.add(createTab("Trans", API_URL + "&tag-categories=trans")); + return tabs; + } + + private Tab createTab(String title, String url) { + var updateService = new DreamcamUpdateService((Dreamcam) site, url); + var tab = new ThumbOverviewTab(title, updateService, site); + tab.setImageAspectRatio(10.0 / 16.0); + tab.setRecorder(recorder); + return tab; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamUpdateService.java b/client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamUpdateService.java new file mode 100644 index 00000000..b413b8aa --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/dreamcam/DreamcamUpdateService.java @@ -0,0 +1,86 @@ +package ctbrec.ui.sites.dreamcam; + +import static ctbrec.io.HttpConstants.*; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.io.HttpException; +import ctbrec.sites.dreamcam.Dreamcam; +import ctbrec.sites.dreamcam.DreamcamModel; +import ctbrec.ui.tabs.PaginatedScheduledService; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import javafx.concurrent.Task; +import okhttp3.Request; +import okhttp3.Response; +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class DreamcamUpdateService extends PaginatedScheduledService { + private static final Logger LOG = LoggerFactory.getLogger(DreamcamUpdateService.class); + private static final String API_URL = "https://api.dreamcam.co.kr/v1/live"; + private static final int modelsPerPage = 64; + private Dreamcam site; + private String url; + + public DreamcamUpdateService(Dreamcam site, String url) { + this.site = site; + this.url = url; + } + + @Override + protected Task> createTask() { + return new Task>() { + @Override + public List call() throws IOException { + return loadModelList(); + } + }; + } + + private List loadModelList() throws IOException { + int offset = (getPage() - 1) * modelsPerPage; + int limit = modelsPerPage; + String paginatedUrl = url + "&offset=" + offset + "&limit=" + limit; + LOG.debug("Fetching page {}", paginatedUrl); + Request req = new Request.Builder() + .url(paginatedUrl) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, MIMETYPE_APPLICATION_JSON) + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) + .header(REFERER, site.getBaseUrl() + "/") + .header(ORIGIN, site.getBaseUrl()) + .build(); + try (Response response = site.getHttpClient().execute(req)) { + if (response.isSuccessful()) { + List models = new ArrayList<>(); + String content = response.body().string(); + JSONObject json = new JSONObject(content); + if (json.has("pageItems")) { + JSONArray modelNodes = json.getJSONArray("pageItems"); + parseModels(modelNodes, models); + } + return models; + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + private void parseModels(JSONArray jsonModels, List models) { + for (int i = 0; i < jsonModels.length(); i++) { + JSONObject m = jsonModels.getJSONObject(i); + String name = m.optString("modelNickname"); + DreamcamModel model = (DreamcamModel) site.createModel(name); + model.setDisplayName(name); + model.setPreview(m.optString("modelProfilePhotoUrl")); + model.setDescription(m.optString("broadcastTextStatus")); + models.add(model); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/fc2live/Fc2FollowedTab.java b/client/src/main/java/ctbrec/ui/sites/fc2live/Fc2FollowedTab.java new file mode 100644 index 00000000..3532fc1a --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/fc2live/Fc2FollowedTab.java @@ -0,0 +1,35 @@ +package ctbrec.ui.sites.fc2live; + +import ctbrec.sites.fc2live.Fc2Live; +import ctbrec.ui.tabs.FollowedTab; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.geometry.Insets; +import javafx.scene.control.RadioButton; +import javafx.scene.control.ToggleGroup; +import javafx.scene.layout.HBox; + +public class Fc2FollowedTab extends ThumbOverviewTab implements FollowedTab { + + public Fc2FollowedTab(Fc2Live fc2live) { + super("Followed", new Fc2FollowedUpdateService(fc2live), fc2live); + } + + @SuppressWarnings("unused") + private void addOnlineOfflineSelector() { + var group = new ToggleGroup(); + var online = new RadioButton("online"); + online.setToggleGroup(group); + var 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 -> { + ((Fc2FollowedUpdateService)updateService).setShowOnline(online.isSelected()); + queue.clear(); + updateService.restart(); + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/fc2live/Fc2FollowedUpdateService.java b/client/src/main/java/ctbrec/ui/sites/fc2live/Fc2FollowedUpdateService.java new file mode 100644 index 00000000..6dbb7af3 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/fc2live/Fc2FollowedUpdateService.java @@ -0,0 +1,90 @@ +package ctbrec.ui.sites.fc2live; + +import static ctbrec.io.HttpConstants.*; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.json.JSONObject; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.io.HttpException; +import ctbrec.sites.fc2live.Fc2Live; +import ctbrec.sites.fc2live.Fc2Model; +import ctbrec.ui.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; +import okhttp3.FormBody; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +public class Fc2FollowedUpdateService extends PaginatedScheduledService { + + private Fc2Live fc2live; + + public Fc2FollowedUpdateService(Fc2Live fc2live) { + this.fc2live = fc2live; + } + + @Override + protected Task> createTask() { + return new Task>() { + @Override + public List call() throws IOException { + return loadModels(); + } + }; + } + + private List loadModels() throws IOException { + if(!fc2live.login()) { + throw new IOException("Login didn't work"); + } + + RequestBody body = new FormBody.Builder() + .add("mode", "list") + .add("page", Integer.toString(page - 1)) + .build(); + Request req = new Request.Builder() + .url(fc2live.getBaseUrl() + "/api/favoriteManager.php") + .header(REFERER, fc2live.getBaseUrl()) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header("Content-Type", "application/x-www-form-urlencoded") + .post(body) + .build(); + try (Response resp = fc2live.getHttpClient().execute(req)) { + if (resp.isSuccessful()) { + List models = new ArrayList<>(); + var content = resp.body().string(); + var json = new JSONObject(content); + if (json.optInt("status") == 1) { + var data = json.getJSONArray("data"); + for (var i = 0; i < data.length(); i++) { + var m = data.getJSONObject(i); + Fc2Model model = (Fc2Model) fc2live.createModel(m.getString("name")); + model.setId(m.getString("id")); + model.setUrl(Fc2Live.BASE_URL + '/' + model.getId()); + var previewUrl = m.optString("icon"); + if (previewUrl == null || previewUrl.trim().isEmpty()) { + previewUrl = "https://live-storage.fc2.com/thumb/" + model.getId() + "/thumb.jpg"; + } + model.setPreview(previewUrl); + model.setDescription(""); + models.add(model); + } + return models; + } else { + throw new IOException("Request was not successful: " + json.toString()); + } + } else { + throw new HttpException(resp.code(), resp.message()); + } + } + } + + public void setShowOnline(boolean yes) { + // not implemented yet + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/fc2live/Fc2LiveConfigUI.java b/client/src/main/java/ctbrec/ui/sites/fc2live/Fc2LiveConfigUI.java new file mode 100644 index 00000000..f9dde8b5 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/fc2live/Fc2LiveConfigUI.java @@ -0,0 +1,92 @@ +package ctbrec.ui.sites.fc2live; + +import ctbrec.Config; +import ctbrec.sites.fc2live.Fc2Live; +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 Fc2LiveConfigUI extends AbstractConfigUI { + private Fc2Live fc2live; + + public Fc2LiveConfigUI(Fc2Live fc2live) { + this.fc2live = fc2live; + } + + @Override + public Parent createConfigPanel() { + var layout = SettingsTab.createGridLayout(); + var settings = Config.getInstance().getSettings(); + + var row = 0; + var l = new Label("Active"); + layout.add(l, 0, row); + var enabled = new CheckBox(); + enabled.setSelected(!settings.disabledSites.contains(fc2live.getName())); + enabled.setOnAction(e -> { + if(enabled.isSelected()) { + settings.disabledSites.remove(fc2live.getName()); + } else { + settings.disabledSites.add(fc2live.getName()); + } + save(); + }); + GridPane.setMargin(enabled, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + layout.add(enabled, 1, row++); + + layout.add(new Label("FC2Live User"), 0, row); + var username = new TextField(settings.fc2liveUsername); + username.textProperty().addListener((ob, o, n) -> { + if(!n.equals(Config.getInstance().getSettings().fc2liveUsername)) { + Config.getInstance().getSettings().fc2liveUsername = username.getText(); + fc2live.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("FC2Live Password"), 0, row); + var password = new PasswordField(); + password.setText(settings.fc2livePassword); + password.textProperty().addListener((ob, o, n) -> { + if(!n.equals(Config.getInstance().getSettings().fc2livePassword)) { + Config.getInstance().getSettings().fc2livePassword = password.getText(); + fc2live.getHttpClient().logout(); + save(); + } + }); + GridPane.setFillWidth(password, true); + GridPane.setHgrow(password, Priority.ALWAYS); + GridPane.setColumnSpan(password, 2); + layout.add(password, 1, row++); + + var createAccount = new Button("Create new Account"); + createAccount.setOnAction(e -> DesktopIntegration.open(fc2live.getAffiliateLink())); + layout.add(createAccount, 1, row++); + GridPane.setColumnSpan(createAccount, 2); + + var deleteCookies = new Button("Delete Cookies"); + deleteCookies.setOnAction(e -> fc2live.getHttpClient().clearCookies()); + layout.add(deleteCookies, 1, row); + GridPane.setColumnSpan(deleteCookies, 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)); + GridPane.setMargin(deleteCookies, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + return layout; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/fc2live/Fc2LiveSiteUi.java b/client/src/main/java/ctbrec/ui/sites/fc2live/Fc2LiveSiteUi.java new file mode 100644 index 00000000..e9efaa37 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/fc2live/Fc2LiveSiteUi.java @@ -0,0 +1,79 @@ +package ctbrec.ui.sites.fc2live; + +import java.io.IOException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.GlobalThreadPool; +import ctbrec.Model; +import ctbrec.sites.fc2live.Fc2Live; +import ctbrec.sites.fc2live.Fc2Model; +import ctbrec.ui.JavaFxModel; +import ctbrec.ui.Player; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.sites.AbstractSiteUi; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; + +public class Fc2LiveSiteUi extends AbstractSiteUi { + private static final Logger LOG = LoggerFactory.getLogger(Fc2LiveSiteUi.class); + private final Fc2Live fc2live; + private Fc2TabProvider tabProvider; + private Fc2LiveConfigUI configUi; + + public Fc2LiveSiteUi(Fc2Live fc2live) { + this.fc2live = fc2live; + } + + @Override + public TabProvider getTabProvider() { + if (tabProvider == null) { + this.tabProvider = new Fc2TabProvider(fc2live); + } + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + if (configUi == null) { + this.configUi = new Fc2LiveConfigUI(fc2live); + } + return configUi; + } + + @Override + public boolean login() throws IOException { + return fc2live.login(); + } + + @Override + public boolean play(Model model) { + GlobalThreadPool.submit(() -> { + Fc2Model m; + if (model instanceof JavaFxModel) { + m = (Fc2Model) ((JavaFxModel) model).getDelegate(); + } else { + m = (Fc2Model) model; + } + try { + m.openWebsocket(); + LOG.debug("Starting player for {}", model); + Player.play(model, false); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + handleException(e); + } catch (IOException e) { + handleException(e); + } finally { + m.closeWebsocket(); + } + }); + return true; + } + + private void handleException(Exception e) { + LOG.error("Error playing the stream", e); + Dialogs.showError("Player", "Error playing the stream", e); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/fc2live/Fc2TabProvider.java b/client/src/main/java/ctbrec/ui/sites/fc2live/Fc2TabProvider.java new file mode 100644 index 00000000..866993cb --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/fc2live/Fc2TabProvider.java @@ -0,0 +1,43 @@ +package ctbrec.ui.sites.fc2live; + +import ctbrec.sites.fc2live.Fc2Live; +import ctbrec.ui.sites.AbstractTabProvider; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +import java.util.ArrayList; +import java.util.List; + +public class Fc2TabProvider extends AbstractTabProvider { + + private Fc2FollowedTab followed; + + public Fc2TabProvider(Fc2Live fc2live) { + super(fc2live); + } + + @Override + protected List getSiteTabs(Scene scene) { + List tabs = new ArrayList<>(); + tabs.add(createTab("Online", Fc2Live.BASE_URL + "/adult/contents/allchannellist.php")); + + followed = new Fc2FollowedTab((Fc2Live) site); + followed.setRecorder(site.getRecorder()); + tabs.add(followed); + + return tabs; + } + + private Tab createTab(String title, String url) { + var updateService = new Fc2UpdateService(url, (Fc2Live) site); + var tab = new ThumbOverviewTab(title, updateService, site); + tab.setRecorder(site.getRecorder()); + return tab; + } + + @Override + public Tab getFollowedTab() { + return followed; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/fc2live/Fc2UpdateService.java b/client/src/main/java/ctbrec/ui/sites/fc2live/Fc2UpdateService.java new file mode 100644 index 00000000..6a1893e7 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/fc2live/Fc2UpdateService.java @@ -0,0 +1,87 @@ +package ctbrec.ui.sites.fc2live; + +import static ctbrec.io.HttpConstants.*; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.io.HttpException; +import ctbrec.sites.fc2live.Fc2Live; +import ctbrec.sites.fc2live.Fc2Model; +import ctbrec.ui.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; +import okhttp3.Request; +import okhttp3.RequestBody; + +public class Fc2UpdateService extends PaginatedScheduledService { + private static final Logger LOG = LoggerFactory.getLogger(Fc2UpdateService.class); + + private String url; + private Fc2Live fc2live; + private int modelsPerPage = 30; + + public Fc2UpdateService(String url, Fc2Live fc2live) { + this.url = url; + this.fc2live = fc2live; + } + + @Override + protected Task> createTask() { + return new Task>() { + @Override + public List call() throws IOException { + var body = RequestBody.create(new byte[0]); + Request req = new Request.Builder() + .url(url) + .method("POST", body) + .header(ACCEPT, "*/*") + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(REFERER, Fc2Live.BASE_URL) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) + .build(); + LOG.debug("Fetching page {}", url); + try (var resp = fc2live.getHttpClient().execute(req)) { + if (resp.isSuccessful()) { + List models = new ArrayList<>(); + var msg = resp.body().string(); + var json = new JSONObject(msg); + var channels = json.getJSONArray("channel"); + for (var i = 0; i < channels.length(); i++) { + var channel = channels.getJSONObject(i); + Fc2Model model = (Fc2Model) fc2live.createModel(channel.getString("name")); + model.setId(channel.getString("id")); + model.setUrl(Fc2Live.BASE_URL + '/' + model.getId()); + var previewUrl = channel.getString("image"); + if (previewUrl == null || previewUrl.trim().isEmpty()) { + previewUrl = getClass().getResource("/image_not_found.png").toString(); + } + model.setPreview(previewUrl); + model.setDescription(channel.optString("title")); + model.setViewerCount(channel.optInt("count")); + if (channel.getInt("login") == 0) { + models.add(model); + } + } + return models.stream() + .sorted((m1, m2) -> m2.getViewerCount() - m1.getViewerCount()) + .skip((long) (page - 1) * modelsPerPage) + .limit(modelsPerPage) + .collect(Collectors.toList()); + } else { + throw new HttpException(resp.code(), resp.message()); + } + } + } + }; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/flirt4free/Flirt4FreeConfigUI.java b/client/src/main/java/ctbrec/ui/sites/flirt4free/Flirt4FreeConfigUI.java new file mode 100644 index 00000000..3d146668 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/flirt4free/Flirt4FreeConfigUI.java @@ -0,0 +1,92 @@ +package ctbrec.ui.sites.flirt4free; + +import ctbrec.Config; +import ctbrec.sites.flirt4free.Flirt4Free; +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 Flirt4FreeConfigUI extends AbstractConfigUI { + private Flirt4Free flirt4free; + + public Flirt4FreeConfigUI(Flirt4Free flirt4free) { + this.flirt4free = flirt4free; + } + + @Override + public Parent createConfigPanel() { + var layout = SettingsTab.createGridLayout(); + var settings = Config.getInstance().getSettings(); + + var row = 0; + var l = new Label("Active"); + layout.add(l, 0, row); + var enabled = new CheckBox(); + enabled.setSelected(!settings.disabledSites.contains(flirt4free.getName())); + enabled.setOnAction(e -> { + if(enabled.isSelected()) { + settings.disabledSites.remove(flirt4free.getName()); + } else { + settings.disabledSites.add(flirt4free.getName()); + } + save(); + }); + GridPane.setMargin(enabled, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + layout.add(enabled, 1, row++); + + layout.add(new Label("Flirt4Free User"), 0, row); + var username = new TextField(settings.flirt4freeUsername); + username.textProperty().addListener((ob, o, n) -> { + if(!n.equals(Config.getInstance().getSettings().flirt4freeUsername)) { + Config.getInstance().getSettings().flirt4freeUsername = username.getText(); + flirt4free.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("Flirt4Free Password"), 0, row); + var password = new PasswordField(); + password.setText(settings.flirt4freePassword); + password.textProperty().addListener((ob, o, n) -> { + if(!n.equals(Config.getInstance().getSettings().flirt4freePassword)) { + Config.getInstance().getSettings().flirt4freePassword = password.getText(); + flirt4free.getHttpClient().logout(); + save(); + } + }); + GridPane.setFillWidth(password, true); + GridPane.setHgrow(password, Priority.ALWAYS); + GridPane.setColumnSpan(password, 2); + layout.add(password, 1, row++); + + var createAccount = new Button("Create new Account"); + createAccount.setOnAction(e -> DesktopIntegration.open(flirt4free.getAffiliateLink())); + layout.add(createAccount, 1, row++); + GridPane.setColumnSpan(createAccount, 2); + + var deleteCookies = new Button("Delete Cookies"); + deleteCookies.setOnAction(e -> flirt4free.getHttpClient().clearCookies()); + layout.add(deleteCookies, 1, row); + GridPane.setColumnSpan(deleteCookies, 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)); + GridPane.setMargin(deleteCookies, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + return layout; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/flirt4free/Flirt4FreeFavoritesTab.java b/client/src/main/java/ctbrec/ui/sites/flirt4free/Flirt4FreeFavoritesTab.java new file mode 100644 index 00000000..2ead54e3 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/flirt4free/Flirt4FreeFavoritesTab.java @@ -0,0 +1,23 @@ +package ctbrec.ui.sites.flirt4free; + +import ctbrec.sites.flirt4free.Flirt4Free; +import ctbrec.ui.tabs.FollowedTab; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.scene.Scene; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; + +public class Flirt4FreeFavoritesTab extends ThumbOverviewTab implements FollowedTab { + + public Flirt4FreeFavoritesTab(Flirt4Free flirt4free) { + super("Favorites", new Flirt4FreeFavoritesUpdateService(flirt4free), flirt4free); + } + + public void setScene(Scene scene) { + scene.addEventFilter(KeyEvent.KEY_PRESSED, event -> { + if (this.isSelected() && event.getCode() == KeyCode.DELETE) { + follow(selectedThumbCells, false); + } + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/flirt4free/Flirt4FreeFavoritesUpdateService.java b/client/src/main/java/ctbrec/ui/sites/flirt4free/Flirt4FreeFavoritesUpdateService.java new file mode 100644 index 00000000..2b420dcb --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/flirt4free/Flirt4FreeFavoritesUpdateService.java @@ -0,0 +1,82 @@ +package ctbrec.ui.sites.flirt4free; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.io.HtmlParser; +import ctbrec.io.HttpException; +import ctbrec.sites.flirt4free.Flirt4Free; +import ctbrec.sites.flirt4free.Flirt4FreeModel; +import ctbrec.ui.SiteUiFactory; +import ctbrec.ui.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; +import okhttp3.Request; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.ExecutionException; + +import static ctbrec.io.HttpClient.gunzipBody; +import static ctbrec.io.HttpConstants.*; + +public class Flirt4FreeFavoritesUpdateService extends PaginatedScheduledService { + private final Flirt4Free flirt4free; + + public Flirt4FreeFavoritesUpdateService(Flirt4Free flirt4free) { + this.flirt4free = flirt4free; + } + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() throws IOException { + return loadModelList(); + } + }; + } + + private List loadModelList() throws IOException { + List models = new ArrayList<>(); + String url = flirt4free.getBaseUrl() + "/my-account/secure/favorites.php?a=models&sort=online&status=all&pg=" + page; + SiteUiFactory.getUi(flirt4free).login(); + var request = new Request.Builder() + .url(url) + .header(ACCEPT, "*/*") + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(ACCEPT_ENCODING, ACCEPT_ENCODING_GZIP) + .header(REFERER, flirt4free.getBaseUrl()) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .build(); + try (var response = flirt4free.getHttpClient().execute(request)) { + if (response.isSuccessful()) { + var body = gunzipBody(response); + Elements modelContainers = HtmlParser.getTags(body, "div.model-container"); + for (Element modelContainer : modelContainers) { + String modelHtml = modelContainer.html(); + Element bioLink = HtmlParser.getTag(modelHtml, "a.common-link"); + bioLink.setBaseUri(flirt4free.getBaseUrl()); + Flirt4FreeModel model = (Flirt4FreeModel) flirt4free.createModelFromUrl(bioLink.absUrl("href")); + Element img = HtmlParser.getTag(modelHtml, "a > img[alt]"); + model.setDisplayName(img.attr("alt")); + model.setPreview(img.attr("src")); + model.setDescription(""); + try { + model.setOnlineState(model.isOnline() ? Model.State.ONLINE : Model.State.OFFLINE); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (ExecutionException e) { + // ignore + } + models.add(model); + } + return models; + } else { + throw new HttpException(response.code(), response.message()); + } + } + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/flirt4free/Flirt4FreeSiteUi.java b/client/src/main/java/ctbrec/ui/sites/flirt4free/Flirt4FreeSiteUi.java new file mode 100644 index 00000000..5d184dd7 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/flirt4free/Flirt4FreeSiteUi.java @@ -0,0 +1,40 @@ +package ctbrec.ui.sites.flirt4free; + +import java.io.IOException; + +import ctbrec.sites.flirt4free.Flirt4Free; +import ctbrec.ui.sites.AbstractSiteUi; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; + +public class Flirt4FreeSiteUi extends AbstractSiteUi { + + private final Flirt4Free flirt4Free; + private Flirt4FreeTabProvider tabProvider; + private Flirt4FreeConfigUI configUi; + + public Flirt4FreeSiteUi(Flirt4Free flirt4Free) { + this.flirt4Free = flirt4Free; + } + + @Override + public TabProvider getTabProvider() { + if (tabProvider == null) { + tabProvider = new Flirt4FreeTabProvider(flirt4Free); + } + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + if (configUi == null) { + configUi = new Flirt4FreeConfigUI(flirt4Free); + } + return configUi; + } + + @Override + public synchronized boolean login() throws IOException { + return flirt4Free.login(); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/flirt4free/Flirt4FreeTabProvider.java b/client/src/main/java/ctbrec/ui/sites/flirt4free/Flirt4FreeTabProvider.java new file mode 100644 index 00000000..56b2536b --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/flirt4free/Flirt4FreeTabProvider.java @@ -0,0 +1,49 @@ +package ctbrec.ui.sites.flirt4free; + +import ctbrec.sites.flirt4free.Flirt4Free; +import ctbrec.sites.flirt4free.Flirt4FreeModel; +import ctbrec.ui.sites.AbstractTabProvider; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.scene.Scene; +import javafx.scene.control.Tab; +import javafx.util.Duration; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; + +public class Flirt4FreeTabProvider extends AbstractTabProvider { + + private final ThumbOverviewTab followedTab; + + public Flirt4FreeTabProvider(Flirt4Free flirt4Free) { + super(flirt4Free); + followedTab = new Flirt4FreeFavoritesTab(flirt4Free); + followedTab.setRecorder(flirt4Free.getRecorder()); + } + + @Override + protected List getSiteTabs(Scene scene) { + List tabs = new ArrayList<>(); + tabs.add(createTab("Girls", site.getBaseUrl() + "/live/girls/?tpl=index2&model=json", m -> true)); + tabs.add(createTab("New Girls", site.getBaseUrl() + "/live/girls/?tpl=index2&model=json", Flirt4FreeModel::isNew)); + tabs.add(createTab("Boys", site.getBaseUrl() + "/live/guys/?tpl=index2&model=json", m -> true)); + tabs.add(createTab("Couples", site.getBaseUrl() + "/live/couples/?tpl=index2&model=json", m -> m.getCategories().contains("2"))); + tabs.add(createTab("Trans", site.getBaseUrl() + "/live/trans/?tpl=index2&model=json", m -> true)); + tabs.add(followedTab); + return tabs; + } + + @Override + public Tab getFollowedTab() { + return followedTab; + } + + private ThumbOverviewTab createTab(String title, String url, Predicate filter) { + var s = new Flirt4FreeUpdateService((Flirt4Free) site, url, filter); + var tab = new ThumbOverviewTab(title, s, site); + tab.setRecorder(site.getRecorder()); + s.setPeriod(Duration.seconds(60)); + return tab; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/flirt4free/Flirt4FreeUpdateService.java b/client/src/main/java/ctbrec/ui/sites/flirt4free/Flirt4FreeUpdateService.java new file mode 100644 index 00000000..cc305a13 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/flirt4free/Flirt4FreeUpdateService.java @@ -0,0 +1,122 @@ +package ctbrec.ui.sites.flirt4free; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.io.HttpException; +import ctbrec.sites.flirt4free.Flirt4Free; +import ctbrec.sites.flirt4free.Flirt4FreeModel; +import ctbrec.ui.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; +import okhttp3.Request; +import org.json.JSONObject; +import org.jsoup.nodes.Entities; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +import static ctbrec.io.HttpClient.gunzipBody; +import static ctbrec.io.HttpConstants.*; + +public class Flirt4FreeUpdateService extends PaginatedScheduledService { + + private static final Logger LOG = LoggerFactory.getLogger(Flirt4FreeUpdateService.class); + private static final int MODELS_PER_PAGE = 50; + private final String url; + private final Flirt4Free flirt4Free; + private final Predicate filter; + + public Flirt4FreeUpdateService(Flirt4Free flirt4Free, String url, Predicate filter) { + this.flirt4Free = flirt4Free; + this.url = url; + this.filter = filter; + } + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() throws IOException { + LOG.debug("Fetching page {}", url); + var request = new Request.Builder() + .url(url) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT_ENCODING, ACCEPT_ENCODING_GZIP) + .build(); + try (var response = flirt4Free.getHttpClient().execute(request)) { + if (response.isSuccessful()) { + var body = gunzipBody(response); + return parseResponse(body); + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + }; + } + + private List parseResponse(String body) throws IOException { + List models = new ArrayList<>(); + var m = Pattern.compile("window\\.__homePageData__ = (\\{.*})", Pattern.DOTALL).matcher(body); + if (m.find()) { + var data = new JSONObject(m.group(1)); + var modelData = data.getJSONArray("models"); + LOG.debug("Found {} models", modelData.length()); + for (var i = 0; i < modelData.length(); i++) { + var modelJson = modelData.getJSONObject(i); + try { + Flirt4FreeModel model = parseModel(modelJson); + models.add(model); + } catch (Exception e) { + LOG.warn("Couldn't parse model {}", modelJson); + } + } + return models.stream() + .filter(filter) + .skip((page - 1) * (long) MODELS_PER_PAGE) + .limit(MODELS_PER_PAGE) + .map(Model.class::cast) + .toList(); + } else { + throw new IOException("Pattern didn't match model JSON data"); + } + } + + private Flirt4FreeModel parseModel(JSONObject modelData) { + var name = modelData.getString("model_seo_name"); + Flirt4FreeModel model = (Flirt4FreeModel) flirt4Free.createModel(name); + model.setDisplayName(Entities.unescape(modelData.getString("display"))); + model.setDescription(modelData.getString("topic")); + model.setUrl(Flirt4Free.BASE_URI + "/rooms/" + model.getName() + '/'); + model.setNew(modelData.optString("is_new", "0").equals("1")); + var videoHost = modelData.getString("video_host"); + var modelId = modelData.getString("model_id"); + model.setId(modelId); + String streamUrl = "https://manifest.vscdns.com/manifest.m3u8.m3u8?key=nil&provider=level3&secure=true&host=" + videoHost + "&model_id=" + modelId; + model.setStreamUrl(streamUrl); + model.setPreview("https://live-screencaps.vscdns.com/" + modelId + "-desktop.jpg"); + model.setOnlineState(ctbrec.Model.State.ONLINE); + if (modelData.has("category_id")) { + model.getCategories().add(modelData.getString("category_id")); + } + if (modelData.has("category_id_2")) { + model.getCategories().add(modelData.getString("category_id_2")); + } + if (modelData.has("category_id_3")) { + model.getCategories().add(modelData.getString("category_id_3")); + } + if (modelData.has("video_width") && modelData.has("video_aspect_ratio")) { + String[] aspectRatioParts = modelData.getString("video_aspect_ratio").split(":"); + double aspectWidth = Integer.parseInt(aspectRatioParts[0]); + double aspectHeight = Integer.parseInt(aspectRatioParts[1]); + int width = Integer.parseInt(modelData.getString("video_width")); + int height = (int) Math.ceil(width * aspectHeight / aspectWidth); + model.setStreamResolution(new int[]{width, height}); + } + return model; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminConfigUi.java b/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminConfigUi.java new file mode 100644 index 00000000..a9806412 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminConfigUi.java @@ -0,0 +1,107 @@ +package ctbrec.ui.sites.jasmin; + +import ctbrec.Config; +import ctbrec.sites.jasmin.LiveJasmin; +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 LiveJasminConfigUi extends AbstractConfigUI { + private LiveJasmin liveJasmin; + + public LiveJasminConfigUi(LiveJasmin liveJasmin) { + this.liveJasmin = liveJasmin; + } + + @Override + public Parent createConfigPanel() { + var settings = Config.getInstance().getSettings(); + var layout = SettingsTab.createGridLayout(); + + var row = 0; + var l = new Label("Active"); + layout.add(l, 0, row); + var enabled = new CheckBox(); + enabled.setSelected(!settings.disabledSites.contains(liveJasmin.getName())); + enabled.setOnAction(e -> { + if(enabled.isSelected()) { + settings.disabledSites.remove(liveJasmin.getName()); + } else { + settings.disabledSites.add(liveJasmin.getName()); + } + save(); + }); + GridPane.setMargin(enabled, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + layout.add(enabled, 1, row++); + + layout.add(new Label("LiveJasmin User"), 0, row); + var username = new TextField(Config.getInstance().getSettings().livejasminUsername); + username.textProperty().addListener((ob, o, n) -> { + if(!n.equals(Config.getInstance().getSettings().livejasminUsername)) { + Config.getInstance().getSettings().livejasminUsername = n; + liveJasmin.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("LiveJasmin Password"), 0, row); + var password = new PasswordField(); + password.setText(Config.getInstance().getSettings().livejasminPassword); + password.textProperty().addListener((ob, o, n) -> { + if(!n.equals(Config.getInstance().getSettings().livejasminPassword)) { + Config.getInstance().getSettings().livejasminPassword = n; + liveJasmin.getHttpClient().logout(); + save(); + } + }); + GridPane.setFillWidth(password, true); + GridPane.setHgrow(password, Priority.ALWAYS); + GridPane.setColumnSpan(password, 2); + layout.add(password, 1, row++); + + layout.add(new Label("LiveJasmin Base URL"), 0, row); + var baseUrl = new TextField(); + baseUrl.setText(Config.getInstance().getSettings().livejasminBaseUrl); + baseUrl.textProperty().addListener((ob, o, n) -> { + Config.getInstance().getSettings().livejasminBaseUrl = baseUrl.getText(); + save(); + }); + GridPane.setFillWidth(baseUrl, true); + GridPane.setHgrow(baseUrl, Priority.ALWAYS); + GridPane.setColumnSpan(baseUrl, 2); + layout.add(baseUrl, 1, row++); + + var createAccount = new Button("Create new Account"); + createAccount.setOnAction(e -> DesktopIntegration.open(liveJasmin.getAffiliateLink())); + layout.add(createAccount, 1, row++); + GridPane.setColumnSpan(createAccount, 2); + + var deleteCookies = new Button("Delete Cookies"); + deleteCookies.setOnAction(e -> liveJasmin.getHttpClient().clearCookies()); + layout.add(deleteCookies, 1, row); + GridPane.setColumnSpan(deleteCookies, 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)); + GridPane.setMargin(deleteCookies, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + + username.setPrefWidth(300); + + return layout; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminElectronLoginDialog.java b/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminElectronLoginDialog.java new file mode 100644 index 00000000..d7e0cf89 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminElectronLoginDialog.java @@ -0,0 +1,98 @@ +package ctbrec.ui.sites.jasmin; + +import java.io.IOException; +import java.util.Collections; +import java.util.function.Consumer; + +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.sites.jasmin.LiveJasmin; +import ctbrec.ui.ExternalBrowser; +import okhttp3.Cookie; +import okhttp3.Cookie.Builder; +import okhttp3.CookieJar; +import okhttp3.HttpUrl; + +public class LiveJasminElectronLoginDialog { + + private static final Logger LOG = LoggerFactory.getLogger(LiveJasminElectronLoginDialog.class); + public static final String URL = LiveJasmin.baseUrl + "/en/auth/login"; + private CookieJar cookieJar; + private ExternalBrowser browser; + + public LiveJasminElectronLoginDialog(CookieJar cookieJar) throws IOException { + this.cookieJar = cookieJar; + browser = ExternalBrowser.getInstance(); + try { + var config = new JSONObject(); + config.put("url", URL); + config.put("w", 640); + config.put("h", 720); + var msg = new JSONObject(); + msg.put("config", config); + browser.run(msg, msgHandler); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Couldn't wait for login dialog", e); + } catch (IOException e) { + LOG.debug("Error while starting the browser or communication to it", e); + } finally { + browser.close(); + } + } + + private Consumer msgHandler = line -> { + if(!line.startsWith("{")) { + System.err.println(line); // NOSONAR + } else { + var json = new JSONObject(line); + if(json.has("url")) { + var url = json.getString("url"); + if(url.endsWith("/auth/login")) { + try { + String username = Config.getInstance().getSettings().livejasminUsername; + if (username != null && !username.trim().isEmpty()) { + browser.executeJavaScript("document.querySelector('#login_form input[name=\"username\"]').value = '" + username + "';"); + } + String password = Config.getInstance().getSettings().livejasminPassword; + if (password != null && !password.trim().isEmpty()) { + password = password.replace("'", "\\'"); + browser.executeJavaScript("document.querySelector('#login_form input[name=\"password\"]').value = '" + password + "';"); + } + browser.executeJavaScript("document.getElementById('header_container').setAttribute('style', 'display:none');"); + browser.executeJavaScript("document.getElementById('footer').setAttribute('style', 'display:none');"); + browser.executeJavaScript("document.getElementById('react-container').setAttribute('style', 'display:none');"); + browser.executeJavaScript("document.getElementById('inner_container').setAttribute('style', 'padding: 0; margin: 1em');"); + browser.executeJavaScript("document.querySelector('div[class~=\"content_box\"]').setAttribute('style', 'margin: 1em');"); + } catch(Exception e) { + LOG.warn("Couldn't auto fill username and password", e); + } + } + if(json.has("cookies")) { + var cookiesFromBrowser = json.getJSONArray("cookies"); + for (var i = 0; i < cookiesFromBrowser.length(); i++) { + var cookie = cookiesFromBrowser.getJSONObject(i); + Builder b = new Cookie.Builder() + .path("/") + .domain(LiveJasmin.baseDomain) + .name(cookie.getString("name")) + .value(cookie.getString("value")) + .expiresAt(0); + Cookie c = b.build(); + cookieJar.saveFromResponse(HttpUrl.parse(LiveJasmin.baseUrl), Collections.singletonList(c)); + } + } + if(url.contains("/member/")) { + try { + browser.close(); + } catch(IOException e) { + LOG.error("Couldn't send close request to browser", e); + } + } + } + } + }; +} diff --git a/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminFollowedTab.java b/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminFollowedTab.java new file mode 100644 index 00000000..330b8cc9 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminFollowedTab.java @@ -0,0 +1,51 @@ +package ctbrec.ui.sites.jasmin; + +import ctbrec.sites.jasmin.LiveJasmin; +import ctbrec.ui.tabs.FollowedTab; +import javafx.geometry.Insets; +import javafx.scene.Scene; +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 LiveJasminFollowedTab extends LiveJasminTab implements FollowedTab { + + public LiveJasminFollowedTab(LiveJasmin liveJasmin) { + super("Followed", new LiveJasminFollowedUpdateService(liveJasmin), liveJasmin); + } + + @Override + public void setScene(Scene scene) { + scene.addEventFilter(KeyEvent.KEY_PRESSED, event -> { + if (this.isSelected() && event.getCode() == KeyCode.DELETE) { + follow(selectedThumbCells, false); + } + }); + } + + @Override + protected void createGui() { + super.createGui(); + addOnlineOfflineSelector(); + } + + private void addOnlineOfflineSelector() { + var group = new ToggleGroup(); + var online = new RadioButton("online"); + online.setToggleGroup(group); + var 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 -> { + ((LiveJasminFollowedUpdateService)updateService).setShowOnline(online.isSelected()); + queue.clear(); + updateService.restart(); + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminFollowedUpdateService.java b/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminFollowedUpdateService.java new file mode 100644 index 00000000..9d32d853 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminFollowedUpdateService.java @@ -0,0 +1,140 @@ +package ctbrec.ui.sites.jasmin; + +import ctbrec.Config; +import ctbrec.GlobalThreadPool; +import ctbrec.Model; +import ctbrec.io.HttpException; +import ctbrec.sites.jasmin.LiveJasmin; +import ctbrec.sites.jasmin.LiveJasminModel; +import ctbrec.ui.SiteUiFactory; +import ctbrec.ui.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; +import okhttp3.HttpUrl; +import okhttp3.Request; +import okhttp3.Response; +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.Future; + +import static ctbrec.io.HttpConstants.*; + +public class LiveJasminFollowedUpdateService extends PaginatedScheduledService { + + private static final Logger LOG = LoggerFactory.getLogger(LiveJasminFollowedUpdateService.class); + private final LiveJasmin liveJasmin; + private final String url; + private boolean showOnline = true; + + public LiveJasminFollowedUpdateService(LiveJasmin liveJasmin) { + this.liveJasmin = liveJasmin; + this.url = liveJasmin.getBaseUrl() + "/en/free/favourite/get-favourite-list"; + } + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() throws IOException { + if (!liveJasmin.credentialsAvailable()) { + throw new RuntimeException("Credentials missing"); + } + + boolean loggedIn = SiteUiFactory.getUi(liveJasmin).login(); + if (!loggedIn) { + throw new RuntimeException("Couldn't login to livejasmin"); + } + Request request = new Request.Builder() + .url(url) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, "*/*") + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(REFERER, liveJasmin.getBaseUrl() + "/en/free/favorite") + .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) + .build(); + try (Response response = liveJasmin.getHttpClient().execute(request)) { + if (response.isSuccessful()) { + String body = response.body().string(); + List models = new ArrayList<>(); + JSONObject json = new JSONObject(body); + if (json.has("success")) { + JSONObject data = json.getJSONObject("data"); + JSONArray performers = data.getJSONArray("performers"); + List> loadDetailsFutures = new LinkedList<>(); + for (int i = 0; i < performers.length(); i++) { + JSONObject m = performers.getJSONObject(i); + String name = m.optString("pid"); + if (name.isEmpty()) { + continue; + } + + LiveJasminModel model = (LiveJasminModel) liveJasmin.createModel(name); + model.setId(String.valueOf(m.get("id"))); + model.setDisplayName(m.getString("display_name")); + Model.State onlineState = LiveJasminModel.mapStatus(m.getInt("status")); + boolean online = onlineState == Model.State.ONLINE; + model.setOnlineState(onlineState); + if (online == showOnline) { + models.add(model); + } + loadDetailsFutures.add(GlobalThreadPool.submit(() -> loadModelDetails(model))); + } + for (Future future : loadDetailsFutures) { + try { + future.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + // details couldn't be loaded, but that doesn't matter + } + } + LOG.debug("done"); + } else { + LOG.error("Request failed:\n{}", body); + throw new IOException("Response was not successful"); + } + return models; + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + private void loadModelDetails(LiveJasminModel model) { + try { + String sessionId = liveJasmin.getHttpClient().getCookieJar().getCookie(HttpUrl.parse(liveJasmin.getBaseUrl()), "session").value(); + String detailsUrl = liveJasmin.getBaseUrl() + "/en/member/flash/get-performer-details/" + model.getName() + "?appletType=html5&noFlash=0&session=" + sessionId; + Request request = new Request.Builder() + .url(detailsUrl) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, "*/*") + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(REFERER, liveJasmin.getBaseUrl() + "/en/member/chat-html5/" + model.getName()) + .build(); + try (Response response = liveJasmin.getHttpClient().execute(request)) { + if (response.isSuccessful()) { + JSONObject json = new JSONObject(response.body().string()); + if (json.optBoolean("success")) { + JSONObject data = json.getJSONObject("data"); + model.setPreview(data.getString("profile_picture_url")); + } + } + } + } catch (IOException e) { + // details couldn't be loaded, but that doesn't matter + } + } + }; + } + + public void setShowOnline(boolean showOnline) { + this.showOnline = showOnline; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminSiteUi.java b/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminSiteUi.java new file mode 100644 index 00000000..51b3d5bd --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminSiteUi.java @@ -0,0 +1,78 @@ +package ctbrec.ui.sites.jasmin; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.sites.jasmin.LiveJasmin; +import ctbrec.sites.jasmin.LiveJasminHttpClient; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.sites.AbstractSiteUi; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; + +public class LiveJasminSiteUi extends AbstractSiteUi { + + private static final Logger LOG = LoggerFactory.getLogger(LiveJasminSiteUi.class); + private final LiveJasmin liveJasmin; + private LiveJasminTabProvider tabProvider; + private LiveJasminConfigUi configUi; + private long lastLoginTime = 0; + + public LiveJasminSiteUi(LiveJasmin liveJasmin) { + this.liveJasmin = liveJasmin; + } + + @Override + public TabProvider getTabProvider() { + if (tabProvider == null) { + tabProvider = new LiveJasminTabProvider(liveJasmin); + } + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + if (configUi == null) { + configUi = new LiveJasminConfigUi(liveJasmin); + } + return configUi; + } + + @Override + public synchronized boolean login() throws IOException { + // renew login every 30 min + long now = System.currentTimeMillis(); + var renew = false; + if ((now - lastLoginTime) > TimeUnit.MINUTES.toMillis(30)) { + renew = true; + } + + boolean automaticLogin = liveJasmin.login(renew); + if (automaticLogin) { + lastLoginTime = System.currentTimeMillis(); + return true; + } else { + lastLoginTime = System.currentTimeMillis(); + + // login with external browser window + try { + new LiveJasminElectronLoginDialog(liveJasmin.getHttpClient().getCookieJar()); + } catch (Exception e1) { + LOG.error("Error logging in with external browser", e1); + Dialogs.showError("Login error", "Couldn't login to " + liveJasmin.getName(), e1); + } + + LiveJasminHttpClient httpClient = (LiveJasminHttpClient) liveJasmin.getHttpClient(); + boolean loggedIn = httpClient.checkLoginSuccess(); + if (loggedIn) { + LOG.info("Logged in"); + } else { + LOG.info("Login failed"); + } + return loggedIn; + } + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminTab.java b/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminTab.java new file mode 100644 index 00000000..d289a47d --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminTab.java @@ -0,0 +1,80 @@ +package ctbrec.ui.sites.jasmin; + +import java.io.IOException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.sites.Site; +import ctbrec.ui.tabs.PaginatedScheduledService; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.concurrent.WorkerStateEvent; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; + +public class LiveJasminTab extends ThumbOverviewTab { + private static final Logger LOG = LoggerFactory.getLogger(LiveJasminTab.class); + protected Label status; + protected Button acknowledge = new Button("That's alright"); + private boolean betaAcknowledged = Config.getInstance().getSettings().livejasminBetaAcknowledged; + + public LiveJasminTab(String title, PaginatedScheduledService updateService, Site site) { + super(title, updateService, site); + if(!betaAcknowledged) { + status = new Label("LiveJasmin is not fully functional. Live previews do not work.\n" + + "If you get errors while loading the tabs, try to create an account and open the Followed tab first."); + grid.getChildren().add(status); + grid.getChildren().add(acknowledge); + } else { + status = new Label("Loading..."); + grid.getChildren().add(status); + } + + acknowledge.setOnAction(e -> { + betaAcknowledged = true; + Config.getInstance().getSettings().livejasminBetaAcknowledged = true; + try { + Config.getInstance().save(); + } catch (IOException e1) { + LOG.error("Couldn't save config", e1); + } + status.setText("Loading..."); + grid.getChildren().remove(acknowledge); + if(updateService != null) { + updateService.cancel(); + updateService.reset(); + updateService.restart(); + } + }); + } + + @Override + protected void onSuccess() { + if(Config.getInstance().getSettings().livejasminBetaAcknowledged) { + grid.getChildren().remove(status); + grid.getChildren().remove(acknowledge); + super.onSuccess(); + } + } + + @Override + protected void onFail(WorkerStateEvent event) { + if(Config.getInstance().getSettings().livejasminBetaAcknowledged) { + status.setText("Error"); + grid.getChildren().remove(acknowledge); + super.onFail(event); + } + } + + public void setScene(Scene scene) { + scene.addEventFilter(KeyEvent.KEY_PRESSED, event -> { + if (this.isSelected() && event.getCode() == KeyCode.DELETE) { + follow(selectedThumbCells, false); + } + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminTabProvider.java b/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminTabProvider.java new file mode 100644 index 00000000..cd1a5e9b --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminTabProvider.java @@ -0,0 +1,51 @@ +package ctbrec.ui.sites.jasmin; + +import ctbrec.sites.jasmin.LiveJasmin; +import ctbrec.ui.sites.AbstractTabProvider; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.scene.Scene; +import javafx.scene.control.Tab; +import javafx.util.Duration; + +import java.util.ArrayList; +import java.util.List; + +public class LiveJasminTabProvider extends AbstractTabProvider { + + + private final LiveJasminFollowedTab followedTab; + + public LiveJasminTabProvider(LiveJasmin site) { + super(site); + followedTab = new LiveJasminFollowedTab(site); + followedTab.setRecorder(recorder); + followedTab.setImageAspectRatio(9.0 / 16.0); + } + + @Override + protected List getSiteTabs(Scene scene) { + List tabs = new ArrayList<>(); + tabs.add(createTab("Girls", site.getBaseUrl() + "/en/girls/?listPageOrderType=most_popular")); + tabs.add(createTab("New Girls", site.getBaseUrl() + "/en/girls/new-models/?listPageOrderType=most_popular")); + tabs.add(createTab("Boys", site.getBaseUrl() + "/en/boys/?listPageOrderType=most_popular")); + tabs.add(createTab("New Boys", site.getBaseUrl() + "/en/boys/new-models/?listPageOrderType=most_popular")); + tabs.add(createTab("Couples", site.getBaseUrl() + "/en/girls/couple/?listPageOrderType=most_popular")); + tabs.add(createTab("Trans", site.getBaseUrl() + "/en/boys/transboy/?listPageOrderType=most_popular")); + tabs.add(followedTab); + return tabs; + } + + @Override + public Tab getFollowedTab() { + return followedTab; + } + + private ThumbOverviewTab createTab(String title, String url) { + var s = new LiveJasminUpdateService((LiveJasmin) site, url); + s.setPeriod(Duration.seconds(60)); + ThumbOverviewTab tab = new ThumbOverviewTab(title, s, site); + tab.setRecorder(recorder); + tab.setImageAspectRatio(9.0 / 16.0); + return tab; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminUpdateService.java b/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminUpdateService.java new file mode 100644 index 00000000..b3c7ca42 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/jasmin/LiveJasminUpdateService.java @@ -0,0 +1,189 @@ +package ctbrec.ui.sites.jasmin; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.io.HttpException; +import ctbrec.sites.jasmin.LiveJasmin; +import ctbrec.sites.jasmin.LiveJasminModel; +import ctbrec.ui.SiteUiFactory; +import ctbrec.ui.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; +import okhttp3.Cookie; +import okhttp3.HttpUrl; +import okhttp3.Request; +import okhttp3.Response; +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.text.MessageFormat; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +import static ctbrec.io.HttpConstants.*; + +public class LiveJasminUpdateService extends PaginatedScheduledService { + + private static final Logger LOG = LoggerFactory.getLogger(LiveJasminUpdateService.class); + + private final String url; + private final LiveJasmin liveJasmin; + private final int modelsPerPage = 60; + + private String listPageId = ""; + private List modelsList; + private int lastPageLoaded; + + private transient Instant lastListInfoRequest = Instant.EPOCH; + + public LiveJasminUpdateService(LiveJasmin liveJasmin, String url) { + this.liveJasmin = liveJasmin; + this.url = url; + this.lastPageLoaded = 0; + } + + @Override + protected Task> createTask() { + return new Task>() { + @Override + public List call() throws IOException { + return getModelList().stream() + .skip((page - 1) * (long) modelsPerPage) + .limit(modelsPerPage) + .collect(Collectors.toList()); // NOSONAR + } + }; + } + + private List getModelList() throws IOException { + page = Math.min(page, 99); + if ((lastPageLoaded > 0) && Duration.between(lastListInfoRequest, Instant.now()).getSeconds() < 60) { + while (page > lastPageLoaded) { + lastPageLoaded++; + modelsList.addAll(loadMore()); + } + return modelsList; + } + lastPageLoaded = 1; + modelsList = loadModelList(); + while (page > lastPageLoaded) { + lastPageLoaded++; + modelsList.addAll(loadMore()); + } + if (modelsList == null) { + return Collections.emptyList(); + } + return modelsList; + } + + private List loadModelList() throws IOException { + lastListInfoRequest = Instant.now(); + var cookieJar = liveJasmin.getHttpClient().getCookieJar(); + + var sortCookie = new Cookie.Builder().domain(LiveJasmin.baseDomain).name("listPageOrderType").value("most_popular").build(); + cookieJar.saveFromResponse(HttpUrl.parse(liveJasmin.getBaseUrl()), Collections.singletonList(sortCookie)); + + String category = (url.contains("boys")) ? "boys" : "girls"; + var categoryCookie = new Cookie.Builder().domain(LiveJasmin.baseDomain).name("category").value(category).build(); + cookieJar.saveFromResponse(HttpUrl.parse(liveJasmin.getBaseUrl()), Collections.singletonList(categoryCookie)); + + LOG.debug("Fetching page {}", url); + Request req = new Request.Builder() + .url(url) + .addHeader(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .addHeader(ACCEPT, MIMETYPE_APPLICATION_JSON) + .addHeader(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .addHeader(REFERER, liveJasmin.getBaseUrl()) + .addHeader(X_REQUESTED_WITH, XML_HTTP_REQUEST) + .build(); + try (Response response = liveJasmin.getHttpClient().execute(req)) { + LOG.debug("Response {} {}", response.code(), response.message()); + if (response.isSuccessful()) { + String body = response.body().string(); + List models = new ArrayList<>(); + JSONObject json = new JSONObject(body); + if (json.optBoolean("success")) { + parseModels(models, json); + } else if (json.optString("error").equals("Please login.")) { + var siteUI = SiteUiFactory.getUi(liveJasmin); + if (siteUI.login()) { + return loadModelList(); + } else { + LOG.error("Request failed:\n{}", body); + throw new IOException("Response was not successful"); + } + } else { + LOG.error("Request failed:\n{}", body); + throw new IOException("Response was not successful"); + } + return models; + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + private List loadMore() throws IOException { + lastListInfoRequest = Instant.now(); + String moreURL = liveJasmin.getBaseUrl() + MessageFormat.format("/en/list-page-ajax/show-more-json/{0}?wide=true&layout=layout-big&_dc={1}", listPageId, String.valueOf(System.currentTimeMillis())); + LOG.debug("Fetching page {}", moreURL); + Request req = new Request.Builder() + .url(moreURL) + .addHeader(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .addHeader(ACCEPT, MIMETYPE_APPLICATION_JSON) + .addHeader(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .addHeader(REFERER, liveJasmin.getBaseUrl()) + .addHeader(X_REQUESTED_WITH, XML_HTTP_REQUEST) + .build(); + try (Response response = liveJasmin.getHttpClient().execute(req)) { + if (response.isSuccessful()) { + String body = response.body().string(); + List models = new ArrayList<>(); + JSONObject json = new JSONObject(body); + if (json.optBoolean("success")) { + parseModels(models, json); + } + return models; + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + private void parseModels(List models, JSONObject json) { + if (json.has("data")) { + JSONObject data = json.getJSONObject("data"); + if (data.optInt("isLast") > 0) { + lastPageLoaded = 999; + } + if (data.has("content")) { + JSONObject content = data.getJSONObject("content"); + if (content.optInt("isLastPage") > 0) { + lastPageLoaded = 999; + } + listPageId = content.optString("listPageId"); + JSONArray performers = content.getJSONArray("performers"); + for (var i = 0; i < performers.length(); i++) { + var m = performers.getJSONObject(i); + var name = m.optString("pid"); + if (name.isEmpty()) { + continue; + } + LiveJasminModel model = (LiveJasminModel) liveJasmin.createModel(name); + model.setId(m.getString("id")); + model.setPreview(m.optString("profilePictureUrl")); + model.setOnlineState(LiveJasminModel.mapStatus(m.optInt("status"))); + model.setDisplayName(m.optString("display_name", null)); + models.add(model); + } + } // if content + } // if data + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/manyvids/MVLiveConfigUi.java b/client/src/main/java/ctbrec/ui/sites/manyvids/MVLiveConfigUi.java new file mode 100644 index 00000000..fbbda27a --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/manyvids/MVLiveConfigUi.java @@ -0,0 +1,50 @@ +package ctbrec.ui.sites.manyvids; + +import ctbrec.Config; +import ctbrec.sites.manyvids.MVLive; +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.layout.GridPane; + +public class MVLiveConfigUi extends AbstractConfigUI { + private MVLive site; + + public MVLiveConfigUi(MVLive site) { + this.site = site; + } + + @Override + public Parent createConfigPanel() { + var settings = Config.getInstance().getSettings(); + GridPane layout = SettingsTab.createGridLayout(); + + var row = 0; + var l = new Label("Active"); + layout.add(l, 0, row); + var 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()); + } + save(); + }); + layout.add(enabled, 1, row++); + + var deleteCookies = new Button("Delete Cookies"); + deleteCookies.setOnAction(e -> site.getHttpClient().clearCookies()); + layout.add(deleteCookies, 1, row); + GridPane.setColumnSpan(deleteCookies, 2); + + GridPane.setMargin(enabled, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + GridPane.setMargin(deleteCookies, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + return layout; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/manyvids/MVLiveSiteUi.java b/client/src/main/java/ctbrec/ui/sites/manyvids/MVLiveSiteUi.java new file mode 100644 index 00000000..eae2d72d --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/manyvids/MVLiveSiteUi.java @@ -0,0 +1,41 @@ +package ctbrec.ui.sites.manyvids; + +import java.io.IOException; + +import ctbrec.sites.manyvids.MVLive; +import ctbrec.ui.sites.AbstractSiteUi; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; + +public class MVLiveSiteUi extends AbstractSiteUi { + + private final MVLive mvlive; + private MVLiveTabProvider tabProvider; + private MVLiveConfigUi configUi; + + public MVLiveSiteUi(MVLive mvlive) { + this.mvlive = mvlive; + } + + @Override + public TabProvider getTabProvider() { + if (tabProvider == null) { + tabProvider = new MVLiveTabProvider(mvlive); + } + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + if (configUi == null) { + configUi = new MVLiveConfigUi(mvlive); + } + return configUi; + } + + @Override + public boolean login() throws IOException { + return false; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/manyvids/MVLiveTabProvider.java b/client/src/main/java/ctbrec/ui/sites/manyvids/MVLiveTabProvider.java new file mode 100644 index 00000000..60dbdfa2 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/manyvids/MVLiveTabProvider.java @@ -0,0 +1,32 @@ +package ctbrec.ui.sites.manyvids; + +import ctbrec.sites.manyvids.MVLive; +import ctbrec.ui.sites.AbstractTabProvider; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +import java.util.ArrayList; +import java.util.List; + +public class MVLiveTabProvider extends AbstractTabProvider { + + public MVLiveTabProvider(MVLive mvlive) { + super(mvlive); + } + + @Override + protected List getSiteTabs(Scene scene) { + List tabs = new ArrayList<>(); + tabs.add(createTab("Online")); + return tabs; + } + + private Tab createTab(String title) { + var updateService = new MVLiveUpdateService((MVLive) site, "https://api.vidchat.manyvids.com/creator/live"); + var tab = new ThumbOverviewTab(title, updateService, site); + tab.setRecorder(site.getRecorder()); + tab.setImageAspectRatio(1); + return tab; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/manyvids/MVLiveUpdateService.java b/client/src/main/java/ctbrec/ui/sites/manyvids/MVLiveUpdateService.java new file mode 100644 index 00000000..2881c0c6 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/manyvids/MVLiveUpdateService.java @@ -0,0 +1,103 @@ +package ctbrec.ui.sites.manyvids; + +import ctbrec.Model; +import ctbrec.io.HttpException; +import ctbrec.sites.manyvids.MVLive; +import ctbrec.sites.manyvids.MVLiveModel; +import ctbrec.ui.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; +import okhttp3.Request; +import okhttp3.Response; +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +import static ctbrec.io.HttpConstants.*; + +public class MVLiveUpdateService extends PaginatedScheduledService { + + private final MVLive mvlive; + private final String url; + private final int modelsPerPage = 48; + private static List modelsList; + private static Instant lastListInfoRequest = Instant.EPOCH; + + private static final Logger LOG = LoggerFactory.getLogger(MVLiveUpdateService.class); + + public MVLiveUpdateService(MVLive site, String url) { + this.mvlive = site; + this.url = url; + } + + @Override + protected Task> createTask() { + return new Task>() { + @Override + public List call() throws IOException { + return getModelList().stream() + .skip((page - 1) * (long) modelsPerPage) + .limit(modelsPerPage) + .collect(Collectors.toList()); // NOSONAR + } + }; + } + + private List getModelList() throws IOException { + if (Duration.between(lastListInfoRequest, Instant.now()).getSeconds() < 30) { + return modelsList; + } + lastListInfoRequest = Instant.now(); + modelsList = loadModels(url); + if (modelsList == null) { + return Collections.emptyList(); + } + return modelsList; + } + + protected List loadModels(String url) throws IOException { + List models = new ArrayList<>(); + LOG.debug("Loading live models from {}", url); + Request req = new Request.Builder() + .url(url) + .header(ACCEPT, "*/*") + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(ORIGIN, MVLive.BASE_URL) + .header(REFERER, MVLive.BASE_URL) + .header(AUTHORIZATION, "GUEST") + .build(); + try (Response response = mvlive.getHttpClient().execute(req)) { + String body = response.body().string(); + LOG.trace("response body: {}", body); + if (response.isSuccessful()) { + JSONObject json = new JSONObject(body); + if (!json.has("live_creators")) { + LOG.debug("Unexpected response:\n{}", json.toString(2)); + return Collections.emptyList(); + } + JSONArray creators = json.getJSONArray("live_creators"); + for (int i = 0; i < creators.length(); i++) { + JSONObject creator = creators.getJSONObject(i); + MVLiveModel model = mvlive.createModel(creator.getString("url_handle")); + model.updateStateFromJson(creator); + models.add(model); + } + if (!json.optString("next_token").isBlank()) { + models.addAll(loadModels(url + "?next_token=" + json.optString("next_token"))); + } + } else { + throw new HttpException(response.code(), body); + } + } + return models; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/myfreecams/FriendsUpdateService.java b/client/src/main/java/ctbrec/ui/sites/myfreecams/FriendsUpdateService.java new file mode 100644 index 00000000..c4914f5f --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/myfreecams/FriendsUpdateService.java @@ -0,0 +1,112 @@ +package ctbrec.ui.sites.myfreecams; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.StringUtil; +import ctbrec.sites.mfc.*; +import ctbrec.ui.SiteUiFactory; +import ctbrec.ui.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; +import okhttp3.Request; +import okhttp3.Response; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static ctbrec.io.HttpConstants.REFERER; +import static ctbrec.io.HttpConstants.USER_AGENT; + +public class FriendsUpdateService extends PaginatedScheduledService { + + private static final Logger LOG = LoggerFactory.getLogger(FriendsUpdateService.class); + private final MyFreeCams myFreeCams; + private Mode mode = Mode.ONLINE; + + public enum Mode { + ONLINE, OFFLINE + } + + public FriendsUpdateService(MyFreeCams myFreeCams) { + this.myFreeCams = myFreeCams; + } + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() throws IOException { + if (StringUtil.isBlank(ctbrec.Config.getInstance().getSettings().mfcUsername)) { + return Collections.emptyList(); + } else { + List models = new ArrayList<>(); + SiteUiFactory.getUi(myFreeCams).login(); + String url = myFreeCams.getBaseUrl() + "/php/manage_lists2.php?passcode=&list_type=friends&data_mode=online&get_user_list=1"; + Request req = new Request.Builder().url(url) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(REFERER, myFreeCams.getBaseUrl()) + .build(); + try (Response resp = myFreeCams.getHttpClient().execute(req)) { + if (resp.isSuccessful()) { + var body = resp.body().string().substring(4); + parseModels(body, models); + } else { + LOG.error("Couldn't load friends list {} {}", resp.code(), resp.message()); + } + } + boolean filterOnline = mode == Mode.ONLINE; + return models.stream() + .filter(m -> { + try { + return m.isOnline() == filterOnline; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } catch (Exception e) { + return false; + } + }) + .sorted((m1, m2) -> (int) (m2.getCamScore() - m1.getCamScore())) + .skip((page - 1) * 50L) + .limit(50) + .map(Model.class::cast) + .toList(); + } + } + + }; + } + + private void parseModels(String body, List models) { + try { + var json = new JSONObject(body); + for (String key : json.keySet()) { + var uid = Integer.parseInt(key); + MyFreeCamsModel model = MyFreeCamsClient.getInstance().getModel(uid); + if (model == null) { + var modelObject = json.getJSONObject(key); + var name = modelObject.getString("u"); + model = myFreeCams.createModel(name); + var st = new SessionState(); + st.setM(new ctbrec.sites.mfc.Model()); + st.getM().setCamscore(0.0); + st.setU(new User()); + st.setUid(uid); + st.setLv(modelObject.getInt("lv")); + st.setVs(127); + } + models.add(model); + } + } catch (Exception e) { + LOG.info("Exception getting friends list. Response was: {}", body, e); + } + } + + public void setMode(Mode mode) { + this.mode = mode; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/myfreecams/HDCamsUpdateService.java b/client/src/main/java/ctbrec/ui/sites/myfreecams/HDCamsUpdateService.java new file mode 100644 index 00000000..5a1ffce7 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/myfreecams/HDCamsUpdateService.java @@ -0,0 +1,45 @@ +package ctbrec.ui.sites.myfreecams; + + +import ctbrec.Model; +import ctbrec.sites.mfc.SessionState; +import ctbrec.sites.mfc.User; +import javafx.concurrent.Task; + +import java.util.List; +import java.util.Optional; + +public class HDCamsUpdateService extends MyFreeCamsAbstractUpdateService { + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() { + return HDCamsUpdateService.super.getModels().stream() + .filter(m -> m.getPreview() != null) + .filter(m -> m.getStreamUrl() != null) + .filter(m -> Optional.ofNullable(client.getSessionState(m)) + .map(SessionState::getU) + .map(User::getPhase) + .orElse("").equalsIgnoreCase("a")) + .filter(m -> { + try { + return m.isOnline(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } catch (Exception e) { + return false; + } + }) + .sorted((m1, m2) -> (int) (m2.getCamScore() - m1.getCamScore())) + .skip((page - 1L) * MODELS_PER_PAGE) + .limit(MODELS_PER_PAGE) + .map(Model.class::cast) + .toList(); + } + }; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsAbstractUpdateService.java b/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsAbstractUpdateService.java new file mode 100644 index 00000000..986d65c9 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsAbstractUpdateService.java @@ -0,0 +1,34 @@ +package ctbrec.ui.sites.myfreecams; + +import ctbrec.GlobalThreadPool; +import ctbrec.Model; +import ctbrec.sites.mfc.MyFreeCamsClient; +import ctbrec.sites.mfc.MyFreeCamsModel; +import ctbrec.ui.tabs.PaginatedScheduledService; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; + +@Slf4j +public abstract class MyFreeCamsAbstractUpdateService extends PaginatedScheduledService { + static final int MODELS_PER_PAGE = 50; + MyFreeCamsClient client = MyFreeCamsClient.getInstance(); + + protected List getModels() { + List models = client.getModels().stream().toList(); + + for (Model model : models) { + GlobalThreadPool.submit(() -> { + try { + client.getSessionState(model); + model.isOnline(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + log.debug("Couldn't update model online state", e); + } + }); + } + return models; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsConfigUI.java b/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsConfigUI.java new file mode 100644 index 00000000..fa345062 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsConfigUI.java @@ -0,0 +1,114 @@ +package ctbrec.ui.sites.myfreecams; + +import ctbrec.Config; +import ctbrec.sites.mfc.MyFreeCams; +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 MyFreeCamsConfigUI extends AbstractConfigUI { + private MyFreeCams myFreeCams; + + public MyFreeCamsConfigUI(MyFreeCams myFreeCams) { + this.myFreeCams = myFreeCams; + } + + @Override + public Parent createConfigPanel() { + var row = 0; + var layout = SettingsTab.createGridLayout(); + var settings = Config.getInstance().getSettings(); + + var 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)); + var l = new Label("Active"); + l.labelForProperty().set(enabled); + layout.add(l, 0, row); + layout.add(enabled, 1, row++); + + var username = new TextField(Config.getInstance().getSettings().mfcUsername); + username.setId("mfcUsername"); + 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); + l = new Label("MyFreeCams User"); + l.labelForProperty().set(username); + layout.add(l, 0, row); + layout.add(username, 1, row++); + + var password = new PasswordField(); + password.setText(Config.getInstance().getSettings().mfcPassword); + 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); + l = new Label("MyFreeCams Password"); + l.labelForProperty().set(password); + layout.add(l, 0, row); + layout.add(password, 1, row++); + + var 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); + l = new Label("MyFreeCams Base URL"); + l.labelForProperty().set(baseUrl); + layout.add(l, 0, row); + layout.add(baseUrl, 1, row++); + + var createAccount = new Button("Create new Account"); + createAccount.setOnAction(e -> DesktopIntegration.open(myFreeCams.getAffiliateLink())); + layout.add(createAccount, 1, row++); + GridPane.setColumnSpan(createAccount, 2); + + var deleteCookies = new Button("Delete Cookies"); + deleteCookies.setOnAction(e -> myFreeCams.getHttpClient().clearCookies()); + layout.add(deleteCookies, 1, row); + GridPane.setColumnSpan(deleteCookies, 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)); + GridPane.setMargin(deleteCookies, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + + return layout; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsFriendsTab.java b/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsFriendsTab.java new file mode 100644 index 00000000..a7e5a555 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsFriendsTab.java @@ -0,0 +1,55 @@ +package ctbrec.ui.sites.myfreecams; +import static ctbrec.ui.sites.myfreecams.FriendsUpdateService.Mode.*; + +import ctbrec.sites.mfc.MyFreeCams; +import ctbrec.ui.tabs.FollowedTab; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.geometry.Insets; +import javafx.scene.Scene; +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 MyFreeCamsFriendsTab extends ThumbOverviewTab implements FollowedTab { + public MyFreeCamsFriendsTab(MyFreeCams mfc) { + super("Friends", new FriendsUpdateService(mfc), mfc); + } + + @Override + protected void createGui() { + super.createGui(); + addOnlineOfflineSelector(); + } + + private void addOnlineOfflineSelector() { + var group = new ToggleGroup(); + var online = new RadioButton("online"); + online.setToggleGroup(group); + var 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 -> { + if(online.isSelected()) { + ((FriendsUpdateService)updateService).setMode(ONLINE); + } else { + ((FriendsUpdateService)updateService).setMode(OFFLINE); + } + queue.clear(); + updateService.restart(); + }); + } + + public void setScene(Scene scene) { + scene.addEventFilter(KeyEvent.KEY_PRESSED, event -> { + if (this.isSelected() && event.getCode() == KeyCode.DELETE) { + follow(selectedThumbCells, false); + } + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsSiteUi.java b/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsSiteUi.java new file mode 100644 index 00000000..4241f35b --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsSiteUi.java @@ -0,0 +1,41 @@ +package ctbrec.ui.sites.myfreecams; + +import java.io.IOException; + +import ctbrec.sites.mfc.MyFreeCams; +import ctbrec.ui.sites.AbstractSiteUi; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; + +public class MyFreeCamsSiteUi extends AbstractSiteUi { + + private MyFreeCamsTabProvider tabProvider; + private MyFreeCamsConfigUI configUi; + private MyFreeCams myFreeCams; + + public MyFreeCamsSiteUi(MyFreeCams myFreeCams) { + this.myFreeCams = myFreeCams; + } + + @Override + public TabProvider getTabProvider() { + if (tabProvider == null) { + tabProvider = new MyFreeCamsTabProvider(myFreeCams); + } + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + if (configUi == null) { + configUi = new MyFreeCamsConfigUI(myFreeCams); + } + return configUi; + } + + @Override + 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 new file mode 100644 index 00000000..46207539 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsTabProvider.java @@ -0,0 +1,60 @@ +package ctbrec.ui.sites.myfreecams; + +import ctbrec.sites.mfc.MyFreeCams; +import ctbrec.ui.sites.AbstractTabProvider; +import ctbrec.ui.tabs.PaginatedScheduledService; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +import java.util.ArrayList; +import java.util.List; + +public class MyFreeCamsTabProvider extends AbstractTabProvider { + private MyFreeCamsFriendsTab friends; + + + public MyFreeCamsTabProvider(MyFreeCams myFreeCams) { + super(myFreeCams); + } + + @Override + protected List getSiteTabs(Scene scene) { + List tabs = new ArrayList<>(); + + PaginatedScheduledService updateService = new OnlineCamsUpdateService(); + tabs.add(createTab("Online", updateService)); + + friends = new MyFreeCamsFriendsTab((MyFreeCams) site); + friends.setRecorder(recorder); + friends.setImageAspectRatio(9.0 / 16.0); + friends.preserveAspectRatioProperty().set(false); + tabs.add(friends); + + updateService = new HDCamsUpdateService(); + tabs.add(createTab("HD", updateService)); + + updateService = new PopularModelService(); + tabs.add(createTab("Most Popular", updateService)); + + updateService = new NewModelService(); + tabs.add(createTab("New", updateService)); + + tabs.add(new MyFreeCamsTableTab((MyFreeCams) site, recorder)); + + return tabs; + } + + private ThumbOverviewTab createTab(String title, PaginatedScheduledService updateService) { + var tab = new ThumbOverviewTab(title, updateService, site); + tab.setImageAspectRatio(9.0 / 16.0); + tab.preserveAspectRatioProperty().set(false); + tab.setRecorder(recorder); + return tab; + } + + @Override + public Tab getFollowedTab() { + return friends; + } +} 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..ec3e432a --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/myfreecams/MyFreeCamsTableTab.java @@ -0,0 +1,775 @@ +package ctbrec.ui.sites.myfreecams; + +import com.iheartradio.m3u8.ParseException; +import com.iheartradio.m3u8.PlaylistException; +import ctbrec.Config; +import ctbrec.GlobalThreadPool; +import ctbrec.Model; +import ctbrec.StringUtil; +import ctbrec.recorder.Recorder; +import ctbrec.recorder.download.StreamSource; +import ctbrec.sites.mfc.MyFreeCams; +import ctbrec.sites.mfc.MyFreeCamsModel; +import ctbrec.sites.mfc.SessionState; +import ctbrec.sites.mfc.User; +import ctbrec.ui.controls.CustomMouseBehaviorContextMenu; +import ctbrec.ui.controls.SearchBox; +import ctbrec.ui.menu.ModelMenuContributor; +import ctbrec.ui.tabs.TabSelectionListener; +import javafx.beans.property.*; +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.Cursor; +import javafx.scene.control.*; +import javafx.scene.control.TableColumn.SortType; +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.stage.FileChooser; +import javafx.util.Duration; +import lombok.extern.slf4j.Slf4j; +import org.json.JSONArray; +import org.json.JSONObject; + +import javax.xml.bind.JAXBException; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.net.URLDecoder; +import java.nio.file.Files; +import java.util.*; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.nio.file.StandardOpenOption.CREATE; +import static java.nio.file.StandardOpenOption.WRITE; + +@Slf4j +public class MyFreeCamsTableTab extends Tab implements TabSelectionListener { + private final ScrollPane scrollPane = new ScrollPane(); + private final TableView table = new TableView<>(); + private final ObservableList filteredModels = FXCollections.observableArrayList(); + private final ObservableList observableModels = FXCollections.observableArrayList(); + private final Recorder recorder; + private final MyFreeCams mfc; + private final ReentrantLock lock = new ReentrantLock(); + private final Label count = new Label("models"); + private final List> columns = new ArrayList<>(); + private TableUpdateService updateService; + private SearchBox filterInput; + private ContextMenu popup; + private long lastJsonWrite = 0; + + public MyFreeCamsTableTab(MyFreeCams mfc, Recorder recorder) { + this.mfc = mfc; + this.recorder = recorder; + setText("Tabular"); + setClosable(false); + createGui(); + restoreState(); + loadData(); + initUpdateService(); + 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) { + var 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(); + var 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(); + + long now = System.currentTimeMillis(); + if ((now - lastJsonWrite) > TimeUnit.SECONDS.toMillis(30)) { + lastJsonWrite = now; + saveData(); + } + } + + private void createGui() { + Config config = Config.getInstance(); + var 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); + var export = new Button("⬇"); + export.setOnAction(this::export); + export.setTooltip(new Tooltip("Export data")); + + var columnSelection = new Button("⚙"); + columnSelection.setOnAction(this::showColumnSelection); + columnSelection.setTooltip(new Tooltip("Select columns")); + var topBar = new HBox(5); + topBar.getChildren().addAll(filterInput, count, export, 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()); + if (!config.getSettings().showGridLinesInTables) { + table.setStyle("-fx-table-cell-border-color: transparent;"); + } + 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(); + } + }); + table.getColumns().addListener((ListChangeListener>) (e -> saveState())); + + var idx = 0; + TableColumn uid = createTableColumn("UID", 65, idx++); + uid.setId("uid"); + uid.setCellValueFactory(cdf -> cdf.getValue().uidProperty()); + addTableColumnIfEnabled(uid); + + TableColumn name = createTableColumn("Name", 200, idx++); + name.setId("name"); + name.setCellValueFactory(cdf -> cdf.getValue().nameProperty()); + addTableColumnIfEnabled(name); + + TableColumn state = createTableColumn("State", 130, idx++); + state.setId("state"); + state.setCellValueFactory(cdf -> cdf.getValue().stateProperty()); + addTableColumnIfEnabled(state); + + TableColumn hd = createTableColumn("HD", 130, idx++); + hd.setId("hd"); + hd.setCellValueFactory(cdf -> cdf.getValue().hdProperty()); + addTableColumnIfEnabled(hd); + + TableColumn webrtc = createTableColumn("webrtc", 130, idx++); + webrtc.setId("webrtc"); + webrtc.setCellValueFactory(cdf -> cdf.getValue().webrtcProperty()); + addTableColumnIfEnabled(webrtc); + + TableColumn flags = createTableColumn("Flags", 75, idx++); + flags.setId("flags"); + flags.setCellValueFactory(cdf -> cdf.getValue().flagsProperty()); + addTableColumnIfEnabled(flags); + + TableColumn camscore = createTableColumn("Score", 75, idx++); + camscore.setId("camscore"); + camscore.setCellValueFactory(cdf -> cdf.getValue().camScoreProperty()); + addTableColumnIfEnabled(camscore); + + TableColumn newModel = createTableColumn("New", 60, idx++); + newModel.setId("newModel"); + newModel.setCellValueFactory(cdf -> cdf.getValue().newModelProperty()); + addTableColumnIfEnabled(newModel); + + TableColumn ethnic = createTableColumn("Ethnicity", 130, idx++); + ethnic.setId("ethnic"); + ethnic.setCellValueFactory(cdf -> cdf.getValue().ethnicityProperty()); + addTableColumnIfEnabled(ethnic); + + TableColumn country = createTableColumn("Country", 160, idx++); + country.setId("country"); + country.setCellValueFactory(cdf -> cdf.getValue().countryProperty()); + addTableColumnIfEnabled(country); + + TableColumn continent = createTableColumn("Continent", 100, idx++); + continent.setId("continent"); + continent.setCellValueFactory(cdf -> cdf.getValue().continentProperty()); + addTableColumnIfEnabled(continent); + + TableColumn occupation = createTableColumn("Occupation", 160, idx++); + occupation.setId("occupation"); + occupation.setCellValueFactory(cdf -> cdf.getValue().occupationProperty()); + addTableColumnIfEnabled(occupation); + + TableColumn tags = createTableColumn("Tags", 300, idx++); + tags.setId("tags"); + tags.setCellValueFactory(cdf -> cdf.getValue().tagsProperty()); + addTableColumnIfEnabled(tags); + + TableColumn blurp = createTableColumn("Blurp", 300, idx++); + blurp.setId("blurp"); + blurp.setCellValueFactory(cdf -> cdf.getValue().blurpProperty()); + addTableColumnIfEnabled(blurp); + + TableColumn topic = createTableColumn("Topic", 600, idx); + topic.setId("topic"); + 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() { + List selectedModels = getSelectedModels(); + if (selectedModels.isEmpty()) { + return null; + } + + ContextMenu menu = new CustomMouseBehaviorContextMenu(); + + ModelMenuContributor.newContributor(getTabPane(), Config.getInstance(), recorder) // + .withStartStopCallback(m -> getTabPane().setCursor(Cursor.DEFAULT)) // + .afterwards(table::refresh) + .contributeToMenu(selectedModels, menu); + + addDebuggingInDevMode(menu, selectedModels); + + return menu; + } + + private void addDebuggingInDevMode(ContextMenu menu, List selectedModels) { + menu.getItems().add(new SeparatorMenuItem()); + if (Objects.equals(System.getenv("CTBREC_DEV"), "1")) { + var debug = new MenuItem("debug"); + debug.setOnAction(e -> GlobalThreadPool.submit(() -> { + for (Model m : selectedModels) { + try { + List sources = m.getStreamSources(); + for (StreamSource src : sources) { + log.info("m:{} s:{} bandwidth:{} height:{}", m.getName(), src.getMediaPlaylistUrl(), src.getBandwidth(), src.getHeight()); + } + log.info("==============="); + } catch (IOException | ExecutionException | ParseException | PlaylistException | JAXBException e1) { + log.error("Couldn't get stream sources", e1); + } + } + })); + menu.getItems().add(debug); + } + } + + private List getSelectedModels() { + ObservableList selectedStates = table.getSelectionModel().getSelectedItems(); + ArrayList 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); + } + } + return selectedModels; + } + + 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 (var i = 0; i < table.getItems().size(); i++) { + var sb = new StringBuilder(); + for (TableColumn tc : table.getColumns()) { + Object cellData = tc.getCellData(i); + if (cellData != null) { + var content = cellData.toString(); + sb.append(content).append(' '); + } + } + var searchText = sb.toString(); + + var 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 export(ActionEvent evt) { + var chooser = new FileChooser(); + var file = chooser.showSaveDialog(getTabPane().getScene().getWindow()); + if (file != null) { + try (var fout = new FileOutputStream(file)) { + var ps = new PrintStream(fout); + List union = new ArrayList<>(); + union.addAll(filteredModels); + union.addAll(observableModels); + ps.println("\"uid\",\"blurp\",\"camScore\",\"continent\",\"country\",\"ethnic\",\"name\",\"new\",\"occupation\",\"state\",\"tags\",\"topic\""); + for (ModelTableRow row : union) { + ps.print("\"" + row.uid + "\""); + ps.print(','); + ps.print(escape(row.blurp)); + ps.print(','); + ps.print(escape(row.camScore)); + ps.print(','); + ps.print(escape(row.continent)); + ps.print(','); + ps.print(escape(row.country)); + ps.print(','); + ps.print(escape(row.ethnic)); + ps.print(','); + ps.print(escape(row.name)); + ps.print(','); + ps.print(escape(row.newModel)); + ps.print(','); + ps.print(escape(row.occupation)); + ps.print(','); + ps.print(escape(row.state)); + ps.print(','); + ps.print(escape(row.tags)); + ps.print(','); + ps.print(escape(row.topic)); + ps.println(); + } + } catch (Exception e) { + log.debug("Couldn't write mfc models table data", e); + } + } + } + + private String escape(Property prop) { + String value = prop.getValue() != null ? prop.getValue().toString() : ""; + return "\"" + value.replace("\"", "\"\"") + "\""; + } + + private void showColumnSelection(ActionEvent evt) { + ContextMenu menu = new CustomMouseBehaviorContextMenu(); + for (TableColumn tc : columns) { + var 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()); + boolean added = false; + for (int i = table.getColumns().size() - 1; i >= 0; i--) { + TableColumn other = table.getColumns().get(i); + log.debug("Adding column {}", tc.getText()); + int idx = (int) tc.getUserData(); + int otherIdx = (int) other.getUserData(); + if (otherIdx < idx) { + table.getColumns().add(i + 1, tc); + added = true; + break; + } + } + if (!added) { + table.getColumns().add(0, tc); + } + } 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) { + var s = updateService.getState(); + if (s != State.SCHEDULED && s != State.RUNNING) { + updateService.reset(); + updateService.restart(); + } + } + } + + @Override + public void deselected() { + if (updateService != null) { + updateService.cancel(); + } + saveData(); + observableModels.clear(); + } + + private void saveData() { + try { + List union = new ArrayList<>(); + union.addAll(filteredModels); + union.addAll(observableModels); + var data = new JSONArray(); + for (ModelTableRow row : union) { + var model = new JSONObject(); + model.put("uid", row.uid); + model.put("blurp", row.blurp.get()); + model.put("camScore", row.camScore.get()); + model.put("continent", row.continent.get()); + model.put("country", row.country.get()); + model.put("ethnic", row.ethnic.get()); + model.put("name", row.name.get()); + model.put("newModel", row.newModel.get()); + model.put("occupation", row.occupation.get()); + model.put("state", row.state.get()); + model.put("tags", row.tags.get()); + model.put("topic", row.topic.get()); + data.put(model); + } + var file = new File(Config.getInstance().getConfigDir(), "mfc-models.json"); + Files.writeString(file.toPath(), data.toString(2), CREATE, WRITE); + saveState(); + } catch (Exception e) { + log.debug("Couldn't write mfc models table data: {}", e.getMessage()); + } + } + + private void loadData() { + try { + var file = new File(Config.getInstance().getConfigDir(), "mfc-models.json"); + if (!file.exists()) { + return; + } + var json = Files.readString(file.toPath()); + var data = new JSONArray(json); + for (var i = 0; i < data.length(); i++) { + createRow(data, i).ifPresent(observableModels::add); + } + } catch (Exception e) { + log.debug("Couldn't read mfc models table data: {}", e.getMessage()); + } + } + + private Optional createRow(JSONArray data, int i) { + try { + var row = new ModelTableRow(); + var model = data.getJSONObject(i); + row.uid = model.getInt("uid"); + row.blurp.set(model.optString("blurp")); + row.camScore.set(model.optDouble("camScore")); + row.continent.set(model.optString("continent")); + row.country.set(model.optString("country")); + row.ethnic.set(model.optString("ethnic")); + row.name.set(model.optString("name")); + row.newModel.set(model.optString("newModel")); + row.occupation.set(model.optString("occupation")); + row.state.set(model.optString("state")); + row.tags.set(model.optString("tags")); + row.topic.set(model.optString("topic")); + return Optional.of(row); + } catch (Exception e) { + // ignore this error + } + return Optional.empty(); + } + + private void saveState() { + var settings = Config.getInstance().getSettings(); + if (!table.getSortOrder().isEmpty()) { + TableColumn col = table.getSortOrder().get(0); + settings.mfcModelsTableSortColumn = col.getText(); + settings.mfcModelsTableSortType = col.getSortType().toString(); + } + int size = table.getColumns().size(); + var columnWidths = new double[size]; + var columnIds = new String[size]; + for (var i = 0; i < columnWidths.length; i++) { + columnWidths[i] = table.getColumns().get(i).getWidth(); + columnIds[i] = table.getColumns().get(i).getId(); + } + settings.mfcModelsTableColumnWidths = columnWidths; + settings.mfcModelsTableColumnIds = columnIds; + } + + private void restoreState() { + restoreColumnOrder(); + restoreColumnWidths(); + restoreSorting(); + + filterInput.setText(Config.getInstance().getSettings().mfcModelsTableFilter); + } + + private void restoreSorting() { + 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; + } + } + } + } + + private void restoreColumnOrder() { + String[] columnIds = Config.getInstance().getSettings().mfcModelsTableColumnIds; + ObservableList> tableColumns = table.getColumns(); + for (var i = 0; i < columnIds.length; i++) { + for (var j = 0; j < table.getColumns().size(); j++) { + if (Objects.equals(columnIds[i], tableColumns.get(j).getId())) { + TableColumn col = tableColumns.get(j); + tableColumns.remove(j); // NOSONAR + tableColumns.add(i, col); + } + } + } + } + + private void restoreColumnWidths() { + double[] columnWidths = Config.getInstance().getSettings().mfcModelsTableColumnWidths; + if (columnWidths != null && columnWidths.length == table.getColumns().size()) { + for (var i = 0; i < columnWidths.length; i++) { + table.getColumns().get(i).setPrefWidth(columnWidths[i]); + } + } + } + + private ListChangeListener> createSortOrderChangedListener() { + return c -> saveState(); + } + + private static class ModelTableRow { + private Integer uid; + private final StringProperty name = new SimpleStringProperty(); + private final StringProperty state = new SimpleStringProperty(); + private final DoubleProperty camScore = new SimpleDoubleProperty(); + private final StringProperty newModel = new SimpleStringProperty(); + private final StringProperty ethnic = new SimpleStringProperty(); + private final StringProperty country = new SimpleStringProperty(); + private final StringProperty continent = new SimpleStringProperty(); + private final StringProperty occupation = new SimpleStringProperty(); + private final StringProperty tags = new SimpleStringProperty(); + private final StringProperty blurp = new SimpleStringProperty(); + private final StringProperty topic = new SimpleStringProperty(); + private final BooleanProperty isHd = new SimpleBooleanProperty(); + private final BooleanProperty isWebrtc = new SimpleBooleanProperty(); + private final SimpleIntegerProperty uidProperty = new SimpleIntegerProperty(); + private final SimpleIntegerProperty flagsProperty = new SimpleIntegerProperty(); + + public ModelTableRow(SessionState st) { + update(st); + } + + private ModelTableRow() { + } + + public void update(SessionState st) { + uid = st.getUid(); + uidProperty.set(uid); + setProperty(name, Optional.ofNullable(st.getNm())); + setProperty(state, Optional.ofNullable(st.getVs()).map(vs -> ctbrec.sites.mfc.State.of(vs).toString())); + setProperty(camScore, Optional.ofNullable(st.getM()).map(ctbrec.sites.mfc.Model::getCamscore)); + Optional isNew = Optional.ofNullable(st.getM()).map(ctbrec.sites.mfc.Model::getNewModel); + isNew.ifPresent(integer -> newModel.set(integer == 1 ? "new" : "")); + setProperty(ethnic, Optional.ofNullable(st.getU()).map(User::getEthnic)); + setProperty(country, Optional.ofNullable(st.getU()).map(User::getCountry)); + setProperty(continent, Optional.ofNullable(st.getM()).map(ctbrec.sites.mfc.Model::getContinent)); + setProperty(occupation, Optional.ofNullable(st.getU()).map(User::getOccupation)); + int flags = Optional.ofNullable(st.getM()).map(ctbrec.sites.mfc.Model::getFlags).orElse(0); + isWebrtc.set((flags & 524288) == 524288); + isHd.set(Optional.ofNullable(st.getU()).map(User::getPhase).orElse("z").equalsIgnoreCase("a")); + flagsProperty.setValue(flags); + Set tagSet = Optional.ofNullable(st.getM()).map(ctbrec.sites.mfc.Model::getTags).orElse(Collections.emptySet()); + if (!tagSet.isEmpty()) { + var sb = new StringBuilder(); + for (String t : tagSet) { + sb.append(t).append(',').append(' '); + } + tags.set(sb.substring(0, sb.length() - 2)); + } + setProperty(blurp, Optional.ofNullable(st.getU()).map(User::getBlurb)); + String tpc = Optional.ofNullable(st.getM()).map(ctbrec.sites.mfc.Model::getTopic).orElse("n/a"); + tpc = URLDecoder.decode(tpc, UTF_8); + topic.set(tpc); + } + + private void setProperty(Property prop, Optional value) { + if (value.isPresent() && !Objects.equals(value.get(), prop.getValue())) { + prop.setValue(value.get()); + } + } + + 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; + } + + public BooleanProperty hdProperty() { + return isHd; + } + + public BooleanProperty webrtcProperty() { + return isWebrtc; + } + + public IntegerProperty flagsProperty() { + return flagsProperty; + } + + public IntegerProperty uidProperty() { + return uidProperty; + } + + @Override + public int hashCode() { + final var prime = 31; + var 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) { + return other.uid == null; + } else return uid.equals(other.uid); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/myfreecams/NewModelService.java b/client/src/main/java/ctbrec/ui/sites/myfreecams/NewModelService.java new file mode 100644 index 00000000..7461762e --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/myfreecams/NewModelService.java @@ -0,0 +1,33 @@ +package ctbrec.ui.sites.myfreecams; + +import ctbrec.Model; +import javafx.concurrent.Task; + +import java.util.List; + +public class NewModelService extends MyFreeCamsAbstractUpdateService { + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() { + return NewModelService.super.getModels().stream() + .filter(m -> m.getPreview() != null) + .filter(m -> m.getStreamUrl() != null) + .filter(m -> { + try { + return m.isNew(); + } catch (Exception e) { + return false; + } + }) + .sorted((m1, m2) -> m2.getViewerCount() - m1.getViewerCount()) + .skip((page - 1) * (long) MODELS_PER_PAGE) + .limit(MODELS_PER_PAGE) + .map(Model.class::cast) + .toList(); + } + }; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/myfreecams/OnlineCamsUpdateService.java b/client/src/main/java/ctbrec/ui/sites/myfreecams/OnlineCamsUpdateService.java new file mode 100644 index 00000000..4dabf035 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/myfreecams/OnlineCamsUpdateService.java @@ -0,0 +1,39 @@ +package ctbrec.ui.sites.myfreecams; + + +import ctbrec.Model; +import javafx.concurrent.Task; + +import java.util.List; + +public class OnlineCamsUpdateService extends MyFreeCamsAbstractUpdateService { + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() { + + return OnlineCamsUpdateService.super.getModels().stream() + .filter(m -> m.getPreview() != null) + .filter(m -> m.getStreamUrl() != null) + .filter(m -> { + try { + return m.isOnline(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } catch (Exception e) { + return false; + } + }) + .sorted((m1, m2) -> (int) (m2.getCamScore() - m1.getCamScore())) + .skip((page - 1) * (long) MODELS_PER_PAGE) + .limit(MODELS_PER_PAGE) + .map(Model.class::cast) + .toList(); + } + }; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/myfreecams/PopularModelService.java b/client/src/main/java/ctbrec/ui/sites/myfreecams/PopularModelService.java new file mode 100644 index 00000000..dd2d13d7 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/myfreecams/PopularModelService.java @@ -0,0 +1,36 @@ +package ctbrec.ui.sites.myfreecams; + +import ctbrec.Model; +import javafx.concurrent.Task; + +import java.util.List; + +public class PopularModelService extends MyFreeCamsAbstractUpdateService { + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() { + return PopularModelService.super.getModels().stream() + .filter(m -> m.getPreview() != null) + .filter(m -> m.getStreamUrl() != null) + .filter(m -> { + try { + return m.isOnline(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } catch (Exception e) { + return false; + } + }) + .sorted((m1, m2) -> m2.getViewerCount() - m1.getViewerCount()) + .skip((page - 1) * (long) MODELS_PER_PAGE) + .limit(MODELS_PER_PAGE) + .map(Model.class::cast) + .toList(); + } + }; + } +} 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..b62859e2 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/myfreecams/TableUpdateService.java @@ -0,0 +1,30 @@ +package ctbrec.ui.sites.myfreecams; + +import ctbrec.sites.mfc.MyFreeCams; +import ctbrec.sites.mfc.MyFreeCamsClient; +import ctbrec.sites.mfc.SessionState; +import javafx.concurrent.ScheduledService; +import javafx.concurrent.Task; + +import java.util.Collection; + +public class TableUpdateService extends ScheduledService> { + + private final MyFreeCams mfc; + + public TableUpdateService(MyFreeCams mfc) { + this.mfc = mfc; + } + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public Collection call() { + MyFreeCamsClient client = mfc.getClient(); + return client.getSessionStates(); + } + }; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/secretfriends/SecretFriendsConfigUI.java b/client/src/main/java/ctbrec/ui/sites/secretfriends/SecretFriendsConfigUI.java new file mode 100644 index 00000000..f52bb11e --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/secretfriends/SecretFriendsConfigUI.java @@ -0,0 +1,89 @@ + +package ctbrec.ui.sites.secretfriends; + +import ctbrec.Config; +import ctbrec.sites.secretfriends.SecretFriends; +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.layout.GridPane; + +public class SecretFriendsConfigUI extends AbstractConfigUI { + private final SecretFriends site; + + public SecretFriendsConfigUI(SecretFriends secretFriends) { + this.site = secretFriends; + } + + @Override + public Parent createConfigPanel() { + GridPane layout = SettingsTab.createGridLayout(); + var settings = Config.getInstance().getSettings(); + + var row = 0; + var l = new Label("Active"); + layout.add(l, 0, row); + var 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()); + } + save(); + }); + GridPane.setMargin(enabled, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + layout.add(enabled, 1, row++); + +// layout.add(new Label(site.getName() + " User"), 0, row); +// var username = new TextField(Config.getInstance().getSettings().stripchatUsername); +// username.textProperty().addListener((ob, o, n) -> { +// if(!n.equals(Config.getInstance().getSettings().stripchatUsername)) { +// Config.getInstance().getSettings().stripchatUsername = username.getText(); +// site.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(site.getName() + " Password"), 0, row); +// var password = new PasswordField(); +// password.setText(Config.getInstance().getSettings().stripchatPassword); +// password.textProperty().addListener((ob, o, n) -> { +// if(!n.equals(Config.getInstance().getSettings().stripchatPassword)) { +// Config.getInstance().getSettings().stripchatPassword = password.getText(); +// site.getHttpClient().logout(); +// save(); +// } +// }); +// GridPane.setFillWidth(password, true); +// GridPane.setHgrow(password, Priority.ALWAYS); +// GridPane.setColumnSpan(password, 2); +// layout.add(password, 1, row++); + +// var createAccount = new Button("Create new Account"); +// createAccount.setOnAction(e -> DesktopIntegration.open(site.getAffiliateLink())); +// layout.add(createAccount, 1, row++); +// GridPane.setColumnSpan(createAccount, 2); + + var deleteCookies = new Button("Delete Cookies"); + deleteCookies.setOnAction(e -> site.getHttpClient().clearCookies()); + layout.add(deleteCookies, 1, row); + GridPane.setColumnSpan(deleteCookies, 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)); + GridPane.setMargin(deleteCookies, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + return layout; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/secretfriends/SecretFriendsSiteUi.java b/client/src/main/java/ctbrec/ui/sites/secretfriends/SecretFriendsSiteUi.java new file mode 100644 index 00000000..6164ac0e --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/secretfriends/SecretFriendsSiteUi.java @@ -0,0 +1,40 @@ +package ctbrec.ui.sites.secretfriends; + +import ctbrec.sites.secretfriends.SecretFriends; +import ctbrec.ui.sites.AbstractSiteUi; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; + +import java.io.IOException; + +public class SecretFriendsSiteUi extends AbstractSiteUi { + + private SecretFriendsTabProvider tabProvider; + private SecretFriendsConfigUI configUi; + private final SecretFriends site; + + public SecretFriendsSiteUi(SecretFriends site) { + this.site = site; + } + + @Override + public TabProvider getTabProvider() { + if (tabProvider == null) { + tabProvider = new SecretFriendsTabProvider(site); + } + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + if (configUi == null) { + configUi = new SecretFriendsConfigUI(site); + } + return configUi; + } + + @Override + public synchronized boolean login() throws IOException { + return site.login(); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/secretfriends/SecretFriendsTabProvider.java b/client/src/main/java/ctbrec/ui/sites/secretfriends/SecretFriendsTabProvider.java new file mode 100644 index 00000000..8940e10d --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/secretfriends/SecretFriendsTabProvider.java @@ -0,0 +1,34 @@ +package ctbrec.ui.sites.secretfriends; + +import ctbrec.sites.secretfriends.SecretFriends; +import ctbrec.ui.sites.AbstractTabProvider; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +import java.util.ArrayList; +import java.util.List; + +public class SecretFriendsTabProvider extends AbstractTabProvider { + + public SecretFriendsTabProvider(SecretFriends site) { + super(site); + } + + @Override + protected List getSiteTabs(Scene scene) { + List tabs = new ArrayList<>(); + tabs.add(createTab("Girls", SecretFriends.BASE_URI + "/users")); + tabs.add(createTab("New", SecretFriends.BASE_URI + "/newgirls")); + tabs.add(createTab("Couples", SecretFriends.BASE_URI + "/site/couple")); + return tabs; + } + + private Tab createTab(String title, String url) { + var updateService = new SecretFriendsUpdateService(url, false, (SecretFriends) site); + var tab = new ThumbOverviewTab(title, updateService, site); + tab.setRecorder(recorder); + return tab; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/secretfriends/SecretFriendsUpdateService.java b/client/src/main/java/ctbrec/ui/sites/secretfriends/SecretFriendsUpdateService.java new file mode 100644 index 00000000..8a1b7e26 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/secretfriends/SecretFriendsUpdateService.java @@ -0,0 +1,92 @@ +package ctbrec.ui.sites.secretfriends; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.io.HtmlParser; +import ctbrec.io.HttpException; +import ctbrec.sites.secretfriends.SecretFriends; +import ctbrec.sites.secretfriends.SecretFriendsModelParser; +import ctbrec.ui.SiteUiFactory; +import ctbrec.ui.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; +import okhttp3.HttpUrl; +import okhttp3.Request; +import okhttp3.Response; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.*; + +import static ctbrec.ErrorMessages.HTTP_RESPONSE_BODY_IS_NULL; +import static ctbrec.io.HttpConstants.*; + +public class SecretFriendsUpdateService extends PaginatedScheduledService { + + private static final Logger LOG = LoggerFactory.getLogger(SecretFriendsUpdateService.class); + + private final String url; + private final boolean loginRequired; + private final SecretFriends site; + + public SecretFriendsUpdateService(String url, boolean loginRequired, SecretFriends site) { + this.url = url; + this.loginRequired = loginRequired; + this.site = site; + } + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() throws IOException { + if (loginRequired && !site.credentialsAvailable()) { + return Collections.emptyList(); + } else { + String paginatedUrl = url; + if (page > 1) { + String pager = (url.indexOf("/users") > 0) ? "Friend_page" : "AModel_page"; + paginatedUrl = HttpUrl.parse(url).newBuilder().addQueryParameter(pager, String.valueOf(page)).build().toString(); + } + LOG.debug("Fetching page {}", paginatedUrl); + if (loginRequired) { + SiteUiFactory.getUi(site).login(); + } + Request request = new Request.Builder() + .url(paginatedUrl) + .header(ACCEPT, "*/*") + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(REFERER, SecretFriends.BASE_URI) + .build(); + try (Response response = site.getHttpClient().execute(request)) { + if (response.isSuccessful()) { + return parseModels(Objects.requireNonNull(response.body(), HTTP_RESPONSE_BODY_IS_NULL).string()); + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + } + }; + } + + private List parseModels(String body) { + List models = new ArrayList<>(); + Elements modelDivs = HtmlParser.getTags(body, "div[class~=model-wrapper]"); + LOG.debug("Found {} models", modelDivs.size()); + for (Element div : modelDivs) { + try { + models.add(SecretFriendsModelParser.parse(site, div)); + } catch (Exception e) { + LOG.warn("Couldn't parse one of the models: {}", div.html(), e); + } + } + return models; + } + + +} diff --git a/client/src/main/java/ctbrec/ui/sites/showup/ShowupConfigUI.java b/client/src/main/java/ctbrec/ui/sites/showup/ShowupConfigUI.java new file mode 100644 index 00000000..905568eb --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/showup/ShowupConfigUI.java @@ -0,0 +1,92 @@ +package ctbrec.ui.sites.showup; + +import ctbrec.Config; +import ctbrec.sites.showup.Showup; +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 ShowupConfigUI extends AbstractConfigUI { + private Showup site; + + public ShowupConfigUI(Showup site) { + this.site = site; + } + + @Override + public Parent createConfigPanel() { + GridPane layout = SettingsTab.createGridLayout(); + var settings = Config.getInstance().getSettings(); + + var row = 0; + var l = new Label("Active"); + layout.add(l, 0, row); + var 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()); + } + save(); + }); + GridPane.setMargin(enabled, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + layout.add(enabled, 1, row++); + + layout.add(new Label("Showup User"), 0, row); + var username = new TextField(Config.getInstance().getSettings().showupUsername); + username.textProperty().addListener((ob, o, n) -> { + if(!n.equals(Config.getInstance().getSettings().showupUsername)) { + Config.getInstance().getSettings().showupUsername = username.getText(); + site.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("Showup Password"), 0, row); + var password = new PasswordField(); + password.setText(Config.getInstance().getSettings().showupPassword); + password.textProperty().addListener((ob, o, n) -> { + if(!n.equals(Config.getInstance().getSettings().showupPassword)) { + Config.getInstance().getSettings().showupPassword = password.getText(); + site.getHttpClient().logout(); + save(); + } + }); + GridPane.setFillWidth(password, true); + GridPane.setHgrow(password, Priority.ALWAYS); + GridPane.setColumnSpan(password, 2); + layout.add(password, 1, row++); + + var createAccount = new Button("Create new Account"); + createAccount.setOnAction(e -> DesktopIntegration.open(site.getAffiliateLink())); + layout.add(createAccount, 1, row++); + GridPane.setColumnSpan(createAccount, 2); + + var deleteCookies = new Button("Delete Cookies"); + deleteCookies.setOnAction(e -> site.getHttpClient().clearCookies()); + layout.add(deleteCookies, 1, row); + GridPane.setColumnSpan(deleteCookies, 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)); + GridPane.setMargin(deleteCookies, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + return layout; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/showup/ShowupElectronLoginDialog.java b/client/src/main/java/ctbrec/ui/sites/showup/ShowupElectronLoginDialog.java new file mode 100644 index 00000000..45f3a93d --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/showup/ShowupElectronLoginDialog.java @@ -0,0 +1,122 @@ +package ctbrec.ui.sites.showup; + +import java.io.IOException; +import java.util.Collections; +import java.util.function.Consumer; + +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Config; +import ctbrec.sites.showup.Showup; +import ctbrec.ui.ExternalBrowser; +import okhttp3.Cookie; +import okhttp3.Cookie.Builder; +import okhttp3.CookieJar; +import okhttp3.HttpUrl; + +public class ShowupElectronLoginDialog { + + private static final Logger LOG = LoggerFactory.getLogger(ShowupElectronLoginDialog.class); + public static final String DOMAIN = "showup.tv"; + public static final String URL = Showup.BASE_URL; + private CookieJar cookieJar; + private ExternalBrowser browser; + private boolean firstCall = true; + + public ShowupElectronLoginDialog(CookieJar cookieJar) throws IOException { + this.cookieJar = cookieJar; + browser = ExternalBrowser.getInstance(); + try { + var config = new JSONObject(); + config.put("url", URL); + config.put("w", 640); + config.put("h", 480); + config.put("userAgent", Config.getInstance().getSettings().httpUserAgent); + var msg = new JSONObject(); + msg.put("config", config); + browser.run(msg, msgHandler); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Couldn't wait for login dialog", e); + } finally { + browser.close(); + } + } + + private Consumer msgHandler = line -> { + if(!line.startsWith("{")) { + System.err.println(line); // NOSONAR + } else { + var json = new JSONObject(line); + if(json.has("url")) { + var url = json.getString("url"); + LOG.debug(url); + if(url.contains("/site/accept_rules")) { + try { + Thread.sleep(500); + var simplify = new String[] { "document.getElementById(\"acceptrules\").submit();" }; + for (String js : simplify) { + browser.executeJavaScript(js); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOG.warn("Couldn't auto fill username and password for Showup", e); + } catch (Exception e) { + LOG.warn("Couldn't auto fill username and password for Showup", e); + } + } else if (url.equals(URL + '/') && firstCall) { + firstCall = false; + try { + Thread.sleep(500); + browser.executeJavaScript("user.openLoginPopUp();"); + String username = Config.getInstance().getSettings().showupUsername; + if (username != null && !username.trim().isEmpty()) { + browser.executeJavaScript("$('input[name=\"email\"]').attr('value','" + username + "')"); + } + String password = Config.getInstance().getSettings().showupPassword; + if (password != null && !password.trim().isEmpty()) { + password = password.replace("'", "\\'"); + browser.executeJavaScript("$('input[name=\"password\"]').attr('value','" + password + "')"); + } + browser.executeJavaScript("$('input[name=\"remember\"]').attr('value','true')"); + return; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOG.warn("Couldn't auto fill username and password for Showup", e); + } catch (Exception e) { + LOG.warn("Couldn't auto fill username and password for Showup", e); + } + } + + if (json.has("cookies")) { + var cookies = json.getJSONArray("cookies"); + for (var i = 0; i < cookies.length(); i++) { + var cookie = cookies.getJSONObject(i); + if(cookie.getString("domain").contains(DOMAIN)) { + Builder b = new Cookie.Builder() + .path(cookie.getString("path")) + .domain(DOMAIN) + .name(cookie.getString("name")) + .value(cookie.getString("value")) + .expiresAt(Double.valueOf(cookie.optDouble("expirationDate")).longValue()); // NOSONAR + if (cookie.optBoolean("hostOnly")) { + b.hostOnlyDomain(DOMAIN); + } + if (cookie.optBoolean("httpOnly")) { + b.httpOnly(); + } + if (cookie.optBoolean("secure")) { + b.secure(); + } + Cookie c = b.build(); + LOG.debug("Adding cookie {}={}", c.name(), c.value()); + cookieJar.saveFromResponse(HttpUrl.parse(Showup.BASE_URL), Collections.singletonList(c)); + } + } + } + } + } + }; +} diff --git a/client/src/main/java/ctbrec/ui/sites/showup/ShowupFollowedTab.java b/client/src/main/java/ctbrec/ui/sites/showup/ShowupFollowedTab.java new file mode 100644 index 00000000..cb2559ed --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/showup/ShowupFollowedTab.java @@ -0,0 +1,79 @@ +package ctbrec.ui.sites.showup; + +import ctbrec.sites.showup.Showup; +import ctbrec.ui.tabs.FollowedTab; +import ctbrec.ui.tabs.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 ShowupFollowedTab extends ThumbOverviewTab implements FollowedTab { + private Label status; + boolean showOnline = true; + + public ShowupFollowedTab(String title, Showup showup) { + super(title, new ShowupFollowedUpdateService(showup), showup); + status = new Label("Logging in..."); + grid.getChildren().add(status); + } + + @Override + protected void createGui() { + super.createGui(); + addOnlineOfflineSelector(); + } + + private void addOnlineOfflineSelector() { + var group = new ToggleGroup(); + var online = new RadioButton("online"); + online.setToggleGroup(group); + var 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 -> { + queue.clear(); + ((ShowupFollowedUpdateService)updateService).showOnline(online.isSelected()); + updateService.restart(); + }); + } + + @Override + protected void onSuccess() { + grid.getChildren().remove(status); + super.onSuccess(); + } + + @Override + protected void onFail(WorkerStateEvent event) { + var msg = ""; + if (event.getSource().getException() != null) { + msg = ": " + event.getSource().getException().getMessage(); + } + status.setText("Login failed" + msg); + 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() && event.getCode() == KeyCode.DELETE) { + follow(selectedThumbCells, false); + } + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/showup/ShowupFollowedUpdateService.java b/client/src/main/java/ctbrec/ui/sites/showup/ShowupFollowedUpdateService.java new file mode 100644 index 00000000..5975b720 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/showup/ShowupFollowedUpdateService.java @@ -0,0 +1,96 @@ +package ctbrec.ui.sites.showup; + +import static ctbrec.io.HttpConstants.*; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.json.JSONObject; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.UnexpectedResponseException; +import ctbrec.io.HttpException; +import ctbrec.sites.showup.Showup; +import ctbrec.sites.showup.ShowupModel; +import ctbrec.ui.SiteUiFactory; +import ctbrec.ui.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; +import okhttp3.Request; + +public class ShowupFollowedUpdateService extends PaginatedScheduledService { + + private Showup site; + private boolean showOnline = true; + + public ShowupFollowedUpdateService(Showup site) { + this.site = site; + } + + @Override + protected Task> createTask() { + return new Task>() { + @Override + public List call() throws IOException { + SiteUiFactory.getUi(site).login(); + + var request = new Request.Builder() + .url(site.getBaseUrl() + "/site/favorites") + .header(ACCEPT, MIMETYPE_APPLICATION_JSON) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) + .build(); + try (var response = site.getHttpClient().execute(request)) { + if (response.isSuccessful()) { + var body = response.body().string(); + var json = new JSONObject(body); + if (json.optString("status").equalsIgnoreCase("success")) { + Map onlineModels = parseOnlineModels(json); + return parseFavorites(json).stream() + .filter(m -> onlineModels.containsKey(m.getUid()) == showOnline) + .collect(Collectors.toList()); + } else { + throw new UnexpectedResponseException("Request was not successful: " + body); + } + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + private List parseFavorites(JSONObject json) { + var favorites = new ArrayList(); + var list = json.getJSONArray("list"); + for (var i = 0; i < list.length(); i++) { + var m = list.getJSONObject(i); + var model = new ShowupModel(); + model.setSite(site); + model.setUid(m.optString("fav_uid")); + model.setName(m.optString("username")); + model.setUrl(site.getBaseUrl() + '/' + model.getName()); + favorites.add(model); + } + return favorites; + } + + private Map parseOnlineModels(JSONObject json) { + var onlineModels = new HashMap(); + var online = json.getJSONArray("online"); + for (var i = 0; i < online.length(); i++) { + var m = online.getJSONObject(i); + String preview = site.getBaseUrl() + "/files/" + m.optString("big_img") + ".jpg"; + onlineModels.put(String.valueOf(m.optLong("uid")), preview); + } + return onlineModels; + } + }; + } + + void showOnline(boolean online) { + this.showOnline = online; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/showup/ShowupSiteUi.java b/client/src/main/java/ctbrec/ui/sites/showup/ShowupSiteUi.java new file mode 100644 index 00000000..6a75bf9a --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/showup/ShowupSiteUi.java @@ -0,0 +1,67 @@ +package ctbrec.ui.sites.showup; + +import java.io.IOException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.sites.showup.Showup; +import ctbrec.sites.showup.ShowupHttpClient; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.sites.AbstractSiteUi; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; + +public class ShowupSiteUi extends AbstractSiteUi { + + private static final Logger LOG = LoggerFactory.getLogger(ShowupSiteUi.class); + + private final Showup site; + private ConfigUI configUi; + private TabProvider tabProvider; + + public ShowupSiteUi(Showup site) { + this.site = site; + } + + @Override + public TabProvider getTabProvider() { + if (tabProvider == null) { + tabProvider = new ShowupTabProvider(site); + } + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + if (configUi == null) { + configUi = new ShowupConfigUI(site); + } + return configUi; + } + + @Override + public boolean login() throws IOException { + boolean automaticLogin = site.login(); + if (automaticLogin) { + return true; + } else { + // login with external browser window + try { + new ShowupElectronLoginDialog(site.getHttpClient().getCookieJar()); + } catch (Exception e1) { + LOG.error("Error logging in with external browser", e1); + Dialogs.showError("Login error", "Couldn't login to " + site.getName(), e1); + } + + ShowupHttpClient httpClient = (ShowupHttpClient) site.getHttpClient(); + boolean loggedIn = httpClient.checkLoginSuccess(); + if (loggedIn) { + LOG.info("Logged in"); + } else { + LOG.info("Login failed"); + } + return loggedIn; + } + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/showup/ShowupTabProvider.java b/client/src/main/java/ctbrec/ui/sites/showup/ShowupTabProvider.java new file mode 100644 index 00000000..241bb25b --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/showup/ShowupTabProvider.java @@ -0,0 +1,41 @@ +package ctbrec.ui.sites.showup; + +import ctbrec.sites.showup.Showup; +import ctbrec.ui.sites.AbstractTabProvider; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +import java.util.ArrayList; +import java.util.List; + +public class ShowupTabProvider extends AbstractTabProvider { + + public ShowupTabProvider(Showup site) { + super(site); + } + + @Override + protected List getSiteTabs(Scene scene) { + List tabs = new ArrayList<>(); + tabs.add(createTab("Women", "female")); + tabs.add(createTab("Men", "male")); + tabs.add(createTab("All", "all")); + var showupFollowedTab = new ShowupFollowedTab("Favorites", (Showup) site); + showupFollowedTab.setRecorder(site.getRecorder()); + tabs.add(showupFollowedTab); + return tabs; + } + + @Override + public Tab getFollowedTab() { + return null; + } + + private Tab createTab(String title, String category) { + var updateService = new ShowupUpdateService((Showup) site, category); + var tab = new ThumbOverviewTab(title, updateService, site); + tab.setRecorder(site.getRecorder()); + return tab; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/showup/ShowupUpdateService.java b/client/src/main/java/ctbrec/ui/sites/showup/ShowupUpdateService.java new file mode 100644 index 00000000..29800d58 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/showup/ShowupUpdateService.java @@ -0,0 +1,48 @@ +package ctbrec.ui.sites.showup; + +import ctbrec.Model; +import ctbrec.sites.showup.Showup; +import ctbrec.sites.showup.ShowupHttpClient; +import ctbrec.ui.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +public class ShowupUpdateService extends PaginatedScheduledService { + + private final Showup showup; + private final String category; + protected int modelsPerPage = 48; + + public ShowupUpdateService(Showup showup, String category) { + this.showup = showup; + this.category = category; + } + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() throws IOException { + return getModelList().stream() + .skip((page - 1) * (long) modelsPerPage) + .limit(modelsPerPage) + .collect(Collectors.toList()); // NOSONAR + } + }; + } + + private List getModelList() throws IOException { + ShowupHttpClient httpClient = (ShowupHttpClient) showup.getHttpClient(); + httpClient.setCookie("category", category); + var modelsList = showup.getModelList(true); + if (modelsList == null) { + return Collections.emptyList(); + } + return modelsList; + } + +} 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..5de1c327 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateConfigUI.java @@ -0,0 +1,92 @@ +package ctbrec.ui.sites.streamate; + +import ctbrec.Config; +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(); + var settings = Config.getInstance().getSettings(); + + var row = 0; + var l = new Label("Active"); + layout.add(l, 0, row); + var 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); + var 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); + var 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++); + + var createAccount = new Button("Create new Account"); + createAccount.setOnAction(e -> DesktopIntegration.open(streamate.getAffiliateLink())); + layout.add(createAccount, 1, row++); + GridPane.setColumnSpan(createAccount, 2); + + var deleteCookies = new Button("Delete Cookies"); + deleteCookies.setOnAction(e -> streamate.getHttpClient().clearCookies()); + layout.add(deleteCookies, 1, row); + GridPane.setColumnSpan(deleteCookies, 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)); + GridPane.setMargin(deleteCookies, 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..5a470779 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateFollowedService.java @@ -0,0 +1,123 @@ +package ctbrec.ui.sites.streamate; + +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.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; +import okhttp3.Request; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.*; + +import static ctbrec.ErrorMessages.HTTP_RESPONSE_BODY_IS_NULL; +import static ctbrec.Model.State.OFFLINE; +import static ctbrec.Model.State.ONLINE; +import static ctbrec.io.HttpConstants.*; +import static ctbrec.sites.streamate.Streamate.NAIAD_URL; + +public class StreamateFollowedService extends PaginatedScheduledService { + + private static final Logger LOG = LoggerFactory.getLogger(StreamateFollowedService.class); + + private static final int MODELS_PER_PAGE = 48; + private final Streamate streamate; + private final StreamateHttpClient httpClient; + private final String url; + private boolean showOnline = true; + + public StreamateFollowedService(Streamate streamate) { + this.streamate = streamate; + this.httpClient = streamate.getHttpClient(); + this.url = NAIAD_URL + "/favorites?domain=streamate.com&filters="; + } + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() throws IOException { + try { + httpClient.login(); + } catch (Exception e) { + LOG.debug("Login was not successful"); + return Collections.emptyList(); + } + String saKey = httpClient.getSaKey(); + String pageUrl = url + "&from=" + ((page - 1) * MODELS_PER_PAGE) + "&size=" + MODELS_PER_PAGE; + LOG.debug("Fetching page {}", pageUrl); + String smxxx = UUID.randomUUID() + "G0211569057409"; + String smtid = Optional.ofNullable(httpClient.getCookieValue("smtid")).orElse(smxxx); + String smeid = Optional.ofNullable(httpClient.getCookieValue("smeid")).orElse(smxxx); + String smvid = Optional.ofNullable(httpClient.getCookieValue("smvid")).orElse(smxxx); + var request = new Request.Builder() + .url(pageUrl) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, MIMETYPE_APPLICATION_JSON) + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(ORIGIN, streamate.getBaseUrl()) + .header(REFERER, streamate.getBaseUrl() + "/view/favorites") + .header("sakey", saKey) + .header("platform", "SCP") + .header("smtid", smtid) + .header("smeid", smeid) + .header("smvid", smvid) + .build(); + try (var response = streamate.getHttpClient().execute(request)) { + if (response.isSuccessful()) { + return parseModels(Objects.requireNonNull(response.body(), HTTP_RESPONSE_BODY_IS_NULL).string()); + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + }; + } + + private List parseModels(String content) throws IOException { + List models = new ArrayList<>(); + var json = new JSONObject(content); + if (json.has("totalResultCount")) { + var performers = json.getJSONArray("performers"); + for (var i = 0; i < performers.length(); i++) { + var p = performers.getJSONObject(i); + var nickname = p.getString("nickname"); + StreamateModel model = (StreamateModel) streamate.createModel(nickname); + model.setId(p.getLong("id")); + model.setPreview("https://cdn.nsimg.net/snap/320x240/" + model.getId() + ".jpg"); + boolean online = p.optBoolean("online") && notPrivateEtc(p); + model.setOnline(online); + model.setOnlineState(online ? ONLINE : OFFLINE); + if (online == showOnline) { + models.add(model); + } + } + } else { + throw new IOException("Response: " + json.optString("message")); + } + return models; + } + + private boolean notPrivateEtc(JSONObject p) { + if (p.has("liveState")) { + var liveState = p.getJSONObject("liveState"); + boolean offline = liveState.optBoolean("onBreak") + || liveState.optBoolean("privateChat") + || liveState.optBoolean("exclusiveShow") + || liveState.optBoolean("specialShow") + || liveState.optBoolean("goldShow"); + return !offline; + } + return false; + } + + 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..3222b479 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateFollowedTab.java @@ -0,0 +1,75 @@ +package ctbrec.ui.sites.streamate; + +import ctbrec.sites.streamate.Streamate; +import ctbrec.ui.tabs.FollowedTab; +import ctbrec.ui.tabs.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() { + var group = new ToggleGroup(); + var online = new RadioButton("online"); + online.setToggleGroup(group); + var 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() && 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..5612f6fc --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateSiteUi.java @@ -0,0 +1,41 @@ +package ctbrec.ui.sites.streamate; + +import java.io.IOException; + +import ctbrec.sites.streamate.Streamate; +import ctbrec.ui.sites.AbstractSiteUi; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; + +public class StreamateSiteUi extends AbstractSiteUi { + + private StreamateTabProvider tabProvider; + private StreamateConfigUI configUi; + private Streamate streamate; + + public StreamateSiteUi(Streamate streamate) { + this.streamate = streamate; + } + + @Override + public TabProvider getTabProvider() { + if (tabProvider == null) { + tabProvider = new StreamateTabProvider(streamate); + } + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + if (configUi == null) { + configUi = new StreamateConfigUI(streamate); + } + return configUi; + } + + @Override + public boolean login() throws IOException { + return streamate.login(); + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/streamate/StreamateTab.java b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateTab.java new file mode 100644 index 00000000..f77b0a02 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateTab.java @@ -0,0 +1,39 @@ +package ctbrec.ui.sites.streamate; + +import ctbrec.sites.Site; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.geometry.Insets; +import javafx.scene.control.RadioButton; +import javafx.scene.control.ToggleGroup; +import javafx.scene.layout.HBox; + +public class StreamateTab extends ThumbOverviewTab { + + public StreamateTab(String title, StreamateUpdateService updateService, Site site) { + super(title, updateService, site); + } + + @Override + protected void createGui() { + super.createGui(); + addOnlineOfflineSelector(); + } + + private void addOnlineOfflineSelector() { + var group = new ToggleGroup(); + var online = new RadioButton("online"); + online.setToggleGroup(group); + var 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 -> { + ((StreamateUpdateService) updateService).setOnline(online.isSelected()); + queue.clear(); + updateService.restart(); + }); + } +} 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..d48348d6 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateTabProvider.java @@ -0,0 +1,47 @@ +package ctbrec.ui.sites.streamate; + +import ctbrec.sites.streamate.Streamate; +import ctbrec.ui.sites.AbstractTabProvider; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +import java.util.ArrayList; +import java.util.List; + +public class StreamateTabProvider extends AbstractTabProvider { + private ThumbOverviewTab followedTab; + + public StreamateTabProvider(Streamate streamate) { + super(streamate); + } + + @Override + protected List getSiteTabs(Scene scene) { + List tabs = new ArrayList<>(); + tabs.add(createTab("Girls", Streamate.NAIAD_URL + "/performers?domain=streamate.com&loggedInRec=1&boostedFilters=&country=US&language=en&index=default&filters=gender:f,ff%3Bonline:true")); + tabs.add(createTab("Girls New", Streamate.NAIAD_URL + "/performers?domain=streamate.com&loggedInRec=1&boostedFilters=&country=US&language=en&index=default&filters=gender:f,ff%3Bnew:true%3Bonline:true")); + tabs.add(createTab("Guys", Streamate.NAIAD_URL + "/performers?domain=streamate.com&loggedInRec=1&boostedFilters=&country=US&language=en&index=default&filters=gender:m%3Bonline:true")); + tabs.add(createTab("Couples", Streamate.NAIAD_URL + "/performers?domain=streamate.com&loggedInRec=1&boostedFilters=&country=US&language=en&index=default&filters=gender:mf,mm,g%3Bonline:true")); + tabs.add(createTab("Trans", Streamate.NAIAD_URL + "/performers?domain=streamate.com&loggedInRec=1&boostedFilters=&country=US&language=en&index=default&filters=gender:tm2f,tf2m%3Bonline:true")); + tabs.add(createTab("New", Streamate.NAIAD_URL + "/performers?domain=streamate.com&loggedInRec=1&boostedFilters=&country=US&language=en&index=default&filters=new:true%3Bonline:true")); + + followedTab = new StreamateFollowedTab((Streamate) site); + followedTab.setRecorder(recorder); + tabs.add(followedTab); + + return tabs; + } + + @Override + public Tab getFollowedTab() { + return followedTab; + } + + private Tab createTab(String title, String url) { + var updateService = new StreamateUpdateService((Streamate) site, url); + var tab = new StreamateTab(title, updateService, site); + 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..b6ce4ff3 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamate/StreamateUpdateService.java @@ -0,0 +1,110 @@ +package ctbrec.ui.sites.streamate; + +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.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.*; + +import static ctbrec.ErrorMessages.HTTP_RESPONSE_BODY_IS_NULL; +import static ctbrec.Model.State.*; +import static ctbrec.io.HttpConstants.ORIGIN; + +public class StreamateUpdateService extends PaginatedScheduledService { + + private static final Logger LOG = LoggerFactory.getLogger(StreamateUpdateService.class); + + private static final int MODELS_PER_PAGE = 48; + private final Streamate streamate; + private final StreamateHttpClient httpClient; + private String url; + + public StreamateUpdateService(Streamate streamate, String url) { + this.streamate = streamate; + this.url = url; + this.httpClient = streamate.getHttpClient(); + } + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() throws IOException { + int from = (page - 1) * MODELS_PER_PAGE; + String saKey = httpClient.getSaKey(); + String pageUrl = url + "&from=" + from + "&size=" + MODELS_PER_PAGE; + LOG.debug("Fetching page {}", pageUrl); + String smxxx = UUID.randomUUID() + "G0211569057409"; + String smtid = Optional.ofNullable(httpClient.getCookieValue("smtid")).orElse(smxxx); + String smeid = Optional.ofNullable(httpClient.getCookieValue("smeid")).orElse(smxxx); + String smvid = Optional.ofNullable(httpClient.getCookieValue("smvid")).orElse(smxxx); + + var request = httpClient.newRequestBuilder() + .url(pageUrl) + .header(ORIGIN, streamate.getBaseUrl()) + .header("sakey", saKey) + .header("platform", "SCP") + .header("smtid", smtid) + .header("smeid", smeid) + .header("smvid", smvid) + .build(); + try (var response = httpClient.execute(request)) { + if (response.isSuccessful()) { + return parseModels(Objects.requireNonNull(response.body(), HTTP_RESPONSE_BODY_IS_NULL).string()); + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + }; + } + + private List parseModels(String content) { + List models = new ArrayList<>(); + var json = new JSONObject(content); + var performers = json.getJSONArray("performers"); + for (var i = 0; i < performers.length(); i++) { + var p = performers.getJSONObject(i); + var nickname = p.getString("nickname"); + StreamateModel model = (StreamateModel) streamate.createModel(nickname); + model.setId(p.getLong("id")); + model.setPreview("https://cdn.nsimg.net/snap/320x240/" + model.getId() + ".jpg"); + model.setDescription(p.optString("headlineMessage")); + var online = p.optBoolean("online"); + model.setOnline(online); + model.setOnlineState(online ? ONLINE : OFFLINE); + // TODO figure out, what all the states mean + // liveState {…} + // exclusiveShow false + // goldShow true + // onBreak false + // partyChat true + // preGoldShow true + // privateChat false + // specialShow false + if (p.optBoolean("onBreak")) { + model.setOnlineState(AWAY); + } else if (p.optBoolean("goldShow") || p.optBoolean("privateChat") || p.optBoolean("exclusiveShow")) { + model.setOnlineState(PRIVATE); + } + models.add(model); + } + return models; + } + + public void setOnline(boolean online) { + if (online) { + url = url.replace("online:false", "online:true"); + } else { + url = url.replace("online:true", "online:false"); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/streamray/AbstractStreamrayUpdateService.java b/client/src/main/java/ctbrec/ui/sites/streamray/AbstractStreamrayUpdateService.java new file mode 100644 index 00000000..f38a04eb --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamray/AbstractStreamrayUpdateService.java @@ -0,0 +1,96 @@ +package ctbrec.ui.sites.streamray; + +import ctbrec.sites.streamray.Streamray; +import ctbrec.ui.tabs.PaginatedScheduledService; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import org.json.JSONArray; + +import java.net.URLEncoder; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import static java.nio.charset.StandardCharsets.UTF_8; + +@RequiredArgsConstructor +public abstract class AbstractStreamrayUpdateService extends PaginatedScheduledService { + + @Getter + @Setter + private static JSONArray mapping; + protected static final int MODELS_PER_PAGE = 48; + protected static final String API_URL = "https://beta-api.cams.com/won/compressed/"; + protected final Streamray site; + + protected String getPreviewURL(String name) { + String lname = name.toLowerCase(); + String url = MessageFormat.format("https://images4.streamray.com/images/streamray/won/jpg/{0}/{1}/{2}_640.jpg", lname.substring(0, 1), lname.substring(lname.length() - 1), lname); + try { + return MessageFormat.format("https://dynimages.securedataimages.com/unsigned/rs:fill:640::0/g:no/plain/{0}@jpg", URLEncoder.encode(url, UTF_8)); + } catch (Exception ex) { + return url; + } + } + + protected List createTags(JSONArray m) { + List tags = new ArrayList<>(); + int idx1 = mappingIndex("gender"); + switch (m.optString(idx1)) { + case "M" -> tags.add("male"); + case "F" -> tags.add("female"); + case "TS" -> tags.add("trans"); + default -> { + // don't add anything + } + } + int idx2 = mappingIndex("ethnicity"); + switch (m.optString(idx2)) { + case "02" -> tags.add("asian"); + case "03" -> tags.add("ebony"); + case "04" -> tags.add("white"); + case "05" -> tags.add("indian"); + case "06" -> tags.add("latina"); + case "07" -> tags.add("middle-eastern"); + default -> { + // don't add anything + } + } + int idx3 = mappingIndex("hair_color"); + switch (m.optString(idx3)) { + case "01" -> tags.add("black-hair"); + case "02" -> tags.add("blonde"); + case "03" -> tags.add("brunette"); + case "06" -> tags.add("redhead"); + default -> { + // don't add anything + } + } + int idx4 = mappingIndex("chat_type"); + switch (m.optString(idx4)) { + case "0" -> tags.add("offline"); + case "1" -> tags.add("public"); + case "2" -> tags.add("nude-show"); + case "3" -> tags.add("private"); + case "4" -> tags.add("exclusive"); + case "6" -> tags.add("ticket-show"); + case "7" -> tags.add("voyeur"); + case "10" -> tags.add("party"); + case "13" -> tags.add("group"); + case "14" -> tags.add("c2c"); + default -> { + // don't add anything + } + } + return tags; + } + + protected int mappingIndex(String s) { + for (var i = 0; i < mapping.length(); i++) { + if (Objects.equals(s, mapping.get(i))) return i; + } + return -1; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayConfigUI.java b/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayConfigUI.java new file mode 100644 index 00000000..3cc33e35 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayConfigUI.java @@ -0,0 +1,67 @@ +package ctbrec.ui.sites.streamray; + +import ctbrec.Config; +import ctbrec.sites.streamray.Streamray; +import ctbrec.ui.settings.SettingsTab; +import ctbrec.ui.sites.AbstractConfigUI; +import javafx.geometry.Insets; +import javafx.scene.Parent; +import javafx.scene.control.ToggleGroup; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.RadioButton; +import javafx.scene.control.Label; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; + +public class StreamrayConfigUI extends AbstractConfigUI { + private final Streamray site; + + public StreamrayConfigUI(Streamray site) { + this.site = site; + } + + @Override + public Parent createConfigPanel() { + GridPane layout = SettingsTab.createGridLayout(); + var settings = Config.getInstance().getSettings(); + + var row = 0; + var l = new Label("Active"); + layout.add(l, 0, row); + var 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()); + } + save(); + }); + GridPane.setMargin(enabled, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + layout.add(enabled, 1, row++); + row++; + + l = new Label("Record Goal/Tipping shows"); + layout.add(l, 0, row); + var cb = new CheckBox(); + cb.setSelected(settings.streamrayRecordGoalShows); + cb.setOnAction(e -> { + settings.streamrayRecordGoalShows = cb.isSelected(); + save(); + }); + GridPane.setMargin(cb, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + layout.add(cb, 1, row++); + row++; + + var deleteCookies = new Button("Delete Cookies"); + deleteCookies.setOnAction(e -> site.getHttpClient().clearCookies()); + layout.add(deleteCookies, 1, row); + GridPane.setColumnSpan(deleteCookies, 2); + + GridPane.setMargin(deleteCookies, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + return layout; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayElectronLoginDialog.java b/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayElectronLoginDialog.java new file mode 100644 index 00000000..3d1a01c1 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayElectronLoginDialog.java @@ -0,0 +1,71 @@ +package ctbrec.ui.sites.streamray; + +import ctbrec.Config; +import ctbrec.sites.streamray.Streamray; +import ctbrec.ui.ExternalBrowser; +import lombok.extern.slf4j.Slf4j; +import okhttp3.Cookie; +import okhttp3.Cookie.Builder; +import okhttp3.CookieJar; +import okhttp3.HttpUrl; +import org.json.JSONObject; + +import java.io.IOException; +import java.util.Collections; +import java.util.function.Consumer; + +@Slf4j +public class StreamrayElectronLoginDialog { + + public static final String DOMAIN = "streamray.com"; + private CookieJar cookieJar; + + + public StreamrayElectronLoginDialog(CookieJar cookieJar) throws IOException { + this.cookieJar = cookieJar; + try (ExternalBrowser browser = ExternalBrowser.getInstance()) { + var config = new JSONObject(); + config.put("url", Streamray.BASE_URI); + config.put("w", 800); + config.put("h", 600); + config.put("userAgent", Config.getInstance().getSettings().httpUserAgent); + var msg = new JSONObject(); + msg.put("config", config); + browser.run(msg, msgHandler); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Couldn't wait for login dialog", e); + } + } + + private final Consumer msgHandler = line -> { + if (!line.startsWith("{")) return; + JSONObject json = new JSONObject(line); + if (json.has("cookies")) { + var cookies = json.getJSONArray("cookies"); + for (var i = 0; i < cookies.length(); i++) { + var cookie = cookies.getJSONObject(i); + if (cookie.getString("domain").contains(DOMAIN)) { + Builder b = new Cookie.Builder() + .path(cookie.getString("path")) + .domain(DOMAIN) + .name(cookie.optString("name")) + .value(cookie.optString("value")) + .expiresAt(Double.valueOf(cookie.optDouble("expirationDate")).longValue()); // NOSONAR + if (cookie.optBoolean("hostOnly")) { + b.hostOnlyDomain(DOMAIN); + } + if (cookie.optBoolean("httpOnly")) { + b.httpOnly(); + } + if (cookie.optBoolean("secure")) { + b.secure(); + } + Cookie c = b.build(); + log.trace("Adding cookie {}={}", c.name(), c.value()); + cookieJar.saveFromResponse(HttpUrl.parse(Streamray.BASE_URI), Collections.singletonList(c)); + } // if + } // for + } + }; +} diff --git a/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayFavoritesService.java b/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayFavoritesService.java new file mode 100644 index 00000000..da03f3a9 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayFavoritesService.java @@ -0,0 +1,159 @@ +package ctbrec.ui.sites.streamray; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.StringUtil; +import ctbrec.io.HttpException; +import ctbrec.sites.streamray.Streamray; +import ctbrec.sites.streamray.StreamrayHttpClient; +import ctbrec.sites.streamray.StreamrayModel; +import javafx.concurrent.Task; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import okhttp3.Request; +import okhttp3.Response; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.*; + +import static ctbrec.io.HttpConstants.*; + +@Slf4j +public class StreamrayFavoritesService extends AbstractStreamrayUpdateService { + + private List modelsList; + private Instant lastListInfoRequest = Instant.EPOCH; + @Getter + private boolean loggedIn = false; + private boolean showOnline = true; + + public StreamrayFavoritesService(Streamray site) { + super(site); + } + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() throws IOException { + if (showOnline) { + return getModelList().stream() + .skip((page - 1) * (long) MODELS_PER_PAGE) + .limit(MODELS_PER_PAGE) + .map(Model.class::cast) + .toList(); + } else { + return loadOfflineModelList().stream() + .map(Model.class::cast) + .toList(); + } + } + }; + } + + private List getModelList() throws IOException { + if (Duration.between(lastListInfoRequest, Instant.now()).getSeconds() < 30) { + return Objects.nonNull(modelsList) ? modelsList : loadModelList(API_URL); + } + modelsList = loadModelList(API_URL); + return modelsList; + } + + private List loadOfflineModelList() throws IOException { + String url = "https://beta-api.cams.com/favorites/member_favorites/?gender=female"; + int offset = (getPage() - 1) * MODELS_PER_PAGE; + String paginatedUrl = url + "&offset=" + offset + "&limit=" + MODELS_PER_PAGE; + return loadModelList(paginatedUrl); + } + + private List loadModelList(String url) throws IOException { + log.debug("Fetching page {}", url); + lastListInfoRequest = Instant.now(); + StreamrayHttpClient client = (StreamrayHttpClient) site.getHttpClient(); + String token; + if (site.login()) { + loggedIn = true; + token = client.getUserToken(); + } else { + loggedIn = false; + return Collections.emptyList(); + } + Request req = new Request.Builder() + .url(url) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, MIMETYPE_APPLICATION_JSON) + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(REFERER, site.getBaseUrl() + "/") + .header(ORIGIN, site.getBaseUrl()) + .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) + .header(AUTHORIZATION, "Bearer " + token) + .build(); + try (Response response = client.execute(req)) { + if (response.isSuccessful()) { + List models = new ArrayList<>(); + JSONObject json = new JSONObject(response.body().string()); + if (showOnline) { + if (json.has("models")) { + JSONArray modelNodes = json.getJSONArray("models"); + AbstractStreamrayUpdateService.setMapping(json.getJSONArray("mapping")); + parseModels(modelNodes, models); + } + } else { + if (json.has("results")) { + JSONArray modelNodes = json.getJSONArray("results"); + parseOfflineModels(modelNodes, models); + } + } + return models; + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + private void parseModels(JSONArray jsonModels, List models) { + int nameIdx = mappingIndex("stream_name"); + int favIdx = mappingIndex("is_favorite"); + for (int i = 0; i < jsonModels.length(); i++) { + JSONArray m = jsonModels.getJSONArray(i); + String name = m.optString(nameIdx); + boolean favorite = m.optBoolean(favIdx); + if (favorite) { + StreamrayModel model = site.createModel(name); + String preview = getPreviewURL(name); + model.setPreview(preview); + model.setTags(createTags(m)); + StringBuilder description = new StringBuilder(); + for (String tag : model.getTags()) { + description.append("#").append(tag).append(" "); + } + model.setDescription(description.toString()); + models.add(model); + } + } + } + + private void parseOfflineModels(JSONArray jsonModels, List models) { + for (int i = 0; i < jsonModels.length(); i++) { + JSONObject m = jsonModels.getJSONObject(i); + String name = m.optString("stream_name"); + if (StringUtil.isBlank(name) || m.optBoolean("is_online")) { + continue; + } + StreamrayModel model = site.createModel(name); + String preview = getPreviewURL(name); + model.setPreview(preview); + model.setDisplayName(m.getString("screen_name")); + model.setOnlineState(Model.State.OFFLINE); + models.add(model); + } + } + + public void setOnline(boolean online) { + showOnline = online; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayFavoritesTab.java b/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayFavoritesTab.java new file mode 100644 index 00000000..2265564a --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayFavoritesTab.java @@ -0,0 +1,103 @@ +package ctbrec.ui.sites.streamray; + +import ctbrec.sites.streamray.Streamray; +import ctbrec.ui.tabs.FollowedTab; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.concurrent.WorkerStateEvent; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Button; +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 StreamrayFavoritesTab extends ThumbOverviewTab implements FollowedTab { + private final Label status; + private final Button loginButton; + private final StreamrayFavoritesService streamrayFavoritesService; + + public StreamrayFavoritesTab(String title, StreamrayFavoritesService updateService, Streamray site) { + super(title, updateService, site); + this.streamrayFavoritesService = updateService; + + status = new Label("Logging in..."); + grid.getChildren().addAll(status); + + loginButton = new Button("Login"); + loginButton.setPadding(new Insets(20)); + loginButton.setOnAction(e -> { + try { + new StreamrayElectronLoginDialog(site.getHttpClient().getCookieJar()); + queue.clear(); + updateService.restart(); + } catch (Exception ex) { + // fail silently + } + }); + } + + @Override + protected void createGui() { + super.createGui(); + addOnlineOfflineSelector(); + } + + private void addOnlineOfflineSelector() { + var group = new ToggleGroup(); + var online = new RadioButton("online"); + online.setToggleGroup(group); + var 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 -> { + ((StreamrayFavoritesService) updateService).setOnline(online.isSelected()); + queue.clear(); + updateService.restart(); + }); + } + + protected void addLoginButton() { + grid.getChildren().clear(); + grid.setAlignment(Pos.CENTER); + grid.getChildren().add(loginButton); + } + + @Override + protected void onSuccess() { + grid.getChildren().removeAll(status, loginButton); + grid.setAlignment(Pos.TOP_LEFT); + if (!streamrayFavoritesService.isLoggedIn()) { + addLoginButton(); + } else { + 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() && event.getCode() == KeyCode.DELETE) { + follow(selectedThumbCells, false); + } + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/streamray/StreamraySiteUi.java b/client/src/main/java/ctbrec/ui/sites/streamray/StreamraySiteUi.java new file mode 100644 index 00000000..549bbdf3 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamray/StreamraySiteUi.java @@ -0,0 +1,40 @@ +package ctbrec.ui.sites.streamray; + +import ctbrec.sites.streamray.Streamray; +import ctbrec.ui.sites.AbstractSiteUi; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; + +import java.io.IOException; + +public class StreamraySiteUi extends AbstractSiteUi { + + private StreamrayTabProvider tabProvider; + private StreamrayConfigUI configUi; + private final Streamray site; + + public StreamraySiteUi(Streamray site) { + this.site = site; + } + + @Override + public TabProvider getTabProvider() { + if (tabProvider == null) { + tabProvider = new StreamrayTabProvider(site); + } + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + if (configUi == null) { + configUi = new StreamrayConfigUI(site); + } + return configUi; + } + + @Override + public boolean login() throws IOException { + return site.login(); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayTabProvider.java b/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayTabProvider.java new file mode 100644 index 00000000..8e1d41ce --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayTabProvider.java @@ -0,0 +1,55 @@ +package ctbrec.ui.sites.streamray; + +import ctbrec.sites.streamray.Streamray; +import ctbrec.sites.streamray.StreamrayModel; +import ctbrec.ui.sites.AbstractTabProvider; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Predicate; + +public class StreamrayTabProvider extends AbstractTabProvider { + + private final Tab followedTab; + + public StreamrayTabProvider(Streamray site) { + super(site); + followedTab = favoritesTab(); + } + + @Override + protected List getSiteTabs(Scene scene) { + List tabs = new ArrayList<>(); + tabs.add(createTab("Girls", m -> Objects.equals("F", m.getGender()))); + tabs.add(createTab("Boys", m -> Objects.equals("M", m.getGender()))); + tabs.add(createTab("Trans", m -> Objects.equals("TS", m.getGender()))); + tabs.add(createTab("New", StreamrayModel::isNew)); + tabs.add(followedTab); + return tabs; + } + + private Tab createTab(String title, Predicate filter) { + var updateService = new StreamrayUpdateService((Streamray) site, filter); + var tab = new ThumbOverviewTab(title, updateService, site); + tab.setImageAspectRatio(9.0 / 16.0); + tab.setRecorder(recorder); + return tab; + } + + private Tab favoritesTab() { + var updateService = new StreamrayFavoritesService((Streamray) site); + var tab = new StreamrayFavoritesTab("Favorites", updateService, (Streamray) site); + tab.setImageAspectRatio(9.0 / 16.0); + tab.setRecorder(recorder); + return tab; + } + + @Override + public Tab getFollowedTab() { + return followedTab; + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayUpdateService.java b/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayUpdateService.java new file mode 100644 index 00000000..aec262d5 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/streamray/StreamrayUpdateService.java @@ -0,0 +1,124 @@ +package ctbrec.ui.sites.streamray; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.io.HttpException; +import ctbrec.sites.streamray.Streamray; +import ctbrec.sites.streamray.StreamrayHttpClient; +import ctbrec.sites.streamray.StreamrayModel; +import javafx.concurrent.Task; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import okhttp3.Request; +import okhttp3.Response; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.function.Predicate; + +import static ctbrec.io.HttpConstants.*; + +@Slf4j +public class StreamrayUpdateService extends AbstractStreamrayUpdateService { + + private List modelsList; + private Instant lastListInfoRequest = Instant.EPOCH; + + @Setter + protected Predicate filter; + + public StreamrayUpdateService(Streamray site, Predicate filter) { + super(site); + this.filter = filter; + } + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() throws IOException { + return getModelList().stream() + .filter(filter) + .skip((page - 1) * (long) MODELS_PER_PAGE) + .limit(MODELS_PER_PAGE) + .map(Model.class::cast) + .toList(); + } + }; + } + + private List getModelList() throws IOException { + if (Duration.between(lastListInfoRequest, Instant.now()).getSeconds() < 30) { + return Objects.nonNull(modelsList) ? modelsList : loadModelList(); + } + modelsList = loadModelList(); + return modelsList; + } + + private List loadModelList() throws IOException { + log.debug("Fetching page {}", API_URL); + lastListInfoRequest = Instant.now(); + StreamrayHttpClient client = (StreamrayHttpClient) site.getHttpClient(); + Request req = new Request.Builder() + .url(API_URL) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, MIMETYPE_APPLICATION_JSON) + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(REFERER, site.getBaseUrl() + "/") + .header(ORIGIN, site.getBaseUrl()) + .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) + .build(); + try (Response response = client.execute(req)) { + if (response.isSuccessful()) { + List models = new ArrayList<>(); + String content = response.body().string(); + JSONObject json = new JSONObject(content); + JSONArray modelNodes = json.getJSONArray("models"); + AbstractStreamrayUpdateService.setMapping(json.getJSONArray("mapping")); + parseModels(modelNodes, models); + return models; + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + private void parseModels(JSONArray jsonModels, List models) { + int nameIdx = mappingIndex("stream_name"); + int dateIdx = mappingIndex("create_date"); + int genIdx = mappingIndex("gender"); + for (var i = 0; i < jsonModels.length(); i++) { + var m = jsonModels.getJSONArray(i); + String name = m.optString(nameIdx); + String gender = m.optString(genIdx); + String reg = m.optString(dateIdx); + StreamrayModel model = site.createModel(name); + try { + LocalDate regDate = LocalDate.parse(reg, DateTimeFormatter.BASIC_ISO_DATE); + model.setRegDate(regDate); + } catch (DateTimeParseException e) { + model.setRegDate(LocalDate.EPOCH); + } + String preview = getPreviewURL(name); + model.setPreview(preview); + model.setGender(gender); + model.setTags(createTags(m)); + StringBuilder description = new StringBuilder(); + for (String tag : model.getTags()) { + description.append("#").append(tag).append(" "); + } + model.setDescription(description.toString()); + models.add(model); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/stripchat/AbstractStripchatUpdateService.java b/client/src/main/java/ctbrec/ui/sites/stripchat/AbstractStripchatUpdateService.java new file mode 100644 index 00000000..69e67172 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/stripchat/AbstractStripchatUpdateService.java @@ -0,0 +1,65 @@ +package ctbrec.ui.sites.stripchat; + +import ctbrec.StringUtil; +import ctbrec.ui.tabs.PaginatedScheduledService; +import org.json.JSONObject; + +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +public abstract class AbstractStripchatUpdateService extends PaginatedScheduledService { + + protected static final Random RNG = new Random(); + + protected String getPreviewUrl(JSONObject model) { + long id = model.optLong("id"); + long timestamp = model.optLong("snapshotTimestamp"); + if (timestamp == 0) { + return model.optString("previewUrlThumbBig"); + } + return MessageFormat.format("https://img.strpst.com/thumbs/{0}/{1}", String.valueOf(timestamp), String.valueOf(id)); + } + + protected List createTags(JSONObject model) { + List tags = new ArrayList<>(); + if (model.optBoolean("isHd")) { + tags.add("HD"); + } + if (model.optBoolean("isVr")) { + tags.add("VR"); + } + if (model.optBoolean("isLovense")) { + tags.add("lovense"); + } + if (model.optBoolean("isNew")) { + tags.add("new"); + } + if (model.optBoolean("isMobile")) { + tags.add("mobile"); + } + if (model.optBoolean("isNonNude")) { + tags.add("non-nude"); + } + if (StringUtil.isNotBlank(model.optString("country"))) { + tags.add(model.getString("country").toUpperCase()); + } + if (StringUtil.isNotBlank(model.optString("broadcastGender"))) { + tags.add(model.getString("broadcastGender")); + } + if (StringUtil.isNotBlank(model.optString("status"))) { + tags.add(model.getString("status")); + } + return tags; + } + + protected String getUniq() { + String dict = "0123456789abcdefghijklmnopqrstuvwxyz"; + char[] text = new char[16]; + for (int i = 0; i < 16; i++) { + text[i] = dict.charAt(RNG.nextInt(dict.length())); + } + return new String(text); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatConfigUI.java b/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatConfigUI.java new file mode 100644 index 00000000..fc3d86fa --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatConfigUI.java @@ -0,0 +1,121 @@ +package ctbrec.ui.sites.stripchat; + +import ctbrec.Config; +import ctbrec.sites.stripchat.Stripchat; +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.*; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; + +public class StripchatConfigUI extends AbstractConfigUI { + private final Stripchat stripchat; + + public StripchatConfigUI(Stripchat stripchat) { + this.stripchat = stripchat; + } + + @Override + public Parent createConfigPanel() { + GridPane layout = SettingsTab.createGridLayout(); + var settings = Config.getInstance().getSettings(); + + var row = 0; + var l = new Label("Active"); + layout.add(l, 0, row); + var enabled = new CheckBox(); + enabled.setSelected(!settings.disabledSites.contains(stripchat.getName())); + enabled.setOnAction(e -> { + if (enabled.isSelected()) { + settings.disabledSites.remove(stripchat.getName()); + } else { + settings.disabledSites.add(stripchat.getName()); + } + save(); + }); + GridPane.setMargin(enabled, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + layout.add(enabled, 1, row++); + + l = new Label("Site"); + layout.add(l, 0, row); + var toggleGroup = new ToggleGroup(); + var optionA = new RadioButton("Stripchat"); + optionA.setSelected(!Config.getInstance().getSettings().stripchatUseXhamster); + optionA.setToggleGroup(toggleGroup); + var optionB = new RadioButton("xHamsterLive"); + optionB.setSelected(!optionA.isSelected()); + optionB.setToggleGroup(toggleGroup); + optionA.selectedProperty().addListener((obs, oldV, newV) -> { + Config.getInstance().getSettings().stripchatUseXhamster = !newV; + save(); + }); + var hbox = new HBox(); + hbox.getChildren().addAll(optionA, optionB); + HBox.setMargin(optionA, new Insets(5)); + HBox.setMargin(optionB, new Insets(5)); + GridPane.setMargin(hbox, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + layout.add(hbox, 1, row++); + + layout.add(new Label("Stripchat User"), 0, row); + var username = new TextField(Config.getInstance().getSettings().stripchatUsername); + username.textProperty().addListener((ob, o, n) -> { + if (!n.equals(Config.getInstance().getSettings().stripchatUsername)) { + Config.getInstance().getSettings().stripchatUsername = username.getText(); + stripchat.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("Stripchat Password"), 0, row); + var password = new PasswordField(); + password.setText(Config.getInstance().getSettings().stripchatPassword); + password.textProperty().addListener((ob, o, n) -> { + if (!n.equals(Config.getInstance().getSettings().stripchatPassword)) { + Config.getInstance().getSettings().stripchatPassword = password.getText(); + stripchat.getHttpClient().logout(); + save(); + } + }); + GridPane.setFillWidth(password, true); + GridPane.setHgrow(password, Priority.ALWAYS); + GridPane.setColumnSpan(password, 2); + layout.add(password, 1, row++); + + var createAccount = new Button("Create new Account"); + createAccount.setOnAction(e -> DesktopIntegration.open(stripchat.getAffiliateLink())); + layout.add(createAccount, 1, row++); + GridPane.setColumnSpan(createAccount, 2); + + var deleteCookies = new Button("Delete Cookies"); + deleteCookies.setOnAction(e -> stripchat.getHttpClient().clearCookies()); + layout.add(deleteCookies, 1, row++); + GridPane.setColumnSpan(deleteCookies, 2); + + row++; + l = new Label("Get VR stream if available"); + layout.add(l, 0, row); + var vr = new CheckBox(); + vr.setSelected(settings.stripchatVR); + vr.setOnAction(e -> { + settings.stripchatVR = vr.isSelected(); + save(); + }); + GridPane.setMargin(vr, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + layout.add(vr, 1, row); + + 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)); + GridPane.setMargin(deleteCookies, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + return layout; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatElectronLoginDialog.java b/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatElectronLoginDialog.java new file mode 100644 index 00000000..bebaa408 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatElectronLoginDialog.java @@ -0,0 +1,124 @@ +package ctbrec.ui.sites.stripchat; + +import ctbrec.Config; +import ctbrec.sites.stripchat.Stripchat; +import ctbrec.ui.ExternalBrowser; +import okhttp3.Cookie; +import okhttp3.Cookie.Builder; +import okhttp3.CookieJar; +import okhttp3.HttpUrl; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.Collections; +import java.util.function.Consumer; + +public class StripchatElectronLoginDialog { + + private static final Logger LOG = LoggerFactory.getLogger(StripchatElectronLoginDialog.class); + public static final String DOMAIN = Stripchat.getDomain(); + public static final String URL = Stripchat.getBaseUri(); + private CookieJar cookieJar; + private ExternalBrowser browser; + + public StripchatElectronLoginDialog(CookieJar cookieJar) throws IOException { + this.cookieJar = cookieJar; + browser = ExternalBrowser.getInstance(); + try { + var config = new JSONObject(); + config.put("url", URL); + config.put("w", 640); + config.put("h", 640); + var msg = new JSONObject(); + msg.put("config", config); + browser.run(msg, msgHandler); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Couldn't wait for login dialog", e); + } finally { + browser.close(); + } + } + + private Consumer msgHandler = line -> { + if (!line.startsWith("{")) { + System.err.println(line); // NOSONAR + } else { + var json = new JSONObject(line); + if (json.has("url")) { + var url = json.getString("url"); + + if (url.endsWith(DOMAIN) || url.endsWith(DOMAIN + '/')) { + try { + browser.executeJavaScript("document.querySelector('button[class~=\"btn-visitors-agreement-accept\"]').click();"); + browser.executeJavaScript("document.querySelector('div[class~=\"header-dropdown\"] a[class~=\"dropdown-link\"]').click();"); + browser.executeJavaScript("document.querySelector('a[class~=\"btn\"][href*=\"login\"]').click();"); + String username = Config.getInstance().getSettings().stripchatUsername; + if (username != null && !username.trim().isEmpty()) { + browser.executeJavaScript("document.querySelector('#login_login_or_email').value = '" + username + "';"); + } + String password = Config.getInstance().getSettings().stripchatPassword; + if (password != null && !password.trim().isEmpty()) { + password = password.replace("'", "\\'"); + browser.executeJavaScript("document.querySelector('#login_password').value = '" + password + "';"); + } + browser.executeJavaScript("document.querySelector('#recaptcha-checkbox-border').click();"); + browser.executeJavaScript("document.querySelector('*[class~=btn-login]').addEventListener('click', function() {window.setTimeout(function() {location.reload()}, 2000)});"); + } catch (Exception e) { + LOG.warn("Couldn't auto fill username and password for Stripchat", e); + } + } + + if (json.has("cookies")) { + var cookiesFromBrowser = json.getJSONArray("cookies"); + var sessionCookieFound = false; + for (var i = 0; i < cookiesFromBrowser.length(); i++) { + var cookie = cookiesFromBrowser.getJSONObject(i); + if (cookie.getString("domain").contains(DOMAIN)) { + var domain = cookie.getString("domain"); + if (domain.startsWith(".")) { + domain = domain.substring(1); + } + var c = createCookie(domain, cookie); + cookieJar.saveFromResponse(HttpUrl.parse(url), Collections.singletonList(c)); + c = createCookie(DOMAIN, cookie); + cookieJar.saveFromResponse(HttpUrl.parse(Stripchat.getBaseUri()), Collections.singletonList(c)); + if (c.name().contains("_com_sessionId")) { + sessionCookieFound = true; + } + } + } + + if (sessionCookieFound) { + try { + browser.close(); + } catch (IOException e) { + LOG.error("Couldn't send close request to browser", e); + } + } + } + } + } + }; + + private Cookie createCookie(String domain, JSONObject cookie) { + Builder b = new Cookie.Builder() + .path(cookie.getString("path")) + .domain(domain) + .name(cookie.getString("name")) + .value(cookie.getString("value")) + .expiresAt(Double.valueOf(cookie.optDouble("expirationDate")).longValue()); // NOSONAR + if (cookie.optBoolean("hostOnly")) { + b.hostOnlyDomain(domain); + } + if (cookie.optBoolean("httpOnly")) { + b.httpOnly(); + } + if (cookie.optBoolean("secure")) { + b.secure(); + } + return b.build(); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatFollowedTab.java b/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatFollowedTab.java new file mode 100644 index 00000000..a56006bf --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatFollowedTab.java @@ -0,0 +1,82 @@ +package ctbrec.ui.sites.stripchat; + +import ctbrec.sites.stripchat.Stripchat; +import ctbrec.ui.tabs.FollowedTab; +import ctbrec.ui.tabs.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 StripchatFollowedTab extends ThumbOverviewTab implements FollowedTab { + private Label status; + + public StripchatFollowedTab(String title, Stripchat stripchat) { + super(title, new StripchatFollowedUpdateService(stripchat), stripchat); + status = new Label("Logging in..."); + grid.getChildren().add(status); + } + + @Override + protected void createGui() { + super.createGui(); + addOnlineOfflineSelector(); + } + + private void addOnlineOfflineSelector() { + var group = new ToggleGroup(); + var online = new RadioButton("online"); + online.setToggleGroup(group); + var 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 -> { + if (online.isSelected()) { + ((StripchatFollowedUpdateService) updateService).setOnline(true); + } else { + ((StripchatFollowedUpdateService) updateService).setOnline(false); + } + queue.clear(); + updateService.restart(); + }); + } + + @Override + protected void onSuccess() { + grid.getChildren().remove(status); + super.onSuccess(); + } + + @Override + protected void onFail(WorkerStateEvent event) { + var msg = ""; + if (event.getSource().getException() != null) { + msg = ": " + event.getSource().getException().getMessage(); + } + status.setText("Login failed" + msg); + 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() && event.getCode() == KeyCode.DELETE) { + follow(selectedThumbCells, false); + } + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatFollowedUpdateService.java b/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatFollowedUpdateService.java new file mode 100644 index 00000000..3a402f44 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatFollowedUpdateService.java @@ -0,0 +1,124 @@ +package ctbrec.ui.sites.stripchat; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.io.HttpException; +import ctbrec.sites.stripchat.Stripchat; +import ctbrec.sites.stripchat.StripchatHttpClient; +import ctbrec.sites.stripchat.StripchatModel; +import ctbrec.ui.SiteUiFactory; +import javafx.concurrent.Task; +import lombok.extern.slf4j.Slf4j; +import okhttp3.Request; +import okhttp3.Response; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.IOException; +import java.util.*; + +import static ctbrec.io.HttpConstants.*; + +@Slf4j +public class StripchatFollowedUpdateService extends AbstractStripchatUpdateService { + + private static final int MODELS_PER_PAGE = 60; + private final Stripchat stripchat; + private final boolean loginRequired; + private String url; + + public StripchatFollowedUpdateService(Stripchat stripchat) { + this.stripchat = stripchat; + this.loginRequired = true; + this.url = stripchat.getBaseUrl() + "/api/front/models/favorites?sortBy=lastAdded"; + } + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() throws IOException { + if (loginRequired && !stripchat.credentialsAvailable()) { + return Collections.emptyList(); + } else { + int offset = (getPage() - 1) * MODELS_PER_PAGE; + int limit = MODELS_PER_PAGE; + String paginatedUrl = url + "&offset=" + offset + "&limit=" + limit + "&uniq=" + getUniq(); + log.debug("Fetching page {}", paginatedUrl); + if (loginRequired) { + SiteUiFactory.getUi(stripchat).login(); + } + String jwtToken = ((StripchatHttpClient) stripchat.getHttpClient()).getJwtToken(); + Request request = new Request.Builder() + .url(paginatedUrl) + .header(ACCEPT, MIMETYPE_APPLICATION_JSON) + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header("Authorization", Optional.ofNullable(jwtToken).orElse("")) + .build(); + try (Response response = stripchat.getHttpClient().execute(request)) { + if (response.isSuccessful()) { + List models = new ArrayList<>(); + JSONObject json = new JSONObject(response.body().string()); + if (json.has("totalCount") && offset >= json.getInt("totalCount")) { + return Collections.emptyList(); + } + if (json.has("models")) { + JSONArray jsonModels = json.getJSONArray("models"); + models.addAll(parseModels(jsonModels)); + } else { + log.debug("Response was not successful: {}", json); + return Collections.emptyList(); + } + return models; + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + } + }; + } + + private List parseModels(JSONArray jsonModels) { + List models = new ArrayList<>(); + for (var i = 0; i < jsonModels.length(); i++) { + var jsonModel = jsonModels.getJSONObject(i); + try { + StripchatModel model = stripchat.createModel(jsonModel.getString("username")); + model.setPreview(getPreviewUrl(jsonModel)); + model.setDisplayName(model.getName()); + String status = jsonModel.optString("status"); + mapOnlineState(model, status); + model.setTags(createTags(jsonModel)); + StringBuilder description = new StringBuilder(); + for (String tag : model.getTags()) { + description.append("#").append(tag).append(" "); + } + model.setDescription(description.toString()); + models.add(model); + } catch (Exception e) { + log.warn("Couldn't parse one of the models: {}", jsonModel, e); + } + } + return models; + } + + private void mapOnlineState(StripchatModel model, String status) { + switch (status) { + case "public" -> model.setOnlineState(Model.State.ONLINE); + case "idle" -> model.setOnlineState(Model.State.AWAY); + case "private", "p2p", "groupShow", "virtualPrivate" -> model.setOnlineState(Model.State.PRIVATE); + default -> model.setOnlineState(Model.State.OFFLINE); + } + } + + public void setOnline(boolean online) { + if (online) { + url = stripchat.getBaseUrl() + "/api/front/models/favorites?sortBy=lastAdded"; + } else { + url = stripchat.getBaseUrl() + "/api/front/models/favorites/offline?sortBy=lastAdded"; + } + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatSiteUi.java b/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatSiteUi.java new file mode 100644 index 00000000..3621d921 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatSiteUi.java @@ -0,0 +1,62 @@ +package ctbrec.ui.sites.stripchat; + +import java.io.IOException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.sites.stripchat.Stripchat; +import ctbrec.sites.stripchat.StripchatHttpClient; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.sites.AbstractSiteUi; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; + +public class StripchatSiteUi extends AbstractSiteUi { + + private static final Logger LOG = LoggerFactory.getLogger(StripchatSiteUi.class); + + private StripchatTabProvider tabProvider; + private StripchatConfigUI configUi; + private Stripchat site; + + public StripchatSiteUi(Stripchat stripchat) { + this.site = stripchat; + } + + @Override + public TabProvider getTabProvider() { + if (tabProvider == null) { + tabProvider = new StripchatTabProvider(site); + } + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + if (configUi == null) { + configUi = new StripchatConfigUI(site); + } + return configUi; + } + + @Override + public synchronized boolean login() throws IOException { + boolean automaticLogin = site.login(); + if (automaticLogin) { + return true; + } else { + // login with external browser + try { + new StripchatElectronLoginDialog(site.getHttpClient().getCookieJar()); + } catch (Exception e1) { + LOG.error("Error logging in with external browser", e1); + Dialogs.showError("Login error", "Couldn't login to " + site.getName(), e1); + } + + StripchatHttpClient httpClient = (StripchatHttpClient) site.getHttpClient(); + boolean loggedIn = httpClient.checkLoginSuccess(); + return loggedIn; + } + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatTabProvider.java b/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatTabProvider.java new file mode 100644 index 00000000..694e5e50 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatTabProvider.java @@ -0,0 +1,58 @@ +package ctbrec.ui.sites.stripchat; + +import ctbrec.sites.stripchat.Stripchat; +import ctbrec.ui.sites.AbstractTabProvider; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.List; + +public class StripchatTabProvider extends AbstractTabProvider { + + private final String urlTemplate; + private final String urlFilterTemplate; + private final StripchatFollowedTab followedTab; + + public StripchatTabProvider(Stripchat stripchat) { + super(stripchat); + followedTab = new StripchatFollowedTab("Followed", stripchat); + urlTemplate = stripchat.getBaseUrl() + "/api/front/models?primaryTag={0}&sortBy=viewersRating&withMixedTags=true&parentTag="; + urlFilterTemplate = stripchat.getBaseUrl() + "/api/front/models?primaryTag=girls&filterGroupTags=%5B%5B%22{0}%22%5D%5D&parentTag={0}"; + } + + @Override + protected List getSiteTabs(Scene scene) { + List tabs = new ArrayList<>(); + tabs.add(createTab("Girls", MessageFormat.format(urlTemplate, "girls"))); + tabs.add(createTab("Girls New", MessageFormat.format(urlFilterTemplate, "autoTagNew"))); + tabs.add(createTab("Girls HD", MessageFormat.format(urlFilterTemplate, "autoTagHd"))); + tabs.add(createTab("Girls VR", MessageFormat.format(urlFilterTemplate, "autoTagVr"))); + tabs.add(createTab("Mobile", MessageFormat.format(urlFilterTemplate, "mobile"))); + tabs.add(createTab("Private", MessageFormat.format(urlFilterTemplate, "autoTagSpy"))); + tabs.add(createTab("Couples", MessageFormat.format(urlTemplate, "couples"))); + tabs.add(createTab("Boys", MessageFormat.format(urlTemplate, "men"))); + tabs.add(createTab("Trans", MessageFormat.format(urlTemplate, "trans"))); + followedTab.setRecorder(recorder); + followedTab.setScene(scene); + followedTab.setImageAspectRatio(9.0 / 16.0); + tabs.add(followedTab); + return tabs; + } + + @Override + public Tab getFollowedTab() { + return followedTab; + } + + private Tab createTab(String title, String url) { + var updateService = new StripchatUpdateService(url, false, (Stripchat) site); + var tab = new ThumbOverviewTab(title, updateService, site); + tab.setRecorder(recorder); + tab.setImageAspectRatio(9.0 / 16.0); + return tab; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatUpdateService.java b/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatUpdateService.java new file mode 100644 index 00000000..e119f2a9 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/stripchat/StripchatUpdateService.java @@ -0,0 +1,102 @@ +package ctbrec.ui.sites.stripchat; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.io.HttpException; +import ctbrec.sites.stripchat.Stripchat; +import ctbrec.sites.stripchat.StripchatModel; +import ctbrec.ui.SiteUiFactory; +import javafx.concurrent.Task; +import okhttp3.Request; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +import static ctbrec.io.HttpConstants.*; + +public class StripchatUpdateService extends AbstractStripchatUpdateService { + + private static final Logger LOG = LoggerFactory.getLogger(StripchatUpdateService.class); + + private final String url; + private final boolean loginRequired; + private final Stripchat stripchat; + int modelsPerPage = 48; + + public StripchatUpdateService(String url, boolean loginRequired, Stripchat stripchat) { + this.url = url; + this.loginRequired = loginRequired; + this.stripchat = stripchat; + } + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() throws IOException { + if (loginRequired && !stripchat.credentialsAvailable()) { + return Collections.emptyList(); + } else { + int offset = (getPage() - 1) * modelsPerPage; + int limit = modelsPerPage; + String paginatedUrl = url + "&offset=" + offset + "&limit=" + limit; + LOG.debug("Fetching page {}", paginatedUrl); + if (loginRequired) { + SiteUiFactory.getUi(stripchat).login(); + } + var request = new Request.Builder() + .url(paginatedUrl) + .header(ACCEPT, MIMETYPE_APPLICATION_JSON) + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(X_REQUESTED_WITH, XML_HTTP_REQUEST) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .build(); + try (var response = stripchat.getHttpClient().execute(request)) { + if (response.isSuccessful()) { + return parseModels(response.body().string()); + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + } + }; + } + + private List parseModels(String body) { + List models = new ArrayList<>(); + var json = new JSONObject(body); + if (json.has("models")) { + var jsonModels = json.getJSONArray("models"); + for (var i = 0; i < jsonModels.length(); i++) { + var jsonModel = jsonModels.getJSONObject(i); + try { + StripchatModel model = stripchat.createModel(jsonModel.getString("username")); + model.setPreview(getPreviewUrl(jsonModel)); + model.setDisplayName(model.getName()); + model.setOnlineState(Model.State.ONLINE); + model.setTags(createTags(jsonModel)); + StringBuilder description = new StringBuilder(); + for (String tag : model.getTags()) { + description.append("#").append(tag).append(" "); + } + model.setDescription(description.toString()); + models.add(model); + } catch (Exception e) { + LOG.warn("Couldn't parse one of the models: {}", jsonModel, e); + } + } + return models; + } else { + LOG.debug("Response was not successful: {}", json); + return Collections.emptyList(); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/winktv/WinkTvConfigUI.java b/client/src/main/java/ctbrec/ui/sites/winktv/WinkTvConfigUI.java new file mode 100644 index 00000000..d3c3cc42 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/winktv/WinkTvConfigUI.java @@ -0,0 +1,55 @@ +package ctbrec.ui.sites.winktv; + +import ctbrec.Config; +import ctbrec.sites.winktv.WinkTv; +import ctbrec.ui.settings.SettingsTab; +import ctbrec.ui.sites.AbstractConfigUI; +import javafx.geometry.Insets; +import javafx.scene.Parent; +import javafx.scene.control.ToggleGroup; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.RadioButton; +import javafx.scene.control.Label; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; + +public class WinkTvConfigUI extends AbstractConfigUI { + private final WinkTv site; + + public WinkTvConfigUI(WinkTv site) { + this.site = site; + } + + @Override + public Parent createConfigPanel() { + GridPane layout = SettingsTab.createGridLayout(); + var settings = Config.getInstance().getSettings(); + + var row = 0; + var l = new Label("Active"); + layout.add(l, 0, row); + var 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()); + } + save(); + }); + GridPane.setMargin(enabled, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + layout.add(enabled, 1, row++); + row++; + + var deleteCookies = new Button("Delete Cookies"); + deleteCookies.setOnAction(e -> site.getHttpClient().clearCookies()); + layout.add(deleteCookies, 1, row); + GridPane.setColumnSpan(deleteCookies, 2); + + GridPane.setMargin(deleteCookies, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + return layout; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/winktv/WinkTvSiteUi.java b/client/src/main/java/ctbrec/ui/sites/winktv/WinkTvSiteUi.java new file mode 100644 index 00000000..d87d3c99 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/winktv/WinkTvSiteUi.java @@ -0,0 +1,40 @@ +package ctbrec.ui.sites.winktv; + +import ctbrec.sites.winktv.WinkTv; +import ctbrec.ui.sites.AbstractSiteUi; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; + +import java.io.IOException; + +public class WinkTvSiteUi extends AbstractSiteUi { + + private WinkTvTabProvider tabProvider; + private WinkTvConfigUI configUi; + private final WinkTv site; + + public WinkTvSiteUi(WinkTv site) { + this.site = site; + } + + @Override + public TabProvider getTabProvider() { + if (tabProvider == null) { + tabProvider = new WinkTvTabProvider(site); + } + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + if (configUi == null) { + configUi = new WinkTvConfigUI(site); + } + return configUi; + } + + @Override + public synchronized boolean login() throws IOException { + return site.login(); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/winktv/WinkTvTabProvider.java b/client/src/main/java/ctbrec/ui/sites/winktv/WinkTvTabProvider.java new file mode 100644 index 00000000..da580a1e --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/winktv/WinkTvTabProvider.java @@ -0,0 +1,37 @@ +package ctbrec.ui.sites.winktv; + +import ctbrec.sites.winktv.WinkTv; +import ctbrec.sites.winktv.WinkTvModel; +import ctbrec.ui.sites.AbstractTabProvider; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; + +public class WinkTvTabProvider extends AbstractTabProvider { + + public WinkTvTabProvider(WinkTv site) { + super(site); + } + + @Override + protected List getSiteTabs(Scene scene) { + List tabs = new ArrayList<>(); + tabs.add(createTab("Live", Predicate.not(WinkTvModel::isAdult))); + // adult streams are only available with korean age verification, you have to be logged in + //tabs.add(createTab("Adult", WinkTvModel::isAdult)); + return tabs; + } + + private Tab createTab(String title, Predicate filter) { + var updateService = new WinkTvUpdateService((WinkTv) site, filter); + var tab = new ThumbOverviewTab(title, updateService, site); + tab.setImageAspectRatio(9.0 / 16.0); + tab.setRecorder(recorder); + return tab; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/winktv/WinkTvUpdateService.java b/client/src/main/java/ctbrec/ui/sites/winktv/WinkTvUpdateService.java new file mode 100644 index 00000000..99225f14 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/winktv/WinkTvUpdateService.java @@ -0,0 +1,108 @@ +package ctbrec.ui.sites.winktv; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.io.HttpException; +import ctbrec.sites.winktv.WinkTv; +import ctbrec.sites.winktv.WinkTvModel; +import ctbrec.ui.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; +import lombok.extern.slf4j.Slf4j; +import okhttp3.FormBody; +import okhttp3.Request; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.function.Predicate; + +import static ctbrec.io.HttpConstants.*; + +@Slf4j +public class WinkTvUpdateService extends PaginatedScheduledService { + + private static final String API_URL = "https://api.winktv.co.kr/v1/live"; + + private final WinkTv site; + protected int modelsPerPage = 48; + protected Predicate filter; + + public WinkTvUpdateService(WinkTv site, Predicate filter) { + this.site = site; + this.filter = filter; + } + + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() throws IOException { + return loadModelList() + .stream() + .filter(filter) + .map(Model.class::cast) + .toList(); + } + }; + } + + private List loadModelList() throws IOException { + int offset = (page - 1) * modelsPerPage; + log.debug("Fetching page {} offset:{}, limit:{}", API_URL, offset, modelsPerPage); + + + FormBody body = new FormBody.Builder() + .add("offset", String.valueOf(offset)) + .add("limit", String.valueOf(modelsPerPage)) + .add("orderBy", "hot") + .add("onlyNewBj", "N") + .build(); + Request req = new Request.Builder() + .url(API_URL) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(ACCEPT, "*/*") + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .post(body) + .build(); + try (var response = site.getHttpClient().execute(req)) { + if (response.isSuccessful()) { + List result = new ArrayList<>(); + var content = response.body().string(); + var json = new JSONObject(content); + if (json.optBoolean("result")) { + var modelNodes = json.getJSONArray("list"); + parseModels(modelNodes, result); + } + return result; + } else { + throw new HttpException(response.code(), response.message()); + } + } + } + + private void parseModels(JSONArray jsonModels, List models) { + for (var i = 0; i < jsonModels.length(); i++) { + var m = jsonModels.getJSONObject(i); + String name = m.optString("userId"); + WinkTvModel model = site.createModel(name); + model.setDisplayName(m.getString("userNick")); + boolean isAdult = m.optBoolean("isAdult"); + model.setAdult(isAdult); + if (isAdult && m.has("ivsThumbnail")) { + model.setPreview(m.optString("ivsThumbnail")); + } else { + model.setPreview(m.optString("thumbUrl")); + } + boolean isLive = m.optBoolean("isLive"); + if (isLive) models.add(model); + } + } + + public void setFilter(Predicate filter) { + this.filter = filter; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/xlovecam/XloveCamConfigUI.java b/client/src/main/java/ctbrec/ui/sites/xlovecam/XloveCamConfigUI.java new file mode 100644 index 00000000..b52d460c --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/xlovecam/XloveCamConfigUI.java @@ -0,0 +1,92 @@ +package ctbrec.ui.sites.xlovecam; + +import ctbrec.Config; +import ctbrec.sites.xlovecam.XloveCam; +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 XloveCamConfigUI extends AbstractConfigUI { + private XloveCam site; + + public XloveCamConfigUI(XloveCam site) { + this.site = site; + } + + @Override + public Parent createConfigPanel() { + GridPane layout = SettingsTab.createGridLayout(); + var settings = Config.getInstance().getSettings(); + + var row = 0; + var l = new Label("Active"); + layout.add(l, 0, row); + var 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()); + } + save(); + }); + GridPane.setMargin(enabled, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + layout.add(enabled, 1, row++); + + layout.add(new Label("XloveCam User"), 0, row); + var username = new TextField(Config.getInstance().getSettings().xlovecamUsername); + username.textProperty().addListener((ob, o, n) -> { + if(!n.equals(Config.getInstance().getSettings().xlovecamUsername)) { + Config.getInstance().getSettings().xlovecamUsername = username.getText(); + site.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("XloveCam Password"), 0, row); + var password = new PasswordField(); + password.setText(Config.getInstance().getSettings().xlovecamPassword); + password.textProperty().addListener((ob, o, n) -> { + if(!n.equals(Config.getInstance().getSettings().xlovecamPassword)) { + Config.getInstance().getSettings().xlovecamPassword = password.getText(); + site.getHttpClient().logout(); + save(); + } + }); + GridPane.setFillWidth(password, true); + GridPane.setHgrow(password, Priority.ALWAYS); + GridPane.setColumnSpan(password, 2); + layout.add(password, 1, row++); + + var createAccount = new Button("Create new Account"); + createAccount.setOnAction(e -> DesktopIntegration.open(site.getAffiliateLink())); + layout.add(createAccount, 1, row++); + GridPane.setColumnSpan(createAccount, 2); + + var deleteCookies = new Button("Delete Cookies"); + deleteCookies.setOnAction(e -> site.getHttpClient().clearCookies()); + layout.add(deleteCookies, 1, row); + GridPane.setColumnSpan(deleteCookies, 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)); + GridPane.setMargin(deleteCookies, new Insets(0, 0, 0, SettingsTab.CHECKBOX_MARGIN)); + return layout; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/xlovecam/XloveCamSiteUi.java b/client/src/main/java/ctbrec/ui/sites/xlovecam/XloveCamSiteUi.java new file mode 100644 index 00000000..06ef3306 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/xlovecam/XloveCamSiteUi.java @@ -0,0 +1,43 @@ +package ctbrec.ui.sites.xlovecam; + +import java.io.IOException; + +import ctbrec.sites.xlovecam.XloveCam; +import ctbrec.ui.sites.AbstractSiteUi; +import ctbrec.ui.sites.ConfigUI; +import ctbrec.ui.tabs.TabProvider; + +public class XloveCamSiteUi extends AbstractSiteUi { + + private final XloveCam site; + private XloveCamTabProvider tabProvider; + private XloveCamConfigUI configUi; + + public XloveCamSiteUi(XloveCam xloveCam) { + this.site = xloveCam; + } + + @Override + public TabProvider getTabProvider() { + if (tabProvider == null) { + tabProvider = new XloveCamTabProvider(site); + } + return tabProvider; + } + + @Override + public ConfigUI getConfigUI() { + if (configUi == null) { + configUi = new XloveCamConfigUI(site); + } + return configUi; + } + + @Override + public synchronized boolean login() throws IOException { + if (!site.credentialsAvailable()) { + return false; + } + return site.getHttpClient().login(); + } +} diff --git a/client/src/main/java/ctbrec/ui/sites/xlovecam/XloveCamTabProvider.java b/client/src/main/java/ctbrec/ui/sites/xlovecam/XloveCamTabProvider.java new file mode 100644 index 00000000..7c033303 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/xlovecam/XloveCamTabProvider.java @@ -0,0 +1,76 @@ +package ctbrec.ui.sites.xlovecam; + +import ctbrec.sites.xlovecam.XloveCam; +import ctbrec.ui.sites.AbstractTabProvider; +import ctbrec.ui.tabs.PaginatedScheduledService; +import ctbrec.ui.tabs.ThumbOverviewTab; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class XloveCamTabProvider extends AbstractTabProvider { + + private final XloveCam xloveCam; + + private static final String FILTER_PARAM = "config[filter][10][]"; + private static final String FILTER_PARAM_NEW = "config[filter][100522][]"; + + public XloveCamTabProvider(XloveCam xloveCam) { + super(xloveCam); + this.xloveCam = xloveCam; + } + + @Override + protected List getSiteTabs(Scene scene) { + List tabs = new ArrayList<>(); + + // all + var updateService = new XloveCamUpdateService(xloveCam, Collections.emptyMap()); + tabs.add(createTab("All", updateService)); + + // new + updateService = new XloveCamUpdateService(xloveCam, Map.of(FILTER_PARAM_NEW, "3")); + tabs.add(createTab("New", updateService)); + + // Young Women + updateService = new XloveCamUpdateService(xloveCam, Map.of(FILTER_PARAM, "1")); + tabs.add(createTab("Young Women", updateService)); + + // Ladies + updateService = new XloveCamUpdateService(xloveCam, Map.of(FILTER_PARAM, "13")); + tabs.add(createTab("Ladies", updateService)); + + // Mature + updateService = new XloveCamUpdateService(xloveCam, Map.of(FILTER_PARAM, "6")); + tabs.add(createTab("Mature Female", updateService)); + + // Couples + updateService = new XloveCamUpdateService(xloveCam, Map.of(FILTER_PARAM, "2")); + tabs.add(createTab("Couples", updateService)); + + // Lesbian + updateService = new XloveCamUpdateService(xloveCam, Map.of(FILTER_PARAM, "3")); + tabs.add(createTab("Lesbian", updateService)); + + // Male + updateService = new XloveCamUpdateService(xloveCam, Map.of(FILTER_PARAM, "7")); + tabs.add(createTab("Male", updateService)); + + // Trans + updateService = new XloveCamUpdateService(xloveCam, Map.of(FILTER_PARAM, "5")); + tabs.add(createTab("Trans", updateService)); + + return tabs; + } + + private Tab createTab(String title, PaginatedScheduledService updateService) { + var tab = new ThumbOverviewTab(title, updateService, xloveCam); + tab.setRecorder(recorder); + return tab; + } + +} diff --git a/client/src/main/java/ctbrec/ui/sites/xlovecam/XloveCamUpdateService.java b/client/src/main/java/ctbrec/ui/sites/xlovecam/XloveCamUpdateService.java new file mode 100644 index 00000000..a64bc6cc --- /dev/null +++ b/client/src/main/java/ctbrec/ui/sites/xlovecam/XloveCamUpdateService.java @@ -0,0 +1,42 @@ +package ctbrec.ui.sites.xlovecam; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import ctbrec.Model; +import ctbrec.sites.xlovecam.XloveCam; +import ctbrec.sites.xlovecam.XloveCamModelLoader; +import ctbrec.ui.tabs.PaginatedScheduledService; +import javafx.concurrent.Task; + +public class XloveCamUpdateService extends PaginatedScheduledService { + + private XloveCamModelLoader loader; + private Map filter; + + public XloveCamUpdateService(XloveCam xloveCam, Map filter) { + this.loader = new XloveCamModelLoader(xloveCam); + this.filter = new HashMap<>(filter); + this.filter.putAll(Map.of( // @formatter:off + "config[nickname]", "", + "config[favorite]", "0", + "config[recent]", "0", + "config[vip]", "0", + "origin", "postop-eol", + "stat", "0" + )); // @formatter:on + + } + + @Override + protected Task> createTask() { + return new Task>() { + @Override + public List call() throws IOException { + return loader.loadModelList(page, filter); + } + }; + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/DonateTabFx.java b/client/src/main/java/ctbrec/ui/tabs/DonateTabFx.java new file mode 100644 index 00000000..35dc9aac --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/DonateTabFx.java @@ -0,0 +1,79 @@ +package ctbrec.ui.tabs; + + + +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.control.Tab; +import javafx.scene.control.TextField; +import javafx.scene.image.ImageView; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.scene.text.Font; + +public class DonateTabFx extends Tab { + + public DonateTabFx() { + setClosable(false); + setText("Donate"); + var container = new BorderPane(); + container.setPadding(new Insets(10)); + setContent(container); + + var headerVbox = new VBox(10); + headerVbox.setAlignment(Pos.CENTER); + var beer = new Label("Buy me some beer?!"); + beer.setFont(new Font(36)); + var desc = new Label("If you like this software and want to buy me some beer or pizza, here are some possibilities!"); + desc.setFont(new Font(24)); + headerVbox.getChildren().addAll(beer, desc); + var header = new HBox(); + header.setAlignment(Pos.CENTER); + header.getChildren().add(headerVbox); + header.setPadding(new Insets(20, 0, 0, 0)); + container.setTop(header); + + var prefWidth = 360; + var bitcoinAddress = new TextField("15sLWZon8diPqAX4UdPQU1DcaPuvZs2GgA"); + bitcoinAddress.setEditable(false); + bitcoinAddress.setPrefWidth(prefWidth); + var bitcoinQrCode = new ImageView(getClass().getResource("/html/bitcoin-address.png").toString()); + var bitcoinLabel = new Label("Bitcoin"); + bitcoinLabel.setGraphic(new ImageView(getClass().getResource("/html/bitcoin.png").toString())); + var bitcoinBox = new VBox(5); + bitcoinBox.setAlignment(Pos.TOP_CENTER); + bitcoinBox.getChildren().addAll(bitcoinLabel, bitcoinAddress, bitcoinQrCode); + + var ethereumAddress = new TextField("0x996041638eEAE7E31f39Ef6e82068d69bA7C090e"); + ethereumAddress.setEditable(false); + ethereumAddress.setPrefWidth(prefWidth); + var ethereumQrCode = new ImageView(getClass().getResource("/html/ethereum-address.png").toString()); + var ethereumLabel = new Label("Ethereum"); + ethereumLabel.setGraphic(new ImageView(getClass().getResource("/html/ethereum.png").toString())); + var ethereumBox = new VBox(5); + ethereumBox.setAlignment(Pos.TOP_CENTER); + ethereumBox.getChildren().addAll(ethereumLabel, ethereumAddress, ethereumQrCode); + + var moneroAddress = new TextField("871K7xaLR2X8E84CUBi7D88diXgKjbhjZHTEFfJv9ec9eo4NVPCQ2UsGxkroseCcKQbZsHMgW3kg6HR4tfct3fX2HoFDzK6"); + moneroAddress.setEditable(false); + moneroAddress.setPrefWidth(prefWidth); + var moneroQrCode = new ImageView(getClass().getResource("/html/monero-address.png").toString()); + var moneroLabel = new Label("Monero"); + moneroLabel.setGraphic(new ImageView(getClass().getResource("/html/monero.png").toString())); + var moneroBox = new VBox(5); + moneroBox.setAlignment(Pos.TOP_CENTER); + moneroBox.getChildren().addAll(moneroLabel, moneroAddress, moneroQrCode); + + var bottomBox = new HBox(5); + bottomBox.setAlignment(Pos.CENTER); + bottomBox.setSpacing(50); + bottomBox.getChildren().addAll(bitcoinBox, ethereumBox, moneroBox); + VBox.setMargin(bottomBox, new Insets(20, 0, 0, 0)); + + var centerBox = new VBox(50); + centerBox.getChildren().addAll(bottomBox); + container.setCenter(centerBox); + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/DownloadPostprocessor.java b/client/src/main/java/ctbrec/ui/tabs/DownloadPostprocessor.java new file mode 100644 index 00000000..787b042c --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/DownloadPostprocessor.java @@ -0,0 +1,20 @@ +package ctbrec.ui.tabs; + +import java.io.IOException; + +import ctbrec.recorder.postprocessing.AbstractPlaceholderAwarePostProcessor; +import ctbrec.recorder.postprocessing.PostProcessingContext; + +public class DownloadPostprocessor extends AbstractPlaceholderAwarePostProcessor { + + @Override + public String getName() { + return "download renamer"; + } + + @Override + public boolean postprocess(PostProcessingContext ctx) throws IOException, InterruptedException { + // nothing really to do in here, we just inherit from AbstractPlaceholderAwarePostProcessor to use fillInPlaceHolders + return true; + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/FollowTabBlinkTransition.java b/client/src/main/java/ctbrec/ui/tabs/FollowTabBlinkTransition.java new file mode 100644 index 00000000..a33f332a --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/FollowTabBlinkTransition.java @@ -0,0 +1,44 @@ +package ctbrec.ui.tabs; + +import javafx.animation.Transition; +import javafx.scene.control.Tab; +import javafx.scene.paint.Color; +import javafx.util.Duration; + +public class FollowTabBlinkTransition extends Transition { + + private final String normalStyle; + private final Tab followedTab; + private final Color normal; + private final Color highlight; + + FollowTabBlinkTransition(Tab followedTab) { + this.followedTab = followedTab; + normalStyle = followedTab.getStyle(); + normal = Color.web("#f4f4f4"); + highlight = Color.web("#2b8513"); + + setCycleDuration(Duration.millis(500)); + setCycleCount(6); + setAutoReverse(true); + setOnFinished(evt -> followedTab.setStyle(normalStyle)); + } + + @Override + protected void interpolate(double fraction) { + double rh = highlight.getRed(); + double rn = normal.getRed(); + double diff = rh - rn; + double r = (rn + diff * fraction) * 255; + double gh = highlight.getGreen(); + double gn = normal.getGreen(); + diff = gh - gn; + double g = (gn + diff * fraction) * 255; + double bh = highlight.getBlue(); + double bn = normal.getBlue(); + diff = bh - bn; + double b = (bn + diff * fraction) * 255; + String style = "-fx-background-color: rgb(" + r + "," + g + "," + b + ")"; + followedTab.setStyle(style); + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/FollowedTab.java b/client/src/main/java/ctbrec/ui/tabs/FollowedTab.java new file mode 100644 index 00000000..7da276a4 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/FollowedTab.java @@ -0,0 +1,8 @@ +package ctbrec.ui.tabs; + +/** + * Marker interface to mark tabs, which contain followed models + */ +public interface FollowedTab { + +} diff --git a/client/src/main/java/ctbrec/ui/tabs/HelpTab.java b/client/src/main/java/ctbrec/ui/tabs/HelpTab.java new file mode 100644 index 00000000..6760b706 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/HelpTab.java @@ -0,0 +1,49 @@ +package ctbrec.ui.tabs; + +import ctbrec.docs.DocServer; +import ctbrec.ui.DesktopIntegration; +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.scene.control.Button; +import javafx.scene.control.Tab; +import javafx.scene.layout.BorderPane; +import lombok.extern.slf4j.Slf4j; + +import java.util.concurrent.atomic.AtomicBoolean; + +@Slf4j +public class HelpTab extends Tab { + + public HelpTab() { + setClosable(true); + setText("Help"); + + var openHelp = new Button("Open Help"); + openHelp.setPadding(new Insets(20)); + var layout = new BorderPane(openHelp); + BorderPane.setMargin(openHelp, new Insets(20)); + setContent(layout); + AtomicBoolean started = new AtomicBoolean(false); + openHelp.setOnAction(e -> { + synchronized (started) { + if (!started.get()) { + new Thread(() -> { + try { + DocServer.start(() -> + Platform.runLater(() -> { + started.set(true); + DesktopIntegration.open("http://localhost:5689/docs/index.md"); + }) + ); + } catch (Exception ex) { + log.error("Couldn't start documentation server", ex); + } + }).start(); + } else { + DesktopIntegration.open("http://localhost:5689/docs/index.md"); + } + } + } + ); + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/PaginatedScheduledService.java b/client/src/main/java/ctbrec/ui/tabs/PaginatedScheduledService.java new file mode 100644 index 00000000..bd1afb7e --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/PaginatedScheduledService.java @@ -0,0 +1,15 @@ +package ctbrec.ui.tabs; + +import ctbrec.Model; +import javafx.concurrent.ScheduledService; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public abstract class PaginatedScheduledService extends ScheduledService> { + + protected int page = 1; +} diff --git a/client/src/main/java/ctbrec/ui/tabs/RecentlyWatchedTab.java b/client/src/main/java/ctbrec/ui/tabs/RecentlyWatchedTab.java new file mode 100644 index 00000000..aae42a70 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/RecentlyWatchedTab.java @@ -0,0 +1,300 @@ +package ctbrec.ui.tabs; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.eventbus.Subscribe; +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.StringUtil; +import ctbrec.event.EventBusHolder; +import ctbrec.io.json.ObjectMapperFactory; +import ctbrec.recorder.Recorder; +import ctbrec.sites.Site; +import ctbrec.sites.SiteUtil; +import ctbrec.ui.ShutdownListener; +import ctbrec.ui.action.PlayAction; +import ctbrec.ui.controls.CustomMouseBehaviorContextMenu; +import ctbrec.ui.controls.DateTimeCellFactory; +import ctbrec.ui.controls.SearchBox; +import ctbrec.ui.event.PlayerStartedEvent; +import ctbrec.ui.io.json.dto.PlayerStartedEventDto; +import ctbrec.ui.io.json.mapper.PlayerStartedEventMapper; +import ctbrec.ui.menu.ModelMenuContributor; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.scene.Cursor; +import javafx.scene.control.*; +import javafx.scene.control.TableColumn.SortType; +import javafx.scene.input.*; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.util.Callback; +import lombok.extern.slf4j.Slf4j; +import org.mapstruct.factory.Mappers; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.nio.file.StandardOpenOption.*; + +@Slf4j +public class RecentlyWatchedTab extends Tab implements ShutdownListener { + + private final ObservableList filteredModels = FXCollections.observableArrayList(); + private final ObservableList observableModels = FXCollections.observableArrayList(); + private final TableView table = new TableView<>(); + private final ReentrantLock lock = new ReentrantLock(); + private final Recorder recorder; + private final ObjectMapper mapper = ObjectMapperFactory.getMapper(); + private ContextMenu popup; + private List sites; + + public RecentlyWatchedTab(Recorder recorder, List sites) { + this.recorder = recorder; + this.sites = sites; + setText("Recently Watched"); + createGui(); + loadHistory(); + subscribeToPlayerEvents(); + setOnClosed(evt -> onShutdown()); + } + + private void createGui() { + Config config = Config.getInstance(); + var layout = new BorderPane(); + layout.setPadding(new Insets(5, 10, 10, 10)); + + var filterInput = new SearchBox(false); + filterInput.setPromptText("Filter"); + filterInput.textProperty().addListener((observableValue, oldValue, newValue) -> { + String filter = filterInput.getText(); + lock.lock(); + try { + filter(filter); + } finally { + lock.unlock(); + } + }); + filterInput.getStyleClass().remove("search-box-icon"); + HBox.setHgrow(filterInput, Priority.ALWAYS); + var topBar = new HBox(5); + topBar.getChildren().addAll(filterInput); + layout.setTop(topBar); + BorderPane.setMargin(topBar, new Insets(0, 0, 5, 0)); + + table.setItems(observableModels); + table.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); + if (!config.getSettings().showGridLinesInTables) { + table.setStyle("-fx-table-cell-border-color: transparent;"); + } + 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(); + } + }); + table.addEventHandler(KeyEvent.KEY_PRESSED, event -> { + List selectedModels = table.getSelectionModel().getSelectedItems(); + if (event.getCode() == KeyCode.DELETE) { + delete(selectedModels); + } + }); + + var idx = 0; + TableColumn name = createTableColumn("Model", 200, idx++); + name.setId("name"); + name.setCellValueFactory(cdf -> new SimpleStringProperty(cdf.getValue().getModel().getDisplayName())); + name.setCellFactory(new ClickableCellFactory<>()); + table.getColumns().add(name); + + TableColumn url = createTableColumn("URL", 400, idx); + url.setCellValueFactory(cdf -> new SimpleStringProperty(cdf.getValue().getModel().getUrl())); + url.setCellFactory(new ClickableCellFactory<>()); + url.setEditable(false); + url.setId("url"); + table.getColumns().add(url); + + TableColumn timestamp = createTableColumn("Timestamp", 150, idx); + timestamp.setId("timestamp"); + timestamp.setCellValueFactory(cdf -> new SimpleObjectProperty<>(cdf.getValue().getTimestamp())); + timestamp.setCellFactory(new DateTimeCellFactory<>(config.getDateTimeFormatter())); + timestamp.setEditable(false); + timestamp.setSortType(SortType.DESCENDING); + table.getColumns().add(timestamp); + table.getSortOrder().add(timestamp); + + var scrollPane = new ScrollPane(); + scrollPane.setFitToHeight(true); + scrollPane.setFitToWidth(true); + scrollPane.setContent(table); + scrollPane.setStyle("-fx-background-color: -fx-background"); + layout.setCenter(scrollPane); + setContent(layout); + } + + private TableColumn createTableColumn(String text, int width, int idx) { + TableColumn tc = new TableColumn<>(text); + tc.setPrefWidth(width); + tc.setUserData(idx); + return 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 (var i = 0; i < table.getItems().size(); i++) { + var sb = new StringBuilder(); + for (TableColumn tc : table.getColumns()) { + Object cellData = tc.getCellData(i); + if (cellData != null) { + var content = cellData.toString(); + sb.append(content).append(' '); + } + } + var searchText = sb.toString(); + + var tokensMissing = false; + for (String token : tokens) { + if (!searchText.toLowerCase().contains(token.toLowerCase())) { + tokensMissing = true; + break; + } + } + if (tokensMissing) { + PlayerStartedEvent sessionState = table.getItems().get(i); + filteredModels.add(sessionState); + } + } + observableModels.removeAll(filteredModels); + } finally { + lock.unlock(); + } + } + + private ContextMenu createContextMenu() { + ObservableList selectedRows = table.getSelectionModel().getSelectedItems(); + if (selectedRows.isEmpty()) { + return null; + } + + List selectedModels = table.getSelectionModel().getSelectedItems().stream().map(PlayerStartedEvent::getModel).collect(Collectors.toList()); + ContextMenu menu = new CustomMouseBehaviorContextMenu(); + + ModelMenuContributor.newContributor(getTabPane(), Config.getInstance(), recorder) // + .withStartStopCallback(m -> getTabPane().setCursor(Cursor.DEFAULT)) // + .afterwards(table::refresh) + .contributeToMenu(selectedModels, menu); + + menu.getItems().add(new SeparatorMenuItem()); + var delete = new MenuItem("Delete"); + delete.setOnAction(e -> delete(selectedRows)); + var clearHistory = new MenuItem("Clear history"); + clearHistory.setOnAction(e -> clearHistory()); + menu.getItems().addAll(delete, clearHistory); + + return menu; + } + + private void clearHistory() { + observableModels.clear(); + filteredModels.clear(); + } + + private void delete(List selectedRows) { + observableModels.removeAll(selectedRows); + } + + private void subscribeToPlayerEvents() { + EventBusHolder.BUS.register(new Object() { + @Subscribe + public void handleEvent(PlayerStartedEvent evt) { + table.getItems().add(evt); + table.sort(); + } + }); + } + + private class ClickableCellFactory implements Callback, TableCell> { + @Override + public TableCell call(TableColumn param) { + TableCell cell = new TableCell<>() { + @Override + protected void updateItem(Object item, boolean empty) { + setText(empty ? "" : Objects.toString(item)); + } + }; + + cell.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> { + if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) { + var selectedModel = table.getSelectionModel().getSelectedItem().getModel(); + if (selectedModel != null) { + new PlayAction(table, selectedModel).execute(); + } + } + }); + return cell; + } + } + + private void saveHistory() throws IOException { + String json = mapper.writeValueAsString(observableModels.stream().map(Mappers.getMapper(PlayerStartedEventMapper.class)::toDto).toList()); + var recentlyWatched = new File(Config.getInstance().getConfigDir(), "recently_watched.json"); + log.debug("Saving recently watched models to {}", recentlyWatched.getAbsolutePath()); + Files.createDirectories(recentlyWatched.getParentFile().toPath()); + Files.writeString(recentlyWatched.toPath(), json, CREATE, WRITE, TRUNCATE_EXISTING); + } + + private void loadHistory() { + var recentlyWatched = new File(Config.getInstance().getConfigDir(), "recently_watched.json"); + if (!recentlyWatched.exists()) { + return; + } + + log.debug("Loading recently watched models from {}", recentlyWatched.getAbsolutePath()); + try { + List fromJson = mapper.readValue(Files.readString(recentlyWatched.toPath(), UTF_8), new TypeReference>() { + }); + observableModels.addAll(fromJson.stream() + .map(Mappers.getMapper(PlayerStartedEventMapper.class)::toEvent) + .toList()); + observableModels.forEach(evt -> SiteUtil.getSiteForModel(sites, evt.getModel()).ifPresent(evt.getModel()::setSite)); + } catch (IOException e) { + log.error("Couldn't load recently watched models", e); + } + } + + @Override + public void onShutdown() { + try { + saveHistory(); + } catch (IOException e) { + log.error("Couldn't safe recently watched models", e); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java b/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java new file mode 100644 index 00000000..20376f55 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java @@ -0,0 +1,875 @@ +package ctbrec.ui.tabs; + +import ctbrec.*; +import ctbrec.Recording.State; +import ctbrec.event.EventBusHolder; +import ctbrec.event.RecordingStateChangedEvent; +import ctbrec.io.UrlUtil; +import ctbrec.notes.ModelNotesService; +import ctbrec.recorder.ProgressListener; +import ctbrec.recorder.Recorder; +import ctbrec.recorder.download.StreamSource; +import ctbrec.recorder.postprocessing.PostProcessingContext; +import ctbrec.ui.*; +import ctbrec.ui.action.PauseAction; +import ctbrec.ui.action.PlayAction; +import ctbrec.ui.action.StopRecordingAction; +import ctbrec.ui.controls.CustomMouseBehaviorContextMenu; +import ctbrec.ui.controls.DateTimeCellFactory; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.controls.Toast; +import ctbrec.ui.controls.table.SettingTableViewStateStore; +import ctbrec.ui.controls.table.StatePersistingTableView; +import ctbrec.ui.menu.ModelMenuContributor; +import ctbrec.ui.tabs.recorded.ModelName; +import javafx.application.Platform; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.concurrent.ScheduledService; +import javafx.concurrent.Task; +import javafx.geometry.Insets; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.control.Alert.AlertType; +import javafx.scene.control.*; +import javafx.scene.input.*; +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.Duration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +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.util.*; +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.stream.Collectors; + +import static ctbrec.Recording.State.*; +import static javafx.scene.control.ButtonType.NO; +import static javafx.scene.control.ButtonType.YES; + +public class RecordingsTab extends Tab implements TabSelectionListener, ShutdownListener { + private static final String ERROR_WHILE_DOWNLOADING_RECORDING = "Error while downloading recording"; + + private static final Logger LOG = LoggerFactory.getLogger(RecordingsTab.class); + + private ScheduledService> updateService; + private final Config config; + private final ModelNotesService modelNotesService; + private final Recorder recorder; + private long spaceTotal = -1; + private long spaceFree = -1; + + FlowPane grid = new FlowPane(); + ScrollPane scrollPane = new ScrollPane(); + SettingTableViewStateStore tableStateStore = new SettingTableViewStateStore(Config.getInstance(), "recordingsTable"); + StatePersistingTableView table = new StatePersistingTableView<>(tableStateStore); + ObservableList observableRecordings = FXCollections.observableArrayList(); + ContextMenu popup; + ProgressBar spaceLeft; + Label spaceFreeLabel; + Label spaceUsedValue; + Lock recordingsLock = new ReentrantLock(); + + public RecordingsTab(String title, Recorder recorder, Config config, ModelNotesService modelNotesService) { + super(title); + this.recorder = recorder; + this.config = config; + this.modelNotesService = modelNotesService; + createGui(); + setClosable(false); + initializeUpdateService(); + } + + @SuppressWarnings("unchecked") + private void createGui() { + grid.setPadding(new Insets(5)); + grid.setHgap(5); + grid.setVgap(5); + + scrollPane.setContent(grid); + scrollPane.setFitToHeight(true); + scrollPane.setFitToWidth(true); + BorderPane.setMargin(scrollPane, new Insets(5)); + + table.setEditable(false); + table.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); + if (!config.getSettings().showGridLinesInTables) { + table.setStyle("-fx-table-cell-border-color: transparent;"); + } + + TableColumn name = new TableColumn<>("Model"); + name.setId("name"); + name.setPrefWidth(200); + name.setCellValueFactory(param -> { + var modelName = new ModelName(param.getValue().getModel(), recorder); + return new SimpleObjectProperty<>(modelName); + }); + TableColumn date = new TableColumn<>("Date"); + date.setId("date"); + date.setCellValueFactory(cdf -> { + var instant = cdf.getValue().getStartDate(); + return new SimpleObjectProperty<>(instant); + }); + date.setCellFactory(new DateTimeCellFactory<>(config.getDateTimeFormatter())); + date.setPrefWidth(200); + TableColumn status = new TableColumn<>("Status"); + status.setId("status"); + status.setCellValueFactory(cdf -> cdf.getValue().getStatusProperty()); + status.setPrefWidth(300); + TableColumn progress = new TableColumn<>("Progress"); + progress.setId("progress"); + progress.setCellValueFactory(cdf -> cdf.getValue().getProgressProperty()); + progress.setPrefWidth(100); + TableColumn size = new TableColumn<>("Size"); + size.setId("size"); + size.setStyle("-fx-alignment: CENTER-RIGHT;"); + size.setPrefWidth(100); + size.setCellValueFactory(cdf -> cdf.getValue().getSizeProperty()); + size.setCellFactory(tc -> createSizeCell()); + TableColumn resolution = new TableColumn<>("Resolution"); + resolution.setId("resolution"); + resolution.setPrefWidth(100); + resolution.setCellValueFactory(cdf -> new SimpleIntegerProperty(cdf.getValue().getSelectedResolution())); + resolution.setCellFactory(tc -> createResolutionCell()); + + TableColumn siteName = new TableColumn<>("Site"); + siteName.setId("siteName"); + siteName.setPrefWidth(200); + siteName.setCellValueFactory(cdf -> { + var sname = cdf.getValue().getModel().getSite().getName(); + return new SimpleObjectProperty<>(sname); + }); + + TableColumn notes = new TableColumn<>("Notes"); + notes.setId("notes"); + notes.setPrefWidth(400); + notes.setCellValueFactory(cdf -> cdf.getValue().getNoteProperty()); + TableColumn modelNotes = new TableColumn<>("Model Notes"); + modelNotes.setId("modelNotes"); + modelNotes.setPrefWidth(400); + modelNotes.setCellValueFactory(cdf -> { + String modelNts; + try { + modelNts = modelNotesService.loadModelNotes(cdf.getValue().getModel().getUrl()).orElse(""); + } catch (IOException e) { + LOG.warn("Could not load model notes", e); + modelNts = ""; + } + return new SimpleStringProperty(modelNts); + }); + + table.getColumns().addAll(siteName, name, date, status, progress, size, resolution, notes, modelNotes); + table.setItems(observableRecordings); + table.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, this::onContextMenuRequested); + table.addEventHandler(MouseEvent.MOUSE_PRESSED, this::onMousePressed); + table.addEventFilter(MouseEvent.MOUSE_CLICKED, this::onMouseClicked); + table.addEventFilter(KeyEvent.KEY_PRESSED, this::onKeyPressed); + scrollPane.setContent(table); + + var spaceBox = new HBox(5); + var spaceLeftLabel = new Label("Space left on device"); + spaceBox.getChildren().add(spaceLeftLabel); + spaceLeft = new ProgressBar(0); + spaceLeft.setPrefSize(200, 22); + spaceFreeLabel = new Label(); + spaceFreeLabel.setFont(Font.font(11)); + var stack = new StackPane(spaceLeft, spaceFreeLabel); + var spaceUsedLabel = new Label("Space used:"); + spaceUsedValue = new Label(); + spaceBox.getChildren().addAll(stack, spaceUsedLabel, spaceUsedValue); + HBox.setMargin(spaceLeftLabel, new Insets(2, 0, 0, 0)); + HBox.setMargin(spaceUsedLabel, new Insets(2, 0, 0, 20)); + HBox.setMargin(spaceUsedValue, new Insets(2, 0, 0, 0)); + BorderPane.setMargin(spaceBox, new Insets(5)); + + var root = new BorderPane(); + root.setPadding(new Insets(5)); + root.setTop(spaceBox); + root.setCenter(scrollPane); + setContent(root); + + table.restoreState(); + } + + public boolean isDownloadRunning() { + return observableRecordings.stream().map(Recording::getStatus).anyMatch(s -> s == DOWNLOADING); + } + + private TableCell createSizeCell() { + return 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() == RECORDING) { + setStyle("-fx-alignment: CENTER-RIGHT; -fx-background-color: red"); + } + } + } + } + }; + } + + private TableCell createResolutionCell() { + return new TableCell<>() { + @Override + protected void updateItem(Number resolution, boolean empty) { + if (empty || resolution == null) { + setText(null); + setStyle(null); + } else { + String text = String.valueOf(resolution); + if (resolution.intValue() <= 0) { + text = "n/a"; + } + if (resolution.intValue() == StreamSource.UNKNOWN) { + text = "unknown"; + } + setStyle("-fx-alignment: CENTER-RIGHT;"); + setText(text); + } + } + }; + } + + private void onContextMenuRequested(ContextMenuEvent event) { + List recordings = table.getSelectionModel().getSelectedItems(); + if (recordings != null && !recordings.isEmpty()) { + popup = createContextMenu(recordings); + if (!popup.getItems().isEmpty()) { + popup.show(table, event.getScreenX(), event.getScreenY()); + } + } + event.consume(); + } + + private void onMousePressed(MouseEvent event) { + if (popup != null) { + popup.hide(); + } + } + + private void onMouseClicked(MouseEvent event) { + if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) { + Recording recording = table.getSelectionModel().getSelectedItem(); + if (recording != null) { + var state = recording.getStatus(); + if (state == FINISHED || state == RECORDING) { + play(recording); + } + } + } + } + + private void onKeyPressed(KeyEvent event) { + List recordings = table.getSelectionModel().getSelectedItems(); + if (recordings != null && !recordings.isEmpty()) { + State status = recordings.get(0).getStatus(); + if (event.getCode() == KeyCode.DELETE) { + if (recordings.size() > 1 || status == FINISHED || status == FAILED || status == WAITING) { + delete(recordings); + } + } else if (event.getCode() == KeyCode.ENTER && status == FINISHED) { + play(recordings.get(0)); + } else { + jumpToNextModel(event.getCode()); + } + } + } + + void initializeUpdateService() { + updateService = createUpdateService(); + updateService.setPeriod(new Duration(TimeUnit.SECONDS.toMillis(2))); + updateService.setOnSucceeded(event -> { + updateRecordingsTable(); + updateFreeSpaceDisplay(); + updateUsedSpaceDisplay(); + }); + updateService.setOnFailed(event -> { + LOG.info("Couldn't get list of recordings from recorder", event.getSource().getException()); + var autosizeAlert = new AutosizeAlert(AlertType.ERROR, getTabPane().getScene()); + autosizeAlert.setTitle("Whoopsie!"); + autosizeAlert.setHeaderText("Recordings not available"); + autosizeAlert.setContentText("An error occurred while retrieving the list of recordings"); + autosizeAlert.showAndWait(); + }); + } + + private void updateUsedSpaceDisplay() { + long sum = table.getItems().stream().mapToLong(Recording::getSizeInByte).sum(); + spaceUsedValue.setText(StringUtil.formatSize(sum)); + } + + 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; + var df = new DecimalFormat("0.00"); + String tt = df.format(freeGiB) + " / " + df.format(totalGiB) + " GiB"; + spaceLeft.setTooltip(new Tooltip(tt)); + spaceFreeLabel.setText(tt); + } + } + + private void updateRecordingsTable() { + List recordings = updateService.getValue(); + if (recordings == null) { + return; + } + + recordingsLock.lock(); + try { + // remove deleted recordings + observableRecordings.removeIf(old -> !recordings.contains(old)); + + for (JavaFxRecording recording : recordings) { + if (!observableRecordings.contains(recording)) { + // add new recordings + observableRecordings.add(recording); + } else { + // update existing ones + int index = observableRecordings.indexOf(recording); + JavaFxRecording old = observableRecordings.get(index); + old.update(recording); + } + } + } finally { + recordingsLock.unlock(); + } + table.sort(); + } + + private ScheduledService> createUpdateService() { + ScheduledService> service = new ScheduledService<>() { + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() throws IOException, InvalidKeyException, NoSuchAlgorithmException { + 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); + } + } + }; + } + }; + ExecutorService executor = Executors.newSingleThreadExecutor(r -> { + var t = new Thread(r); + t.setDaemon(true); + t.setName("RecordingsTab UpdateService"); + return t; + }); + service.setExecutor(executor); + return service; + } + + @Override + public void selected() { + if (updateService != null) { + updateService.reset(); + updateService.restart(); + } + } + + @Override + public void deselected() { + if (updateService != null) { + updateService.cancel(); + } + } + + private ContextMenu createContextMenu(List recordings) { + ContextMenu contextMenu = new CustomMouseBehaviorContextMenu(); + contextMenu.setHideOnEscape(true); + contextMenu.setAutoHide(true); + contextMenu.setAutoFix(true); + + JavaFxRecording first = recordings.get(0); + var openInPlayer = new MenuItem("Open in Player"); + openInPlayer.setOnAction(e -> play(first)); + if (first.getStatus() == RECORDING || first.getStatus() == FINISHED) { + contextMenu.getItems().add(openInPlayer); + } + if (first.getStatus() == RECORDING) { + var openLiveStream = new MenuItem("Open live stream"); + openLiveStream.setOnAction(e -> play(first.getModel())); + contextMenu.getItems().add(openLiveStream); + } + + var openContactSheet = new MenuItem("Open contact sheet"); + openContactSheet.setOnAction(e -> openContactSheet(first)); + openContactSheet.setDisable(first.getContactSheet().isEmpty()); + contextMenu.getItems().add(openContactSheet); + + var stopRecording = new MenuItem("Stop Recording"); + stopRecording.setOnAction(e -> stopRecording(recordings.stream().map(JavaFxRecording::getModel).collect(Collectors.toList()))); + if (recordings.stream().anyMatch(r -> r.getStatus() == RECORDING)) { + contextMenu.getItems().add(stopRecording); + } + + var pauseRecording = new MenuItem("Pause Recording"); + pauseRecording.setOnAction(e -> pauseRecording(recordings.stream().map(JavaFxRecording::getModel).collect(Collectors.toList()))); + if (recordings.stream().anyMatch(r -> r.getStatus() == RECORDING)) { + contextMenu.getItems().add(pauseRecording); + } + + var deleteRecording = new MenuItem("Delete"); + deleteRecording.setOnAction(e -> delete(recordings)); + if (first.getStatus() == FINISHED || first.getStatus() == WAITING || first.getStatus() == FAILED || recordings.size() > 1) { + contextMenu.getItems().add(deleteRecording); + deleteRecording.setDisable(recordings.stream().allMatch(Recording::isPinned)); + } + + var tmp = new CustomMouseBehaviorContextMenu(); + ModelMenuContributor.newContributor(getTabPane(), Config.getInstance(), recorder) // + .withStartStopCallback(m -> getTabPane().setCursor(Cursor.DEFAULT)) // + .removeModelAfterIgnore(true) // + .afterwards(table::refresh) // + .contributeToMenu(List.of(recordings.get(0).getModel()), tmp); + var modelSubMenu = new Menu("Model"); + modelSubMenu.getItems().addAll(tmp.getItems()); + contextMenu.getItems().add(modelSubMenu); + + var openDir = new MenuItem("Open directory"); + openDir.setOnAction(e -> onOpenDirectory(first)); + if (Config.getInstance().getSettings().localRecording) { + contextMenu.getItems().add(openDir); + } + + var downloadRecording = new MenuItem("Download"); + downloadRecording.setOnAction(e -> download(first)); + if (!Config.getInstance().getSettings().localRecording && first.getStatus() == FINISHED) { + contextMenu.getItems().add(downloadRecording); + } + + var notes = new MenuItem("Notes"); + notes.setOnAction(e -> notes(first)); + contextMenu.getItems().add(notes); + + if (first.isPinned()) { + var unpinRecording = new MenuItem("Unpin"); + unpinRecording.setOnAction(e -> unpin(recordings)); + contextMenu.getItems().add(unpinRecording); + } else { + var pinRecording = new MenuItem("Pin"); + pinRecording.setOnAction(e -> pin(recordings)); + contextMenu.getItems().add(pinRecording); + } + + var rerunPostProcessing = new MenuItem("Rerun Post-Processing"); + rerunPostProcessing.setOnAction(e -> triggerPostProcessing(recordings)); + contextMenu.getItems().add(rerunPostProcessing); + rerunPostProcessing.setDisable(!recordings.stream().allMatch(Recording::canBePostProcessed)); + + if (recordings.size() > 1) { + openInPlayer.setDisable(true); + openDir.setDisable(true); + } + + return contextMenu; + } + + private void stopRecording(List selectedModels) { + var confirmed = true; + if (Config.getInstance().getSettings().confirmationForDangerousActions) { + int n = selectedModels.size(); + String plural = n > 1 ? "s" : ""; + String header = "This will stop the recording of " + n + " model" + plural; + confirmed = Dialogs.showConfirmDialog("Stop Recording", "Continue?", header, table.getScene()); + } + if (confirmed) { + new StopRecordingAction(getTabPane(), selectedModels, recorder).execute(); + } + } + + private void pauseRecording(List selectedModels) { + new PauseAction(getTabPane(), selectedModels, recorder).execute(); + } + + private void openContactSheet(JavaFxRecording recording) { + if (config.getSettings().localRecording) { + recording.getContactSheet().ifPresent(f -> GlobalThreadPool.submit(() -> DesktopIntegration.open(f))); + } else { + recording.getContactSheet().ifPresent(f -> GlobalThreadPool.submit(() -> { + File target; + try { + target = File.createTempFile("cs_", ".jpg"); + target.deleteOnExit(); + var download = new FileDownload(CamrecApplication.httpClient, p -> { + if (p == 100) { + DesktopIntegration.open(target); + } + }); + String url = config.getServerUrl() + "/hls/" + recording.getId() + f.getCanonicalPath(); + if (config.getSettings().requireAuthentication) { + url = UrlUtil.addHmac(url, config); + } + download.start(new URL(url), target); + } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e) { + Dialogs.showError(getTabPane().getScene(), "Download Error", "An error occurred while downloading the contact sheet", e); + } + })); + } + } + + private void notes(JavaFxRecording recording) { + Node source = getTabPane(); + String notes = recording.getNote(); + Optional newNote = Dialogs.showTextInput(source.getScene(), "Recording Notes", "", notes); + if (newNote.isPresent()) { + table.setCursor(Cursor.WAIT); + GlobalThreadPool.submit(() -> { + List exceptions = new ArrayList<>(); + try { + recording.setNote(newNote.get()); + recorder.setNote(recording.getDelegate(), newNote.get()); + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { + exceptions.add(e); + } finally { + Platform.runLater(() -> { + table.setCursor(Cursor.DEFAULT); + if (!exceptions.isEmpty()) { + showErrorDialog("Error while saving note", "", exceptions); + } + }); + } + }); + } + } + + private void pin(List recordings) { + table.setCursor(Cursor.WAIT); + GlobalThreadPool.submit(() -> { + List exceptions = new ArrayList<>(); + try { + for (JavaFxRecording javaFxRecording : recordings) { + var rec = javaFxRecording.getDelegate(); + try { + recorder.pin(rec); + javaFxRecording.setPinned(true); + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { + exceptions.add(e); + } + } + } finally { + Platform.runLater(() -> { + table.setCursor(Cursor.DEFAULT); + if (!exceptions.isEmpty()) { + showErrorDialog("Error while pinning recordings", "At least one recording couldn't be pinned", exceptions); + } + }); + } + }); + } + + private void unpin(List recordings) { + table.setCursor(Cursor.WAIT); + GlobalThreadPool.submit(() -> { + List exceptions = new ArrayList<>(); + try { + for (JavaFxRecording javaFxRecording : recordings) { + var rec = javaFxRecording.getDelegate(); + try { + recorder.unpin(rec); + javaFxRecording.setPinned(false); + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { + exceptions.add(e); + } + } + } finally { + Platform.runLater(() -> { + table.setCursor(Cursor.DEFAULT); + if (!exceptions.isEmpty()) { + showErrorDialog("Error while unpinning recordings", "At least one recording couldn't be unpinned", exceptions); + } + }); + } + }); + } + + private void jumpToNextModel(KeyCode code) { + try { + ensureTableIsNotEmpty(); + ensureKeyCodeIsLetterOrDigit(code); + + // determine where to start looking for the next model + var startAt = 0; + if (table.getSelectionModel().getSelectedIndex() >= 0) { + startAt = table.getSelectionModel().getSelectedIndex() + 1; + if (startAt >= table.getItems().size()) { + startAt = 0; + } + } + + String c = code.getChar().toLowerCase(); + int i = startAt; + do { + JavaFxRecording current = table.getItems().get(i); + if (current.getModel().getName().toLowerCase().replaceAll("[^0-9a-z]", "").startsWith(c)) { + table.getSelectionModel().clearAndSelect(i); + table.scrollTo(i); + break; + } + + i++; + if (i >= table.getItems().size()) { + i = 0; + } + } while (i != startAt); + } catch (IllegalStateException | IllegalArgumentException e) { + // GUI was not in the state to process the user input + } + } + + private void ensureKeyCodeIsLetterOrDigit(KeyCode code) { + if (!(code.isLetterKey() || code.isDigitKey())) { + throw new IllegalArgumentException("keycode not allowed"); + } + } + + private void ensureTableIsNotEmpty() { + if (table.getItems().isEmpty()) { + throw new IllegalStateException("table is empty"); + } + } + + private void onOpenDirectory(JavaFxRecording first) { + var tsFile = first.getAbsoluteFile(); + GlobalThreadPool.submit(() -> DesktopIntegration.open(tsFile.getParent())); + } + + private void triggerPostProcessing(List recs) { + GlobalThreadPool.submit(() -> { + for (JavaFxRecording rec : recs) { + try { + recorder.rerunPostProcessing(rec.getDelegate()); + } catch (IOException | InvalidKeyException | NoSuchAlgorithmException | IllegalStateException e1) { + showErrorDialog("Error while starting post-processing", "The post-processing could not be started", e1); + LOG.error("Error while starting post-processing", e1); + } + } + }); + } + + private void download(Recording recording) { + try { + LOG.debug("Path {}", recording.getAbsoluteFile()); + String filename = proposeTargetFilename(recording); + var chooser = new FileChooser(); + chooser.setInitialFileName(filename); + if (config.getSettings().lastDownloadDir != null && !config.getSettings().lastDownloadDir.equals("")) { + var 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(); + startDownloadThread(target, recording); + recording.setStatus(DOWNLOADING); + recording.setProgress(0); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + private String proposeTargetFilename(Recording recording) { + if (recording.isSingleFile()) { + return recording.getPostProcessedFile().getName(); + } else { + String downloadFilename = config.getSettings().downloadFilename; + String fileSuffix = config.getSettings().ffmpegFileSuffix; + var ctx = new PostProcessingContext(); + ctx.setRecording(recording); + ctx.setConfig(config); + ctx.setRecorder(recorder); + return new DownloadPostprocessor().fillInPlaceHolders(downloadFilename, ctx) + '.' + fileSuffix; + } + } + + private void startDownloadThread(File target, Recording recording) { + var t = new Thread(() -> { + try { + String hlsBase = config.getServerUrl() + "/hls"; + if (recording.isSingleFile()) { + var download = new FileDownload(CamrecApplication.httpClient, createDownloadListener(recording)); + download.start(new URL(hlsBase + '/' + recording.getId()), target); + } else { + var url = new URL(hlsBase + '/' + recording.getId() + "/playlist.m3u8"); + var download = new RecordingDownload(CamrecApplication.httpClient); + download.init(config, recording.getModel(), Instant.now(), Executors.newSingleThreadExecutor()); + LOG.info("Downloading {}", url); + download.downloadFinishedRecording(url.toString(), target, createDownloadListener(recording), recording.getSizeInByte()); + } + } catch (FileNotFoundException e) { + showErrorDialog(ERROR_WHILE_DOWNLOADING_RECORDING, "The target file couldn't be created", e); + LOG.error(ERROR_WHILE_DOWNLOADING_RECORDING, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + showErrorDialog(ERROR_WHILE_DOWNLOADING_RECORDING, "The recording could not be downloaded", e); + LOG.error(ERROR_WHILE_DOWNLOADING_RECORDING, e); + } catch (Exception e) { + showErrorDialog(ERROR_WHILE_DOWNLOADING_RECORDING, "The recording could not be downloaded", e); + LOG.error(ERROR_WHILE_DOWNLOADING_RECORDING, e); + } finally { + Platform.runLater(() -> { + recording.setStatus(FINISHED); + recording.setProgress(-1); + var evt = new RecordingStateChangedEvent(target, recording.getStatus(), recording.getModel(), recording.getStartDate()); + EventBusHolder.BUS.post(evt); + }); + } + }); + t.setDaemon(true); + t.setName("Download Thread " + recording.getAbsoluteFile().toString()); + t.start(); + } + + private ProgressListener createDownloadListener(Recording recording) { + return progress -> Platform.runLater(() -> { + if (progress == 100) { + recording.setStatus(FINISHED); + recording.setProgress(-1); + LOG.debug("Download finished for recording {} - {}", recording.getId(), recording.getAbsoluteFile()); + } else { + recording.setStatus(DOWNLOADING); + recording.setProgress(progress); + } + }); + } + + private void showErrorDialog(final String title, final String msg, final Exception e) { + showErrorDialog(title, msg, Collections.singletonList(e)); + } + + private void showErrorDialog(final String title, final String msg, final List exceptions) { + Platform.runLater(() -> { + var autosizeAlert = new AutosizeAlert(AlertType.ERROR, getTabPane().getScene()); + autosizeAlert.setTitle(title); + autosizeAlert.setHeaderText(msg); + var contentText = new StringBuilder("On or more error(s) occurred:"); + for (Exception exception : exceptions) { + contentText.append("\n• ").append(exception.getLocalizedMessage()); + } + autosizeAlert.setContentText(contentText.toString()); + autosizeAlert.showAndWait(); + }); + } + + private void play(Recording recording) { + GlobalThreadPool.submit(() -> { + boolean started = Player.play(recording); + if (started && Config.getInstance().getSettings().showPlayerStarting) { + Platform.runLater(() -> Toast.makeText(getTabPane().getScene(), "Starting Player", 2000, 500, 500)); + } + }); + } + + private void play(Model model) { + new PlayAction(table, model).execute(); + } + + private void delete(List recordings) { + table.setCursor(Cursor.WAIT); + String msg; + if (recordings.size() > 1) { + msg = "Delete " + recordings.size() + " recordings for good?"; + } else { + Recording r = recordings.get(0); + msg = "Delete " + r.getModel().getName() + "/" + r.getStartDate() + " for good?"; + } + var confirm = new AutosizeAlert(AlertType.CONFIRMATION, msg, getTabPane().getScene(), YES, NO); + confirm.setTitle("Delete recording?"); + confirm.setHeaderText(msg); + confirm.setContentText(""); + confirm.showAndWait(); + if (confirm.getResult() == ButtonType.YES) { + deleteAsync(recordings); + } else { + table.setCursor(Cursor.DEFAULT); + } + } + + private void deleteAsync(List recordings) { + GlobalThreadPool.submit(() -> { + recordingsLock.lock(); + try { + List deleted = new ArrayList<>(); + List exceptions = new ArrayList<>(); + for (JavaFxRecording r : recordings) { + if (r.getStatus() != FINISHED && r.getStatus() != FAILED && r.getStatus() != WAITING) { + continue; + } + try { + recorder.delete(r.getDelegate()); + deleted.add(r); + } catch (Exception e1) { + exceptions.add(e1); + LOG.error("Error while deleting recording", e1); + } + } + if (!exceptions.isEmpty()) { + showErrorDialog("Error while deleting recording", "Recording not deleted", exceptions); + } + observableRecordings.removeAll(deleted); + } finally { + recordingsLock.unlock(); + Platform.runLater(() -> table.setCursor(Cursor.DEFAULT)); + } + }); + } + + @Override + public void onShutdown() { + table.saveState(); + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/SiteTab.java b/client/src/main/java/ctbrec/ui/tabs/SiteTab.java new file mode 100644 index 00000000..889140a7 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/SiteTab.java @@ -0,0 +1,27 @@ +package ctbrec.ui.tabs; + +import ctbrec.sites.Site; +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +public class SiteTab extends Tab implements TabSelectionListener { + + private SiteTabPane siteTabPane; + + public SiteTab(Site site, Scene scene) { + super(site.getTitle()); + setClosable(false); + siteTabPane = new SiteTabPane(site, scene); + setContent(siteTabPane); + } + + @Override + public void selected() { + siteTabPane.selected(); + } + + @Override + public void deselected() { + siteTabPane.deselected(); + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/SiteTabPane.java b/client/src/main/java/ctbrec/ui/tabs/SiteTabPane.java new file mode 100644 index 00000000..8699bfc2 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/SiteTabPane.java @@ -0,0 +1,46 @@ +package ctbrec.ui.tabs; + +import ctbrec.sites.Site; +import ctbrec.ui.SiteUiFactory; +import javafx.beans.value.ChangeListener; +import javafx.geometry.Side; +import javafx.scene.Scene; +import javafx.scene.control.Tab; +import javafx.scene.control.TabPane; + +public class SiteTabPane extends TabPane { + + public SiteTabPane(Site site, Scene scene) { + setSide(Side.LEFT); + + // add all tabs + var tabProvider = SiteUiFactory.getUi(site).getTabProvider(); + for (Tab tab : tabProvider.getTabs(scene)) { + getTabs().add(tab); + } + + // register changelistener to activate / deactivate tabs, when the user switches between them + getSelectionModel().selectedItemProperty().addListener((ChangeListener) (ov, from, to) -> { + if (from instanceof TabSelectionListener) { + ((TabSelectionListener) from).deselected(); + } + if (to instanceof TabSelectionListener) { + ((TabSelectionListener) to).selected(); + } + }); + } + + public void selected() { + var selectedTab = getSelectionModel().getSelectedItem(); + if (selectedTab instanceof TabSelectionListener) { + ((TabSelectionListener) selectedTab).selected(); + } + } + + public void deselected() { + var selectedTab = getSelectionModel().getSelectedItem(); + if (selectedTab instanceof TabSelectionListener) { + ((TabSelectionListener) selectedTab).deselected(); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/TabProvider.java b/client/src/main/java/ctbrec/ui/tabs/TabProvider.java new file mode 100644 index 00000000..5e7f1fbd --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/TabProvider.java @@ -0,0 +1,12 @@ +package ctbrec.ui.tabs; + +import java.util.List; + +import javafx.scene.Scene; +import javafx.scene.control.Tab; + +public interface TabProvider { + + public List getTabs(Scene scene); + public Tab getFollowedTab(); +} diff --git a/client/src/main/java/ctbrec/ui/tabs/TabSelectionListener.java b/client/src/main/java/ctbrec/ui/tabs/TabSelectionListener.java new file mode 100644 index 00000000..e91c7785 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/TabSelectionListener.java @@ -0,0 +1,6 @@ +package ctbrec.ui.tabs; + +public interface TabSelectionListener { + public void selected(); + public void deselected(); +} diff --git a/client/src/main/java/ctbrec/ui/tabs/ThumbCell.css b/client/src/main/java/ctbrec/ui/tabs/ThumbCell.css new file mode 100644 index 00000000..4e239d7c --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/ThumbCell.css @@ -0,0 +1,7 @@ +.thumbcell-selection-background { + -fx-fill: -fx-accent; +} + +.thumbcell-name { + -fx-font: normal bold 1.2em 'sans-serif'; +} \ No newline at end of file diff --git a/client/src/main/java/ctbrec/ui/tabs/ThumbCell.java b/client/src/main/java/ctbrec/ui/tabs/ThumbCell.java new file mode 100644 index 00000000..889815ba --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/ThumbCell.java @@ -0,0 +1,736 @@ +package ctbrec.ui.tabs; + +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import ctbrec.Config; +import ctbrec.GlobalThreadPool; +import ctbrec.Model; +import ctbrec.io.HttpException; +import ctbrec.recorder.Recorder; +import ctbrec.ui.AutosizeAlert; +import ctbrec.ui.Icon; +import ctbrec.ui.SiteUiFactory; +import ctbrec.ui.action.EditGroupAction; +import ctbrec.ui.action.PlayAction; +import ctbrec.ui.action.StopRecordingAction; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.controls.RecordingIndicator; +import ctbrec.ui.controls.StreamPreview; +import javafx.animation.FadeTransition; +import javafx.animation.FillTransition; +import javafx.animation.ParallelTransition; +import javafx.animation.Transition; +import javafx.application.Platform; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.value.ObservableValue; +import javafx.collections.ObservableList; +import javafx.embed.swing.SwingFXUtils; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.control.Alert; +import javafx.scene.control.Label; +import javafx.scene.control.Tooltip; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; +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.Text; +import javafx.scene.text.TextAlignment; +import javafx.util.Duration; +import okhttp3.Request; +import okhttp3.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import javax.imageio.ImageIO; + +import static ctbrec.Model.State.OFFLINE; +import static ctbrec.Model.State.ONLINE; +import static ctbrec.io.HttpConstants.*; +import static ctbrec.ui.Icon.*; + +public class ThumbCell extends StackPane { + + private static final String ERROR = "Error"; + private static final Logger LOG = LoggerFactory.getLogger(ThumbCell.class); + private static final Duration ANIMATION_DURATION = new Duration(250); + + private static final Image imgRecordIndicator = new Image(MEDIA_RECORD_16.url()); + private static final Image imgForceRecordIndicator = new Image(MEDIA_FORCE_RECORD_16.url()); + private static final Image imgPauseIndicator = new Image(MEDIA_PLAYBACK_PAUSE_16.url()); + private static final Image imgBookmarkIndicator = new Image(BOOKMARK_16.url()); + private static final Image imgGroupIndicator = new Image(Icon.GROUP_16.url()); + + private ModelRecordingState modelRecordingState = ModelRecordingState.NOT; + private final Model model; + private final StreamPreview streamPreview; + private final ImageView iv; + private final Rectangle resolutionBackground; + private final Paint resolutionOnlineColor = new Color(0.22, 0.8, 0.29, 1); + private final Color resolutionOfflineColor = new Color(0.8, 0.28, 0.28, 1); + private final Rectangle nameBackground; + private final Rectangle topicBackground; + private final Rectangle selectionOverlay; + private final Text name; + private final Text topic; + private final Text resolutionTag; + private final Recorder recorder; + private final RecordingIndicator recordingIndicator; + private final Tooltip recordingIndicatorTooltip; + private StackPane previewTrigger; + private final StackPane groupIndicator; + private final Label groupIndicatorTooltipTrigger; + private int index = 0; + private static final Color colorNormal = Color.BLACK; + private static final Color colorHighlight = Color.WHITE; + private final Color colorRecording = new Color(0.8, 0.28, 0.28, .8); + private final SimpleBooleanProperty selectionProperty = new SimpleBooleanProperty(false); + private double imgAspectRatio; + private final SimpleBooleanProperty preserveAspectRatio = new SimpleBooleanProperty(true); + + private final ObservableList thumbCellList; + private boolean mouseHovering = false; + private boolean recording; + static LoadingCache resolutionCache = CacheBuilder.newBuilder() + .expireAfterAccess(5, TimeUnit.MINUTES) + .maximumSize(10000) + .build(CacheLoader.from(ThumbCell::getStreamResolution)); + private final ThumbOverviewTab parent; + private CompletableFuture startPreview; + + public ThumbCell(ThumbOverviewTab parent, Model model, Recorder recorder, double aspectRatio) { + this.parent = parent; + this.thumbCellList = parent.grid.getChildren(); + this.model = model; + this.recorder = recorder; + this.imgAspectRatio = aspectRatio; + recording = recorder.isTracked(model); + model.setSuspended(recorder.isSuspended(model)); + model.setForcePriority(recorder.isForcePriority(model)); + 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); + iv.setPreserveRatio(true); + getChildren().add(iv); + + topicBackground = new Rectangle(); + topicBackground.setFill(Color.BLACK); + topicBackground.setOpacity(0); + StackPane.setAlignment(topicBackground, Pos.TOP_LEFT); + getChildren().add(topicBackground); + + resolutionBackground = new Rectangle(34, 16); + resolutionBackground.setFill(resolutionOnlineColor); + resolutionBackground.setVisible(false); + resolutionBackground.setArcHeight(5); + resolutionBackground.setArcWidth(resolutionBackground.getArcHeight()); + StackPane.setAlignment(resolutionBackground, Pos.TOP_RIGHT); + StackPane.setMargin(resolutionBackground, new Insets(2)); + getChildren().add(resolutionBackground); + + topic = new Text(); + String txt = recording ? " " : ""; + txt += model.getDescription(); + topic.setText(txt); + + topic.setFill(Color.WHITE); + topic.setTextAlignment(TextAlignment.LEFT); + topic.setOpacity(0); + var margin = 4; + StackPane.setMargin(topic, new Insets(margin)); + StackPane.setAlignment(topic, Pos.TOP_CENTER); + getChildren().add(topic); + + nameBackground = new Rectangle(); + nameBackground.setFill(recording ? colorRecording : colorNormal); + nameBackground.setOpacity(.7); + StackPane.setAlignment(nameBackground, Pos.BOTTOM_CENTER); + getChildren().add(nameBackground); + + name = new Text(model.getDisplayName()); + name.setFill(Color.WHITE); + name.setTextAlignment(TextAlignment.CENTER); + name.getStyleClass().add("thumbcell-name"); + StackPane.setAlignment(name, Pos.BOTTOM_CENTER); + getChildren().add(name); + + resolutionTag = new Text(); + resolutionTag.setFill(Color.WHITE); + resolutionTag.setVisible(false); + StackPane.setAlignment(resolutionTag, Pos.TOP_RIGHT); + StackPane.setMargin(resolutionTag, new Insets(2, 4, 2, 2)); + getChildren().add(resolutionTag); + + recordingIndicator = new RecordingIndicator(16); + recordingIndicator.setCursor(Cursor.HAND); + recordingIndicator.setOnMouseClicked(this::recordingInidicatorClicked); + recordingIndicatorTooltip = new Tooltip("Pause Recording"); + Tooltip.install(recordingIndicator, recordingIndicatorTooltip); + StackPane.setMargin(recordingIndicator, new Insets(3)); + StackPane.setAlignment(recordingIndicator, Pos.TOP_LEFT); + getChildren().add(recordingIndicator); + + groupIndicator = new StackPane(); + groupIndicator.setMaxSize(24, 24); + var groupIndicatorImg = new ImageView(imgGroupIndicator); + groupIndicatorImg.setVisible(false); + groupIndicatorImg.visibleProperty().bind(groupIndicator.visibleProperty()); + groupIndicatorTooltipTrigger = new Label(); + groupIndicatorTooltipTrigger.setPrefSize(16, 16); + groupIndicatorTooltipTrigger.setMinSize(16, 16); + groupIndicatorTooltipTrigger.visibleProperty().bind(groupIndicator.visibleProperty()); + groupIndicatorTooltipTrigger.setCursor(Cursor.HAND); + groupIndicatorTooltipTrigger.setOnMouseClicked(e -> { + if (e.getButton() == MouseButton.PRIMARY) { + new EditGroupAction(this, recorder, model).execute(); + e.consume(); + } + }); + var groupIndicatorBackground = new Circle(12, Color.WHITE); + groupIndicatorBackground.visibleProperty().bind(groupIndicator.visibleProperty()); + groupIndicatorBackground.setOpacity(0.7); + groupIndicator.getChildren().addAll(groupIndicatorBackground, groupIndicatorImg, groupIndicatorTooltipTrigger); + StackPane.setAlignment(groupIndicator, Pos.BOTTOM_RIGHT); + getChildren().add(groupIndicator); + + if (Config.getInstance().getSettings().livePreviews) { + getChildren().add(createPreviewTrigger()); + } + + selectionOverlay = new Rectangle(); + selectionOverlay.visibleProperty().bind(selectionProperty); + selectionOverlay.widthProperty().bind(widthProperty()); + selectionOverlay.heightProperty().bind(heightProperty()); + StackPane.setAlignment(selectionOverlay, Pos.TOP_LEFT); + getChildren().add(selectionOverlay); + + setOnMouseEntered(e -> { + mouseHovering = true; + Color normal = recording ? colorRecording : colorNormal; + new ParallelTransition(changeColor(nameBackground, normal, colorHighlight), changeColor(name, colorHighlight, normal)).playFromStart(); + new ParallelTransition(changeOpacity(topicBackground, 0.7), changeOpacity(topic, 0.7)).playFromStart(); + new ParallelTransition(changeOpacity(nameBackground, 1), changeOpacity(name, 1)).playFromStart(); + if (Config.getInstance().getSettings().determineResolution) { + resolutionBackground.setVisible(false); + resolutionTag.setVisible(false); + } + }); + setOnMouseExited(e -> { + mouseHovering = false; + Color normal = recording ? colorRecording : colorNormal; + new ParallelTransition(changeColor(nameBackground, colorHighlight, normal), changeColor(name, normal, colorHighlight)).playFromStart(); + new ParallelTransition(changeOpacity(topicBackground, 0), changeOpacity(topic, 0)).playFromStart(); + new ParallelTransition(changeOpacity(nameBackground, 0.7), changeOpacity(name, 0.7)).playFromStart(); + if (Config.getInstance().getSettings().determineResolution && !resolutionTag.getText().isEmpty()) { + resolutionBackground.setVisible(true); + resolutionTag.setVisible(true); + } + }); + setThumbWidth(Config.getInstance().getSettings().thumbWidth); + + setRecording(recording); + update(); + } + + private void recordingInidicatorClicked(MouseEvent evt) { + switch (modelRecordingState) { + case RECORDING -> pauseResumeAction(true); + case PAUSED -> pauseResumeAction(false); + case BOOKMARKED -> forgetModel(); + } + } + + private void forgetModel() { + new StopRecordingAction(this, List.of(model), recorder) + .execute() + .thenAccept(r -> update()); + } + + private Node createPreviewTrigger() { + var s = 24; + previewTrigger = new StackPane(); + previewTrigger.setStyle("-fx-background-color: white;"); + previewTrigger.setOpacity(.8); + previewTrigger.setMaxSize(s, s); + + var play = new Polygon(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); + + var clip = new Circle(s / 2.0); + 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 -> startPreview()); + previewTrigger.setOnMouseExited(evt -> stopPreview()); + return previewTrigger; + } + + private void stopPreview() { + if (startPreview != null) { + startPreview.cancel(true); + } + setPreviewVisible(previewTrigger, false); + } + + private void startPreview() { + previewTrigger.setCursor(Cursor.HAND); + startPreview = CompletableFuture.supplyAsync(() -> { + try { + Thread.sleep(500); + return true; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + }, GlobalThreadPool.get()).whenComplete((result, exception) -> { + startPreview = null; + if (Boolean.TRUE.equals(result)) { + setPreviewVisible(previewTrigger, true); + } + }); + } + + 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); + if (visible) { + streamPreview.startStream(model); + } else { + streamPreview.stop(); + } + recordingIndicator.setVisible(!visible); + if (!visible) { + updateRecordingIndicator(); + } + previewTrigger.setCursor(visible ? Cursor.HAND : Cursor.DEFAULT); + } + + public void setSelected(boolean selected) { + selectionProperty.set(selected); + selectionOverlay.setOpacity(selected ? .75 : 0); + if (selected) { + selectionOverlay.getStyleClass().add("thumbcell-selection-background"); + } else { + selectionOverlay.getStyleClass().remove("thumbcell-selection-background"); + } + } + + public boolean isSelected() { + return selectionProperty.get(); + } + + public ObservableValue selectionProperty() { + return selectionProperty; + } + + private void updateResolutionTag() { + ThumbOverviewTab.threadPool.submit(() -> { + int[] resolution; + String tagText; + Paint resolutionBackgroundColor; + try { + resolution = resolutionCache.get(model); + resolutionBackgroundColor = resolutionOnlineColor; + final int w = resolution[1]; + tagText = w != Integer.MAX_VALUE ? Integer.toString(w) : "HD"; + if (w == 0) { + var state = model.getOnlineState(false); + tagText = state.name(); + if (model.isOnline() && state == ONLINE) { + resolutionCache.invalidate(model); + } else { + resolutionBackgroundColor = resolutionOfflineColor; + if (state == ONLINE) { + // state can't be ONLINE while the model is offline + tagText = OFFLINE.name(); + } + } + } else { + var state = model.getOnlineState(true); + if (state != ONLINE) { + tagText = state.name(); + resolutionBackgroundColor = resolutionOfflineColor; + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + tagText = "error"; + resolutionBackgroundColor = resolutionOfflineColor; + } catch (ExecutionException | IOException e) { + tagText = "error"; + resolutionBackgroundColor = resolutionOfflineColor; + } + + final String text = tagText; + final Paint c = resolutionBackgroundColor; + Platform.runLater(() -> { + String oldText = resolutionTag.getText(); + resolutionTag.setText(text); + if (!mouseHovering) { + resolutionTag.setVisible(true); + resolutionBackground.setVisible(true); + } + resolutionBackground.setWidth(resolutionTag.getLayoutBounds().getWidth() + 4); + resolutionBackground.setFill(c); + if (!Objects.equals(oldText, text)) { + parent.filter(); + } + }); + }); + } + + private void setImage(String url) { + if (!Objects.equals(System.getenv("CTBREC_DEV"), "1")) { + boolean updateThumbs = Config.getInstance().getSettings().updateThumbnails; + if (updateThumbs || iv.getImage() == null) { + GlobalThreadPool.submit(createThumbDownload(url)); + } + } + } + + private Runnable createThumbDownload(String url) { + return () -> { + Request req = new Request.Builder() + .url(url) + .header(ACCEPT, "*/*") + .header(ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()) + .header(USER_AGENT, Config.getInstance().getSettings().httpUserAgent) + .header(REFERER, getModel().getSite().getBaseUrl()) + .build(); + try (Response resp = model.getSite().getHttpClient().executeWithCache(req)) { + if (resp.isSuccessful()) { + // double width = 480; + // double height = width * imgAspectRatio; + InputStream bodyStream = Objects.requireNonNull(resp.body(), "HTTP body is null").byteStream(); + // javafx supports only a few image formats (not webp, for example), so we go through ImageIO that does + var img = SwingFXUtils.toFXImage(ImageIO.read(bodyStream), null); + + if (img.progressProperty().get() == 1.0) { + if (img.isError()) { + throw new IOException(img.getException()); + } + Platform.runLater(() -> { + iv.setImage(img); + setThumbWidth(Config.getInstance().getSettings().thumbWidth); + }); + } else { + img.progressProperty().addListener((observable, oldValue, 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.warn("Error loading thumbnail: {} {}", url, e.getLocalizedMessage()); + } + }; + } + + Image getImage() { + return iv.getImage(); + } + + private Transition changeColor(Shape shape, Color from, Color to) { + var transition = new FillTransition(ANIMATION_DURATION, from, to); + transition.setShape(shape); + return transition; + } + + private Transition changeOpacity(Shape shape, double opacity) { + var transition = new FadeTransition(ANIMATION_DURATION, shape); + transition.setFromValue(shape.getOpacity()); + transition.setToValue(opacity); + return transition; + } + + void startPlayer() { + new PlayAction(this, model).execute(); + } + + private void setRecording(boolean recording) { + this.recording = recording; + Color c; + if (recording) { + c = mouseHovering ? colorHighlight : colorRecording; + } else { + c = mouseHovering ? colorHighlight : colorNormal; + } + nameBackground.setFill(c); + updateRecordingIndicator(); + } + + private void updateRecordingIndicator() { + if (recording) { + recordingIndicator.setVisible(true); + if (model.isSuspended()) { + modelRecordingState = ModelRecordingState.PAUSED; + recordingIndicator.setImage(imgPauseIndicator); + recordingIndicatorTooltip.setText("Resume Recording"); + } else { + modelRecordingState = ModelRecordingState.RECORDING; + if (model.isForcePriority()) { + recordingIndicator.setImage(imgForceRecordIndicator); + } else { + recordingIndicator.setImage(imgRecordIndicator); + } + recordingIndicatorTooltip.setText("Pause Recording"); + } + } else { + if (model.isMarkedForLaterRecording()) { + recordingIndicator.setVisible(true); + modelRecordingState = ModelRecordingState.BOOKMARKED; + recordingIndicator.setImage(imgBookmarkIndicator); + recordingIndicatorTooltip.setText("Forget Model"); + } else { + recordingIndicator.setVisible(false); + modelRecordingState = ModelRecordingState.NOT; + recordingIndicator.setImage(null); + } + } + } + + void pauseResumeAction(boolean pause) { + setCursor(Cursor.WAIT); + GlobalThreadPool.submit(() -> { + try { + if (pause) { + recorder.suspendRecording(model); + } else { + recorder.resumeRecording(model); + } + setRecording(recording); + } catch (Exception e1) { + LOG.error("Couldn't pause/resume recording", e1); + Platform.runLater(() -> { + Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, getScene()); + alert.setTitle(ERROR); + alert.setHeaderText("Couldn't pause/resume recording"); + alert.setContentText("I/O error while pausing/resuming the recording: " + e1.getLocalizedMessage()); + alert.showAndWait(); + }); + } finally { + Platform.runLater(() -> setCursor(Cursor.DEFAULT)); + } + }); + } + + CompletableFuture follow(boolean follow) { + setCursor(Cursor.WAIT); + return CompletableFuture.supplyAsync(() -> { + try { + if (follow) { + SiteUiFactory.getUi(model.getSite()).login(); + boolean followed = model.follow(); + if (followed) { + return true; + } else { + Dialogs.showError(getScene(), "Couldn't follow model", "", null); + return false; + } + } else { + SiteUiFactory.getUi(model.getSite()).login(); + boolean unfollowed = model.unfollow(); + if (unfollowed) { + Platform.runLater(() -> thumbCellList.remove(ThumbCell.this)); + return true; + } else { + Dialogs.showError(getScene(), "Couldn't unfollow model", "", null); + return false; + } + } + } catch (Exception e1) { + LOG.error("Couldn't follow/unfollow model {}", model.getName(), e1); + String msg = "I/O error while following/unfollowing model " + model.getName() + ": "; + Dialogs.showError(getScene(), "Couldn't follow/unfollow model", msg, e1); + return false; + } finally { + Platform.runLater(() -> setCursor(Cursor.DEFAULT)); + } + }, GlobalThreadPool.get()); + } + + public Model getModel() { + return model; + } + + public void setModel(Model model) { + this.model.setName(model.getName()); + this.model.setDescription(model.getDescription()); + this.model.setPreview(model.getPreview()); + this.model.setTags(model.getTags()); + this.model.setUrl(model.getUrl()); + update(); + } + + public int getIndex() { + return index; + } + + public void setIndex(int index) { + this.index = index; + } + + protected void update() { + model.setSuspended(recorder.isSuspended(model)); + model.setMarkedForLaterRecording(recorder.isMarkedForLaterRecording(model)); + setRecording(recorder.isTracked(model)); + updateRecordingIndicator(); + setImage(model.getPreview()); + String txt = (modelRecordingState != ModelRecordingState.NOT) ? " " : ""; + txt += model.getDescription() != null ? model.getDescription() : ""; + topic.setText(txt); + recorder.getModelGroup(model).ifPresentOrElse(group -> { + var tooltip = group.getName() + ": " + group.getModelUrls().size() + " models:\n"; + tooltip += String.join("\n", group.getModelUrls()); + groupIndicatorTooltipTrigger.setTooltip(new Tooltip(tooltip)); + groupIndicator.setVisible(true); + }, () -> groupIndicator.setVisible(false)); + + if (Config.getInstance().getSettings().determineResolution) { + updateResolutionTag(); + } else { + resolutionBackground.setVisible(false); + resolutionTag.setVisible(false); + } + + requestLayout(); + } + + @Override + public int hashCode() { + final var prime = 31; + var result = 1; + result = prime * result + ((model == null) ? 0 : model.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; + ThumbCell other = (ThumbCell) obj; + if (model == null) { + return other.model == null; + } else return model.equals(other.model); + } + + public void setThumbWidth(int width) { + int height = (int) (width * imgAspectRatio); + setSize(width, height); + iv.prefHeight(width); + iv.prefWidth(height); + } + + private void setSize(int w, int 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(25); + topicBackground.setWidth(w); + topicBackground.setHeight(h - nameBackground.getHeight()); + topic.prefHeight(getHeight() - 25); + topic.maxHeight(getHeight() - 25); + var margin = 4; + topic.maxWidth(w - margin * 2.0); + topic.setWrappingWidth(w - margin * 2.0); + + streamPreview.resizeTo(w, h); + + var clip = new Rectangle(w, h); + clip.setArcWidth(10); + clip.arcHeightProperty().bind(clip.arcWidthProperty()); + this.setClip(clip); + } + + private static int[] getStreamResolution(Model model) { + try { + return model.getStreamResolution(false); + } catch (ExecutionException e) { + LOG.trace("Error loading stream resolution for model {}: {}", model, e.getLocalizedMessage()); + return new int[2]; + } + } + + public void releaseResources() { + iv.setImage(null); + } + + public void setImageAspectRatio(double imageAspectRatio) { + this.imgAspectRatio = imageAspectRatio; + } + + public BooleanProperty preserveAspectRatioProperty() { + return preserveAspectRatio; + } + + private enum ModelRecordingState { + RECORDING, + PAUSED, + BOOKMARKED, + NOT + } + + @Override + protected void layoutChildren() { + nameBackground.setHeight(name.getLayoutBounds().getHeight()); + resolutionBackground.setHeight(resolutionTag.getLayoutBounds().getHeight()); + topicBackground.setHeight(getHeight() - nameBackground.getHeight()); + + StackPane.setMargin(groupIndicator, new Insets(0, 3, nameBackground.getHeight() + 4, 0)); + if (Config.getInstance().getSettings().livePreviews) { + StackPane.setMargin(previewTrigger, new Insets(0, 0, nameBackground.getHeight() + 4, 4)); + } + super.layoutChildren(); + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTab.java b/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTab.java new file mode 100644 index 00000000..a8953c29 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTab.java @@ -0,0 +1,845 @@ +package ctbrec.ui.tabs; + +import ctbrec.Config; +import ctbrec.GlobalThreadPool; +import ctbrec.Model; +import ctbrec.ModelGroup; +import ctbrec.recorder.Recorder; +import ctbrec.sites.Site; +import ctbrec.sites.mfc.MyFreeCamsClient; +import ctbrec.sites.mfc.MyFreeCamsModel; +import ctbrec.ui.DesktopIntegration; +import ctbrec.ui.SiteUiFactory; +import ctbrec.ui.TokenLabel; +import ctbrec.ui.action.SetThumbAsPortraitAction; +import ctbrec.ui.controls.*; +import ctbrec.ui.menu.ModelMenuContributor; +import javafx.animation.*; +import javafx.application.Platform; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +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.ActionEvent; +import javafx.event.EventHandler; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.control.*; +import javafx.scene.image.ImageView; +import javafx.scene.input.*; +import javafx.scene.layout.*; +import javafx.util.Duration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; + +public class ThumbOverviewTab extends Tab implements TabSelectionListener { + private static final Logger LOG = LoggerFactory.getLogger(ThumbOverviewTab.class); + + protected static BlockingQueue queue = new LinkedBlockingQueue<>(); + static ExecutorService threadPool = new ThreadPoolExecutor(2, 2, 10, TimeUnit.MINUTES, queue, createThreadFactory()); + + protected FlowPane grid = new FlowPane(); + protected PaginatedScheduledService updateService; + protected HBox pagination; + protected List selectedThumbCells = Collections.synchronizedList(new ArrayList<>()); + + List filteredThumbCells = Collections.synchronizedList(new ArrayList<>()); + Recorder recorder; + private String filter; + ReentrantLock gridLock = new ReentrantLock(); + ScrollPane scrollPane = new ScrollPane(); + TextField pageInput = new TextField(Integer.toString(1)); + Button pageFirst = new Button("1"); + Button pagePrev = new Button("◀"); + Button pageNext = new Button("▶"); + private volatile boolean updatesSuspended = false; + ContextMenu popup; + Site site; + StackPane root = new StackPane(); + Task> searchTask; + SearchPopover popover; + SearchPopoverTreeList popoverTreeList = new SearchPopoverTreeList(); + double imageAspectRatio = 3.0 / 4.0; + private final SimpleBooleanProperty preserveAspectRatio = new SimpleBooleanProperty(true); + ProgressIndicator progressIndicator; + Label noResultsFound = new Label("Nothing found!"); + Label errorLabel = new Label(""); + TokenLabel tokenBalance; + + private ComboBox thumbWidth; + + public ThumbOverviewTab(String title, PaginatedScheduledService updateService, Site site) { + super(title); + this.updateService = updateService; + this.site = site; + setClosable(false); + createGui(); + initializeUpdateService(); + } + + protected void createGui() { + grid.setPadding(new Insets(5)); + grid.setHgap(5); + grid.setVgap(5); + + progressIndicator = new ProgressIndicator(); + progressIndicator.setPrefSize(100, 100); + + var filterInput = new SearchBox(false); + filterInput.setPromptText("Filter models on this page"); + filterInput.textProperty().addListener((observableValue, oldValue, newValue) -> { + filter = filterInput.getText(); + gridLock.lock(); + try { + filter(); + moveActiveRecordingsToFront(); + } finally { + gridLock.unlock(); + } + }); + var filterTooltip = new Tooltip("Filter the models by their name, stream description or #hashtags.\n\nIf the display of stream resolution is enabled, you can even filter for public rooms or by resolution.\n\nTry \"1080\" or \">720\" or \"public\""); + filterInput.setTooltip(filterTooltip); + filterInput.getStyleClass().remove("search-box-icon"); + + var searchInput = new SearchBox(); + searchInput.setPromptText("Search Model"); + searchInput.prefWidth(200); + searchInput.prefHeightProperty().bind(filterInput.heightProperty()); + 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)); + + var topBar = new HBox(5); + HBox.setHgrow(filterInput, Priority.ALWAYS); + topBar.getChildren().add(filterInput); + if (site.supportsTips() && site.credentialsAvailable()) { + var buyTokens = new Button("Buy Tokens"); + buyTokens.setOnAction(e -> DesktopIntegration.open(site.getBuyTokensLink())); + tokenBalance = new TokenLabel(site); + tokenBalance.setAlignment(Pos.CENTER_RIGHT); + tokenBalance.prefHeightProperty().bind(buyTokens.heightProperty()); + topBar.getChildren().addAll(tokenBalance, buyTokens); + } + if (site.supportsSearch()) { + topBar.getChildren().add(searchInput); + } + BorderPane.setMargin(topBar, new Insets(0, 5, 0, 5)); + + scrollPane.setContent(grid); + scrollPane.setFitToHeight(true); + scrollPane.setFitToWidth(true); + if (Config.getInstance().getSettings().fastScrollSpeed) { + var scrollPaneSkin = new FasterVerticalScrollPaneSkin(scrollPane); + scrollPane.setSkin(scrollPaneSkin); + } + BorderPane.setMargin(scrollPane, new Insets(5)); + + pagination = new HBox(5); + pagination.getChildren().add(pageFirst); + pagination.getChildren().add(pagePrev); + pagination.getChildren().add(pageNext); + pagination.getChildren().add(pageInput); + BorderPane.setMargin(pagination, new Insets(5)); + pageInput.setPrefWidth(50); + pageInput.setOnAction(this::handlePageNumberInput); + pageFirst.setTooltip(new Tooltip("First Page")); + pageFirst.setOnAction(e -> changePageTo(1)); + pageFirst.setMinHeight(24); + pageFirst.setMinWidth(28); + pagePrev.setTooltip(new Tooltip("Previous Page")); + pagePrev.setOnAction(e -> previousPage()); + pagePrev.setMinHeight(24); + pagePrev.setMinWidth(28); + pageNext.setTooltip(new Tooltip("Next Page")); + pageNext.setOnAction(e -> nextPage()); + pageNext.setMinHeight(24); + pageNext.setMinWidth(28); + + var thumbSizeSelector = new HBox(5); + var l = new Label("Thumb Size"); + l.setPadding(new Insets(5, 0, 0, 0)); + thumbSizeSelector.getChildren().add(l); + List thumbWidths = new ArrayList<>(); + thumbWidths.add(180); + thumbWidths.add(200); + thumbWidths.add(220); + thumbWidths.add(270); + thumbWidths.add(360); + thumbWidth = new ComboBox<>(FXCollections.observableList(thumbWidths)); + thumbWidth.getSelectionModel().select(Integer.valueOf(Config.getInstance().getSettings().thumbWidth)); + thumbWidth.setOnAction(e -> { + Config.getInstance().getSettings().thumbWidth = thumbWidth.getSelectionModel().getSelectedItem(); + updateThumbSize(); + }); + thumbSizeSelector.getChildren().add(thumbWidth); + BorderPane.setMargin(thumbSizeSelector, new Insets(5)); + + var bottomPane = new BorderPane(); + bottomPane.setLeft(pagination); + bottomPane.setRight(thumbSizeSelector); + + var borderPane = new BorderPane(); + borderPane.setPadding(new Insets(5)); + borderPane.setTop(topBar); + borderPane.setCenter(scrollPane); + borderPane.setBottom(bottomPane); + + root.getChildren().add(borderPane); + root.getChildren().add(popover); + setContent(root); + + scrollPane.setOnKeyReleased(this::keyReleased); + } + + private void keyReleased(KeyEvent event) { + if (event.getCode() == KeyCode.F5) { + refresh(); + } else if (event.getCode() == KeyCode.A && event.isControlDown()) { + selectAll(); + } else if (event.getCode() == KeyCode.RIGHT) { + nextPage(); + } else if (event.getCode() == KeyCode.LEFT) { + previousPage(); + } else if (event.getCode().getCode() >= KeyCode.DIGIT1.getCode() && event.getCode().getCode() <= KeyCode.DIGIT9.getCode()) { + changePageTo(event.getCode().getCode() - 48); + } + } + + public void selectAll() { + grid.getChildren().stream().filter(ThumbCell.class::isInstance).forEach(tc -> ((ThumbCell) tc).setSelected(true)); + } + + private void nextPage() { + int page = updateService.getPage(); + page++; + changePageTo(page); + } + + private void previousPage() { + int page = updateService.getPage(); + page = Math.max(1, --page); + changePageTo(page); + } + + private void changePageTo(int page) { + pageInput.setText(Integer.toString(page)); + updateService.setPage(page); + selectedThumbCells.clear(); + restartUpdateService(); + } + + private ChangeListener search() { + return (observableValue, oldValue, newValue) -> { + if (searchTask != null) { + searchTask.cancel(true); + } + if (newValue.length() < 2) { + return; + } + searchTask = new ThumbOverviewTabSearchTask(site, popover, popoverTreeList, newValue); + GlobalThreadPool.submit(searchTask); + }; + } + + private void updateThumbSize() { + int width = Config.getInstance().getSettings().thumbWidth; + thumbWidth.getSelectionModel().select(Integer.valueOf(width)); + for (Node node : grid.getChildren()) { + if (node instanceof ThumbCell cell) { + cell.setThumbWidth(width); + } + } + for (ThumbCell cell : filteredThumbCells) { + cell.setThumbWidth(width); + } + } + + private void handlePageNumberInput(ActionEvent event) { + try { + var page = Integer.parseInt(pageInput.getText()); + page = Math.max(1, page); + changePageTo(page); + } catch (NumberFormatException e) { + // noop + } finally { + pageInput.setText(Integer.toString(updateService.getPage())); + } + } + + private void restartUpdateService() { + gridLock.lock(); + try { + grid.getChildren().clear(); + filteredThumbCells.clear(); + deselected(); + selected(); + } finally { + gridLock.unlock(); + } + } + + void initializeUpdateService() { + int refreshRate = Config.getInstance().getSettings().overviewUpdateIntervalInSecs; + updateService.setPeriod(new Duration(TimeUnit.SECONDS.toMillis(refreshRate))); + updateService.setOnSucceeded(event -> onSuccess()); + updateService.setOnFailed(this::onFail); + } + + protected void onSuccess() { + if (updatesSuspended) { + return; + } + List models = filterModels(updateService.getValue()); + updateGrid(models); + } + + private List filterModels(List models) { + List ignored = Config.getInstance().getSettings().ignoredModels; + String filterBlacklist = Config.getInstance().getSettings().filterBlacklist; + String filterWhitelist = Config.getInstance().getSettings().filterWhitelist; + if (filterBlacklist.isBlank() && filterWhitelist.isBlank()) { + return models.stream() + .filter(m -> !ignored.contains(m.getUrl())) + .collect(Collectors.toList()); + } else if (filterBlacklist.isBlank()) { + return models.stream() + .filter(m -> !ignored.contains(m.getUrl())) + .filter(m -> matches(m, filterWhitelist, true)) + .collect(Collectors.toList()); + } + return models.stream() + .filter(m -> !ignored.contains(m.getUrl())) + .filter(m -> !matches(m, filterBlacklist, true)) + .filter(m -> matches(m, filterWhitelist, true)) + .collect(Collectors.toList()); + } + + protected void updateGrid(List models) { + gridLock.lock(); + try { + ObservableList nodes = grid.getChildren(); + nodes.removeAll(progressIndicator, noResultsFound, errorLabel); + + // first remove models, which are not in the updated list + removeModelsMissingInUpdate(nodes, models); + + // now update existing cells and create new ones models, which are new in the update + createOrUpdateModelCells(nodes, models); + + // remove cells from selectedThumbCells, which are not visible anymore + selectedThumbCells.retainAll(nodes); + + // reapply the filter + filteredThumbCells.clear(); + filter(); + + // move models, which are tracked by the recorder to the front + moveActiveRecordingsToFront(); + } finally { + gridLock.unlock(); + } + } + + private void createOrUpdateModelCells(ObservableList nodes, List models) { + List positionChangedOrNew = new ArrayList<>(); + var index = 0; + for (Model model : models) { + var found = false; + for (Node node : nodes) { // NOSONAR + if (!(node instanceof ThumbCell cell)) + continue; + if (cell.getModel().equals(model)) { + found = true; + cell.setModel(model); + if (index != cell.getIndex()) { + cell.setIndex(index); + positionChangedOrNew.add(cell); + } + break; + } + } + if (!found) { + var newCell = createThumbCell(model, recorder); + newCell.setIndex(index); + positionChangedOrNew.add(newCell); + } + index++; + } + rearrangeCells(nodes, positionChangedOrNew); + } + + private void rearrangeCells(ObservableList nodes, List positionChangedOrNew) { + for (ThumbCell thumbCell : positionChangedOrNew) { + nodes.remove(thumbCell); + if (thumbCell.getIndex() < nodes.size()) { + nodes.add(thumbCell.getIndex(), thumbCell); + } else { + nodes.add(thumbCell); + } + } + } + + private void removeModelsMissingInUpdate(ObservableList nodes, List models) { + for (Iterator iterator = nodes.iterator(); iterator.hasNext(); ) { + var node = iterator.next(); + if (!(node instanceof ThumbCell cell)) + continue; + if (!models.contains(cell.getModel())) { + iterator.remove(); + } + } + } + + ThumbCell createThumbCell(Model model, Recorder recorder) { + var newCell = new ThumbCell(this, model, recorder, imageAspectRatio); + newCell.setImageAspectRatio(imageAspectRatio); + newCell.preserveAspectRatioProperty().bind(preserveAspectRatio); + newCell.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, event -> { + suspendUpdates(true); + popup = createContextMenu(newCell); + popup.show(newCell, event.getScreenX(), event.getScreenY()); + popup.setOnHidden(e -> suspendUpdates(false)); + event.consume(); + }); + newCell.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> { + if (popup != null) { + popup.hide(); + popup = null; + } + }); + newCell.selectionProperty().addListener((obs, oldValue, newValue) -> { + if (Boolean.TRUE.equals(newValue)) { + selectedThumbCells.add(newCell); + } else { + selectedThumbCells.remove(newCell); + } + }); + newCell.setOnMouseClicked(mouseClickListener); + return newCell; + } + + private ContextMenu createContextMenu(ThumbCell cell) { + removeSelectionIfNeeded(cell); + + ContextMenu contextMenu = new CustomMouseBehaviorContextMenu(); + contextMenu.setAutoHide(true); + contextMenu.setHideOnEscape(true); + contextMenu.setAutoFix(true); + + var selectedModels = getSelectedThumbCells(cell).stream().map(ThumbCell::getModel).collect(Collectors.toList()); + ModelMenuContributor.newContributor(getTabPane(), Config.getInstance(), recorder) + .withStartStopCallback(m -> + Platform.runLater(() -> { + getTabPane().setCursor(Cursor.DEFAULT); + getThumbCell(m).ifPresent(ThumbCell::update); + }) + ) + .withFollowCallback((mdl, fllw, success) -> { + if (Boolean.TRUE.equals(fllw) && Boolean.TRUE.equals(success)) { + Platform.runLater(() -> getThumbCell(mdl).ifPresent(this::showAddToFollowedAnimation)); + } + if (Boolean.FALSE.equals(fllw)) { + Platform.runLater(() -> { + if (this instanceof FollowedTab) { + getThumbCell(mdl).ifPresent(thumbCell -> grid.getChildren().remove(thumbCell)); + } + selectedThumbCells.clear(); + }); + } + }) + .withIgnoreCallback(m -> getThumbCell(m).ifPresent(thumbCell -> { + grid.getChildren().remove(thumbCell); + selectedThumbCells.remove(thumbCell); + })) + .afterwards(() -> selectedModels.forEach(m -> getThumbCell(m).ifPresent(ThumbCell::update))) + .contributeToMenu(selectedModels, contextMenu); + + var useImageAsPortrait = new MenuItem("Use As Portrait"); + useImageAsPortrait.setOnAction(e -> new SetThumbAsPortraitAction(getTabPane(), cell.getModel(), cell.getImage()).execute()); + + var refresh = new MenuItem("Refresh Overview"); + refresh.setOnAction(e -> refresh()); + + contextMenu.getItems().addAll(useImageAsPortrait, refresh); + var model = cell.getModel(); + if (model instanceof MyFreeCamsModel && Objects.equals(System.getenv("CTBREC_DEV"), "1")) { + var debug = new MenuItem("debug"); + debug.setOnAction(e -> MyFreeCamsClient.getInstance().getSessionState(model)); + contextMenu.getItems().add(debug); + } + + return contextMenu; + } + + private Optional getThumbCell(Model model) { + for (Node node : grid.getChildren()) { + if (node instanceof ThumbCell thumbCell && Objects.equals(thumbCell.getModel(), model)) { + return Optional.of(thumbCell); + } + } + return Optional.empty(); + } + + private void refresh() { + if (updateService.isRunning()) { + updateService.cancel(); + updateService.reset(); + updateService.restart(); + } + } + + /* + * check, if other cells are selected, too. in that case, we have to disable menu items, which make sense only for single selections. but only do that, if + * the popup has been triggered on a selected cell. otherwise remove the selection and show the normal menu + */ + private void removeSelectionIfNeeded(ThumbCell cell) { + if ((selectedThumbCells.size() > 1 || selectedThumbCells.size() == 1 && selectedThumbCells.get(0) != cell) && !cell.isSelected()) { + removeSelection(); + } + } + + private List getSelectedThumbCells(ThumbCell cell) { + if (selectedThumbCells.isEmpty()) { + return Collections.singletonList(cell); + } else { + return selectedThumbCells; + } + } + + protected void follow(List selection, boolean follow) { + + for (ThumbCell thumbCell : selection) { + thumbCell.follow(follow).thenAccept(success -> { + if (follow && Boolean.TRUE.equals(success)) { + showAddToFollowedAnimation(thumbCell); + } + }); + } + if (!follow) { + selectedThumbCells.clear(); + } + } + + private void showAddToFollowedAnimation(ThumbCell thumbCell) { + Platform.runLater(() -> { + var tx = thumbCell.getLocalToParentTransform(); + var iv = new ImageView(); + iv.setFitWidth(thumbCell.getWidth()); + root.getChildren().add(iv); + StackPane.setAlignment(iv, Pos.TOP_LEFT); + iv.setImage(thumbCell.getImage()); + double scrollPaneTopLeft = scrollPane.getVvalue() * (grid.getHeight() - scrollPane.getViewportBounds().getHeight()); + double offsetInViewPort = tx.getTy() - scrollPaneTopLeft; + var duration = 500; + var translate = new TranslateTransition(Duration.millis(duration), iv); + translate.setFromX(0); + translate.setFromY(0); + translate.setByX(-tx.getTx() - 200); + var tabProvider = SiteUiFactory.getUi(site).getTabProvider(); + var followedTab = tabProvider.getFollowedTab(); + translate.setByY(-offsetInViewPort + getFollowedTabYPosition(followedTab)); + StackPane.setMargin(iv, new Insets(offsetInViewPort, 0, 0, tx.getTx())); + translate.setInterpolator(Interpolator.EASE_BOTH); + var fade = new FadeTransition(Duration.millis(duration), iv); + fade.setFromValue(1); + fade.setToValue(.3); + var scale = new ScaleTransition(Duration.millis(duration), iv); + scale.setToX(0.1); + scale.setToY(0.1); + var pt = new ParallelTransition(translate, scale); + pt.play(); + pt.setOnFinished(evt -> root.getChildren().remove(iv)); + var blink = new FollowTabBlinkTransition(followedTab); + blink.play(); + }); + } + + private double getFollowedTabYPosition(Tab followedTab) { + var tabPane = getTabPane(); + int idx = Math.max(0, tabPane.getTabs().indexOf(followedTab)); + for (Node node : tabPane.getChildrenUnmodifiable()) { + Parent p = (Parent) node; + for (Node child : p.getChildrenUnmodifiable()) { + if (child.getStyleClass().contains("headers-region")) { + Parent tabContainer = (Parent) child; + Node tab = tabContainer.getChildrenUnmodifiable().get(tabContainer.getChildrenUnmodifiable().size() - idx - 1); + return tab.getLayoutX() - 85; + } + } + } + return 0; + } + + private final EventHandler mouseClickListener = e -> { + ThumbCell cell = (ThumbCell) e.getSource(); + if (e.getButton() == MouseButton.PRIMARY && e.getClickCount() == 2) { + cell.setSelected(false); + cell.startPlayer(); + } else if (e.getButton() == MouseButton.PRIMARY && e.isControlDown()) { + if (popup == null) { + cell.setSelected(!cell.isSelected()); + } + } else if (e.getButton() == MouseButton.PRIMARY) { + removeSelection(); + } + }; + + protected void onFail(WorkerStateEvent event) { + if (event.getSource().getException() != null) { + if (event.getSource().getException() instanceof SocketTimeoutException) { + LOG.debug("Fetching model list timed out"); + errorLabel.setText("Timeout while updating"); + } else { + LOG.error("Couldn't update model list", event.getSource().getException()); + errorLabel.setText("Error while updating " + event.getSource().getException().getLocalizedMessage()); + } + } else { + LOG.error("Couldn't update model list {}", event.getEventType()); + errorLabel.setText("Couldn't update model list " + event.getEventType()); + } + grid.getChildren().removeAll(progressIndicator, noResultsFound, errorLabel); + grid.getChildren().add(errorLabel); + } + + void filter() { + filteredThumbCells.sort((c1, c2) -> { + if (c1.getIndex() < c2.getIndex()) + return -1; + if (c1.getIndex() > c2.getIndex()) + return 1; + return c1.getModel().getName().compareTo(c2.getModel().getName()); + }); + + if (filter == null || filter.isEmpty()) { + for (ThumbCell thumbCell : filteredThumbCells) { + insert(thumbCell); + } + filteredThumbCells.clear(); + } else { + // remove the ones from grid, which don't match + for (Iterator iterator = grid.getChildren().iterator(); iterator.hasNext(); ) { + var node = iterator.next(); + if (node instanceof ThumbCell cell) { + var m = cell.getModel(); + if (!matches(m, filter, false)) { + iterator.remove(); + filteredThumbCells.add(cell); + cell.setSelected(false); + } + } + } + + // add the ones, which might have been filtered before, but now match + for (Iterator iterator = filteredThumbCells.iterator(); iterator.hasNext(); ) { + var thumbCell = iterator.next(); + var m = thumbCell.getModel(); + if (matches(m, filter, false)) { + iterator.remove(); + insert(thumbCell); + } + } + } + + if (grid.getChildren().size() > 1 && grid.getChildren().contains(noResultsFound)) { + grid.getChildren().remove(noResultsFound); + } else if (grid.getChildren().isEmpty()) { + grid.getChildren().add(noResultsFound); + } + } + + private void moveActiveRecordingsToFront() { + List thumbsToMove = new ArrayList<>(); + ObservableList thumbs = grid.getChildren(); + for (int i = thumbs.size() - 1; i >= 0; i--) { + var node = thumbs.get(i); + if (node instanceof ThumbCell) { + ThumbCell thumb = (ThumbCell) thumbs.get(i); + if (recorder.isTracked(thumb.getModel())) { + thumbs.remove(i); + thumbsToMove.add(0, thumb); + } + } + } + thumbs.addAll(0, thumbsToMove); + } + + private void insert(ThumbCell thumbCell) { + if (grid.getChildren().contains(thumbCell)) { + return; + } + + if (thumbCell.getIndex() < grid.getChildren().size() - 1) { + grid.getChildren().add(thumbCell.getIndex(), thumbCell); + } else { + grid.getChildren().add(thumbCell); + } + } + + private boolean matches(Model m, String filter, Boolean anyMatch) { + try { + String[] tokens = filter.split(" "); + var tokensMissing = false; + for (String token : tokens) { + if (anyMatch && modelPropertiesMatchToken(token, m)) { + return true; + } else if (!modelPropertiesMatchToken(token, m)) { + tokensMissing = true; + } + } + return !tokensMissing; + } catch (NumberFormatException | ExecutionException | IOException e) { + LOG.error("Error while filtering model list", e); + return false; + } + } + + private boolean modelPropertiesMatchToken(String token, Model m) throws IOException, ExecutionException { + int[] resolution = Optional.ofNullable(ThumbCell.resolutionCache.getIfPresent(m)).orElse(new int[2]); + String searchText = createSearchText(m); + var tokensMissing = false; + if (token.matches(">\\d+")) { + var res = Integer.parseInt(token.substring(1)); + if (resolution[1] < res) { + tokensMissing = true; + } + } else if (token.matches("<\\d+")) { + var res = Integer.parseInt(token.substring(1)); + if (resolution[1] > res) { + tokensMissing = true; + } + } else if (token.equals("public")) { + if (m.getOnlineState(true) != ctbrec.Model.State.ONLINE) { + tokensMissing = true; + } + } else { + var negated = false; + if (token.startsWith("!")) { + negated = true; + token = token.substring(1); + } + boolean tokenFound = searchText.toLowerCase().contains(token.toLowerCase()); + tokensMissing = !tokenFound && !negated || tokenFound && negated; + } + return !tokensMissing; + } + + private String createSearchText(Model m) { + var searchTextBuilder = new StringBuilder(m.getName()); + searchTextBuilder.append(' '); + searchTextBuilder.append(m.getDisplayName()); + searchTextBuilder.append(' '); + for (String tag : m.getTags()) { + searchTextBuilder.append(tag).append(' '); + } + int[] resolution = Optional.ofNullable(ThumbCell.resolutionCache.getIfPresent(m)).orElse(new int[2]); + searchTextBuilder.append(resolution[1]); + searchTextBuilder.append(' '); + searchTextBuilder.append(Optional.ofNullable(m.getDescription()).orElse("")); + searchTextBuilder.append(' '); + searchTextBuilder.append(recorder.getModelGroup(m).map(ModelGroup::getName).orElse("")); + return searchTextBuilder.toString().trim(); + } + + public void setRecorder(Recorder recorder) { + this.recorder = recorder; + popoverTreeList.setRecorder(recorder); + } + + @Override + public void selected() { + grid.getChildren().removeAll(noResultsFound, errorLabel); + if (grid.getChildren().isEmpty()) { + grid.getChildren().add(progressIndicator); + } + queue.clear(); + if (updateService != null) { + var s = updateService.getState(); + if (s != State.SCHEDULED && s != State.RUNNING) { + updateService.reset(); + updateService.restart(); + } + } + updateThumbSize(); + updateTokenLabel(); + } + + private void updateTokenLabel() { + if (tokenBalance != null) { + tokenBalance.loadBalance(); + } + } + + @Override + public void deselected() { + if (updateService != null) { + updateService.cancel(); + } + queue.clear(); + + for (Iterator iterator = grid.getChildren().iterator(); iterator.hasNext(); ) { + var node = iterator.next(); + if (node instanceof ThumbCell thumbCell) { + thumbCell.releaseResources(); + iterator.remove(); + } + } + } + + void suspendUpdates(boolean suspend) { + this.updatesSuspended = suspend; + } + + private void removeSelection() { + while (!selectedThumbCells.isEmpty()) { + selectedThumbCells.get(0).setSelected(false); + } + } + + private static int threadCounter = 0; + + private static ThreadFactory createThreadFactory() { + return r -> { + var t = new Thread(r); + t.setDaemon(true); + t.setPriority(Thread.MIN_PRIORITY); + t.setName("ResolutionDetector-" + threadCounter++); + return t; + }; + } + + public void setImageAspectRatio(double imageAspectRatio) { + this.imageAspectRatio = imageAspectRatio; + } + + public BooleanProperty preserveAspectRatioProperty() { + return preserveAspectRatio; + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTabSearchTask.java b/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTabSearchTask.java new file mode 100644 index 00000000..92dbfa93 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/ThumbOverviewTabSearchTask.java @@ -0,0 +1,72 @@ +package ctbrec.ui.tabs; + +import static ctbrec.ui.controls.Dialogs.*; + +import java.io.IOException; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.Model; +import ctbrec.sites.Site; +import ctbrec.ui.SiteUiFactory; +import ctbrec.ui.controls.SearchPopover; +import ctbrec.ui.controls.SearchPopoverTreeList; +import javafx.application.Platform; +import javafx.concurrent.Task; + +public class ThumbOverviewTabSearchTask extends Task> { + + private static final Logger LOG = LoggerFactory.getLogger(ThumbOverviewTabSearchTask.class); + + private final Site site; + private final SearchPopover popover; + private final SearchPopoverTreeList popoverTreeList; + private final String query; + + public ThumbOverviewTabSearchTask(Site site, SearchPopover popover, SearchPopoverTreeList popoverTreeList, String query) { + this.site = site; + this.popover = popover; + this.popoverTreeList = popoverTreeList; + this.query = query; + } + + @Override + protected List call() throws Exception { + if(site.searchRequiresLogin()) { + var loggedin = false; + try { + loggedin = SiteUiFactory.getUi(site).login(); + } catch (IOException e) { + // nothing to do + } + if(!loggedin) { + showError(popover.getScene(), "Login failed", "Search won't work correctly without login", null); + } + } + return site.search(query); + } + + @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(); + } + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/UpdateTab.java b/client/src/main/java/ctbrec/ui/tabs/UpdateTab.java new file mode 100644 index 00000000..96e42c5f --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/UpdateTab.java @@ -0,0 +1,73 @@ +package ctbrec.ui.tabs; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ctbrec.GlobalThreadPool; +import ctbrec.io.HttpException; +import ctbrec.ui.CamrecApplication; +import ctbrec.ui.CamrecApplication.Release; +import ctbrec.ui.DesktopIntegration; +import ctbrec.ui.controls.Dialogs; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.Tab; +import javafx.scene.control.TextArea; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import okhttp3.Request; +import okhttp3.Response; + +public class UpdateTab extends Tab implements TabSelectionListener { + + private static final Logger LOG = LoggerFactory.getLogger(UpdateTab.class); + + private final TextArea changelog; + + public UpdateTab(Release latest) { + setText("Update Available"); + var vbox = new VBox(10); + var l = new Label("New Version available " + latest.getVersion()); + vbox.getChildren().add(l); + VBox.setMargin(l, new Insets(20, 0, 0, 0)); + var button = new Button("Download"); + button.setOnAction(e -> DesktopIntegration.open(latest.getHtmlUrl())); + vbox.getChildren().add(button); + VBox.setMargin(button, new Insets(0, 0, 10, 0)); + vbox.setAlignment(Pos.CENTER); + changelog = new TextArea(); + changelog.setEditable(false); + changelog.setText("Loading changelog..."); + vbox.getChildren().add(changelog); + VBox.setVgrow(changelog, Priority.ALWAYS); + setContent(vbox); + } + + public void loadChangeLog() { + GlobalThreadPool.submit(() -> { + Request req = new Request.Builder().url("https://pastebin.com/raw/fiAPtM0s").build(); + try (Response resp = CamrecApplication.httpClient.execute(req)) { + if (resp.isSuccessful()) { + changelog.setText(resp.body().string()); + } else { + throw new HttpException(resp.code(), resp.message()); + } + } catch (Exception e1) { + LOG.error("Couldn't download the changelog", e1); + Dialogs.showError(getTabPane().getScene(), "Communication error", "Couldn't download the changelog", e1); + } + }); + } + + @Override + public void selected() { + loadChangeLog(); + } + + @Override + public void deselected() { + // nothing to do + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/logging/CtbrecAppender.java b/client/src/main/java/ctbrec/ui/tabs/logging/CtbrecAppender.java new file mode 100644 index 00000000..14ff5b9e --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/logging/CtbrecAppender.java @@ -0,0 +1,13 @@ +package ctbrec.ui.tabs.logging; + +import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.core.ConsoleAppender; +import ctbrec.event.EventBusHolder; + +public class CtbrecAppender extends ConsoleAppender { + + @Override + protected void append(LoggingEvent event) { + EventBusHolder.BUS.post(event); + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/logging/LoggingTab.java b/client/src/main/java/ctbrec/ui/tabs/logging/LoggingTab.java new file mode 100644 index 00000000..c672cd32 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/logging/LoggingTab.java @@ -0,0 +1,231 @@ +package ctbrec.ui.tabs.logging; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.eventbus.Subscribe; + +import ch.qos.logback.classic.encoder.PatternLayoutEncoder; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.classic.spi.StackTraceElementProxy; +import ctbrec.Config; +import ctbrec.event.EventBusHolder; +import ctbrec.ui.controls.CustomMouseBehaviorContextMenu; +import ctbrec.ui.controls.SearchBox; +import ctbrec.ui.tabs.TabSelectionListener; +import javafx.application.Platform; +import javafx.beans.property.SimpleStringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.MenuItem; +import javafx.scene.control.SelectionMode; +import javafx.scene.control.Tab; +import javafx.scene.control.TableColumn; +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; + +public class LoggingTab extends Tab implements TabSelectionListener { + + private static final int HISTORY_LENGTH = 10_000; + private SearchBox filter = new SearchBox(); + private TableView table = new TableView<>(); + private ObservableList history = FXCollections.observableList(Collections.synchronizedList(new LinkedList<>())); + private ObservableList filteredEvents = FXCollections.observableArrayList(); + private List eventBuffer = new LinkedList<>(); + private DateTimeFormatter timeFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME; + private volatile boolean tabClosed = false; + private volatile boolean tabSelected = false; + private ContextMenu popup; + private Object eventBustSubscriber = new Object() { + @Subscribe + public void publishLoggingEevent(LoggingEvent event) { + if (!tabClosed) { + if (tabSelected) { + Platform.runLater(() -> { + history.add(event); + if (history.size() > HISTORY_LENGTH - 1) { + history.remove(0); + } + filter(); + }); + } else { + eventBuffer.add(event); + if (eventBuffer.size() > HISTORY_LENGTH - 1) { + eventBuffer.remove(0); + } + } + } + } + }; + + private void filter() { + filteredEvents.clear(); + filteredEvents.addAll(history.stream().filter(evt -> { + String q = filter.getText().toLowerCase(); + return evt.getLevel().toString().toLowerCase().contains(q) + || createLogMessage(evt).toLowerCase().contains(q); + }).collect(Collectors.toList())); + } + + public LoggingTab() { + setText("Logging"); + subscribeToEventBus(); + + Config config = Config.getInstance(); + table = new TableView<>(filteredEvents); + if (!config.getSettings().showGridLinesInTables) { + table.setStyle("-fx-table-cell-border-color: transparent;"); + } + + var idx = 0; + TableColumn level = createTableColumn("Level", 65, idx++); + level.setCellValueFactory(cdf -> new SimpleStringProperty(cdf.getValue().getLevel().toString())); + table.getColumns().add(level); + + TableColumn time = createTableColumn("Timestamp", 200, idx++); + time.setCellValueFactory(cdf -> { + var instant = Instant.ofEpochMilli(cdf.getValue().getTimeStamp()); + return new SimpleStringProperty(instant.atZone(ZoneId.systemDefault()).format(timeFormatter)); + }); + table.getColumns().add(time); + + TableColumn location = createTableColumn("Location", 250, idx++); + location.setCellValueFactory(cdf -> { + if(cdf.getValue().getCallerData().length > 0) { + StackTraceElement loc = cdf.getValue().getCallerData()[0]; + String l = loc.getFileName() + ":" + loc.getLineNumber(); + return new SimpleStringProperty(l); + } else { + return new SimpleStringProperty(""); + } + }); + table.getColumns().add(location); + + TableColumn msg = createTableColumn("Message", 2000, idx++); + msg.setCellValueFactory(cdf -> new SimpleStringProperty(createLogMessage(cdf.getValue()))); + table.getColumns().add(msg); + + var layout = new BorderPane(); + BorderPane.setMargin(table, new Insets(10)); + BorderPane.setMargin(filter, new Insets(10, 10, 0, 10)); + layout.setCenter(table); + layout.setTop(filter); + + setContent(layout); + setOnClosed(evt -> { + EventBusHolder.BUS.unregister(eventBustSubscriber); + tabClosed = true; + }); + + filter.setPromptText("Search"); + filter.textProperty().addListener( (observableValue, oldValue, newValue) -> filter()); + + table.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); + 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(); + } + }); + } + + private ContextMenu createContextMenu() { + final ObservableList selectedEvents = table.getSelectionModel().getSelectedItems(); + if (selectedEvents.isEmpty()) { + return null; + } + var copy = new MenuItem("Copy"); + copy.setOnAction(e -> Platform.runLater(() -> { + String formattedMessages = getFormattedMessages(selectedEvents); + final var content = new ClipboardContent(); + content.putString(formattedMessages); + Clipboard.getSystemClipboard().setContent(content); + })); + + ContextMenu menu = new CustomMouseBehaviorContextMenu(copy); + return menu; + } + + private String getFormattedMessages(ObservableList selectedEvents) { + var sb = new StringBuilder(); + + var rootLogger = (ch.qos.logback.classic.Logger)LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); + var loggerContext = rootLogger.getLoggerContext(); + loggerContext.reset(); + + var encoder = new PatternLayoutEncoder(); + encoder.setContext(loggerContext); + encoder.setPattern("%date %level [%thread] %logger{10} [%file:%line] %msg%n"); + encoder.start(); + + for (LoggingEvent evt : selectedEvents) { + byte[] encode = encoder.encode(evt); + sb.append(new String(encode)); + } + return sb.toString(); + } + + private String createLogMessage(LoggingEvent evt) { + var sb = new StringBuilder(evt.getFormattedMessage()); + IThrowableProxy throwableProxy = evt.getThrowableProxy(); + while (throwableProxy != null) { + sb.append('\n').append(throwableProxy.getClassName()).append(':').append(' ').append(throwableProxy.getMessage()); + for (StackTraceElementProxy step : throwableProxy.getStackTraceElementProxyArray()) { + sb.append('\n').append('\t').append(step.getSTEAsString()); + } + throwableProxy = throwableProxy.getCause(); + if (throwableProxy != null) { + sb.append("\nCaused by: "); + } + } + return sb.toString(); + } + + private TableColumn createTableColumn(String text, int width, int idx) { + TableColumn tc = new TableColumn<>(text); + tc.setPrefWidth(width); + tc.setUserData(idx); + return tc; + } + + private void subscribeToEventBus() { + EventBusHolder.BUS.register(eventBustSubscriber); + } + + @Override + public void selected() { + history.addAll(eventBuffer); + tabSelected = true; + while (history.size() > HISTORY_LENGTH - 1) { + history.remove(0); + } + filter(); + eventBuffer.clear(); + } + + @Override + public void deselected() { + tabSelected = false; + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/AbstractRecordedModelsTab.java b/client/src/main/java/ctbrec/ui/tabs/recorded/AbstractRecordedModelsTab.java new file mode 100644 index 00000000..bfa36c90 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/AbstractRecordedModelsTab.java @@ -0,0 +1,593 @@ +package ctbrec.ui.tabs.recorded; + +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import com.google.common.eventbus.Subscribe; +import ctbrec.Config; +import ctbrec.GlobalThreadPool; +import ctbrec.Model; +import ctbrec.StringUtil; +import ctbrec.event.EventBusHolder; +import ctbrec.image.PortraitStore; +import ctbrec.io.HttpException; +import ctbrec.recorder.Recorder; +import ctbrec.sites.Site; +import ctbrec.ui.AutosizeAlert; +import ctbrec.ui.CamrecApplication; +import ctbrec.ui.JavaFxModel; +import ctbrec.ui.PreviewPopupHandler; +import ctbrec.ui.action.AbstractPortraitAction.PortraitChangedEvent; +import ctbrec.ui.action.MarkForLaterRecordingAction; +import ctbrec.ui.action.PlayAction; +import ctbrec.ui.action.StartRecordingAction; +import ctbrec.ui.controls.CustomMouseBehaviorContextMenu; +import ctbrec.ui.controls.DateTimeCellFactory; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.controls.SearchBox; +import ctbrec.ui.controls.autocomplete.AutoFillTextField; +import ctbrec.ui.controls.autocomplete.ObservableListSuggester; +import ctbrec.ui.controls.table.SettingTableViewStateStore; +import ctbrec.ui.controls.table.StatePersistingTableView; +import ctbrec.ui.menu.ModelMenuContributor; +import ctbrec.ui.tabs.TabSelectionListener; +import ctbrec.ui.tabs.recorded.ModelImportExport.ExportOptions; +import javafx.application.Platform; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringPropertyBase; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.concurrent.Task; +import javafx.event.ActionEvent; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Cursor; +import javafx.scene.control.*; +import javafx.scene.control.cell.PropertyValueFactory; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.input.*; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.FlowPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.stage.FileChooser; +import javafx.util.Callback; +import lombok.extern.slf4j.Slf4j; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; + +@Slf4j +public abstract class AbstractRecordedModelsTab extends Tab implements TabSelectionListener { + private static final Image SILHOUETTE = new Image(AbstractRecordedModelsTab.class.getResourceAsStream("/silhouette_256.png")); + protected static final String STYLE_ALIGN_CENTER = "-fx-alignment: CENTER;"; + + protected ReentrantLock lock = new ReentrantLock(); + protected ObservableList observableModels = FXCollections.observableArrayList(); + protected ObservableList filteredModels = FXCollections.observableArrayList(); + + protected SettingTableViewStateStore tableStateStore; + protected StatePersistingTableView table; + protected List> columns = new ArrayList<>(); + protected LoadingCache portraitCache = CacheBuilder.newBuilder() + .expireAfterWrite(10, TimeUnit.MINUTES) + .maximumSize(1000) + .build(CacheLoader.from(this::loadModelPortrait)); + + protected AutoFillTextField modelInputField; + protected List sites; + protected Recorder recorder; + + protected HBox addModelBox = new HBox(5); + protected HBox filterContainer = new HBox(5); + protected Label modelLabel = new Label("Model"); + protected Button addModelButton = new Button("Record"); + protected Button checkModelAccountExistance = new Button("Check URLs"); + protected Button exportModelsButton = new Button(); + protected Button importModelsButton = new Button(); + protected TextField filterTextField; + + protected FlowPane grid = new FlowPane(); + protected ScrollPane scrollPane = new ScrollPane(); + protected ContextMenu popup; + + protected Config config; + protected PortraitStore portraitStore; + + AbstractRecordedModelsTab(String text, String stateStorePrefix) { + super(text); + config = Config.getInstance(); + portraitStore = CamrecApplication.portraitStore; + tableStateStore = new SettingTableViewStateStore(config, stateStorePrefix); + table = new StatePersistingTableView<>(tableStateStore); + registerPortraitListener(); + } + + protected void registerPortraitListener() { + EventBusHolder.BUS.register(this); + } + + @Subscribe + public void portraitChanged(PortraitChangedEvent e) { + log.debug("Invalidate cache for {}", e.getModel()); + portraitCache.invalidate(e.getModel()); + if (table != null) { + table.refresh(); + } + } + + protected void createGui() { + grid.setPadding(new Insets(5)); + grid.setHgap(5); + grid.setVgap(5); + + scrollPane.setContent(grid); + scrollPane.setFitToHeight(true); + scrollPane.setFitToWidth(true); + BorderPane.setMargin(scrollPane, new Insets(5)); + + table.setEditable(true); + if (!config.getSettings().showGridLinesInTables) { + table.setStyle("-fx-table-cell-border-color: transparent;"); + } + table.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); + var previewPopupHandler = new PreviewPopupHandler(table); + table.setRowFactory(tableview -> { + TableRow row = new TableRow<>(); + row.addEventHandler(MouseEvent.ANY, previewPopupHandler); + return row; + }); + table.setItems(observableModels); + 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(); + } + }); + table.addEventHandler(KeyEvent.KEY_PRESSED, event -> { + List selectedModels = table.getSelectionModel().getSelectedItems(); + if (event.getCode() == KeyCode.DELETE) { + stopAction(selectedModels); + } else { + jumpToNextModel(event.getCode()); + } + }); + + scrollPane.setContent(table); + + checkModelAccountExistance.setPadding(new Insets(5)); + checkModelAccountExistance.setTooltip(new Tooltip("Go over all model URLs and check, if the account still exists")); + HBox.setMargin(checkModelAccountExistance, new Insets(0, 0, 0, 20)); + + modelLabel.setPadding(new Insets(5, 0, 0, 0)); + ObservableList suggestions = FXCollections.observableArrayList(); + sites.forEach(site -> suggestions.add(site.getClass().getSimpleName())); + modelInputField = new AutoFillTextField(new ObservableListSuggester(suggestions)); + modelInputField.minWidth(150); + modelInputField.prefWidth(600); + HBox.setHgrow(modelInputField, Priority.ALWAYS); + modelInputField.setPromptText("e.g. MyFreeCams:ModelName or an URL like https://chaturbate.com/modelname/"); + modelInputField.onActionHandler(this::addModel); + modelInputField.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(this::addModel); + addModelButton.setPadding(new Insets(5)); + ImageView exportIcon = new ImageView(Objects.requireNonNull(getClass().getResource("/16/download.png"), "/16/download.png not found").toString()); + exportModelsButton.setGraphic(exportIcon); + exportModelsButton.setTooltip(new Tooltip("Export models to file")); + exportModelsButton.setMinWidth(34); + exportModelsButton.setMinHeight(26); + exportModelsButton.setOnAction(this::exportModels); + ImageView importIcon = new ImageView(Objects.requireNonNull(getClass().getResource("/16/upload.png"), "/16/upload.png not found").toString()); + importModelsButton.setGraphic(importIcon); + importModelsButton.setTooltip(new Tooltip("Import models from file")); + importModelsButton.setMinWidth(34); + importModelsButton.setMinHeight(26); + importModelsButton.setOnAction(this::importModels); + HBox.setMargin(exportModelsButton, new Insets(0, 0, 0, 20)); + addModelBox.getChildren().addAll(modelLabel, modelInputField, addModelButton, checkModelAccountExistance, exportModelsButton, importModelsButton); + + filterContainer.setPadding(new Insets(0)); + filterContainer.setAlignment(Pos.CENTER_RIGHT); + filterContainer.minWidth(100); + filterContainer.prefWidth(150); + HBox.setHgrow(filterContainer, Priority.ALWAYS); + filterTextField = new SearchBox(false); + filterTextField.minWidth(100); + filterTextField.prefWidth(150); + filterTextField.setPromptText("Filter"); + filterTextField.textProperty().addListener((observableValue, oldValue, newValue) -> { + String q = filterTextField.getText(); + lock.lock(); + try { + filter(q); + } finally { + lock.unlock(); + } + }); + filterTextField.getStyleClass().remove("search-box-icon"); + + filterContainer.getChildren().addAll(filterTextField); + addModelBox.getChildren().add(filterContainer); + } + + protected abstract List getExportList(); + + private void exportModels(ActionEvent actionEvent) { + ExportOptions exportOptions = new ModelExportDialog(getTabPane()).showAndWait(); + if (exportOptions != null) { + try { + var groups = recorder.getModelGroups(); + ModelImportExport.exportTo(getExportList(), groups, config, exportOptions); + } catch (IOException e) { + String msg = "An error occurred while exporting the model list"; + Dialogs.showError(getTabPane().getScene(), "Export models", msg, e); + log.error(msg, e); + } + } + } + + protected void importModelList(List models) { + getContent().setCursor(Cursor.WAIT); + Task task = new Task<>() { + @Override + protected Void call() { + for (Model model : models) { + try { + Instant addedTimestamp = model.getAddedTimestamp(); // preserve old addedTimestamp + recorder.addModel(model); + model.setAddedTimestamp(addedTimestamp); // restore old addedTimestamp + } catch (Exception e) { + log.error("Couldn't add model to recording list", e); + } + } + return null; + } + + @Override + protected void done() { + getContent().setCursor(Cursor.DEFAULT); + reload(); + } + }; + GlobalThreadPool.submit(task); + } + + private void importModels(ActionEvent actionEvent) { + var chooser = new FileChooser(); + File target = chooser.showOpenDialog(getTabPane().getScene().getWindow()); + if (target != null) { + try { + List models = ModelImportExport.importFrom(target, sites, config); + importModelList(models); + portraitCache.invalidateAll(); + } catch (IOException e) { + String msg = "An error occurred while importing the model list"; + Dialogs.showError(getTabPane().getScene(), "Import models", msg, e); + log.error(msg, e); + } + } + } + + protected void addPreviewColumn(int columnIdx) { + TableColumn preview = addTableColumn("preview", "🎥", columnIdx, 35); + preview.setCellValueFactory(cdf -> new SimpleStringProperty(" ▶ ")); + preview.setEditable(false); + } + + protected void addPortraitColumn(int columnIdx) { + TableColumn portrait = addTableColumn("portrait", "Portrait", columnIdx, 80); + portrait.setCellValueFactory(param -> { + Model mdl = param.getValue().getDelegate(); + SimpleObjectProperty imgProperty = new SimpleObjectProperty<>(); + Image image = portraitCache.getIfPresent(mdl); + if (image == null && portrait.isVisible()) { + GlobalThreadPool.submit(() -> portraitCache.put(mdl, loadModelPortrait(mdl))); + } + if (Objects.equals(System.getenv("CTBREC_DEV"), "1")) { + image = SILHOUETTE; + } + imgProperty.set(image); + return imgProperty; + }); + portrait.setCellFactory(param -> new ImageTableCell()); + portrait.setEditable(false); + } + + protected void addModelColumn(int columnIdx) { + TableColumn name = addTableColumn("name", "Model", columnIdx, 200); + name.setCellValueFactory(param -> { + var modelName = new ModelName(param.getValue(), recorder); + return new SimpleObjectProperty<>(modelName); + }); + name.setCellFactory(param -> new ModelNameTableCell(recorder)); + name.setEditable(false); + } + + protected void addUrlColumn(int columnIdx) { + TableColumn url = addTableColumn("url", "URL", columnIdx, 400); + url.setCellValueFactory(new PropertyValueFactory<>("url")); + url.setCellFactory(new ClickableCellFactory<>()); + url.setEditable(false); + } + + protected void addAddedTimestampColumn(int columnIdx) { + TableColumn tc = addTableColumn("addedTimestamp", "Added at", columnIdx, 400); + tc.setCellFactory(new DateTimeCellFactory<>(config.getDateTimeFormatter())); + tc.setCellValueFactory(param -> new SimpleObjectProperty<>(param.getValue().getAddedTimestamp())); + tc.setPrefWidth(150); + tc.setEditable(false); + tc.setStyle(STYLE_ALIGN_CENTER); + } + + protected void addNotesColumn(int columnIdx) { + TableColumn notes = addTableColumn("notes", "Notes", columnIdx, 400); + notes.setCellValueFactory(cdf -> { + JavaFxModel m = cdf.getValue(); + return new StringPropertyBase() { + @Override + public String getName() { + return "Model Notes"; + } + + @Override + public Object getBean() { + return null; + } + + @Override + public String get() { + String modelNotes; + try { + modelNotes = CamrecApplication.modelNotesService.loadModelNotes(m.getUrl()).orElse(""); + } catch (IOException e) { + throw new RuntimeException(e); + } + return modelNotes; + } + }; + }); + notes.setEditable(false); + } + + abstract void stopAction(List selectedModels); + + protected TableColumn addTableColumn(String id, String text, int index, int width) { + TableColumn tc = new TableColumn<>(text); + tc.setId(id); + tc.setText(text); + tc.setUserData(index); + tc.setPrefWidth(width); + tc.setStyle("-fx-alignment: CENTER-LEFT;"); + columns.add(tc); + table.getColumns().add(tc); + return tc; + } + + protected ContextMenu createContextMenu() { + List selectedModels = table.getSelectionModel().getSelectedItems().stream().map(JavaFxModel::getDelegate).toList(); + if (selectedModels.isEmpty()) { + return null; + } + + ContextMenu menu = new CustomMouseBehaviorContextMenu(); + + ModelMenuContributor.newContributor(getTabPane(), Config.getInstance(), recorder) // + .withStartStopCallback(m -> Platform.runLater(this::reload)) // + .removeModelAfterIgnore(true) // + // .withPortraitCallback(m -> Platform.runLater(() -> { + // portraitCache.invalidate(m); + // table.refresh(); + // })) + .afterwards(() -> Platform.runLater(this::reload)) + .contributeToMenu(selectedModels, menu); + + return menu; + } + + protected void reload() { + deselected(); + selected(); + } + + protected void addModel(ActionEvent e) { + String input = modelInputField.getText().trim(); + if (StringUtil.isBlank(input)) { + return; + } + + if (input.startsWith("http")) { + addModelByUrl(input); + } else { + addModelByName(input); + } + } + + protected void addModelByUrl(String url) { + for (Site site : sites) { + var newModel = site.createModelFromUrl(url); + if (newModel != null) { + if (getMarkModelsForLaterRecording()) { + new MarkForLaterRecordingAction(modelInputField, List.of(newModel), true, recorder).execute(m -> Platform.runLater(this::reload)); + } else { + new StartRecordingAction(modelInputField, List.of(newModel), recorder) + .execute() + .whenComplete((r, ex) -> Platform.runLater(this::reload)); + } + return; + } + } + + Dialogs.showError(getTabPane().getScene(), "Unknown URL format", + "The URL you entered has an unknown format or the function does not support this site, yet", null); + } + + abstract boolean getMarkModelsForLaterRecording(); + + protected void addModelByName(String siteModelCombo) { + String[] parts = siteModelCombo.trim().split(":"); + if (parts.length != 2) { + Dialogs.showError(getTabPane().getScene(), "Wrong input format", "Use something like \"MyFreeCams:ModelName\"", null); + return; + } + + String siteName = parts[0]; + String modelName = parts[1]; + for (Site site : sites) { + if (Objects.equals(siteName.toLowerCase(), site.getClass().getSimpleName().toLowerCase())) { + var newModel = site.createModel(modelName); + if (getMarkModelsForLaterRecording()) { + new MarkForLaterRecordingAction(modelInputField, List.of(newModel), true, recorder).execute(m -> Platform.runLater(this::reload)); + } else { + new StartRecordingAction(modelInputField, List.of(newModel), recorder) + .execute() + .whenComplete((r, ex) -> Platform.runLater(this::reload)); + } + return; + } + } + + Alert alert = new AutosizeAlert(Alert.AlertType.ERROR, getTabPane().getScene()); + alert.setTitle("Unknown site"); + alert.setHeaderText("Couldn't add model"); + alert.setContentText("The site you entered is unknown"); + alert.showAndWait(); + } + + protected void jumpToNextModel(KeyCode code) { + if (!table.getItems().isEmpty() && (code.isLetterKey() || code.isDigitKey())) { + // determine where to start looking for the next model + var startAt = getJumpToStartIndex(); + + String c = code.getChar().toLowerCase(); + int i = startAt; + do { + JavaFxModel current = table.getItems().get(i); + if (current.getName().toLowerCase().replaceAll("[^0-9a-z]", "").startsWith(c)) { + table.getSelectionModel().clearAndSelect(i); + table.scrollTo(i); + break; + } + + i++; + if (i >= table.getItems().size()) { + i = 0; + } + } while (i != startAt); + } + } + + protected int getJumpToStartIndex() { + var startAt = 0; + if (table.getSelectionModel().getSelectedIndex() >= 0) { + startAt = table.getSelectionModel().getSelectedIndex() + 1; + if (startAt >= table.getItems().size()) { + startAt = 0; + } + } + return startAt; + } + + protected 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 (var i = 0; i < table.getItems().size(); i++) { + var sb = new StringBuilder(); + for (TableColumn tc : table.getColumns()) { + Object cellData = tc.getCellData(i); + if (cellData != null) { + var content = cellData.toString(); + sb.append(content).append(' '); + } + } + var searchText = sb.toString(); + + var tokensMissing = false; + for (String token : tokens) { + if (!searchText.toLowerCase().contains(token.toLowerCase())) { + tokensMissing = true; + break; + } + } + if (tokensMissing) { + JavaFxModel filteredModel = table.getItems().get(i); + filteredModels.add(filteredModel); + } + } + observableModels.removeAll(filteredModels); + } finally { + lock.unlock(); + } + } + + public void saveState() { + table.saveState(); + } + + protected void restoreState() { + table.restoreState(); + table.getColumns().stream().filter(tc -> Objects.equals(tc.getId(), "preview")).findFirst().ifPresent(tc -> tc.setVisible(config.getSettings().livePreviews)); + } + + protected class ClickableCellFactory implements Callback, TableCell> { + @Override + public TableCell call(TableColumn param) { + TableCell cell = new TableCell<>() { + @Override + protected void updateItem(Object item, boolean empty) { + setText(empty ? "" : Objects.toString(item)); + } + }; + + cell.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> { + if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) { + JavaFxModel selectedModel = table.getSelectionModel().getSelectedItem(); + if (selectedModel != null) { + new PlayAction(table, selectedModel).execute(); + } + } + }); + return cell; + } + } + + protected Image loadModelPortrait(Model model) { + try { + return portraitStore + .loadModelPortraitByModelUrl(model.getUrl()) + .map(bytes -> new Image(new ByteArrayInputStream(bytes))) + .orElse(SILHOUETTE); + } catch (HttpException e) { + if (e.getResponseCode() != 404) { + log.debug("Could not load portrait from server", e); + } + } catch (IOException e) { + log.debug("Could not load portrait from server", e); + } + return SILHOUETTE; + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/ClickableTableCell.java b/client/src/main/java/ctbrec/ui/tabs/recorded/ClickableTableCell.java new file mode 100644 index 00000000..85262a0f --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/ClickableTableCell.java @@ -0,0 +1,21 @@ +package ctbrec.ui.tabs.recorded; + +import ctbrec.ui.JavaFxModel; +import ctbrec.ui.action.PlayAction; +import javafx.scene.control.TableCell; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; + +public class ClickableTableCell extends TableCell { + + public ClickableTableCell() { + addEventFilter(MouseEvent.MOUSE_CLICKED, event -> { + if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) { + JavaFxModel selectedModel = getTableView().getSelectionModel().getSelectedItem(); + if(selectedModel != null) { + new PlayAction(getTableView(), selectedModel).execute(); + } + } + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/IconTableCell.java b/client/src/main/java/ctbrec/ui/tabs/recorded/IconTableCell.java new file mode 100644 index 00000000..306859ae --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/IconTableCell.java @@ -0,0 +1,49 @@ +package ctbrec.ui.tabs.recorded; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import ctbrec.ui.Icon; +import javafx.geometry.Pos; +import javafx.scene.control.Tooltip; +import javafx.scene.image.ImageView; +import javafx.scene.layout.HBox; + +public class IconTableCell extends ClickableTableCell { + + protected String tooltip; + protected HBox iconRow; + private Map icons; + + public IconTableCell(Map icons) { + this.icons = Objects.requireNonNullElse(icons, new HashMap<>()); + iconRow = new HBox(3); + iconRow.setAlignment(Pos.CENTER); + } + + protected void show(Icon iconName) { + var imageView = icons.get(iconName); + if (imageView != null) { + iconRow.getChildren().remove(imageView); + iconRow.getChildren().add(imageView); + } + } + + protected void hide(Icon iconName) { + var imageView = icons.get(iconName); + if (imageView != null) { + iconRow.getChildren().remove(imageView); + } + } + + @Override + protected void updateItem(T value, boolean empty) { + if (tooltip != null) { + setTooltip(new Tooltip(tooltip)); + } else { + setTooltip(null); + } + setGraphic(iconRow); + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/ImageTableCell.java b/client/src/main/java/ctbrec/ui/tabs/recorded/ImageTableCell.java new file mode 100644 index 00000000..38e27282 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/ImageTableCell.java @@ -0,0 +1,50 @@ +package ctbrec.ui.tabs.recorded; + +import javafx.geometry.Insets; +import javafx.scene.control.TableColumn; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.shape.Rectangle; + +public class ImageTableCell extends ClickableTableCell { + + protected ImageView imageView; + + public ImageTableCell() { + imageView = new ImageView(); + imageView.setSmooth(true); + imageView.setPreserveRatio(true); + imageView.prefHeight(64); + imageView.setFitHeight(64); + + setPadding(new Insets(5)); + setGraphic(imageView); + } + + @Override + public void requestLayout() { + TableColumn tc = getTableColumn(); + if (tc != null) { + double columnWidth = getTableColumn().getWidth(); + Insets pd = getPadding(); + var height = columnWidth - pd.getTop() - pd.getBottom(); + var width = columnWidth - pd.getLeft() - pd.getRight(); + imageView.prefHeight(height); + imageView.setFitHeight(height); + imageView.prefWidth(width); + imageView.setFitWidth(width); + + var clip = new Rectangle(width, height); + clip.setArcHeight(10); + clip.setArcWidth(10); + imageView.setClip(clip); + } + super.requestLayout(); + } + + @Override + protected void updateItem(Image image, boolean empty) { + imageView.setImage(empty ? null : image); + super.requestLayout(); + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/ModelExportDialog.java b/client/src/main/java/ctbrec/ui/tabs/recorded/ModelExportDialog.java new file mode 100644 index 00000000..7bae7c1e --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/ModelExportDialog.java @@ -0,0 +1,89 @@ +package ctbrec.ui.tabs.recorded; + +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.controls.SaveFileSelectionBox; +import ctbrec.ui.tabs.recorded.ModelImportExport.ExportIncludes; +import ctbrec.ui.tabs.recorded.ModelImportExport.ExportOptions; +import javafx.geometry.Insets; +import javafx.geometry.VPos; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.control.CheckBox; +import javafx.scene.control.Label; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; + +import java.io.File; +import java.util.HashSet; +import java.util.Set; + +import static ctbrec.ui.tabs.recorded.ModelImportExport.ExportIncludes.*; + +public class ModelExportDialog { + + private final Node source; + private final GridPane gridPane = new GridPane(); + + private CheckBox notesButton; + private CheckBox groupsButton; + private CheckBox portraisButton; + private SaveFileSelectionBox fileSelectionBox; + + public ModelExportDialog(Node source) { + this.source = source; + createGui(); + } + + private void createGui() { + source.setCursor(Cursor.WAIT); + gridPane.setHgap(10); + gridPane.setVgap(10); + gridPane.setPadding(new Insets(20, 150, 10, 10)); + Label l = new Label("Export to file"); + gridPane.add(l, 0, 0); + fileSelectionBox = new SaveFileSelectionBox(); + gridPane.add(fileSelectionBox, 1, 0); + GridPane.setValignment(l, VPos.TOP); + GridPane.setMargin(l, new Insets(5, 0, 0, 0)); + GridPane.setHgrow(fileSelectionBox, Priority.ALWAYS); + notesButton = new CheckBox("notes"); + notesButton.setSelected(true); + groupsButton = new CheckBox("groups"); + groupsButton.setSelected(true); + portraisButton = new CheckBox("portraits"); + portraisButton.setSelected(true); + var row = new VBox(); + row.getChildren().addAll(notesButton, groupsButton, portraisButton); + VBox.setMargin(notesButton, new Insets(5)); + VBox.setMargin(groupsButton, new Insets(5)); + VBox.setMargin(portraisButton, new Insets(5)); + l = new Label("Include"); + GridPane.setMargin(l, new Insets(5, 0, 0, 0)); + GridPane.setValignment(l, VPos.TOP); + gridPane.add(l, 0, 1); + gridPane.add(row, 1, 1); + } + + public ExportOptions showAndWait() { + try { + boolean confirmed = Dialogs.showCustomInput(source.getScene(), "Export model list", gridPane); + if (confirmed) { + Set exportIncludes = new HashSet<>(); + if (notesButton.isSelected()) { + exportIncludes.add(NOTES); + } + if (groupsButton.isSelected()) { + exportIncludes.add(GROUPS); + } + if (portraisButton.isSelected()) { + exportIncludes.add(PORTRAITS); + } + return new ExportOptions(exportIncludes, new File(fileSelectionBox.fileProperty().getValue())); + } + return null; + } finally { + source.setCursor(Cursor.DEFAULT); + } + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/ModelImportExport.java b/client/src/main/java/ctbrec/ui/tabs/recorded/ModelImportExport.java new file mode 100644 index 00000000..0760f0f5 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/ModelImportExport.java @@ -0,0 +1,215 @@ +package ctbrec.ui.tabs.recorded; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import ctbrec.Config; +import ctbrec.MigrateModel5_1_2; +import ctbrec.Model; +import ctbrec.ModelGroup; +import ctbrec.image.LocalPortraitStore; +import ctbrec.image.PortraitStore; +import ctbrec.image.RemotePortraitStore; +import ctbrec.io.HttpException; +import ctbrec.io.json.ObjectMapperFactory; +import ctbrec.io.json.dto.ModelDto; +import ctbrec.io.json.mapper.MappingException; +import ctbrec.io.json.mapper.ModelMapper; +import ctbrec.notes.LocalModelNotesService; +import ctbrec.notes.ModelNotesService; +import ctbrec.notes.RemoteModelNotesService; +import ctbrec.sites.Site; +import ctbrec.sites.SiteUtil; +import ctbrec.ui.CamrecApplication; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.json.JSONArray; +import org.json.JSONObject; +import org.json.JSONWriter; +import org.mapstruct.factory.Mappers; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +public class ModelImportExport { + + private static final String KEY_NOTES = "notes"; + private static final String KEY_GROUPS = "groups"; + private static final String KEY_PORTRAITS = "portraits"; + + + enum ExportIncludes { + NOTES, + GROUPS, + PORTRAITS + } + + private static final ObjectMapper mapper = ObjectMapperFactory.getMapper(); + + public record ExportOptions(Set includes, File targetFile) { + } + + private ModelImportExport() { + } + + public static void exportTo(List models, Set groups, Config config, ExportOptions exportOptions) throws IOException { + StringBuilder sb = new StringBuilder(); + JSONWriter writer = new JSONWriter(sb); + + writer.object(); + writer.key("models"); + writer.array(); + var modelArray = models.stream() + .map(Mappers.getMapper(ModelMapper.class)::toDto) + .map(dto -> { + try { + return mapper.writeValueAsString(dto); + } catch (JsonProcessingException e) { + log.error("Error while serializing model {}", dto); + throw new MappingException(e); + } + }) + .collect(Collectors.joining(",")); + sb.append(modelArray); + writer.endArray(); + if (exportOptions.includes().contains(ExportIncludes.NOTES)) { + ModelNotesService modelNotesService = getModelNotesService(config); + writer.key(KEY_NOTES); + writer.value(modelNotesService.loadAllModelNotes()); + } + if (exportOptions.includes().contains(ExportIncludes.GROUPS)) { + writer.key(KEY_GROUPS); + writer.array(); + var groupArray = groups + .stream().map(grp -> { + try { + return mapper.writeValueAsString(grp); + } catch (JsonProcessingException e) { + log.error("Error while serializing model group {}", grp); + throw new MappingException(e); + } + }) + .collect(Collectors.joining(",")); + sb.append(groupArray); + writer.endArray(); + } + if (exportOptions.includes().contains(ExportIncludes.PORTRAITS)) { + PortraitStore portraitLoader = getPortraitStore(config); + writer.key(KEY_PORTRAITS); + writer.array(); + for (Model model : models) { + try { + Optional portrait = portraitLoader.loadModelPortraitByModelUrl(model.getUrl()); + if (portrait.isPresent()) { + writer.object(); + writer.key("url").value(model.getUrl()); + writer.key("id").value(portraitLoader.idForModelUrl(model.getUrl())); + writer.key("data").value(Base64.getEncoder().encodeToString(portrait.get())); + writer.endObject(); + } + } catch (HttpException e) { + if (e.getResponseCode() != 404) { + log.error("Error while loading portrait from server for {}", model, e); + } + } + } + writer.endArray(); + } + writer.endObject(); + Files.writeString(exportOptions.targetFile().toPath(), new JSONObject(sb.toString()).toString(2)); + } + + private static PortraitStore getPortraitStore(Config config) { + PortraitStore portraitLoader; + if (config.getSettings().localRecording) { + portraitLoader = new LocalPortraitStore(config); + } else { + portraitLoader = new RemotePortraitStore(CamrecApplication.httpClient, config); + } + return portraitLoader; + } + + @NotNull + private static ModelNotesService getModelNotesService(Config config) { + ModelNotesService modelNotesService; + if (config.getSettings().localRecording) { + modelNotesService = new LocalModelNotesService(config); + } else { + modelNotesService = new RemoteModelNotesService(CamrecApplication.httpClient, config); + } + return modelNotesService; + } + + public static List importFrom(File target, List sites, Config config) throws IOException { + JSONObject json = new JSONObject(Files.readString(target.toPath(), StandardCharsets.UTF_8)); + List models = readModels(json.getJSONArray("models")); + models.forEach(m -> SiteUtil.getSiteForModel(sites, m).ifPresent(m::setSite)); + if (json.has(KEY_NOTES)) { + importNotes(json.getJSONObject(KEY_NOTES), config); + } + if (json.has(KEY_GROUPS)) { + importGroups(json.getJSONArray(KEY_GROUPS), config); + } + if (json.has(KEY_PORTRAITS)) { + importPortraits(json.getJSONArray(KEY_PORTRAITS), config); + } + return models; + } + + private static void importPortraits(JSONArray portraits, Config config) throws IOException { + PortraitStore portraitStore = getPortraitStore(config); + for (int i = 0; i < portraits.length(); i++) { + JSONObject portrait = portraits.getJSONObject(i); + String url = portrait.getString("url"); + // String id = portrait.getString("id"); + String dataBase64 = portrait.getString("data"); + portraitStore.writePortrait(url, Base64.getDecoder().decode(dataBase64)); + } + } + + + private static void importGroups(JSONArray groups, Config config) { + for (int i = 0; i < groups.length(); i++) { + JSONObject group = groups.getJSONObject(i); + try { + ModelGroup modelGroup = mapper.readValue(group.toString(), ModelGroup.class); + config.getSettings().modelGroups.add(modelGroup); + } catch (JsonProcessingException e) { + log.error("Error while deserializing model group {}", group); + } + } + } + + private static void importNotes(JSONObject notes, Config config) { + ModelNotesService modelNotesService = getModelNotesService(config); + JSONArray urls = notes.names(); + for (int i = 0; urls != null && i < urls.length(); i++) { + String url = urls.getString(i); + String note = notes.getString(url); + try { + modelNotesService.writeModelNotes(url, note); + } catch (IOException e) { + log.error("Error while importing model notes for {}", url, e); + } + } + } + + private static List readModels(JSONArray models) { + List result = new LinkedList<>(); + for (int i = 0; i < models.length(); i++) { + JSONObject model = models.getJSONObject(i); + MigrateModel5_1_2.migrate(model); + try { + ModelDto dto = mapper.readValue(model.toString(), ModelDto.class); + result.add(Mappers.getMapper(ModelMapper.class).toModel(dto)); + } catch (Exception e) { + log.error("Error while deserializing model {}", model.toString(2), e); + } + } + return result; + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/ModelName.java b/client/src/main/java/ctbrec/ui/tabs/recorded/ModelName.java new file mode 100644 index 00000000..422fc9f9 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/ModelName.java @@ -0,0 +1,29 @@ +package ctbrec.ui.tabs.recorded; + +import ctbrec.Model; +import ctbrec.ModelGroup; +import ctbrec.recorder.Recorder; + +import java.util.Optional; + +public class ModelName { + private final Model mdl; + private final Recorder rec; + + public ModelName(Model model, Recorder recorder) { + mdl = model; + rec = recorder; + } + + @Override + public String toString() { + Optional modelGroup = rec.getModelGroup(mdl); + String s; + if (modelGroup.isPresent()) { + s = modelGroup.get().getName() + " (aka " + mdl.getDisplayName() + ')'; + } else { + return mdl.getDisplayName(); + } + return s; + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/ModelNameTableCell.java b/client/src/main/java/ctbrec/ui/tabs/recorded/ModelNameTableCell.java new file mode 100644 index 00000000..b0c46a4e --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/ModelNameTableCell.java @@ -0,0 +1,45 @@ +package ctbrec.ui.tabs.recorded; + +import static ctbrec.ui.Icon.*; + +import java.util.Map; +import java.util.stream.Collectors; + +import ctbrec.Model; +import ctbrec.recorder.Recorder; +import javafx.scene.image.ImageView; + +public class ModelNameTableCell extends IconTableCell { + + private Recorder recorder; + + public ModelNameTableCell(Recorder recorder) { + super(Map.of( // + GROUP_16, new ImageView(GROUP_16.url()), // + BLANK_16, new ImageView(BLANK_16.url()))); + this.recorder = recorder; + } + + @Override + protected void updateItem(ModelName modelName, boolean empty) { + setText(null); + tooltip = null; + show(BLANK_16); + hide(GROUP_16); + + if (modelName != null && !empty) { + setText(modelName.toString()); + Model m = getTableView().getItems().get(getTableRow().getIndex()); + recorder.getModelGroup(m).ifPresent(group -> { + hide(BLANK_16); + show(GROUP_16); + tooltip = group.getModelUrls().size() + " models:\n"; + tooltip += group.getModelUrls().stream().collect(Collectors.joining("\n")); + }); + if (m.isForcePriority()) { + this.setStyle(getStyle() + "-fx-text-fill: darkred;" + "-fx-font-weight: bold;"); + } + } + super.updateItem(modelName, empty); + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/OnlineTableCell.java b/client/src/main/java/ctbrec/ui/tabs/recorded/OnlineTableCell.java new file mode 100644 index 00000000..efedc57d --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/OnlineTableCell.java @@ -0,0 +1,27 @@ +package ctbrec.ui.tabs.recorded; + +import static ctbrec.ui.Icon.*; + +import java.util.Map; + +import com.google.common.base.Objects; + +import javafx.scene.image.ImageView; + +public class OnlineTableCell extends IconTableCell { + + public OnlineTableCell() { + super(Map.of(CHECK_16, new ImageView(CHECK_16.url()))); + } + + @Override + protected void updateItem(Boolean value, boolean empty) { + hide(CHECK_16); + tooltip = null; + if (!empty && Objects.equal(value, Boolean.TRUE)) { + show(CHECK_16); + tooltip = "Online"; + } + super.updateItem(value, empty); + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/RecordLaterTab.java b/client/src/main/java/ctbrec/ui/tabs/recorded/RecordLaterTab.java new file mode 100644 index 00000000..1701b358 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/RecordLaterTab.java @@ -0,0 +1,177 @@ +package ctbrec.ui.tabs.recorded; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.recorder.Recorder; +import ctbrec.sites.Site; +import ctbrec.ui.JavaFxModel; +import ctbrec.ui.action.AbstractModelAction.Result; +import ctbrec.ui.action.CheckModelAccountAction; +import ctbrec.ui.action.StopRecordingAction; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.tabs.TabSelectionListener; +import javafx.application.Platform; +import javafx.concurrent.ScheduledService; +import javafx.concurrent.Task; +import javafx.concurrent.WorkerStateEvent; +import javafx.geometry.Insets; +import javafx.scene.layout.BorderPane; +import javafx.util.Duration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; + +public class RecordLaterTab extends AbstractRecordedModelsTab implements TabSelectionListener { + private static final Logger LOG = LoggerFactory.getLogger(RecordLaterTab.class); + + private ScheduledService> updateService; + + public RecordLaterTab(String title, Recorder recorder, List sites) { + super(title, "recordLaterTable"); + this.recorder = recorder; + this.sites = sites; + createGui(); + setClosable(false); + initializeUpdateService(); + } + + @Override + protected void createGui() { + super.createGui(); + + int columnIdx = 0; + addPreviewColumn(columnIdx++); + addPortraitColumn(columnIdx++); + addModelColumn(columnIdx++); + addUrlColumn(columnIdx++); + addAddedTimestampColumn(columnIdx++); + addNotesColumn(columnIdx); + + var root = new BorderPane(); + root.setPadding(new Insets(5)); + root.setTop(addModelBox); + root.setCenter(scrollPane); + setContent(root); + + checkModelAccountExistance + .setOnAction(evt -> new CheckModelAccountAction(checkModelAccountExistance, recorder).execute(Model::isMarkedForLaterRecording)); + + restoreState(); + } + + @Override + protected List getExportList() { + return recorder.getModels().stream().filter(Model::isMarkedForLaterRecording).toList(); + } + + void initializeUpdateService() { + updateService = createUpdateService(); + updateService.setPeriod(new Duration(TimeUnit.SECONDS.toMillis(2))); + updateService.setOnSucceeded(this::onUpdateSuccess); + updateService.setOnFailed(event -> LOG.info("Couldn't get list of models from recorder", event.getSource().getException())); + } + + private void onUpdateSuccess(WorkerStateEvent event) { + List updatedModels = updateService.getValue(); + if (updatedModels == null) { + return; + } + + lock.lock(); + try { + addOrUpdateModels(updatedModels); + + // remove old ones, which are not in the list of updated models + observableModels.removeIf(Predicate.not(updatedModels::contains)); + } finally { + lock.unlock(); + } + + filteredModels.clear(); + filter(filterTextField.getText()); + table.sort(); + table.refresh(); + } + + private void addOrUpdateModels(List updatedModels) { + for (JavaFxModel updatedModel : updatedModels) { + int index = observableModels.indexOf(updatedModel); + if (index == -1) { + observableModels.add(updatedModel); + } else { + // make sure to update the JavaFX online property, so that the table cell is updated + JavaFxModel oldModel = observableModels.get(index); + oldModel.updateFrom(updatedModel); + } + } + } + + private ScheduledService> createUpdateService() { + ScheduledService> modelUpdateService = new ScheduledService<>() { + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() { + LOG.trace("Updating models marked for later recording"); + return recorder.getModels().stream().filter(Model::isMarkedForLaterRecording).map(JavaFxModel::new).toList(); + } + }; + } + }; + ExecutorService executor = Executors.newSingleThreadExecutor(r -> { + var t = new Thread(r); + t.setDaemon(true); + t.setName("RecordLaterTab UpdateService"); + return t; + }); + modelUpdateService.setExecutor(executor); + return modelUpdateService; + } + + @Override + public void selected() { + if (updateService != null) { + updateService.reset(); + updateService.restart(); + } + } + + @Override + public void deselected() { + if (updateService != null) { + updateService.cancel(); + } + } + + @Override + void stopAction(List selectedModels) { + var confirmed = true; + if (Config.getInstance().getSettings().confirmationForDangerousActions) { + int n = selectedModels.size(); + String plural = n > 1 ? "s" : ""; + String header = "This will remove " + n + " model" + plural; + confirmed = Dialogs.showConfirmDialog("Remove From List", "Continue?", header, table.getScene()); + } + if (confirmed) { + List models = selectedModels.stream().map(JavaFxModel::getDelegate).toList(); + new StopRecordingAction(getTabPane(), models, recorder).execute().whenComplete((r, ex) -> + r.stream().map(Result::getModel).forEach(m -> Platform.runLater(() -> { + table.getSelectionModel().clearSelection(table.getItems().indexOf(m)); + table.getItems().remove(m); + })) + ); + portraitCache.invalidateAll(models); + } + } + + @Override + boolean getMarkModelsForLaterRecording() { + return true; + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/RecordedModelsPerSiteTab.java b/client/src/main/java/ctbrec/ui/tabs/recorded/RecordedModelsPerSiteTab.java new file mode 100644 index 00000000..a68acad4 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/RecordedModelsPerSiteTab.java @@ -0,0 +1,64 @@ +package ctbrec.ui.tabs.recorded; + +import ctbrec.Model; +import ctbrec.recorder.Recorder; +import ctbrec.sites.Site; +import ctbrec.ui.action.PauseAction; +import ctbrec.ui.action.ResumeAction; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.tabs.TabSelectionListener; +import javafx.event.ActionEvent; + +import java.util.List; +import java.util.Objects; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +public class RecordedModelsPerSiteTab extends RecordedModelsTab implements TabSelectionListener { + + public RecordedModelsPerSiteTab(String title, Recorder recorder, Site site) { + super(title, recorder, List.of(site)); + filter(site.getName()); + } + + @Override + protected void createGui() { + super.createGui(); + addModelBox.getChildren().remove(toggleRecording); + addModelBox.getChildren().remove(checkModelAccountExistance); + restoreState(); + } + + @Override + protected void filter(String filter) { + super.filter(filter + " " + sites.get(0).getName()); + } + + @Override + protected void pauseAll(ActionEvent evt) { + boolean yes = Dialogs.showConfirmDialog("Pause all models", "", "Pause the recording of all models in this table?", getTabPane().getScene()); + if (yes) { + new PauseAction(getTabPane(), getFilteredModelsForTab(), recorder).execute(); + } + } + + @Override + protected void resumeAll(ActionEvent evt) { + boolean yes = Dialogs.showConfirmDialog("Resume all models", "", "Pause the recording of all models in this table?", getTabPane().getScene()); + if (yes) { + new ResumeAction(getTabPane(), getFilteredModelsForTab(), recorder).execute(); + } + } + + @Override + protected List getExportList() { + return getFilteredModelsForTab(); + } + + private List getFilteredModelsForTab() { + return recorder.getModels().stream() + .filter(Predicate.not(Model::isMarkedForLaterRecording)) + .filter(m -> Objects.equals(m.getSite(), sites.get(0))) + .toList(); + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/RecordedModelsTab.java b/client/src/main/java/ctbrec/ui/tabs/recorded/RecordedModelsTab.java new file mode 100644 index 00000000..de0595b1 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/RecordedModelsTab.java @@ -0,0 +1,436 @@ +package ctbrec.ui.tabs.recorded; + +import ctbrec.Config; +import ctbrec.Model; +import ctbrec.Recording; +import ctbrec.recorder.Recorder; +import ctbrec.sites.Site; +import ctbrec.ui.JavaFxModel; +import ctbrec.ui.action.AbstractModelAction.Result; +import ctbrec.ui.action.*; +import ctbrec.ui.controls.DateTimeCellFactory; +import ctbrec.ui.controls.Dialogs; +import ctbrec.ui.tabs.TabSelectionListener; +import javafx.application.Platform; +import javafx.beans.value.ChangeListener; +import javafx.concurrent.ScheduledService; +import javafx.concurrent.Task; +import javafx.concurrent.WorkerStateEvent; +import javafx.event.ActionEvent; +import javafx.geometry.Insets; +import javafx.scene.control.Button; +import javafx.scene.control.TableCell; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableColumn.CellEditEvent; +import javafx.scene.control.ToggleButton; +import javafx.scene.control.cell.CheckBoxTableCell; +import javafx.scene.control.cell.TextFieldTableCell; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.util.Callback; +import javafx.util.Duration; +import javafx.util.converter.NumberStringConverter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +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.function.Predicate; + +import static ctbrec.Recording.State.RECORDING; + +public class RecordedModelsTab extends AbstractRecordedModelsTab implements TabSelectionListener { + private static final Logger LOG = LoggerFactory.getLogger(RecordedModelsTab.class); + + private ScheduledService> updateService; + private volatile boolean cellEditing = false; + + Button pauseAll = new Button("Pause All"); + Button resumeAll = new Button("Resume All"); + ToggleButton toggleRecording = new ToggleButton("Pause Recording"); + protected BorderPane root = new BorderPane(); + + public RecordedModelsTab(String title, Recorder recorder, List sites) { + super(title, "recordedModelsTable"); + this.recorder = recorder; + this.sites = sites; + createGui(); + setClosable(false); + initializeUpdateService(); + } + + @Override + protected void createGui() { + super.createGui(); + + int idx = 0; + + addPreviewColumn(idx++); + addPortraitColumn(idx++); + addModelColumn(idx++); + addUrlColumn(idx++); + + TableColumn online = new TableColumn<>("Online"); + online.setCellValueFactory(cdf -> cdf.getValue().getOnlineProperty()); + online.setCellFactory(param -> new OnlineTableCell()); + online.setPrefWidth(100); + online.setEditable(false); + online.setId("online"); + online.setUserData(idx++); + online.setStyle(STYLE_ALIGN_CENTER); + columns.add(online); + table.getColumns().add(online); + TableColumn recording = new TableColumn<>("Recording"); + recording.setCellValueFactory(cdf -> cdf.getValue().getRecordingProperty()); + recording.setCellFactory(tc -> new RecordingTableCell()); + recording.setPrefWidth(100); + recording.setEditable(false); + recording.setId("recording"); + recording.setUserData(idx++); + recording.setStyle(STYLE_ALIGN_CENTER); + columns.add(recording); + table.getColumns().add(recording); + TableColumn paused = new TableColumn<>("Paused"); + paused.setCellValueFactory(cdf -> cdf.getValue().getPausedProperty()); + paused.setCellFactory(CheckBoxTableCell.forTableColumn(paused)); + paused.setPrefWidth(100); + paused.setEditable(true); + paused.setId("paused"); + paused.setUserData(idx++); + paused.setStyle(STYLE_ALIGN_CENTER); + columns.add(paused); + table.getColumns().add(paused); + TableColumn priority = new TableColumn<>("Priority"); + priority.setCellValueFactory(param -> param.getValue().getPriorityProperty()); + priority.setCellFactory(new PriorityCellFactory()); + priority.setPrefWidth(90); + priority.setEditable(true); + priority.setOnEditStart(e -> cellEditing = true); + priority.setOnEditCommit(this::onUpdatePriority); + priority.setOnEditCancel(e -> cellEditing = false); + priority.setId("priority"); + priority.setUserData(idx++); + columns.add(priority); + table.getColumns().add(priority); + TableColumn lastSeen = new TableColumn<>("Last seen"); + lastSeen.setCellValueFactory(cdf -> cdf.getValue().lastSeenProperty()); + lastSeen.setCellFactory(new DateTimeCellFactory<>(config.getDateTimeFormatter())); + lastSeen.setPrefWidth(150); + lastSeen.setEditable(false); + lastSeen.setId("lastSeen"); + lastSeen.setUserData(idx++); + lastSeen.setStyle(STYLE_ALIGN_CENTER); + columns.add(lastSeen); + table.getColumns().add(lastSeen); + TableColumn lastRecorded = new TableColumn<>("Last recorded"); + lastRecorded.setCellValueFactory(cdf -> cdf.getValue().lastRecordedProperty()); + lastRecorded.setCellFactory(new DateTimeCellFactory<>(config.getDateTimeFormatter())); + lastRecorded.setPrefWidth(150); + lastRecorded.setEditable(false); + lastRecorded.setId("lastRecorded"); + lastRecorded.setUserData(idx++); + lastRecorded.setStyle(STYLE_ALIGN_CENTER); + columns.add(lastRecorded); + table.getColumns().add(lastRecorded); + TableColumn onlineState = new TableColumn<>("State"); + onlineState.setCellValueFactory(cdf -> cdf.getValue().onlineStateProperty()); + onlineState.setCellFactory(new ClickableCellFactory<>()); + onlineState.setPrefWidth(150); + onlineState.setEditable(false); + onlineState.setId("onlineState"); + onlineState.setUserData(idx++); + onlineState.setStyle(STYLE_ALIGN_CENTER); + onlineState.setVisible(false); + columns.add(onlineState); + table.getColumns().add(onlineState); + + addAddedTimestampColumn(idx++); + addNotesColumn(idx); + + addModelBox.getChildren().add(3, pauseAll); + addModelBox.getChildren().add(4, resumeAll); + addModelBox.getChildren().add(5, toggleRecording); + HBox.setMargin(pauseAll, new Insets(0, 0, 0, 20)); + pauseAll.setOnAction(this::pauseAll); + resumeAll.setOnAction(this::resumeAll); + pauseAll.setPadding(new Insets(5)); + resumeAll.setPadding(new Insets(5)); + toggleRecording.setPadding(new Insets(5)); + toggleRecording.setOnAction(this::toggleRecording); + HBox.setMargin(toggleRecording, new Insets(0, 0, 0, 20)); + + checkModelAccountExistance + .setOnAction(evt -> new CheckModelAccountAction(checkModelAccountExistance, recorder).execute(Predicate.not(Model::isMarkedForLaterRecording))); + + root.setPadding(new Insets(5)); + root.setTop(addModelBox); + root.setCenter(scrollPane); + setContent(root); + + restoreState(); + } + + @Override + protected List getExportList() { + return recorder.getModels().stream().filter(Predicate.not(Model::isMarkedForLaterRecording)).toList(); + } + + private void onUpdatePriority(CellEditEvent evt) { + try { + int prio = Optional.ofNullable(evt.getNewValue()).map(Number::intValue).orElse(-1); + JavaFxModel m = evt.getRowValue(); + updatePriority(m, prio); + table.refresh(); + } finally { + cellEditing = false; + } + } + + private void updatePriority(JavaFxModel model, int priority) { + try { + if (priority < 0 || priority > Model.MAX_PRIO) { + var msg = "Priority has to be between 0 and " + Model.MAX_PRIO; + Dialogs.showError(table.getScene(), "Invalid value", msg, null); + } else { + model.setPriority(priority); + recorder.priorityChanged(model.getDelegate()); + } + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { + Dialogs.showError(table.getScene(), "Couldn't update priority", e.getMessage(), e); + } + } + + protected void pauseAll(ActionEvent evt) { + boolean yes = Dialogs.showConfirmDialog("Pause all models", "", "Pause the recording of all models?", getTabPane().getScene()); + if (yes) { + List models = recorder.getModels().stream().filter(Predicate.not(Model::isMarkedForLaterRecording)).toList(); + new PauseAction(getTabPane(), models, recorder).execute(); + } + } + + protected void resumeAll(ActionEvent evt) { + boolean yes = Dialogs.showConfirmDialog("Resume all models", "", "Resume the recording of all models?", getTabPane().getScene()); + if (yes) { + List models = recorder.getModels().stream().filter(Predicate.not(Model::isMarkedForLaterRecording)).toList(); + new ResumeAction(getTabPane(), models, recorder).execute(); + } + } + + private void toggleRecording(ActionEvent evt) { + new ToggleRecordingAction(toggleRecording, recorder).execute(); + } + + void initializeUpdateService() { + updateService = createUpdateService(); + updateService.setPeriod(new Duration(TimeUnit.SECONDS.toMillis(2))); + updateService.setOnSucceeded(this::onUpdateSuccess); + updateService.setOnFailed(event -> LOG.info("Couldn't get list of models from recorder", event.getSource().getException())); + } + + private void onUpdateSuccess(WorkerStateEvent event) { + if (cellEditing) { + return; + } + + List updatedModels = updateService.getValue(); + if (updatedModels == null) { + return; + } + + lock.lock(); + try { + addOrUpdateModels(updatedModels); + + // remove old ones, which are not in the list of updated models + observableModels.removeIf(Predicate.not(updatedModels::contains)); + } finally { + lock.unlock(); + } + + filteredModels.clear(); + filter(filterTextField.getText()); + table.sort(); + table.refresh(); + } + + private void addOrUpdateModels(List updatedModels) { + for (JavaFxModel updatedModel : updatedModels) { + int index = observableModels.indexOf(updatedModel); + if (index == -1) { + observableModels.add(updatedModel); + updatedModel.getPausedProperty().addListener(createPauseListener(updatedModel)); + updatedModel.getForcePriorityProperty().addListener(createForcePriorityListener(updatedModel)); + } else { + // make sure to update the JavaFX online property, so that the table cell is updated + JavaFxModel oldModel = observableModels.get(index); + oldModel.updateFrom(updatedModel); + } + } + } + + private ChangeListener createPauseListener(JavaFxModel updatedModel) { + return (obs, oldV, newV) -> { + if (Boolean.TRUE.equals(newV)) { + if (!recorder.isSuspended(updatedModel)) { + pauseRecording(Collections.singletonList(updatedModel)); + } + } else { + if (recorder.isSuspended(updatedModel)) { + resumeRecording(Collections.singletonList(updatedModel)); + } + } + }; + } + + private ChangeListener createForcePriorityListener(JavaFxModel updatedModel) { + return (obs, oldV, newV) -> { + if (Boolean.TRUE.equals(newV)) { + if (!recorder.isForcePriority(updatedModel)) { + forcePriority(Collections.singletonList(updatedModel)); + } + } else { + if (recorder.isForcePriority(updatedModel)) { + resumePriority(Collections.singletonList(updatedModel)); + } + } + }; + } + + private ScheduledService> createUpdateService() { + ScheduledService> modelUpdateService = new ScheduledService<>() { + @Override + protected Task> createTask() { + return new Task<>() { + @Override + public List call() throws InvalidKeyException, NoSuchAlgorithmException, IOException { + LOG.trace("Updating recorded models"); + List recordings = recorder.getRecordings(); + List onlineModels = recorder.getOnlineModels(); + return recorder.getModels().stream().filter(Predicate.not(Model::isMarkedForLaterRecording)).map(JavaFxModel::new).peek(fxm -> { // NOSONAR + for (Recording recording : recordings) { + if (recording.getStatus() == RECORDING && Objects.equals(recording.getModel(), fxm)) { + fxm.setRecordingProperty(true); + break; + } + } + + for (Model onlineModel : onlineModels) { + if (Objects.equals(onlineModel, fxm)) { + fxm.setOnlineProperty(true); + try { + fxm.setOnlineStateProperty(onlineModel.getOnlineState(true)); + } catch (Exception e) {} + break; + } + } + }).toList(); + } + }; + } + }; + ExecutorService executor = Executors.newSingleThreadExecutor(r -> { + var t = new Thread(r); + t.setDaemon(true); + t.setName("RecordedModelsTab UpdateService"); + return t; + }); + modelUpdateService.setExecutor(executor); + return modelUpdateService; + } + + @Override + public void selected() { + if (updateService != null) { + updateService.reset(); + updateService.restart(); + } + } + + @Override + public void deselected() { + if (updateService != null) { + updateService.cancel(); + } + } + + @Override + void stopAction(List selectedModels) { + var confirmed = true; + if (Config.getInstance().getSettings().confirmationForDangerousActions) { + int n = selectedModels.size(); + String plural = n > 1 ? "s" : ""; + String header = "This will stop the recording of " + n + " model" + plural; + confirmed = Dialogs.showConfirmDialog("Stop Recording", "Continue?", header, table.getScene()); + } + if (confirmed) { + List models = selectedModels.stream().map(JavaFxModel::getDelegate).toList(); + new StopRecordingAction(getTabPane(), models, recorder).execute().whenComplete((r, ex) -> + r.stream().map(Result::getModel).forEach(m -> Platform.runLater(() -> { + table.getSelectionModel().clearSelection(table.getItems().indexOf(m)); + table.getItems().remove(m); + })) + ); + portraitCache.invalidateAll(models); + } + } + + private void pauseRecording(List selectedModels) { + List models = selectedModels.stream().map(JavaFxModel::getDelegate).toList(); + new PauseAction(getTabPane(), models, recorder).execute(); + } + + private void resumeRecording(List selectedModels) { + List models = selectedModels.stream().map(JavaFxModel::getDelegate).toList(); + new ResumeAction(getTabPane(), models, recorder).execute(); + } + + private void forcePriority(List selectedModels) { + List models = selectedModels.stream().map(JavaFxModel::getDelegate).toList(); + new ForcePriorityAction(getTabPane(), models, recorder).execute(); + } + + private void resumePriority(List selectedModels) { + List models = selectedModels.stream().map(JavaFxModel::getDelegate).toList(); + new ResumePriorityAction(getTabPane(), models, recorder).execute(); + } + + private class PriorityCellFactory implements Callback, TableCell> { + @Override + public TableCell call(TableColumn param) { + Callback, TableCell> callback = TextFieldTableCell.forTableColumn(new NumberStringConverter()); + TableCell tableCell = callback.call(param); + + tableCell.setOnScroll(event -> { + if (event.isControlDown()) { + event.consume(); + JavaFxModel m = tableCell.getTableRow().getItem(); + int prio = m.getPriority(); + if (event.getDeltaY() < 0) { + prio--; + } else { + prio++; + } + prio = Math.min(Math.max(0, prio), Model.MAX_PRIO); + m.setPriority(prio); + updatePriority(m, prio); + } + }); + tableCell.setStyle("-fx-alignment: CENTER-LEFT;"); + return tableCell; + } + } + + @Override + boolean getMarkModelsForLaterRecording() { + return false; + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/RecordedTab.java b/client/src/main/java/ctbrec/ui/tabs/recorded/RecordedTab.java new file mode 100644 index 00000000..45882fca --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/RecordedTab.java @@ -0,0 +1,64 @@ +package ctbrec.ui.tabs.recorded; + +import java.util.List; + +import ctbrec.recorder.Recorder; +import ctbrec.sites.Site; +import ctbrec.ui.ShutdownListener; +import ctbrec.ui.tabs.TabSelectionListener; +import javafx.beans.value.ChangeListener; +import javafx.geometry.Side; +import javafx.scene.control.Tab; +import javafx.scene.control.TabPane; + +public class RecordedTab extends Tab implements TabSelectionListener, ShutdownListener { + + private TabPane tabPane; + private RecordedModelsTab recordedModelsTab; + private RecordLaterTab recordLaterTab; + + public RecordedTab(Recorder recorder, List sites) { + super("Recording"); + setClosable(false); + + recordedModelsTab = new RecordedModelsTab("Active", recorder, sites); + recordLaterTab = new RecordLaterTab("Later", recorder, sites); + + tabPane = new TabPane(); + tabPane.setSide(Side.LEFT); + tabPane.getTabs().addAll(recordedModelsTab, recordLaterTab); + setContent(tabPane); + + // register changelistener to activate / deactivate tabs, when the user switches between them + tabPane.getSelectionModel().selectedItemProperty().addListener((ChangeListener) (ov, from, to) -> { + if (from instanceof TabSelectionListener) { + ((TabSelectionListener) from).deselected(); + } + if (to instanceof TabSelectionListener) { + ((TabSelectionListener) to).selected(); + } + }); + } + + @Override + public void selected() { + var selectedTab = tabPane.getSelectionModel().getSelectedItem(); + if(selectedTab instanceof TabSelectionListener) { + ((TabSelectionListener) selectedTab).selected(); + } + } + + @Override + public void deselected() { + var selectedTab = tabPane.getSelectionModel().getSelectedItem(); + if(selectedTab instanceof TabSelectionListener) { + ((TabSelectionListener) selectedTab).deselected(); + } + } + + @Override + public void onShutdown() { + recordedModelsTab.saveState(); + recordLaterTab.saveState(); + } +} diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/RecordingTableCell.java b/client/src/main/java/ctbrec/ui/tabs/recorded/RecordingTableCell.java new file mode 100644 index 00000000..ffd35375 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/RecordingTableCell.java @@ -0,0 +1,47 @@ +package ctbrec.ui.tabs.recorded; + +import static ctbrec.ui.Icon.*; + +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.Map; +import java.util.Objects; + +import ctbrec.Model; +import ctbrec.SubsequentAction; +import javafx.scene.image.ImageView; + +public class RecordingTableCell extends IconTableCell { + + public RecordingTableCell() { + super(Map.of( // + CHECK_16, new ImageView(CHECK_16.url()), // + CLOCK_16, new ImageView(CLOCK_16.url()))); + } + + @Override + protected void updateItem(Boolean value, boolean empty) { + tooltip = null; + hide(CHECK_16); + hide(CLOCK_16); + if (value == null || empty) { + return; + } + + if (Objects.equals(value, Boolean.TRUE)) { + show(CHECK_16); + tooltip = "Recording"; + } + + Model m = getTableView().getItems().get(getTableRow().getIndex()); + if (m.isRecordingTimeLimited()) { + show(CLOCK_16); + var dtf = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT); + var zonedDateTime = m.getRecordUntil().atZone(ZoneId.systemDefault()); + String action = m.getRecordUntilSubsequentAction() == SubsequentAction.PAUSE ? "Pause" : "Remove"; + tooltip = action + " at " + dtf.format(zonedDateTime); + } + super.updateItem(value, empty); + } +} \ No newline at end of file diff --git a/client/src/main/java/ctbrec/ui/tasks/AbstractModelTask.java b/client/src/main/java/ctbrec/ui/tasks/AbstractModelTask.java new file mode 100644 index 00000000..6b05fc8a --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tasks/AbstractModelTask.java @@ -0,0 +1,38 @@ +package ctbrec.ui.tasks; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +import ctbrec.GlobalThreadPool; +import ctbrec.Model; +import ctbrec.recorder.Recorder; + +public abstract class AbstractModelTask { + protected Recorder recorder; + private Consumer concreteTask; + + protected AbstractModelTask(Recorder recorder, Consumer concreteTask) { + this.recorder = recorder; + this.concreteTask = concreteTask; + } + + public CompletableFuture executeSync(Model model) { + try { + concreteTask.accept(model); + return CompletableFuture.completedFuture(model); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + } + + public CompletableFuture execute(Model model) { + return CompletableFuture.supplyAsync(() -> { + try { + concreteTask.accept(model); + return model; + } catch (Exception e) { + throw new TaskExecutionException(e); + } + }, GlobalThreadPool.get()); + } +} diff --git a/client/src/main/java/ctbrec/ui/tasks/FollowTask.java b/client/src/main/java/ctbrec/ui/tasks/FollowTask.java new file mode 100644 index 00000000..18f1f4ff --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tasks/FollowTask.java @@ -0,0 +1,22 @@ +package ctbrec.ui.tasks; + +import ctbrec.recorder.Recorder; + +public class FollowTask extends AbstractModelTask { + + public FollowTask(Recorder recorder) { + super(recorder, model -> { + try { + if (model.getSite().login()) { + if (!model.follow()) { + throw new TaskExecutionException(new RuntimeException("Following " + model.getSite().getName() + " failed")); + } + } else { + throw new TaskExecutionException(new RuntimeException("Login to " + model.getSite().getName() + " failed")); + } + } catch (Exception e) { + throw new TaskExecutionException(e); + } + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/tasks/ForcePriorityTask.java b/client/src/main/java/ctbrec/ui/tasks/ForcePriorityTask.java new file mode 100644 index 00000000..875ac8c6 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tasks/ForcePriorityTask.java @@ -0,0 +1,17 @@ +package ctbrec.ui.tasks; + +import ctbrec.recorder.Recorder; + +public class ForcePriorityTask extends AbstractModelTask { + + public ForcePriorityTask(Recorder recorder) { + super(recorder, model -> { + try { + model.setForcePriority(true); + recorder.forcePriorityRecording(model); + } catch (Exception e) { + throw new TaskExecutionException(e); + } + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/tasks/PauseRecordingTask.java b/client/src/main/java/ctbrec/ui/tasks/PauseRecordingTask.java new file mode 100644 index 00000000..9af14a61 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tasks/PauseRecordingTask.java @@ -0,0 +1,17 @@ +package ctbrec.ui.tasks; + +import ctbrec.recorder.Recorder; + +public class PauseRecordingTask extends AbstractModelTask { + + public PauseRecordingTask(Recorder recorder) { + super(recorder, model -> { + try { + model.setSuspended(true); + recorder.suspendRecording(model); + } catch (Exception e) { + throw new TaskExecutionException(e); + } + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/tasks/ResumePriorityTask.java b/client/src/main/java/ctbrec/ui/tasks/ResumePriorityTask.java new file mode 100644 index 00000000..7f6e242b --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tasks/ResumePriorityTask.java @@ -0,0 +1,17 @@ +package ctbrec.ui.tasks; + +import ctbrec.recorder.Recorder; + +public class ResumePriorityTask extends AbstractModelTask { + + public ResumePriorityTask(Recorder recorder) { + super(recorder, model -> { + try { + model.setForcePriority(false); + recorder.resumePriorityRecording(model); + } catch (Exception e) { + throw new TaskExecutionException(e); + } + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/tasks/ResumeRecordingTask.java b/client/src/main/java/ctbrec/ui/tasks/ResumeRecordingTask.java new file mode 100644 index 00000000..7e90ef6b --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tasks/ResumeRecordingTask.java @@ -0,0 +1,17 @@ +package ctbrec.ui.tasks; + +import ctbrec.recorder.Recorder; + +public class ResumeRecordingTask extends AbstractModelTask { + + public ResumeRecordingTask(Recorder recorder) { + super(recorder, model -> { + try { + model.setSuspended(false); + recorder.resumeRecording(model); + } catch (Exception e) { + throw new TaskExecutionException(e); + } + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/tasks/StartRecordingTask.java b/client/src/main/java/ctbrec/ui/tasks/StartRecordingTask.java new file mode 100644 index 00000000..e374e827 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tasks/StartRecordingTask.java @@ -0,0 +1,17 @@ +package ctbrec.ui.tasks; + +import ctbrec.recorder.Recorder; + +public class StartRecordingTask extends AbstractModelTask { + + public StartRecordingTask(Recorder recorder) { + super(recorder, model -> { + try { + model.setMarkedForLaterRecording(false); + recorder.addModel(model); + } catch (Exception e) { + throw new TaskExecutionException(e); + } + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/tasks/StopRecordingTask.java b/client/src/main/java/ctbrec/ui/tasks/StopRecordingTask.java new file mode 100644 index 00000000..8d2be77d --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tasks/StopRecordingTask.java @@ -0,0 +1,16 @@ +package ctbrec.ui.tasks; + +import ctbrec.recorder.Recorder; + +public class StopRecordingTask extends AbstractModelTask { + + public StopRecordingTask(Recorder recorder) { + super(recorder, model -> { + try { + recorder.stopRecording(model); + } catch (Exception e) { + throw new TaskExecutionException(e); + } + }); + } +} diff --git a/client/src/main/java/ctbrec/ui/tasks/TaskExecutionException.java b/client/src/main/java/ctbrec/ui/tasks/TaskExecutionException.java new file mode 100644 index 00000000..a8ca51c0 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tasks/TaskExecutionException.java @@ -0,0 +1,9 @@ +package ctbrec.ui.tasks; + +public class TaskExecutionException extends RuntimeException { + + public TaskExecutionException(Exception e) { + super(e); + } + +} diff --git a/client/src/main/java/ctbrec/ui/tasks/UnfollowTask.java b/client/src/main/java/ctbrec/ui/tasks/UnfollowTask.java new file mode 100644 index 00000000..75fb3794 --- /dev/null +++ b/client/src/main/java/ctbrec/ui/tasks/UnfollowTask.java @@ -0,0 +1,22 @@ +package ctbrec.ui.tasks; + +import ctbrec.recorder.Recorder; + +public class UnfollowTask extends AbstractModelTask { + + public UnfollowTask(Recorder recorder) { + super(recorder, model -> { + try { + if (model.getSite().login()) { + if (!model.unfollow()) { + throw new TaskExecutionException(new RuntimeException("Unfollowing " + model.getSite().getName() + " failed")); + } + } else { + throw new TaskExecutionException(new RuntimeException("Login to " + model.getSite().getName() + " failed")); + } + } catch (Exception e) { + throw new TaskExecutionException(e); + } + }); + } +} diff --git a/client/src/main/resources/16/blank.png b/client/src/main/resources/16/blank.png new file mode 100644 index 0000000000000000000000000000000000000000..1426bca824c12137cf27c27d38469541da8d51c9 GIT binary patch literal 533 zcmV+w0_y#VP)EX>4Tx04R}tkv&MmKp2MKrWQq79PA+CkfC<6AS&XhRVYG*P%E_RU~=gnG-*gu zTpR`0f`dPcRRQHpmtPfoUlBqCVOrxdvy3@OO2c=2-6O#Fy9CejulsXE)Plu;fJi*c4AUmwAfDc| z4bJ<-QC5~!;&b9LlP*a7$aTfzH_j!O1)do-vzd9~D6v@TVx@~&+0=-qh-0dzQ@)V% zSmnIMS*zAr`=0!T;ex)h%ypV0NMR96kRU=q9TikzBSE`PiiHfFCw=_Gu3sXTLaquJ zITlcZ2HEw4|H1FxTE)o;FDaY^LNAW=bb>jVfs16O*-Uuyz0pQJZB zTI>iI*aj}HJDRcwT zaB^>EX>4U6ba`-PAZ2)IW&i+q+O3vruHzsKh5xgPSpv2(hUM^-W(TwUIV5R&=gw7| zNC|3)c=_P(kkpMozo+|!i&AD6)KbhbdR!rg%mok0zpioAdmH(%-5(bncXB^=Fls?9 zd!vP;SC00_`1q``&<`D?-zLa241W!5xnd zytyPGwn7XE5co)<6v-R25e4A{Kn2QK0#^_qRqhbUK_r4rf{zdzbBwmJth~45sDO`3 z6a$+q1+YR;_{Tzmhl+{@RgG$zG;48DVu%`Jw7AY(F|lB3(ae%%D@jsFnqsn)Qcfjv z792^)aanb8>uzUp48B4wieo!p!J^7 z#SFwa5Qy6%07LU)<`ad$i`-)7Gvib!L!>S?oqTZ=fiMhWo%Ce)B6pgbL#{t@<1^&k zLU#i>c0;~#`+-_tZ^E__+fU)jsTZ6+^p8|H6(pPY2alQSforQWMtd}$t=-iL6NI*S zJ;R_dVS=rQxCyC># zrc=I<^H}A)#aXM?SnHnrh2es}yu@`{Lr7r(i;y5fK@AmDVIx7iPKt#LohN<#G1o7V zOCeVUj2!dWfCkz1ga5(r*;>VkaW5&H1iD`w=VKTM?E=lZ<9r`GPV)o^J_A>J+rQBO zrawuqx3%~YFt80=T(>o454hX`2A>SslwB!EODGnB_cQvYJP^4B`c}QUwa#(+0Ay)a z=^NnS5Ev;@_L|STdphU#Z%=D}KlrV3c3^81=l}o!24YJ`L;wH)0002_L%V+f000Sa zNLh0L01ejw01ejxLMWSf0001xNklSAz2(69dD@A*Bumo>UElLaLMR}98%(f7ui-ey z8izVyhz0r$z#X9iI(|$Gw7}ni0yiAXTGTDYAD-ok9X2>&j5%f)GyxZ^u*W$GA+P#b fnr<<|wA=$v#MmB{xDV{g00000NkvXXu0mjfhk%@k literal 0 HcmV?d00001 diff --git a/client/src/main/resources/16/check-circle.png b/client/src/main/resources/16/check-circle.png new file mode 100644 index 0000000000000000000000000000000000000000..2c3c102448d768624883f6ec376fbbb8157a0ad2 GIT binary patch literal 4962 zcmeHKX;f3!77kVc8ASvYC=GE2Z*p(QEMW>LpusSx&@ya6bGlu~xh{KxIj=ec+~L^^BJytXtgn4MB}H0-GJ)om5$tD}FQWuJ|8dved}RM4wO z&nn|MgBSSDu(R*fkh0@-Mv?SM#g87vOt+X_TCnp_#&T$4cLAfG9Bg&(-vb6B^T}7asOFKX7XJeQlE|>N!0DK>Fd% ztvOd;MJ|Rqw$=0&9@Th1qv88Y>n#gIT#WYxX0lcsqC7hrvN);zcSnaA#wrpH#1 z&7}(!r!LRO<*&b=+hI`$~zD21rGAdSlxj)a3+{rjy@npx3p8NJ%ySFUd$ZNb6 zTtl;s&~COI7BuE^R`~BeDWWC-pOX!#!{IZraQr=#EUv!P*rd9X-v@UqCLz_&h zwsHt4%-x#pAs(7v5PbaCJqL?gJR~Cv%Wk*NcZ#^P_jFi6mP7yX8{U=0cf-n-rr%h1 zENkXh>j+cM`Qs*E%ZgM5W!CwgJ5c|_ExRUN==8kctQn?7KHu(^syAF=%gqOLyOawTbQ3lH4@B%rUH_FHKo|l@-4yf~Xs*erJTH*R> z556lWpA~6ix31lFTE6eU>^@HbD+bLEQD!e7W~awrytuReQ=wvlgjGv!pgQ>*H?Wpldtb1Pld-xD@EXV_di(7aF(yla=v zDfYSU$sfeaWmVT-GDq6;>T%x$cwDt;n|m97PM;obrJIBgW7O@2Ckt74>yd=s^D z+2p^_C*5??iaoroJ7#EE3+zM7KYOmTzL1s~WmXZix;w&ql7sN3^(&L_E6+ULZF}uF z&&jlk(b536R-04SW)GzHFm~A1zP5;ZEZIdE)To%aMN@5k)nmI8_WkwsLAoY_Xdpu!d5jpc6Lsgv4HpNLPO=|!__a7i+;Xi zz38SQcS82CJ=UQdP^=S0aF?+JG6@kD%J>LTBavgB3WxLX(#T;!Jfgz$kvLS!CiGW+ zPr#!>HenT&3v%TgWDOdSq(H)wg2M$#@dBoh;N@xNp5Ip1%js#DMS$P*Pv=L!P5-yp%98#p?-_UAh0hsVU0>9XOT#W ziHXESccM%YM}nA4CJ7{y$YcPs0F>*cDp&(Zm9z8^qZodOQlLQPDpV%L>oH-zEJ4L4 z5U_dtoBSklF83|GR5`{1#s^6Q%SjLsBuONs@g7Q*zZwG>OXzPsl;K!ICxs$PS%N}< z_^T1AYSwrNq2R5*JV7Bglp_?75HTXbOqEzv=)I6j0=c}m9(oGmP>I~&g^~T9r3w|j zBkR4~^fQKX#wUWgzr}se`c3TyW6X-nW%cDp)E&^iUW$5yfyI2yq96GzLJU(dht% zN*4i4kS+k|5S7oT2t|;P$sY%?T!CV$gvH~t(nAR`CMDX1RPYkO>m;;-z z43#Em-c5v~5+qCo>)C{8WROZHGpS^VirvO}uS66|Oo@6-2qcmz26z3ku&`nR=}hO8U?8r=e7)G&p| z8)VCZ1*1Wfuo@8>0%3lmQ-U?HG!DV`_n4sGjH7?ESWJ*C1O*@kpt|$P07VFj0GN-6 z0Cy0kGMPdu9}-aCM_0;3szg|U_{3qAV%1;*GE{?~Z=iC)`__qT5Is*I83I5m05ZcN zItyg7AhH`sW`Q7qG*&Q4zpLL=>_Pe;PCN_-w?*5WgSk`bgIYG4MggAJz4dt`B11gN#3_>;FcV*}DS|BE|j%C1S^!BS_m2 zb`&z^FJ0niI27TAIh7l*x5@H=NF@$uZmYkHaEJ0{V1p*AK(4>Z{YjIpCtLI!=25Wo zu2-O+PdMXhYFl+sUdWWoPwtc){ZGw>#MS+PsqsWx;i_m|!IPaJaC zUc-x_tS`i~)e}9JJ##+GTTP4UF4=#eZtX6$b4>yI9ni@r?NwZ(H+ou@z(j~0_2rqb zN6r@2{?VS6IY)J@jh2!bP(Mv$yIgZg!GFQnbt7-dx6B^6W@4glkuE9dpkq&a@1O0>jN_wAS6+ia>U7G0`nvbNgx3fqeUyIXg#io z7OhLM4|$3xh+q{Ft57{w5CK<2?Sg_LAP?%B38-+|e>~^)KXT4YX72Ca@80|UzPTrp zoXCh!8%q~U91ds04+|2a-}cyHZib#`zj+Vha1&l;h-0)uP={Bkl`I<>bS{$fjl zUt<5d&ACUn-{I~l@HiaGP$(*NYt5}FueyKPC+}>+7v1f)KP>+9 zMQ>$dV9$BUEYK~$A*%F9jY%xiwCKf)6;9T3QhMNXg==%&@w02as=F6(MHQJnIYSn? zPix#(>aAtft#+M9miCIsf4FCro<5V=F}d=msw;up@A4A+$j@16(V~#BHx)&zyff`4 zGPC1*kr}lr?}4*>);}@1*J`*>H=VgNWP@{z=iJ}K$nECV5F4VWTXNFAnTvq^n^PWp zQVfE)4W9cQe=WRv+Yr67qt&V9F7RLVR#z+CP65 zZPbrjgB6X=rg(Y@_crgx==AF8(I)=)YyN0nxwX~(l-GfZve#j0x7M%A?d@;6HsEEj z2jv@RbMW@G9O1E~`jBiEr(bN&_H}t+Fo-K^!XpSXR0Vfi_Cja2!5uYQXjXXfY3Eyx zMJvl}eV%Tc8*003n&3o!+}MK@AvYTGzg`~K*><$O;6=rkeJe6% z(YG)5u$h(K+!YmaAgs)bCODAZ(i1DIDvDzLr`kK=c!(3@0l#=%ny5xx8os_M8R=#29pE)xVo?Z^< zn}wY$D*D=d3!Cni2sS&U1^iBn_usnyrrpGi(Y@!h?o_vJym+9?A;Y37dqn{0(4Jf2 zFWyhd?|&27HE)QitUP=rJDIZvhcn4W0s|xYfq@^CkLtZ(%{RQTnnkX)38L_23oO&@ zQ_N!yEZmgK`q#AFNcTII#hktI1EvC(9n+mcFO@xdR3~Y?R2v&TIojWT5SOCS2({lh zyx-#(cSWyi+otPt)4jqSS|Q1Aq}V29)GoOSNjRko%o3(7avC6SG1M=FB6c`*xhC{= zb-yKEFTHSch@Elg-?g}J!?-uBO9L+WbE{%0tXEUNj`d%jb2F!I$$5OY$K7dr9Jdh0G9nW@O*AR+Jj{D^k}E%2^4(-Vuua6{&6r;Vovu2b(Kw(fbs z3;oxdn=W&V3NVd|OBF7hgZMprJX<^HP~oVacInzF1C@R)Z}y;FmTpyt=e)Kfk=EuH zntSWa?b}RU7_EQ$p6$G9@o25t$)4R8<0s2}`6Xp%u1&dBX%ioRjt<+Ex0#q7w7$1< z)=x`C*E$S+w1H#Rx7J!|ODd`ueJKODBj5jU#YUwv-}1~W^M3pN1D#bvnaA!uw3}J3 z&ffE~uoRt8E(kh_VgyS#kWx+rrAi4*)XP=qq{892etH!Mt%9|937m*1c!XD#7YTSo z$|FQG1Z06K5KcnEGSsjrBSH*itb*85f}gJ?SI*dJ`4M)!-7;!o1HD)Fe z@J1KyDjp$55Qz^|s$o2xNGFnkU_FvXCHPw6xoWA5BMb@|fk5wgge0w2#UYV&Ivr6* zBP!L2Bnq3&CXuNmDiuII08P3=3+e%d#sh;G#t4EnkQz~G5v2l;VS*B6s+LC}pzHV# z^~qHN!AE$7W`qTl50V~KktjqmNiHXij?id>(@>C+g8nu_BSr^0NeF9{scHxgPJ6YS65dF)2g&g2;~%7zK%lTxEgNB`_V9 zu!gIVLYxq#S`MPyiO9i3n50r98gpQTbNnOuJOY(S{$zJOWk{9*Z=}8_ylylrThtdZc5DAJM!BP8q%%egu*c;}RZklr0B@hLdQ( zG+1g(gvJdoK}nz@5k{Zy5kY;hBY(45AdN1AXb=lvQY2D<4olepi!5aTY;?DobSa%p zX3EB*Ym_pr4phVbiD;*2H>iM&-QedNsq`A_txJM2p2$=RKxP1BwwS`=&=?#F%bQH) zpq!FM8YW>+^@onRq`z>&H9CwM18Cf^4IMA&v?6^RS0kKZH2#C1k-hi_GoaApNj{6; zak|Fo`YZ-M%XwT~<8*x%1E1wQuCD(ZU6!9-cwhzkD@cdF&UD?#LjRJ%nMuM!gN!dl zxOah-YtbtURamSBhqIcB9VWQ^0%z1XS<4p$Pwp_au=j9Kd=*SZO}qF({$kdRj+;DN z6Mtv3N1=m^q$?)zE8h~rTqBD;A5S{XRGI7&4$k@6&{*#|6NFNq$UXPfzsY*o^~TF- zb%I6WYk~hnQBd=vllX(}xjas~chk2o^J6BhzB=h>bos^^P5Uf>g()=_?Kw=F^1caA;eG=9Lh!|y6P*7f8UP6>CBmp87T)`tUBB=LZXs~!u)6wbhaAw_;mGu@1FZ(1I z??ziPw>P|r4xru5I@?uRWbo-@L*2(}ZAwEO&GSWDkjMgN--*zTTepm#vGk#NNW()( z%t?`T%g)8cm*)s;xN+>3sZp=`GWu3Gc*Q!^UEE8}vy^P2aKC#pul}H)?&waJtn@mo z^q$*OG+6F3R)CpBlY5SS;`7LFDrzHrLi(xm?4nwegA(S;Rpp*}vnu0?_VfFuM|+R6 zB{i07GRKaU1iT0@i*F3r#6<=oEO=}iwU5t{yZ4=4a;>>nR;kutl{t9(g>1$IDB_1{ z*QOkft0J=7a-0K+kyFJ-3U1x3if_sb|GDsZd(uGUIYA=%&JtUFXyxGy^wva#=o_Bd?V~e{@1LD<(0RqgX?(Lb^myF zrWc8ab_GmFUVMN5KsJ)QGF(=GysUhDyfHQWfF>?&$=QW}BdC$XD=EpfdN9IHWK3AD%j*ut9TLC5}|;F8`&cLzxJ zb~W7)_*!)7Oh=zt)!B6A(w*N=UV29}j%9UXwz+jhXVT!Iv}&8Rug?WPa_*g!YI$bU zaz9G(+ran-SN+fJ>;2WuV*ihh&Yd`cV3wiq_YV>I`@fSu5PV@;EW3DNIVzOdWe}qAiEx zBD8nwSt}|QW{p4`0lQcrMO*riBwr@ApmLLBF%KldKES025mP_tu0 zBJ^BxXZ8i3e)6^N?)3E^Q*ss&-ZC_$RfD(8sV_yTXUc^+>)t3^9@Lb(ef3j2kvdS) zkl7iOUf@?uIKA&W2VcK_S&_P_tGp`b#&}2^>#Sb4(Lb&vsQIZw_p`^HUB5EQ-=q_w zpKA9WD4cN*%&6%KFpHwZ3lL1LB%@Nb1ScDm8ZfH}1W#{+2E|t4dPsuH6)HZd`_zvl zNFn8umaxQBvBn>dR|KVM@$l4z5m@Rfj3*^|ds%rJ5CEXW^(bUeCa83Xflo5wBH-R= zrjQ_$i+&ZK6eSLU{MA|zysE;R_Rd#tkO9dA%-vnxDL}QGi8@KoCy~H9 z^sYXoMl600uhI>(0QjI7Pz{Afrc#tj%E$69>Br%uvhxG))4 z>77PGNU`_vnj~$4sU0as!4q&LaMgjVw2x8-io_xBBa9Tt6-tdM3XuJgr(PlZK-NdK z8COj0j4T9%zsLQ^`(5uQXW%6kBLX#+WGqi4;FFB`5vdweND$jYR#xNI21*;1H^u~8TR^I$ejO531mqH>&~QOQjUM#2%_5D}k5CsRLILK0BD3tF;kobpoGcED173n&b_64sA-1LI*rn8x=pS`EWdO=xOK`NKlxTAjl+J1jUAu z=+IOZfpi|Hd5Rj=G$kjxp+sCz}#N$SusB{`kWx-Tl1dW9-sR)DX zPNgG&Q_66|6ysa{u47Ni|8U}Iau_iNK-`cGj2AGiDDTJBFlR;@|H9AkUi^g_0Q6{* zPvUo!u2H%^iGfdY9#z*UU7y6jCpnL*>;FcV)rSKQt^)spQowO$N>OYjI0~6df&&Gn zLlNPX|Eaa$&QcQ;ts@X7Og3I-go46pz&Ktn5(~$-kF&IKopX{Or3R)vk-#^C+fe(j zCaVo0wm$dOp=4bsd4FAnc*?Cg+GX9PRbTiD#ToG}=LhG>W#rx7wPP;F7&7?U&a(4v zS6o9LuN{`tc^T6hZ>bGfMwD$LyeFghizmD4R*w~LqxKMD^ HFK)vhOx+K} literal 0 HcmV?d00001 diff --git a/client/src/main/resources/16/clock.png b/client/src/main/resources/16/clock.png new file mode 100644 index 0000000000000000000000000000000000000000..00c9e221299342b528fcbd2fda480ec7a34c9c73 GIT binary patch literal 4949 zcmeHKX;f3!77kh}qu}hKh}WPf;!SSm!9+y@1|%{B5D~?jo11Wj3{3(FgNle!nNg~J z6kBKo)Cx+W${->t&jCfO^wC;H6h-O@u$7Q}|y3lnbK}6|$@`wGgmL2?CFnZWq3NsC|x>J}VT= z;BRrh-n;m?y4D7~1{9 zGVT63r%eVM+38>H`VRU%6+^xAw$duA61peVUE()+`p(`(eUyCFjgvU$_Q?@yY=WrNB^nO2nJzCLY-e>_-mGSea4m-llkm6a66oYi$! zBq5YmS&5jgKQ^Wpn;koI`6RjO+WM!zIdEKlPs#PQcTQ}CgYN_^n~|?A<^>Eb4e3vB zFG>0&{ninR)r!cCaR(EUjy+BU%O@n)rv6fK#^I!gM9kGjJ;?1XG@m%5ucH0ph-;EP ztlUYP57^Uo2bM%$@k(KHp9NWPn6|1FD~{Z{yI^6i4Z9^unTS+nqYuty(5!(V>WRG* z{ms9NIX-XO5{~AOM7tpP>gNB3Rmm>7Mz#F%nEU+5?gipzS$D|MT7%|EZ@1Ga0~r2M z>bLvCj~ZNyLwv)E!UtlSlEPRW($!xo<0`M7{Qjq1L)c-g`D%V@NLb&k;;w?8n)SmQ z@j}VQT)Y%AUNbsXLXF^qm!Md3TFR>;p3?_P#qp7vAoDnUl~tGjL>~GBMZM z`nEm?zb(Aqq5U%{ETVx4`uuEJrl?MKF#pwV;;yw%Yqz$YzO}32MdyN{sqvQeDH~Uk zi}ts^a;6VFEb84vIUiho*^)poOTj!m{JlLqUP~Vre8HAnzVO@%$EFBzdhp^o zy}-SkBJR2=Gd^uwJiTb;p&4N@P|c8aK7GC`DJwauu`#>p$d2^N^2z1Nz%#RZ-Q5R! zhznkl!K%*&I;-bC`knuKhuO;dEA;Q899wn2`YyO9aj9Dz+dxcmN||#0iz`czHGB5- zJ4Oum4?H3LyYkev7o3r{f+oV(LSD1Y4VMn!tBxw$mOsx=3CRn-r@a@jmn3|CF{b#E zs=%6L&7(<5#DnLjNGe(zeI`|Yb9jp1lvTl^(!_Z;q@NGYl$V90WeJaG?b3`~rnX~~ z=WX{~;+||_9-QaDD|2=k*L7xK)!gBJyV|Y0!>ww3Hr)?hVeTMpw|hCMq3(yrd#7DJ z=b=?^_Wg8xTl7ZS)-!l97 zE1O^PHeB)(UwpWIm^M66Y*``NDkPirsK?Kn{XBBA7` zJ9bOkHMtYA{+NlkCS@J#%oY<{F)#nJ__MTiOZK+@eEuXK!NIDz@Ug$YPkkJ(S*yz z-;Kj$B49GnMe&KDB7eX`r9lBYiB5t*PXiW9B`&i9cp9mU8z}G^gTTM|#7Lb^%_Wod zdOb-`BdIiUGKIt8kRd9WN(FHXP#dSz!3I#Nbv8nbVhB(zqQTTUOr->jn6N|@qvI2a z_&o4BKZRN(dIPW2j8+(L|fdks@R?8dcz?T0AP{osg@%MgDI*j1z&+;Gp2IJCxW}b!F|X2b?qi&+)5rH_O1R%&&y5jPcy#lU+Re^A#4y55U{_cH#VuKycdR&NhHs1pAdq{ojlH@i!3;76g4 zC978nOot-EA0Bnv@Hb1faGjPwu%2eT%n11fv+==6I&YEZqzC4fb`~r_=4S!;d6(iX za1UZPXWpn-6BIDryp`eCXz37+@Nuc8%d0(Po#pzRd0y$64ms+*J35 zi>$Z@8{-vTcj8z7XUT+I!}iR@4O`OY*kx}hfc=|}be;OfE&fngO>^jE(ly-I=nBFEEmRQEZmv- z?T=oDI~3uD>z&pu&F-7CA1|T>Aj_)td3F1+jEKhS(2~!;uVEnv_REeAGGFd%YWEEB UNBNE^g+{u)J=X}zS8huF9}TZ*ivR!s literal 0 HcmV?d00001 diff --git a/client/src/main/resources/16/download.png b/client/src/main/resources/16/download.png new file mode 100644 index 0000000000000000000000000000000000000000..673aa322692b4b28098d1bee499e9d83461d0c09 GIT binary patch literal 4753 zcmeHKX;f3!7LG_|5N8F&fe`em^<}srB)O5mgCs(PAYedn|vOivqQxSRYyysYHs1;Eaekqdq~N$~y_DuwMVT*6TmA)=lm?XYX(C{hjaZ zm6IG8;4{Y7(H4ipjp6%pgTe23(_?K3zAJhe4{MPnh27q2w0h;= zvhkgv(`sS}d45TJapB*6f4N^fX3UzD#@Bl?PGojm)VR%h-1#&j%d%p|;nkPAGIKMm z=0wUn9h{%Z&h<72y)1sV#CyGP;w;`egZIBDPxN3tm@GecBqR7nH3_+qlCW|?R~tV_ z@|EsCtyRtm94M8d@;;I$>8cr#yloFn_k4Az-RueFJiEf$irJR;LJUuZcP`s4dwJ1o zobajr$;u+x^6Yx&7nAR4u9BZ5zbfWDjqiC>cdg#uvqdnrxZIuR`A>LT{f6Jeco)CO zCv_D3SooN@a|Oine`1q>LhiOUty*5}hnI=X1Y=)Ph>tfy+NA4lj&3mKE<8 zC8xxVxBGrabUW!Z*=PTSzl9_*n`0v!!rr`2e2qP7_`@SdeBWzotz}vBSXIZXr&ikE zg7C}Vrc8M&N{Oi1g?E^fwAuQ1*8|6`dTQ(EAG5WN6={iOx`Z>-v%vIn9wv8|s$IvgsR|t|=quHmTl>iPQzRxri59EWTEXzbDC6E@6&p`qI@v>*EQjiddN`#X13N`2@IGnquUX6&? zqB^_?m0?OY;aSxw0v?mF3Cr9B6oHz9$}!&r4Z1WTKqyXFD~2TmPY+vnJqrLRP#uET zE25NImYz*8B$Y-+hF};bQ>bJrl?W_|+IXc7(G!(g7ZXH31{c+eHJDn5 zsg!sVCL&VB=-31TSjT_JPoWkFKEf-t11tbO$a+LghDa2$LO~wvq1EwX0mwi?f9jzX zf|g4TMzyLKjTq&{qDr01Urh)5GN5Tb|)!9*CLF(i-}qA^j%ASk|4t3#Ax)C2{q`=szt8F$KC*hnU!e=&mpfF=z}|CQNr_Qa>4mq8cqwq6rhCkf{CD zn6`xliUGtTCY=HRvj?b!#nGUMPNfm5R8ee#DJ8tg(=2bid;g~RVp?DkZ&Lh#=9i+; z{iFUP5QUkS@OZOqS%|nlh!%-OCFVfDuYXA_N0c%YobLfaeVE7oWV7f@K%s;Q5m89W zB+?KGl?Y2I2$3R{02MG{F$B|wqH9%BT^yo8y=0(LPz?}}xf=XDGnMm)TF1#z6HgQ> zM1-hB2o^%FELR50&2<3Z@3nR8$HSNjKJ+POa+rxI=+Bs)owS*ZjRgW z70q5?%E;$>3Fp`9;(2H zkH*_K%iA1}oJxFf-AG5t^2{T>whrpBjL-pULSe3I0$oUeDZqbdqSIrwWlTBx7x!^P)StO&>uS)ve<0AYj>5AR{$W90N^4L=L-RlQUJ&DC0@ZjPh;=*jPLSYvv5M~MFBAl0-BNIsH z15C~g000{K(ZT*WKal6<?_01!^k@7iDG<<3=fuAC~28EsPoqkpK{9G%|Vj005J}`Hw&=0RYXHq~ibpyyzHQsFW8>#s~laM4*8xut5h5 z!4#~(4xGUqyucR%VFpA%3?#rj5JCpzfE)^;7?wd9RKPme1hudO8lVxH;SjXJF*pt9 z;1XPc>u?taU>Kgl7`%oF1VP9M6Ja4bh!J9r*dopd7nzO(B4J20l7OTj>4+3jBE`sZ zqynizYLQ(?Bl0bB6giDtK>Co|$RIL`{EECsF_eL_Q3KQhbwIhO9~z3rpmWi5G!I>X zmZEFX8nhlgfVQHi(M#xcbO3#dj$?q)F%D*o*1Pf{>6$SWH+$s3q(pv=X`qR|$iJF~TPzlc-O$C3+J1 z#CT#lv5;6stS0Uu9wDA3UMCI{Uz12A4#|?_P6{CkNG+sOq(0IRX`DyT~9-sA|ffUF>wk++Z!kWZ5P$;0Hg6gtI-;!FvmBvPc55=u2?Kjj3apE5$3psG>L zsh-pbs)#zDT1jo7c2F-(3)vyY4>O^>2$gY-Gd%Qm(Z8e zYv>2*=jns=cMJ`N4THx>VkjAF8G9M07`GWOnM|ey)0dgZR4~^v8<}UA514ONSSt1^ zd=-((5|uiYR+WC0=c-gyb5%dpd8!Lkt5pxHURHgkMpd&=fR^vEcAI*_=wwAG2sV%zY%w@v@XU~7=xdm1xY6*0;iwVIXu6TaXrs|dqbIl~ z?uTdNHFy_3W~^@g_pF#!K2~{F^;XxcN!DEJEbDF7 zS8PxlSDOr*I-AS3sI8l=#CDr)-xT5$k15hA^;2%zG3@;83hbKf2JJcaVfH2VZT8O{ z%p4LO);n}Nd~$Sk%yw*Wyz8XlG{dRHsl(}4XB%gsbDi@w7p6;)%MzD%mlsoQr;4X; zpL)xc%+^yMd)ZNTI#eJ*$O)i@o$z8)e??LqN_gLa_%;TM>o2SC_ zkmoO6c3xRt`@J4dvz#WL)-Y|z+r(Soy~}%GIzByR`p)SCKE^%*pL(B%zNWq+-#xw~ ze%5}Oeh2)X`#bu}{g3#+;d$~F@lFL`0l@*~0lk45fwKc^10MvL1f>Tx1&sx}1}_Xg z6+#RN4Ot&@lW)Km@*DYMGu&q^n$Z=?2%QyL8~QNJCQKgI5srq>2;UHXZ>IT7>CCnW zh~P(Th`1kV8JQRPeH1AwGO8}>QM6NZadh`A)~w`N`)9q5@sFvDxjWlxwsLl7tZHmh zY-8-3xPZ8-xPf?w_(k!T5_A(J3GIpG#Ms0=iQ{tu=WLoYoaCBRmULsT<=mpV7v|~C z%bs^USv6UZd^m-e5|^?+<%1wXP%juy<)>~<9TW0|n}ttBzM_qyQL(qUN<5P0omQ3h zINdvaL;7fjPeygdGYL;pD|wL_lDQ-EO;$wK-mK5raoH_7l$?~Dqf!lNmb5F^Ft;eT zPi8AClMUo~=55LwlZVRpxOiFd;3B_8yA~shQx|tGF!j;$toK>JuS&gYLDkTP@C~gS@r~shUu{a>bfJ1` z^^VQ7&C1OKHDNXFTgC{M|V%fo{xK_dk6MK@9S!GZ*1JJzrV5xZBjOk z9!NTH<(q(S+MDf~ceQX@Dh|Ry<-sT4rhI$jQ0Sq~!`#Eo-%($2E^vo}is5J@NVEf|KK?WT&2;PCq@=ncR8z zO#GQ^T~S@VXG71PKNocFOt)Y6$@AXlk6rM*aP%VgV%sIRORYVwJx6|U{ozQjTW{-S z_si{9Jg#)~P3t?+@6&(!YQWWV*Z9{iU7vZq@5byKw{9lg9JnRA_4s!7?H6|n?o8ZW zdXIRo{Jz@#>IeD{>VLHUv1Pz*;P_y`V9&!@5AO~Mho1hF|I>%z(nrik)gwkDjgOrl z9~%uCz4Bzvli{bbrxVZ0epdf^>vOB;-~HnIOV3#R*zgPai_gEVd8zYq@2jb=I>#f& zAH2?aJ@Kaet?vh0eWFbu>Bms%Gic(WZ(qJ<#@-j|B z^seUJbI;s+&wEGU3)gk|kWL$b3Csf%3fckkmg7Xew_0mL2#@1^SOqQtK|oWud|9n(k4q)vax^-tYL;@ArQz$a z0Ne)R{;2@gs#We6i-dKZm|+MYrfL@eWX`e-HFk`Fcu}eFpjZUp%=EM$ngy0~mSt#4 zu#cxrZ43bftyaWn>1GqipjoMQR!L3As z^J*$zxQbJdN!zXiPk;@8cBjL;tu01`;I3(s4hDzGQ3O_#wq5V3p8-~YkMFm)-PZm- z_vYp}JvB9~^M$5q=C;&YG;S87d$idy*}VjNY{_QUR?ae j5IX~fqwDK+zn%X8N_VMIe=p zaB^>EX>4U6ba`-PAZ2)IW&i+q+P#%)lH({4g#UAjJp%Qj;5dj!#NJ?!KZzu}XJMGIHF*&clqb${Lpolgo6KQLNB zD@Uj6`Bl#T+xT>E5$M4OvR@|1Jq`by*z4KPk^iS?=sm~Ba?anJJ0SVvmTh zdOG(Cx|kOSTH7e=iD3Xj^WZ zU(|Sai6XZ_3<(g#L}HXEIV3Xk^c&7q3~eX3Hsi&bj1T zxCoAY}apPym zg@x_{avX;I!tDXIzHY*{6FW}f+S4dFeQi|EmAAG98KbTDjKQzA6Pvup=?WiFMDknQ z_rr$Qbv)8DaQZLN14CY;C(dw<9?sz!J!yt(^uXEI=-C5Zq310hJ<@|f*XVh}N00UJ z7Ov6rvXB0x=bP8a+dlfU9vR^pJums_Gd-h)YxKO}qtErceT}^Bqp$S5d5yg7qp$UR z>+k4gA3dYz8-GVH`{+46f6qtH>UkFb>?QmFr%9&%UQDf+0004mX+uL$Nkc;*aB^>E zX>4Tx0C=2zkv&MmP!xqvQ>7{uhjx(SkfAzR5EXIMDionYs1;guFnQ@8G%+M8E{=k0 z!NH%!s)LKOt`4q(Aov5~=H{g6A|>9J6k5c1;qgAsyXWxUeSpxYGR^8512o+>GpVGQ z%dd!`R|L?D2x17x%ra&rDGlHHx~Fccy9Cej@B6d*)q=%z@3D;ex)r#C2LjNMQkskRU=q4HZ;jBSE`PiiHfFCw=@e*DsMvAy);A9P`+K z2HEw4|H1FsTE&TRFDaY^x?ddUV;BhS0?oSPd>=bb^8^S!16O+6ztI4uKS{5*wfGS* zunk;Xw>4!CxZD8-pA6ZQT`5RQC>DYDGy0}H5V-~VR=v5k&T;wxWNB9E8{ps&7%5Tq zn$Np?I_LIpPiuZZ_^onwU~3fU00006VoOIv00000008+zyMF)x010qNS#tmY4c7nw z4c7reD4Tcy004kVL_t(I%k9Z=_igyApf(NaRo5R@6<&4B2rGYvZ+u|Wn%nH{J} znHDL~5b5EL7R8 zaB^>EX>4U6ba`-PAZ2)IW&i+q+U=HElH4E+hTl0wj(|1<$H6RBxj~MfAKUGo%#!KU zOjTYQsx1nHB=iZp!}#@ch978@NrRf_R8op)TC{M*Ajf+fr#$8;>i)bnI-eBYU0_Io zlw;EMT$OYFrk^eeL+>n*z1kp`8-D57>p9Pn|A?rh%g6mV=WouQ=N!lBswSi%9fvwx zgqs;CZpf1C+m=Dc&oK7A7xWx!Y^1Tr7B}=pg_tBxQtE*!O&Zi88#TnJJlG+b8_PbI z#kp{kC?vW%O?D~LB@0XAAtD-tuUKgJTXuhqGCX&JDie$mw*0k+&lDc%bGC>a5#jW7 z<_d8!FAlWQDC-F|0HJwuvpvDLezoudtbm~HY0fxcjoV>jGrHH7>;)juGsvU9$orzk zdzC1B8-gJLf|y8*5=CcrA{EX6R1rCg@&*EA#vMVjiX^bfs3Z8s9;aQGjgNU;74R`h z;$Tyx0+yw-wA#5#*KR#^@41&= z2Mv@`7&>gE;UkYS>O`$gnL2Hz=`+tV>qYIP`lkN_H9D#BPHO4-MGdp=%g}Cu)_cYl zGZ14X5cfp@1KX$y-8M6+q?pUEh@n>m(2EFS z2*}JbW+f>N-}<_zZmPQk&+_m4v-;J7#ejfFJi`prCSE6=+O!SM`@~UJmQ~_&;!%?> zNc_lk#p5^5C6@)B88x$+IpQd>Sn6PpOyv13o)>!MF{Dt9y zzP!YBT0=--0gI3zLO~4`RAD1QyH1LQ44o%^{4v)rkxL<01&kc?*nkGv^@IPx@7Y?# ziE%F}oCLaG9Oq*g2<-yRy5oEwJ5KWi2tET>dfUIz0H!}lueY`M5iqa~TwJ#`We>RA z0S2E8*_2%=NJ}Udf%h}|raTb21^QOKxwX!5`T%5UR_Pny;1C!oQTCe8yL&q4_HR#X zen0rFa&};A6zBi|00v@9M??Ss00000`9r&Z00009a7bBm000fw000fw0YWI7cmMzZ z>q$gGR5;76(=kp0Q4q%Q-#&Xo#U0dwglr)tR&@5Bz<385y@Qbp*xS91lE#ow7&*gM zgqpB6vxcmRWYMpDZ~ouRo0(T=>3uRe#{fh0;&_84R%OoH!}#I=Ji|5aaf3cmT<=j~ zju{qZ&bx1efN$}L%R}RqYOFEETbc8&$uPnbu6}`=gclJzf+F7hjJqRnyOLKtq-fZ< z1KdSVuDl8LLv0-%wxDIBOVz*!L z*}-6qS()>;Y2qT*^W#V*btIYw^G~1YFW>nK+`CGVFp1yg00000NkvXXu0mjf4`#NV literal 0 HcmV?d00001 diff --git a/client/src/main/resources/16/upload.png b/client/src/main/resources/16/upload.png new file mode 100644 index 0000000000000000000000000000000000000000..ae8637d4370c9bcc0e2e493f383498489b9ec2fe GIT binary patch literal 4761 zcmeHKX;c$g77j210@AFugWVVd+A3tJ?8yWb!WJ%`6ZQaRo+PKvAR> z+wCZWxFMn_0iE7`j6*K|0CyAQt!QczkBcZzIRXF zdO>i&Br6vy3Bc1ZMTDJXs$g^+b@tNCbE`R$30ReU;S!9 zOY;q)&)j31Uz8o#9`s<(y7x!iIXUmnuWOs(o}Sz^qp1<@Z2Pr_0N!g%YY%_emA`-1 zK=->dDcrY*M6O_aruzYL?@g2S*;}>R*CBKpcl%R*A5XLd*_fm{7cWnIose1D*}2zZ zPXFZU)5X%L%;VGFI6Y1~zO>EpX;}8JT^+|MU)M%`Okl7MhGyhsaFX~-FYheNiJRjS zd))7Sf%+6lQRf6yeAj(Trgw5kew@{F=ayMC_+$&?vG=Aam6M%!wjj+JEo;qVJkNhe zrxsdRW;NGW_U8S23GSvSf2QL%k>)3?kEHi!ZbqVApB7#R`rK{cH4(DxGn%yiwG|I% z*}=7!tulas6CM6}8yxE9uWNM{dYPtQYn)waWtr&9+8oG@KNBVj`=D|B*(8c22-zde zlf{`@+8+vMa!pP5`tKLco?31{JUE3l3L=moA%~?C5c&OBq_1;8COwY?%om^~Q zdY8ZEA-g%=Jif?kb>#$8S%R(PZi$nv`hq9prxHO{zDvW*34opFKgtN_%7W4zCaDKE zM4x=~^@Uw)DxI;t{d?hI_rpfx9&{-Ryzyv_osL@6BsgttlVeWNRzw zz1a90v#4*dKQ7@eudral@;u4njt|uxov*LPWgOrKw1EuEYv;RCSGY8Gc@&foV;|k( z^mj$(JR}Qu2SpZs?Hpktl7@F?&1h9x3~YJtEek*TkOoS&hgW`u18rYhBaglH2izeQQ=zYSyjiSx2@% zP$m^Fd-naxnqF(y(}GnQjITDLJ;Vh;dqo6)AsbT437}Xhf(d%L3hgBr3}>ER1wu)% z7Au0Kh{6l^s;U-;MZ{jXB{V*Xuj0Z9NMM>84oeFbLTO15ON^U0*NUTOqX2SP3u5(h znL@+Xd*O_@Z1mkQO~hf1CfXz~Tm)Z$-uvjc2iA*Gu@u&q}ld8~ydb~p8W`G#N@P##y8c}Hx zr2=cf1Vu`n)(eM2*Rdn{$yI#*CwPTsm<5y%q8?Nc0Ro9AmlH>OXtaLGD9CU^f9jzT zqAiyg3Tu=)H3a)5!wRk2Xb3U%$zP>Y%Z%lSAtEe;<*2C!jS7qj8NlNUK6w}@kRo!G z(F-MejHMQld?ss5ZiW?OIinLn-9O=uu^y@2XpCC%`D|Y$q%)+)^Yy|R;bXWv|OcIr0EF-?fJJ8l3^I$vWR8MZq()Fxg0j(B8KA@lCZG@C;Z)W5OgR#1s!fi6OSXQY{D3?L_3D6eg+^QezAQ;cRaK&kIK;kUq}{ zWS~}pI(Xq0A_|@U^MVkO!(m#`z$QSauvh?t%Azx9bbvBcwh=EJR%=it8ZZG8fjpFg zVO!W}F(|R1L8mCdNR@1^8V0pWwNR;)dEpEYSc9ig-dN7irUW7y)FRcO_+ib5!OMn5 zLq|Y{7?-eEqioqAG!#SwCc|Q5Ak=SY2}%GJQW!no!-5)_NB(59V35X=KnxHsp^z!) zW|0|qkOjeb2!feRn95*~81%8|8l^;=0;*weDOxF74JsgGHP~53Drb+iPDy|bJdwx% z9w6fZmJpz@DNHs%_aFgm5(!5fE|_RI)gu*ii2uV0$7nFB4WNEQb7*@(yA|@1x1kmtO;iO&YA_gUN5f-+*;zCNowU&M_(Qw@JYR3&tVUf^S+J?MQ)Wl*gni|}F{b}=E2#caVN=9!oa6$R<~Zo7b+@CS$pEGfA~*VCNuIN05EG)Cb`y_aGY;&fl>rmHe}I`wvg+V$-| zGxJ}>4kdK8b!M1ZpU!8Ioy~RM<_3Y?Tl&NxO5PG__=~_qBF{y!a+SZXXGd(tDGyF_ za(uDpD}4d3_vMXJQ|0Cuo^#X_eA(4GF;!(gx6f|S)th7c!OS0Gt(VW85F0&jieH!; m(f<%G((JYT;k8Sl@2t08h%Fwhnw*H%h~fDK`yTU&U-Ms8Llpi1 literal 0 HcmV?d00001 diff --git a/client/src/main/resources/16/users.png b/client/src/main/resources/16/users.png new file mode 100644 index 0000000000000000000000000000000000000000..cb1ce7035d61e63cd0794216ff21b9e4d2e9c055 GIT binary patch literal 4966 zcmeHKX;c$g77nrpabFmTSVU>XHc2I9tAYp!0RplJVO5k$r9uMP$U-75w5`a9;LzB% zdQfa}A8lG0MMaSo1#uyxxVPehYuhaXqJRy`R01l_^dHZe{%20AQuW@w-+lMH-@7L- zKPn>FnK+3^AP}4dA%W5O?{UWsV|{Dum_r!{-z4QvF(kyk2tIt5 z=eqU!_)dq;QEOI~cuXE~=BQ@~Ke+p73ecK8A-Fs`f^PG08@Haba7F4&L(%LCU;pAI zH>`&j)ixS>%SvCK%^~LT>Qs_-nHTy}I90oh4lj;AdFJ+*)bLx{IS1M7iFyjwhTO~h z_Waw#AbRtvn${g#jDB5gpsk|bdB;3=yLEzmIJSh@b!gu5f_!_|q{ntq=O2p}91#q^ zw>GZq>=%64Si!tEKH+uOimt`yXD3f>s4E56IEr#<+#i4QKDTR*_NTSeSLHMib2@I0 zQ=`T6SwU6~rZ0r9R13HpwQzx`t389S1WIE)CU zR3&sohn8AP?PrX^W!gJ zDe!&JB>2VtmTiS_aaxR|1nxZe^gv@)%rEGIookC$Vz9FONNoI@+? z50>69vhRy=$+6z;y!8y)6uE~$u*#JM1Vjk}0^VsKSNx{zVqVB8KT`do`C+k1q-Ve- z_W*sGzp!rJ-l$w+S)BB0?ZZnnsVF3R0m=6IvgK0>V(llUCQn{q>)&o&XEF(z_@RA^ zqNSrB)MohJx&2l1aqB+Qq!`m?y5H4VQ#VF9zCO8XR2F|!_DUcxw{C8B+&Hgx(xUG6 zj#t#P2dXc;fykjv;-!(mX5=U`df(VcMH2iZ`~Hl*A;b`BuK!WAyY9QX4F1&_cYiP>wp7wO zx8Ax~%{k_`7rAw};m~c^YS@Jn-(TxcF~4~ldC7G$CC6sC+DdbsfMLx(~}v~ zf{7Pjt`9xY`?u^EE0=29KjLrSx!KFjCTOn2KDOg>ZGz6PJ^r}k`JLx?-tsO#Z?i`m zG-aM|Tk(20Pm5 zQB|5$8XbZl8pxnA7!=%sqFt=iAx4T)JJk#^fDwpkQH@Nklc|(|850qy^g14yjL!q_ z)~8U1hrfqcY6n@s`JfpQHH}UMX$l2xsE1a^&%{9n7xbqdS|Q%jY0;QgrPrVsKNC~x zrVfP=qwoFIdX3zYju@q3a!i4nYVoM_k3t3u!lT}Mm?=n=DbyA(oa~P*bu!5ZvOZdy zdB&2?(1qac?{Pn}ewVw&7`F-!hXYlp-n=|PAdhU04~tc(OblBdAr76x#=jI41w|Al zC=yXbs0g9ZAp{jMI2eSX+#yf`rB;V1QOpd5gHvTV4od{FK{0|*#4MCWVY0a(1z}4t z3J6J1mIMbtK*kV=NR13vB_bcXDl?QAhw|cJTqcUrDNHVeP?!uZn*xDs35Cs|dvQRz z7sN!_7AP?a2dOj)1TUvdfuv$IwKCN*VI~~*j}q|63@Z3xBub9xB)9{QJXfaF8$V14 zWeO}thnU%ysGxB;(KO`oGad{BYpGl=#0O1Ad$#_Z&EeABF5hVZnix zLlNP1z_G9KCr5QiqLx5#8f(6+2ql|b@IiZ>Ae?Xi$kx%BCgdppZr zCvFRxZt`VN4>&>Ul;^zus_y=NufrfrocpME?yd=vpTY=m&b_L-ij#f8x!<%+U1#>MBC`ahe#>B8^og04PFJ=)wSm~Oi>Q& z=AL3iq%cl5-ZM6~>fJc)-z7JHv8b@5Gs-8ozO>9EX>4Tx04R}tkv&MmKp2MKrWQq79PA+CkfC<6AS&XhRVYG*P%E_RU~=gnG-*gu zTpR`0f`dPcRRQHpmtPfoUlBqCVOrxdvy3@OO2c=2-6O#Fy9CejulsXE)Plu;fJi*c4AUmwAfDc| z4bJ<-QC5~!;&b9LlP*a7$aTfzH_j!O1)do-vzd9~D6v@TVx@~&+0=-qh-0dzQ@)V% zSmnIMS*zAr`=0!T;ex)h%ypV0NMR96kRU=q9TikzBSE`PiiHfFCw=_Gu3sXTLaquJ zITlcZ2HEw4|H1FxTE)o;FDaY^LNAW=bb>jVfs16O*-Uuyz0pQJZB zTI>iI*aj}HJDRcwT5Fh{nm-koK00000NkvXXu0mjfCcxdW literal 0 HcmV?d00001 diff --git a/client/src/main/resources/32/check-circle.png b/client/src/main/resources/32/check-circle.png new file mode 100644 index 0000000000000000000000000000000000000000..5687d94ece5b14218a6c914094f233deb993f4a2 GIT binary patch literal 822 zcmV-61Ihe}P)A1R0GfCk!ho8Prp|bT zsx@)o#23)`6uyE^ltdu?YVm^C0`W#2R1_v?aj?&^>+GEGeCM?NCnq^sd#&}~m$lYj zdynoof@g3V`wH28e2)Qs+lJ4P=wk`1cr@bs0&n4o-5}nNMQpSn&cDC$9u77U>}eP~ zhmUX~lmCOy@e}SAvOZ=Co4uL-9bCY*ritWu4G&aTw2XNiYOp(0=nOL32bk+Zz8Q?~ z;u3lt7SJmuvL5_wwwc5kDWYRQO?M#9F^TJ{J8^E9@&W0*B6(u~wZuWy?73wteJgk; zDE)!iT_7(ph2Mk!rCgzpdqLsSE|Qm+5B~p@)rlq5Xzvnmb$Ajt@L}-PQ_YSyqQQ+Y zFuz0cDXB)3{@xE`3qd|8g-2|q-9ycJJ_^%V3nY)EEGRKvaFl7;hgXC0r#m2@ks>^j z-^B4u@3S!ea<4Efv9?X}S$u+%L3R_*;9%Wln(h6J8f zZOK;ctRM5qB?R_lMzlHO%yl;zrw*NHyOM03;z8&Hm6H*U1P>qlL z+mu$isAY|4I)Y*7OV9IDwpMpfx}*lS-ixj99~z7&NpwUF)c^nh07*qoM6N<$f)0I# AD*ylh literal 0 HcmV?d00001 diff --git a/client/src/main/resources/32/check-small.png b/client/src/main/resources/32/check-small.png new file mode 100644 index 0000000000000000000000000000000000000000..540010a158e6e7b54199bf830dc903f02ebd5fbf GIT binary patch literal 317 zcmV-D0mA-?P)VHN!vJxO^VUxTyTqe&q9Xv$jmzXUppTucU z_JNH^ZW)ge`2}Xm%3(WF`i}J=vx28c?!2me$X=xU4QmDR$~O;bVLvhAIr97r)1#7y zBMu_6+FpBVM7KbGqJfW03x|ItPmSnds%q(Pf5$zH;{`kTtU4f*$z;9(W8P_}EbeX& P00000NkvXXu0mjfr+A2D literal 0 HcmV?d00001 diff --git a/client/src/main/resources/32/check-small.svg b/client/src/main/resources/32/check-small.svg new file mode 100644 index 00000000..2980ea7a --- /dev/null +++ b/client/src/main/resources/32/check-small.svg @@ -0,0 +1,57 @@ + + + + + + image/svg+xml + + + + + + + + + + diff --git a/client/src/main/resources/32/check.png b/client/src/main/resources/32/check.png new file mode 100644 index 0000000000000000000000000000000000000000..bc8b618bc070631a96dd2a8cece503b5ba68aa0a GIT binary patch literal 291 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdzwj^(N7l!{JxM1({$v}}Co-U3d z5v^~h`f?pI5NO>Wt?`vf@0K=)?!Bn|2T@D&b_>LG-`?fHwD5vj=D&bX31y!9k3Tsp z($#foCDXYBvKhgL%@2r5q}DbhYgMp*k%}?D__S%YRM8>#Bb!s2_cy4q)ky8Q-?+U) z(pE9*h1U9Q@(tU|J}|zKc@X_zo7g?Yw4$no$JYtpYm^q#o8`mexm(kuSgolyN|}Gx z3=X^J)eka%EZ|xe{$N_l?amd_=avayG`|xi?09|k>Q0T_cdxl@k?-#9Tv0H&Q{{uD mu;)WB(}Ibi%iF~NuHSbfkimxUaiTKNj|`r!elF{r5}E++Zh0{P literal 0 HcmV?d00001 diff --git a/client/src/main/resources/32/clock.png b/client/src/main/resources/32/clock.png new file mode 100644 index 0000000000000000000000000000000000000000..a7ad0f32e00e559d5c51822cdeef5746280d44c2 GIT binary patch literal 757 zcmVI6+sjR@L#Tq5+4CW9=;$N?JSfK&|c8mMlDSKjF70< z+F1AxSXydj7qAk=7m2YD1RoK_H!*5bsL^6}y>ln`Zg#KH4;J>$eCK>;?w&JqrbBh~ z;{f)dGaFmL6HMVv8$MS=FV0{FzgBo2X560D5Z;I}%#{#MpRc%pEkyzy1#`o=ik->$ z58S~syvxRVu_v=xoA6)aIPMh%lH#QBUE>u^VFX(X?6zi{>BM#+^I2MaDKdVK)2LNg zKrIX8L*!?vOc28wS3ZN?Ex3n;{20Tb7MwjA6L$eat;9D8-zyd`N}$PzJ6XWnk_0g; zB=<6$k4PUdSl|?HMEn~oa0i5h&G~0jLNDec!s!(pRtN+4nCE1)($?Z z?}!~ZP!no3$Mb(E-b`b#hOTJf^?wN9RrKDKW_!`$RD>a%D#>los0lThV_OB$#pr!A zuHdq;qZVO%^gaj2)U)|!g#dQrc4V7BkA=i;gcyMsO}7e8k?r2qMLR#^ZRR8EK=?ER=0X%A!liiQMlC_4~v!9K^RIh*m9G z8Kf=QqR{pgqFGZqqIy$0YI#$7qO5~vZTWQOw7bBrJL9Bn`L|Z$MSK4cu3=X){u6hF zi%EVD>=!=OFxDjeS>a-Gzd)-cI<(&@4)xTk3O7!)#R3O_%xBG>1Atd`03ARF z&;i&0cEFqJc3Y>aGjCw40oVY;24PJ#1lF1ZGa$zP)6qHxrdsMLaP<&=0~XqFUl2cq z{|ezH+!exWxEA6DEPaPJg=jUr4#GLyHszN61R^<8?h`REmwS=yt*ci|(M`f_5ErpF z+&Dq8+4x1?s>{-b8#^_a7qAD$;)7gM*9r{9ztrYD@DSV9g~P$zlGZ7aZfC3fHWkZR#rgh8DJ!~tv|r|3%*1ZZvjT&MF0Q*07*qoM6N<$f-g&v A3jhEB literal 0 HcmV?d00001 diff --git a/client/src/main/resources/32/upload.png b/client/src/main/resources/32/upload.png new file mode 100644 index 0000000000000000000000000000000000000000..417d7859798492bd144d27ec41808e74963b4613 GIT binary patch literal 349 zcmV-j0iyniP)K~z|U?UqXl!Y~v?PZcUUaRq*G4Wb(qao`@rJ?H{lgHA=Pdl2I! z4t#N-jZNCLo%F(^ge3Q#gr+Iv*BCefM_^RICzpo68PHN!U{Y6jr4Y98N+7brONDR^ zF9pIkJSaro@E{OH!wW$SflGjJQy1}c);3_csZCl?%K~r!?momBx{K$Jq8w&Aqu*|P&p&=jo*f521uV@Zm&;@-=1a>Q1uSID%+d3K<3J7U z{;d34dkG%u{favb8@7{pX{B1n5d7DeceL+P?!4@8gs9 vQWcfi?t!HZpxQtWm;>#=0DUQ2`w6UHainoettu@kM;h_+&*h>2)nA{xl9#k`T1-8Y+^*-aWfaACOjzH`pZdvj;_ zk7^jfG`@$J#_)QHU&J!%4Y7=|wH7~$T0%UJ8V;|Ocn{vS5YOXHRe_DTgGGG6iH7=t zHpKJT->~*%;9S6M^rs9=Y2MTLU|lX@U_4qkqWR3+YZTv>XsTl->X|5EU?N)gG*Ms1 zR_F~)yQAwEdlR{KP+Ntg7kgp@^)^U9mN3wEZ*E}LO4m7FM%OP&7>IjcCF(W2ZcN_S zyiY?>y$5ftzW=igyu+H$*WHxse%zA1D(`VD(R0M=gXmCV-SNP=fJp_fVY?VpJc`eE zLR_hCmD#PirL`$uTZe}=QN z2^H_%23+W(fpfCy7oVjcIHLt_cG19%B9YWTK#onV+?B-UDhAdCtqgqaqRxDkMT5F` p1_rA-AD2z5s={-)R8{=nz#r7Lf`lcZWkUb}002ovPDHLkV1k-#5@9ysY{CRr%=MNTp_bnN11sxSJK@nkL zP}Tpn5RT#i@1+0$>Yp9zdg1l|_s0LPSM2}zgH>6_UWp(8t*9q`rF8%iEK-`T0|0qZ z)?5hx1_MAacz@}t8v(&r7KQ*A){TKgY5~=+e_06-R3!@pxZoHXI3biwgg}vCrbJDy zj0|tU;l1}%4_Mso3qBSiUrGkE6MiP&0sw17HVhuP0AMc+;L*0uf{=yd;}c=mtF(_g z|Jpl@di;BF8MS~IMZd5Qaew0_1jPmE&St^eu#h{4jgL<))&6&s31PxQ=y{Sr^0-Kb zqR6~#+U0JUiAc0^ioj#Lwctn2hzYNF`#O3MKie}#*aJShLibJ+S5Xd`)>-k^uT8!^ zdY_9II61y_l4`Y*XWZIUaxeV&BjJ&|wAa2LSXEcHp?jB$iOFMfb4f=vbk71RdjBf+wX=+uS(S~ZC>3C2x$24ueZKm` zfE*8MOpD9a}I zGNf0?9;71oH{c`j+lLq&LPCMK;i8dkdeEnA9ZFI<7dBoVyU#Fv@{Vbk{TijFxH$d1 zIn+wRXI$n@t)xSd`(n-afvOH>Fcbr%G9&0;V^lTNUKR{2IqA9W8gW-!TQf%K^)_?8 zl#tYJ`#iej^0uwpVC-For3h}2)R#XD!hh2)(_b8PFWjHkaN|?+(gXk9`%g*{WuB(2 zD1zQpEsg+n>Xs-?pXM@`01&b`V-bfa|FD|G3hI@mKk`lU0c7wD6y8Rcy$croy57aO zM(Usbp(t<)-@+VJY1}y4(!)8~En&P%UWZ9?CWvOoz5JbM!lXzBiuc>{nEZcMv=(eG zum613A%ydoxKvc~!Q}{PQ7Gidgy#ICnFGIy*Oi|0g$brDyqRexxIs#7U(HR)y7d#= zQIa{*Z!DEQZnU+ODHBo)w7!r~D0MZfd%(zucjj~71IoD$&4S`s{_Ue)x-^m7&ryv?hN6II4+V<1JR98F_1Jp}cBhbS)OA&%X+W)DA-p9XNc zfhruOcy!dD_XHh}xB$7dY{mcdpG@g?Pn#j%Dn$ce5NHe4 zgTmoz?a$>=wX2UWYk7lXL}yj*WuK}q>``aF6E&~Ijst~!c!Zb9=;XLW%C0lpKzy(0 zgHlo*0bZ$%K&cqP&ZkcY{UIy}9IXi4wtR^%@FVONatphei-*zk*ELa>rMVqp3UwH9 zka0rcYET+}MkzGsfg4!q1_{OnJW`*#Q0YwWc(FhwvSfbBh$KIha}d&9{TZ=0ST#QB z0-7}m&g!TV2{J*UpaiA)F+lOpvN~Y+4Wm77O;iQi`k(%ZP>Ov*3!X4dK~oh?8h5<% z(e5o%FRC;PV}@OwXfFQXIz<|0|0v|< zsa|Fm7z6FFBdzP94S|%k*b9S2zE_-PXF^!EI)JPOg&)1xz=}6#I+Hv74jrdCt=+o& zo|*`vUc$#U1vj)!vJ8>qJmv3*<*?LXe3;Df+W2r~=eF_Rq(V?cx7Y0LuKKD&C6b2oIZGA@ezn(riS3Nb0 z->Q+^XySgOVIEF)KoQA$c~-T(b+s^FB)K6rS30uDZ`iU0{jl-GQ$Jq60%!%861F>> z0#fC^n(*)l^ubaoWlcPx%Zb7jnj-mb4`aq0#xcqAML(7fTxvxRYCNPU_o@uN#E0WZ zaiRw1Tb^YaX?n?JYF3nZ6`OD_Kh-RyZ9oA8(DbxaAinZQTA;Yz=&k#4B#Zz zz~XJWR2o^R#_~Zy@X&p+4STo$D$ak~7>$cG@brKCqEJZLu=tG-<^QV?K$PYM08}O6 z72QfvQD8IsUk(M3d+(=9-E{`ftB|y&D???)xLHq%1xZS}SM8Et$x&)QOePVu`^m(~ zezC7*tV1>nxGYw28K&{VNBKe?N6`OChV)n#-i1ggcv&eNg67-+!Ysim-IZyP>d2C1 zV_Lk4`hd{%_^@%pOSc0}`#x@~h_Ky=iXTsigLjQjPLwYcZkBNe&Tce`c#oXto6G-p zvy%RYMUiRwOCz!%3h(b3)|Id0bHyQV<}yASzZ8Q#W|&m3$kzzD0vVoh z?fB?S{tMOm?$O5Q6G1Nyc7BnXxZk3COouzN#*Wy+c6K>m?2UXmKq|z9CP6Fo*d^FY z&*m^b<)dNj#qAMxH#rK#8qZM(5_C#zS)=Az1xpNMn-I<=^oGvl zXN*>T6sa*u^pH1fW~_YHJXGP+Hghoy3A(Tf>Ox|*D>S#qAaqDj88Min*gx7IjaFEL z*-=1%Baq6nC!&PLV&DER5>@RoM5ze3ret`yYQ81jm|pG^0IXT2X~Q-2I2n z*ufhyU3V=7@yy+(50(Gnc2p73b~7{SM{Vl_5t zDA{m3>q3R!7ZeNAOx-h)bY*(;L-7at)<9`FiGEA*LKZXnN}X|x;ICg!*2k)BBtp}B z7$D%(aSdE*gnGnmq7?xG++>(w5jkYg_hED8i^fqIu@ft-RLkE^>zt}X9zaoP^^-D3 zc+km}+NI`QRsaeUj-fPGCI&3VU*89S@!49@Kc(Gq)1v+KlVa#nWxMfOpQ&*&n)l_U z{b~031(EUJ<&g-2zf+o~KV-#qCf-ULu>Yu_QZ_CQ?=SdlwIF_uG-gk|KYw%=GoFT?Sf1&>uWU-*izhmHpz;c%7+RH|Z zU&4&l-TP_I<68lOrG?rBO5Ps`E8ahQm3Q$%OrD*z-8ij-rgbP0(WGa&FrEI~z5nq? zQGDW^+1K)Blyjy}cIGn~8-63`<6Vu~%=8G#tbEN(FDp#KG^JR5xwc=jC|3U+OtLiZ zIxV#;r%z`=)@a2(*%(U)S#Ucys0d=s`&eQ^f6zc-S+GS}R`4(Ut;U|tqd;WZY14pR zH(*_YDLQ_wA}Lt1PGwJO(OvaeKVP6^-F?R)j6lus`>f35kEpc5Kd!FP28-u6ZBHv^ z48VZ^O=`EXDl^_i5`fU?BcFgWloRT}?`ivl$jR9CE9tZ-V7m0^?BBwf?NBv-RTMIe z>*LCC+M5h*wT%@vhmxx2q@67>W-h&GchMX0o@307OTy8KyBakaGP^2GE<=}q zXx;@V-u;u~jH$FQwF*k}!TFXG#9ZNcjB0y|-+eZuQXjxqYIFO$r0dJ-o2@w%0OD~x$Db_NrE~2& z5da*Y32^cQnuPY&TwCV)A~oiI;uuw8Qz}bu0@3bhA%}0-DqE$4+@zSdR&M8pL41C~ zVy>x1o~+wMwI{KEL_C7{x(Ssn?g0di;sx}(>YF46<>Y}@%D{O(zTdP$2T2LbXM!Z1 z-$Q2?zk~l4sacY|Gx*gG0kNJ<^{su4c-Oqk<#d*7{b)=L3Cz|3?E4C>+34ZamNoLI zaatabUsNOQ4(-^6AF!$DE5h;7Kq{9_g(I)wj=!=j0G8pLog?))(Av!T;L9%?pZ`+S z$6c*Qh>GVdhSyZ8y!^i?bqJzh`;SytgA#zCHn+&-Js8UhU6ej zOAIAB3_uoUm~)Oc4t=0~tv0YHN3&TW>fz-q%V(hf#y{4Keo!-)!nWu|crRf3esiEY zlPVdDwCj4eqN`RUq_%XIY=cEcXalURa(iPa44bn}tF7|PY?VDZ0U~k@;*u;kT&dpU zJq@&ehpzZCmPeL=Bg@CUNb&K{y_8sx20qp?0u@RVmG6tkUzjG@$Z6piwWR zK7#OMHtZ;T4zC{Pz&R$Sj@4(4Z~FV@LEhMED#x~OoF9Ap=oQkB^QN#Qyh|WU(z~bi zw}*Rsxqvqo`wRf^@z(%lxQ-QZ4UiB$_VA06TDl4hLM0(%Fz`subRbjFG@J8A%Q)Ao z$ROd*gw?@+WnIV<)90=Be%UPgztL}M8#5n#XljcU8$LyXA?yJFj)khztAG3r0Yw{j zQ%_RaXF5?&!aQBIp_w@fIvZTa9OPTO9ZYtRsTYg%_IPo4A`*ULbFGY0SKb;88a)83K#vhm^tC%@bQR@vx73$X=tn42AguoT z{ox$0)@$8MG%My=H%R%xPDczwKC$JdMC&u}f6|8l#bsh1Cg*o7`>qHxLw7ygpvraa0{%`yRzF$;n0() z>*vEdELU5l|MZ##dIFzYxTvO`Qo}m-69lpI;-o2QttRy?C>yLomyXH)I2b} ze`h+j3cxkG2|<-sl|o_!MYD1P2%mF|z)O>vHPTT}QZB?9V>Mj0dbmnTkroFulSNHH zQm%3dywSI3n&o_65jD^+K)%IHKX~dpkH;iC;4J64HeZ>0HpON$_p`4n*pr&k{ zHGELNDN@IJi&Oj;M3OrV=t;6#lW7X(B%}*UbgpCDjHuMgNwp3AOd;sa^3N*0bAAAL z;(Be9`OpuZW}ZrB8|CSdcp3%bSP{jl?$oph?#~eNfG?@;B~_rn(F4~nrhO`H-TPIh zJf!4rue-3ex8GJz6Mda|4tQv@aVWFXEUgF=UTqo}v+AfQMf#6+&!Kr|U5pWo%BsX+ zEHH=YZ|X$rFUCB&z1j#eM}mW2aZcpT|M+hHIr_1?hM1BYz-yI;;BcV^K;`i{i!SC= zIEo_yl@po}zmdWUjiDo3fca z^nx*yw*%h|3to{wV5WWC{-k=yxBPU_ZsE-@9&!WS`ni#7v%9TGauWdW62sE)nS}|i z>4f>Mr9w@VSCSaNE^8Eq1kpVDgsQ+0lQ`{QcJHT|YmZ7aA&q#&N{he~n1Qa1aZGUH zpztT=XV=H%n~Xs4+e}>Ws;1%e=~c1L)>l`m`fjt82|!7E6pM;2rZ(BV8_8?ZM5}H~*Fv>ryW> z>$X|+9e)oB?nM-3?hlH~gH_Z~Fb$4hk#Q=M9hcp`8>v^QS1}-pT%6XUp1sK7-9ZRtT#n&kAcv+8TS{_Y~6(8uY(nz~+q zg8)V%~=kwy?RXWO*>DlV0c|^)0&mX96>U(qnqg zIWi@GG6)->O(Y9Z#EHv+4odF_n^&fP*v1S9^}TQeFWdcGP+fkUdvwTl>8&vx-pUnUFa;#J601+d6BzN)=hTCcVRka9w`>NSlwt0dOG0zv2Uz^|iZcjEIU)U>T9RB?CFu5z_aQD<=PCXnDJ^`WFNd``G zW#>ExPl(Y)#nv0t=6y3stcD`Oi4v*7FisL71RFJB8ZEm1IYZwl#ScdF^wnY%rdZee zQi|!TF!@`N^mHrqke->WAnE?^@~74#WoCfM$HA|YX;=wvYPo#oO9YP~RKeRxI*#D} z6*W@;(HASs1Nf6=+I1KDU0$;B4@9Se4(xN-GW*7};HC*m za(+*Sujy32yJl|-B@|i9hsX~jP2Q0gHvY;CGEK;&!1ujZT@>0LUkasIrc1~+@S;Xmo zN7x&z(X6RshzsSjZr1r4msjR3`~Xi}Q*jaLH+HgpoyFoRbr}dwowF>1#ysmk{->}$ z3dX^_mFYU0KDXi!mxqM#$BaH(S_(!ajymI1g$0vfskc4sV&l6srW|7=*OsQPwKuk3 zYOidt*mr0Sh@c1>B@?qg)1T(wXjc>sJc-3vj5$lMWjgvn>?=Qd(pftyprg6dlwuFY z@Wcnxe_D|+_X`;Q2ZGbL3#m11+e3Jc5YM{se0QO8>?&Pq*xMtA@VK96+}8CCV5yb) zSx=DCUcv)p0ZDf`IQClB=eG0)n;~C_7sm=7r==xM{F$^32YOlnl=LS&I7cK9 zNt|ZnOz>SmpaG21NT@{<+x(e7-YT;YRiX}|d%2VKf1DE&K8MG{Xeru;V+F&fn5QTWtt1tPcl z^x?=Ctn+I9x0{gF$rZ_G0If2`+X?kwQFM_=?s=n}_rrGG1D_#GboRTUlc#8+uS=zw zO{{oOHOzXH^z!bsXTDlKoN}cikK+Ug9r&mJGZ^NIQI)byc3Qu8XYMhP3(@>zTAytq z5Id2RO-IFM1&q9=>719v-lO7+rd-8sRh24^p{XBz1&DdgS=d z$%qe#BBsu>-~f_0u@v1=WFa2#m*jcUSZv%&XI;i^RsRw3`|uH-3AgX9H-^Z|o45b{ z5JKXvF5ES#T9jk?!l%h`+9%3nP3{^ClVe4x_xli_1 z7f}U`DZ6MerRdj6qb+GwNG8e0+(oRmsMYFUj<=)-+l%-9Lg$7vbx0BJTI~G$=Vu;` z*Ydt5@;dl|r%;ltPbHvGE) zKyv7)QR527xfG8|6gi0MQD8-hN$L4T2H?{AF~DO13ZnFJllix|z(4*MfZ|IgO`|Z> zr6t>P@nb((nD!n2U48}K#P3WKw3n@XU06ha^L{Pp*N(A$?29}ELd3Z*!t}`Y6kB5) zX1Mb4x;?7CAL6qF8FAUZA%mApM#_PsdDJ1qAQq(FQ@Wb*eHGeY7kE*&meA#o~r&qo~2q_8t9tM6owCL!tZttn`*JAqUC$lq`^|P^4<<&WqM!Z{Hmi zO}3$y*dh;xHGoL4b~nJo$C7Ozl5O_@qb}^HOr`Egn2o?+9(|^AOoU4f?uI_sq~M1A za{A4`Y9C4@VL@KWp^lQ_44uAxVf7*S27YmS`1Zq_Ij-xMwtq^aO-zS#pd&U4;5V-q zxkZ&39h8TjCn?Bk*AZCWClBQF;&*S{8hH#v%LY&`a@P-U%S^EItGA_Ll^^;XB;x>H^&L1XeB69)JjQ|+ zOi7<|MI}lB$Bh!nf&C~!M$IoraZRa%+{A)&NTrP8c6|)`WtHpNv85^4zm+oMj}P4Z zSrvZeY(HoFCV7-{9f6HF`u4m&{I}{X$Is$1K=wt)d;Es4zUZ|+k)1_GunV2c6vbar z#en=b8_{fd{AiQE?+Zm8g_1oF5uma+P47kodfVj!#h%Ky{#RSZKZ9L#2LRZiB}e7s z-=)vldcsC~d6cgkh}h^cJWk5I0Z|-mnzZ}JZNKJ*Qo}xNjGjTlC}^zi6S4Zu;bpnI z<_s^NuG|)^wqK|Hj__U3Mv=ccvrl2`dqEa-m1&p_B@!J%nZ=`@=cp@+XZ7;*-w;^q zWRT$@bfrI@UCIR4-|R~)UH`Ojomvto{o)kmGTO9jK8GKDH8!XBKmITbnno=zlz!S= zF}wV+mL?I>irVLdt%vt+|LwS^f7L6rn8~?OITLVO(f)PwFayhFje?d+FVqf7`|7M=j@|op$i%!C70IHS$WyxF`?$F*{^kpP{i^ z)_oM>boq<^QkrU^GeOHe+VOxw@B8Uk;)oidr zjy(l$c_W^lQJT+7#Rwetmf)Vcs6DDVn$u(o_W9%O^P*CcKU)H;+Gg$8)@BC`e%Fyj z3@<(>en5a6M&i)5CKE!L1mygp+z*vIx6=Iz%PbWQSJ*th{e?fpTKJS|Lx|)0714MV zcA2UF_+Q0g%&14^^bCh>!OQ8M?Fgc2E4c0*e?cP0-NqEMd_wAgH!4=TV~L_h4Jy>1 zkk9}vg9FcH?@13%K&x^xOH#r8>0F?N`YeLVn+_BWNhAKu$5HB!QPe2EORzGaB@!8t z#73F(Spq8rY7{Fcogh%ck5cgoh-3LBYEWg-OEP`u+CJEpB zYFqUaq<4W4UX*mXM;4aBQg0m5xp!ANysdhO9bSkiqW<(iDdvZuNPPKV3@W{+Z;Fds z7xh}PdNNG)(S%)dktQ(-6e?oS5C<_^Sd#d(-D1J4@f(*Y0OVBNq@V3u+qQ^pJoddQ z@QGp)x*$XWkWIH@-C&kFOV*?GLg)B3hi82Yu+tb`m&8FJ-%;K?IKN=Zdl@I9dGTfL z&zo9&b?7e8&8g=_qdn{U`1&9Jo1{JnjdDh=rk#r-55_Z>jj+^;*5|;rM+R0rF@e~T zd*yMpz17-&Z_b42=j!s8aI`WKfyhRJ?ftxjeUR+T`2dKF9JZGXriY>xRk?)nX#L77 zGMgS;5$Hf1$loNVHKktRFHy~~wOYG`)yQneiT_?N2$z= z1IB$I*+bP|PLfIN@u|$<%NNgt?XYw8h1D-=DUZW|Gnh#EeXY0dO=G^MHiKM7ja{b^ zSnPg1u{y|s#YjmY3D8mk$LcsIie5eIjXx0rUe@InqIF#vElgwsIY$#YQ#`J3ke^;- zKrof!yGlI;j4kqwAvv@D@72JB|B|tb=Q1S^dh@F(E8G6@KZo_A$pg-b=Wop-u^8L@=ujX91FvvLlnEXkiGF{>*7MN5*HFY2&*6yxaKtB4;E#yu0?3 zhv|m55nqxX4lmT>MaO2bWkPs8qw6^kLnL&C``>MuV?{?v-Y1S1&Y1BUGDImp2V&n@ zz57}{8PFuXhAtGM5}Qg6aZCuXo6&a>=5kOU_Egs-FDoD32tXGl zhF^V|_YIfarlh+#{C1*I^pa^bkCUW#gtF{OY?-`W(8S|(ng19|-kdHFmj}ViYCHKW z;blZ+ksg3t1xWCzaZWvXnr#LQvXw_ZY~V+Xv17-X+IdUPyeiv02`%r|T#NWVo%N6Z zbzHg<_3&JnL8#@O|9Q-(PIzF$pnF$;en+tWX>un{?Y1ddILk6R`=aQxt1zAsZDv40)JL9E#+!$8`}oU|SaH z7A?^@c!5v6;}bYdXS82#>)#NkqgP$+V}OmgdHD^6i6=9HU|{wq3WVhHL20KEE7*&Y zKZh4Lfq}%s={&hJ^S9(GY-Gc91y~m0v;OsbNw)RafAc~+-){zA_ZeTP(+$LBTF1mE(0oyGE9*)) z3vcaOO(Zr?tdYsd(FbB3hArKu$r?|4?E4h$U=Q!%HGwolmRzJopECi6rF?sYrDO6u z3E0N4=txEeXFl+5Ws~``PF1v&7Z)f%G2`JIX$P|B+M1fLR$80>W8c15G=CfPdv6 zx^z?n2|?8lTOeKx*N^k9wG=n(MdgD*Q$}kQdRV&)`HEQjCmH(?KJV-BFD$+4Oylp~ zATtkIFD^VHerq_+w&$Ftcr@1&wBMo#oREAS5LU!<;TB7E&_Yt-&e$YPN)&4a-FiJf z_2@cK`GUGg6n=6;`6g)S+o#&cO)0SF^w+o&WA`i>zAf2C)bJ8W44PoyO$yF&unan+ zPcZNyA(Aj$Z5e=sj1SVbZvS3C>7Y~lL8*;fu-`$}G|=){$yWf3LUi2fL1VyL0iR>~ zm=@qTdR2nngRwjfj|42H7~QD|JDR(UeICDl@A2a3H?K#|dn1l0u_B3|<*h25vkQUl zeVAz_U^}@S#x3mUU*%v3ti8>EBzD%7>)4c3Zs8{X<9~_5oYJe@&lCCO8haV=SW%l$ z*oya^`^LIXT(NWKgEa+)K!c>MwmD8CL-+JO>b7mR{sdMQPi%=Li}QQK9)mC+&&BEd zqZ)Atr|Jfhh8*WO%0r4i;kvW6z}O{mB>Hh zf;s%NAJJX@vT>3LMW(%AQWUqRP9SQwFZS4C??PNY_4V4>sh#I+0``^=W2R=B<@;4b zW`)H2S4^;(zza>a^WitzW}USF0I>1WYf#i14EF&ig?+jlr1Vt0pC&~y>sC_~|>E7yI(55^QHK_nb#xLu^;P`_kJY65`d(Pqgh1FS#7|R> z_{k@cYuiAFT#*|nB&2PqbMKk=SIdj|M;Zj`R;8fOBoLC^YoaFUhp7Up8$&r|EMOR! z4@%h7C$K{0%mkR5|$Zh?TouWTZEYO+qbH$f` zUs}{KJ`CZ&m&QSAi@#@d(>a$Fqgg0vD|=J^Xs>eFC*hxp`i&NrHM!w$P#`g zDiHt|ALk>1sc4Y;_4kqWEW|)k!olF4bX9>_-09~F^&iQfnt#3mX59u;G_F(PDv;Xmrn;oSd^vrYjvvG)v@r4-^`PslHe>$S2W7#x1`cqEbT@y| zAekPZvQ0ojAA$%=VQb4v{$U+yq6TN$5NI$Gc}}@<^lE-|dT3elIHmA%xbvQ=5gt#wN1(1tmDXh^DT~W=RAWy32)nOmYFKnMnM-!x0o~!)H-W;3C z;`gvo*k0<0F)ocAyR@&T`||Mz5h*%41{cO4P)^KEjHeRSE#!CnNePQ@gac%y@7=e5 z{bATn^enzsL;F3SxS^>?RQkN_!2VqIpBRI$=!)t_R^4w+JQFkj1(_jqRbT?-US*C@K_%ydd3R@m2d9Eqnw(1@|Ym9G7X>|!>(u(sDQq#7aJg{}C z(OF(FTD-Ye>%hTWk2$1V@IXSg$$)Yo0q=p$5Mi8McagtXa~PF?RrlZ=i=w@k!ESyb z9fih2%Q9-lSG$F}@;)fL>v0{6u{xJ|M62OFtbkOK{yG($Y@br0?rl0y-Nae67`iM(T z8Q$OWqlhrEkY{f9VZoB4R;#fXoaQxiACl>`P`0W=7#T3<_X<_FT-VdyRT_X3LonGw z%cUL`BBCtt^0ot~v#DOgY2`vQy*>$so@Y{sm#sF8%bSJl@f)!idOM#=VRt?575|X0 ze0prAw#+`l;7rjs5?9T|x+={IdRSt}Ri%ulJhVEFH%6Vr|76r2MIx^H zHB&$TZ~831Hr2Sgx3g&Vd-+?)3J%&uJ$9Q((y{^Q15JxKxI!iM)#($QqLa!N zW)-I6)o&AOxBY)Rf9+2o$`<{n|5F0YHcfvE8AVpzAPPSOw%$=wk;{gn#7gd4RVz>y$W0a zz+{RR>?)MVm40L&Z(lMZCfEjSsgcHNpfA0yTI=`?LUv}kIN)45J|MCBWjY1= zA*NkFTU7WRS4|WgJ-SpT`gH8_!(5<~I0{=t6QZ(bb4dfaV$8}gGuEsBrg$PQY1^G|y{qL{CK!%B-3}U^QLvfwbtn)yk7Pn(ku;1HnPh9n2f4R%oq)be z;p+eTUuOWulYDSLH?+>6S31X&ND?-#Mk45=ght`pqdNrF6GD!Au6J()kk+5?8~D1` zEodrIS7*%_Sus^QUaYt!eQN@}lqNlvw00+C^=Gae-!4pj<~sKQSP?!}e~>TEP`Z-g9iVI+4LGHGDO1P4tu?``WJp-%Yw-CV#ApyVV4TPLiG%!R$W4LJwIL9WO&a==1`+?$l9* zUJn>8)9W-6hYRi}#GPwb3L!t^yzKOLekV=9DL(h4#1RKy=*cBQCj((GV*E;)FvoBd z2%%dG#eb)X^RR-zP}LN5ijP}Ugk&gab`?J}faCZXm*ZA7mSsrX#GtACVE;A{qxELk1dzY>?=G8tFf0lr-n-&0Rpgk z?^ZX(;s#%ngYzG76a-XVhMQjn3NA!n7L}g5Sa!)kj_8Q0+W((eAaoW%x;ReII+5WigsO$m2oEBH}9n+UZ4?>j4{gXJ1 zSPW9qF<#X1A*Cge3W;<{X5dL2uX>@d;?Nb#s~c<-`F>CECr=w|?YOb5kyI zdVZ!5t=Xu<$kULzg+kM?M&Ss{IC7AP*aEEdy7M|aOnZ;zHm5Fth>+EV4)JN1=WV*R z-2GEi;w1Q@kN+F39!@aqEA}Wmd?jQh%8u90HUGtw@^vPam09H~_!}lK~11BGojk`*3(3&`_wRH-^6!w(i7< z;Rscpr4DJ&ePkfXJCSr=wDj}TJo+A=do$lRe=kXCJJ6bFS!@cJgHcR<@p7xJ?F3W=r)NVEktUZeUr~i|9@Bz89R@jqNHFQg*k`@=yFBMWj9~^Wj z*E+f`VYi4fTJKXs5>L;E>X&Wa0fpML_z52dXay<}XO0GLL4<~;;f)%W(Xg6F;aSS5 z%69*WBs?fykI{QPP@zvAo-yXVy0daH29DiR-NMr}_tO`YTpO=>nw`=^S6=)6ko z*U+2>$d-TqmMa0&y!ua8|L5P4K`jA`ATk%N8X5@Nk-DMj%6?eb%QTFF+?Q?qs@R>A zojtJf8IP`ROwpDp!a%DE5XAXC7mL>Y+8=%z%9Dy(p6%DykF=N2kFOtTXVO)9?72g( zMv4^H9y%Us-AE!5CwS)X(r}3u6ZG^74;LGuq7BTn@c10#r`Y|0#-Ufzf=4i>ew5P? z_Z#OgzA2?Q@WdeQB*`4MAuxmay+{QRrm%7W3T2*KDpcI4QM&Ay(eBA}b?9>X{Re zka8<5RFju~5LVOpUV429#6=*Eg;$tN)LJ9@MxUHCKiD(TyiqSm4QzA|G$HmR)$Z0} zG}mCww5*1Z`DiexzdCQ}^(3j;ey|Sa7k@+?#6@!(etA|xUF>z!8DloDL?qXzh$|AD z5?Epy?rq}BD~@NuaN`%%dXB76dRYoY|dP( ze7HN*mT*4-um61C%XCBV@aoFI3!#oX3X^lxxOcennH~r38R}S^wP^yw$x|v9#mv0& zuI{o@dnrBr?ztr~Ngpf4)Ox&ENKDU&oSv0iS+HZ7LGo*leffIVd}d1PoY*r}>GOXe zSe6HzxcL8B#804q#>x{8z-|3A5;La@%u@I`wX^-f%%2<_F1wC#Xeu*Joz}~6AV6W_ zQV)gu92^N-*F?(kxF|ZryKeXYEU-hCDQj8%>SNcg2(J_i_!Jc1J$csb=K;DNx128g zy!zbmY9wFETcxuzKPp>)gH`T`yR+$`G*K91zwJmG!=4(H~8!H0zrA?YXHv0xFx_8-d|KrJ!ob$p=yI%xf#L214TrOP7}y?| zeTg~P!0?IfuJuhD;!z? literal 0 HcmV?d00001 diff --git a/client/src/main/resources/anonymous.png b/client/src/main/resources/anonymous.png new file mode 100644 index 0000000000000000000000000000000000000000..b0294b265e6abe826ab441fd4c037d92a4564ded GIT binary patch literal 3344 zcmZ`+c{o&U8-K3K2~pOG?CT&hyu{?y!fW3`C?Zkz7)u)? zBzu;U8B3NLJLNn5`CZrdJ=b-g`#R^`=bY>O?&bG;$mXU-NDe^`000ty5oZZ`(jQ|( zK*uf}mtM#*2kYal*`O80=AHzdvj<$X4+bDi_>aNLJHwZti^oGQ*o9d66GDhCK^}le zBr16Mc?Y|>1b8U;2VKuy)fNPRD+Q0!vkuQ&$&a`%Y$Nivy?qp0S4XK9L%8w5aoz&K z56RA`dlpKVA#9(5Ci6*iJM#>dd5f>nygZL&94zCEY&dZ9Xo0%-8`NrZJ~;=+9NpvG zP8iAJB*RmqxAUhKR|{jPZzehio?Q>y3*8>sKbUM^ooS#)@0)CE!BPxM20c{-|CeJR z?>LY+e(oN`g=pr(YjU#95CLe?Wtcj;Z)R4E_m>dGw4d=ChvR9fzPa}9-MjO}R#mqn zyh*XKu_YpJH42jym}&%9rj~7i8mw`+ekF!z?R+VVh^UcU9tPFUw0p3H#Q~3N%mJ_F z)Mt*1i2S;eSbdz6mI4cEa;#`**bXB6Mv$iOf4qbqylq$#xVf;fV0}^JUu8uubD4Bg z=5!oaKnNVj}*G~9=_pvu*U@Bg`4KRZf$&XlP z4eEUlYZHQ9^}cdpFqnY2<>q@#>YB4>w6Y@%h{Wwa0|SF2dZyBuy;EsvY4jt5bl3(9 zHQ1aja3VwgN&(lg6P#!dQc#o6r{&FkSgXA54j;*%P&dBDg+!V(q$y_hmPSTK>Tu7> zfGL=1L}y^04So-I>4N;jqf4869&ENdEg*_gzhXp7_4o0i!U9q}?AtwPec-CBhQ||^ zV}&g1RZZV)@UAyV9UUEW!fzuuD6*18X6V6v+4P!(j z)=%xVe;lgnn4Bh@lD}k-GnHYIeyK$$2b&*GP_cSQ;678a1-+7H1 zB--4HOi4)@&fYb~kKM6+vJrVIj;8w^DHb2Db`Uy}fKsdc7@V}ax_Sj1yj3KW*(i-b z&wJjb{g@lgRr8;&#J!gz2xeB?QldsROKA9}F~i^GxUN|gf5l+xS{>SK#hN}-nY#%&eh6~RVF=_9=Uq1D%)LJH;9aNjW z#w2CRI4*8Tx2qY?6DE z$HYmgErc^I@s#*=9xg8F!_P9Ay~n`?{Fs5v3u=PoF=wtmiv~rbk-$72*;K~)va+%d z@PMb1a<#TS4B>OruTDQpC?ArYVB9B+jpUzv0vq#MrH^>K3PPZYIpC$Fk4(Pl47xtU z^gbILs^Jy*`boTo1;$^Rw)HOay*Q*pCf-O9PxnqBVcw|tmrMdam zM9;T@$X9Lr6aiF}b$>E2t@GU{lYTrG3xwFBx0agS6j?>}%>x*wSy95RPoY!!P|M%s zm$Rd6M*cb`q3Kd~?aM1qQy%})%t1F==N!$H8 zd@Br4RQl55Q4J?4*BuhW9oe3%dR4{>-|ha*LZvO17f#oC4i*d5jeq9^)7)ZD?-t=D z_Uh-<1#BvZ$NCSDovn{y1S@Gie!<&;ETnsS%EgT=r?YnvT^LoL@!AWjXH+bxT5#b` zl|Hv5PZ3@x60KHQenytp*Nv8I_2g4kS65ey)-&ZuS{00dRaXypEEx>v;jKDdoR+;i z5cK1m=Whfwo_Z&hy%2k6`-i3AwWB_$;U4Dv^ zr^2!^)WqYQ54mTrfiN(phtqN#jXwBfWfmRuYj5*bT^hQ#Rhi_K(;JUEO~MX*=Swx2 z;^?^wctAKQ$xtS}r$nUL@0;YGS)zdDa+Al#pmf`dUuc2PdbkFuDK4@KWp8u&^+?qf z6Hq8tye;0jFIMbZKzC|^P@7cs`#hFpY}6vz1F%}*j!d)3KvQSe?e0N-l4?k3=&!~p z5#wSpC4@RXFZjcT_S#L5VN`XYvkn(B+>FH@*H%qxdQ3zG{>kI2Ha%63h~yD<*_lx#!*E2*%` z;pNgQ>RQ`lYnfM^6eEzA=zNp?0}YT~*8AQIbF|Oxegm)x=i^2Ue~K~({EhnBQem`A zW9+glhU`IFfH|kHM=#rOh&k6Fbp-t!qx-}oUD&+Q6-?;+uQ>o>6K_B5VmNC43ZFC*w zCDDA6dNw5vWsk(vv2{fQQzS}HMjy+a`=5Gk08&Eg z0KL4tJaa07p06lO+5Ng#caQ`HsaNYTNFwAH(2ouZhtb}8H#ax8J=Gub z`F8~DM%~hrN#DbguC}X7?1h(F?#4>k8$>BIZ?vz3Q4(i({e={3AQCJUEpqZX!CSG*04jLNMS$f-p|;+aYAmE`vNhK9@UQR=4< zViH#9TD9E1>Bsti3EMRWd|9cr?aYQr7nLk2iYaQ`x-yWEM~4fk`T2wwNKINAZViOQ z4o?%n`OVu%)TJbv_-r9xealr5SZzZ z(@(N-S!SC8)K==6I`27cRn?3vB)=v*cHb{CQI+*gecT-)7&qy@MeM5)D`q_