commit 8421f7056b25de37cd66aef0c7187f1233f72ce1 Author: J62 Date: Wed Mar 12 17:57:28 2025 -0700 first commit 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 00000000..1426bca8 Binary files /dev/null and b/client/src/main/resources/16/blank.png differ diff --git a/client/src/main/resources/16/bookmark-new.png b/client/src/main/resources/16/bookmark-new.png new file mode 100644 index 00000000..d50f515a Binary files /dev/null and b/client/src/main/resources/16/bookmark-new.png differ 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 00000000..2c3c1024 Binary files /dev/null and b/client/src/main/resources/16/check-circle.png differ diff --git a/client/src/main/resources/16/check-small.png b/client/src/main/resources/16/check-small.png new file mode 100644 index 00000000..c3b7d245 Binary files /dev/null and b/client/src/main/resources/16/check-small.png differ diff --git a/client/src/main/resources/16/check.png b/client/src/main/resources/16/check.png new file mode 100644 index 00000000..36b0bc8f Binary files /dev/null and b/client/src/main/resources/16/check.png differ diff --git a/client/src/main/resources/16/clock.png b/client/src/main/resources/16/clock.png new file mode 100644 index 00000000..00c9e221 Binary files /dev/null and b/client/src/main/resources/16/clock.png differ diff --git a/client/src/main/resources/16/download.png b/client/src/main/resources/16/download.png new file mode 100644 index 00000000..673aa322 Binary files /dev/null and b/client/src/main/resources/16/download.png differ diff --git a/client/src/main/resources/16/media-force-record.png b/client/src/main/resources/16/media-force-record.png new file mode 100644 index 00000000..d73004e2 Binary files /dev/null and b/client/src/main/resources/16/media-force-record.png differ diff --git a/client/src/main/resources/16/media-playback-pause.png b/client/src/main/resources/16/media-playback-pause.png new file mode 100644 index 00000000..91c5549f Binary files /dev/null and b/client/src/main/resources/16/media-playback-pause.png differ diff --git a/client/src/main/resources/16/media-record.png b/client/src/main/resources/16/media-record.png new file mode 100644 index 00000000..aec3241b Binary files /dev/null and b/client/src/main/resources/16/media-record.png differ diff --git a/client/src/main/resources/16/upload.png b/client/src/main/resources/16/upload.png new file mode 100644 index 00000000..ae8637d4 Binary files /dev/null and b/client/src/main/resources/16/upload.png differ diff --git a/client/src/main/resources/16/users.png b/client/src/main/resources/16/users.png new file mode 100644 index 00000000..cb1ce703 Binary files /dev/null and b/client/src/main/resources/16/users.png differ diff --git a/client/src/main/resources/32/blank.png b/client/src/main/resources/32/blank.png new file mode 100644 index 00000000..a7ea487f Binary files /dev/null and b/client/src/main/resources/32/blank.png differ 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 00000000..5687d94e Binary files /dev/null and b/client/src/main/resources/32/check-circle.png differ 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 00000000..540010a1 Binary files /dev/null and b/client/src/main/resources/32/check-small.png differ 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 00000000..bc8b618b Binary files /dev/null and b/client/src/main/resources/32/check.png differ diff --git a/client/src/main/resources/32/clock.png b/client/src/main/resources/32/clock.png new file mode 100644 index 00000000..a7ad0f32 Binary files /dev/null and b/client/src/main/resources/32/clock.png differ diff --git a/client/src/main/resources/32/download.png b/client/src/main/resources/32/download.png new file mode 100644 index 00000000..717cd15b Binary files /dev/null and b/client/src/main/resources/32/download.png differ diff --git a/client/src/main/resources/32/upload.png b/client/src/main/resources/32/upload.png new file mode 100644 index 00000000..417d7859 Binary files /dev/null and b/client/src/main/resources/32/upload.png differ diff --git a/client/src/main/resources/32/users.png b/client/src/main/resources/32/users.png new file mode 100644 index 00000000..fe71827c Binary files /dev/null and b/client/src/main/resources/32/users.png differ diff --git a/client/src/main/resources/META-INF/MANIFEST.MF b/client/src/main/resources/META-INF/MANIFEST.MF new file mode 100644 index 00000000..0f5f5121 --- /dev/null +++ b/client/src/main/resources/META-INF/MANIFEST.MF @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +Main-Class: ctbrec.ui.Launcher +Built-By: 0xb00bface diff --git a/client/src/main/resources/Oxygen-Im-Highlight-Msg.mp3 b/client/src/main/resources/Oxygen-Im-Highlight-Msg.mp3 new file mode 100644 index 00000000..ad3a8a54 Binary files /dev/null and b/client/src/main/resources/Oxygen-Im-Highlight-Msg.mp3 differ diff --git a/client/src/main/resources/anonymous.png b/client/src/main/resources/anonymous.png new file mode 100644 index 00000000..b0294b26 Binary files /dev/null and b/client/src/main/resources/anonymous.png differ diff --git a/client/src/main/resources/bookmark-new.png b/client/src/main/resources/bookmark-new.png new file mode 100644 index 00000000..90b9daad Binary files /dev/null and b/client/src/main/resources/bookmark-new.png differ diff --git a/client/src/main/resources/ctb-logo.png b/client/src/main/resources/ctb-logo.png new file mode 100644 index 00000000..5e85af71 Binary files /dev/null and b/client/src/main/resources/ctb-logo.png differ diff --git a/client/src/main/resources/dark.css b/client/src/main/resources/dark.css new file mode 100644 index 00000000..a7af0985 --- /dev/null +++ b/client/src/main/resources/dark.css @@ -0,0 +1,7 @@ +.root { + -fx-base: #4d4d4d; + -fx-accent: #0096c9; + -fx-default-button: -fx-accent; + -fx-focus-color: -fx-accent; + -fx-control-inner-background-alt: derive(-fx-base, 95%); +} \ No newline at end of file diff --git a/client/src/main/resources/html/bitcoin-address.png b/client/src/main/resources/html/bitcoin-address.png new file mode 100644 index 00000000..63d5c2c3 Binary files /dev/null and b/client/src/main/resources/html/bitcoin-address.png differ diff --git a/client/src/main/resources/html/bitcoin.png b/client/src/main/resources/html/bitcoin.png new file mode 100644 index 00000000..8a3b2309 Binary files /dev/null and b/client/src/main/resources/html/bitcoin.png differ diff --git a/client/src/main/resources/html/buymeacoffee-fancy.png b/client/src/main/resources/html/buymeacoffee-fancy.png new file mode 100644 index 00000000..5a4ff1d4 Binary files /dev/null and b/client/src/main/resources/html/buymeacoffee-fancy.png differ diff --git a/client/src/main/resources/html/ethereum-address.png b/client/src/main/resources/html/ethereum-address.png new file mode 100644 index 00000000..27986b74 Binary files /dev/null and b/client/src/main/resources/html/ethereum-address.png differ diff --git a/client/src/main/resources/html/ethereum.png b/client/src/main/resources/html/ethereum.png new file mode 100644 index 00000000..4672dd0d Binary files /dev/null and b/client/src/main/resources/html/ethereum.png differ diff --git a/client/src/main/resources/html/monero-address.png b/client/src/main/resources/html/monero-address.png new file mode 100644 index 00000000..0ee71b7f Binary files /dev/null and b/client/src/main/resources/html/monero-address.png differ diff --git a/client/src/main/resources/html/monero.png b/client/src/main/resources/html/monero.png new file mode 100644 index 00000000..4b881482 Binary files /dev/null and b/client/src/main/resources/html/monero.png differ diff --git a/client/src/main/resources/html/patreon-logo.png b/client/src/main/resources/html/patreon-logo.png new file mode 100644 index 00000000..0a0c8eb7 Binary files /dev/null and b/client/src/main/resources/html/patreon-logo.png differ diff --git a/client/src/main/resources/html/patreon-logo.svg b/client/src/main/resources/html/patreon-logo.svg new file mode 100644 index 00000000..ac2a9f52 --- /dev/null +++ b/client/src/main/resources/html/patreon-logo.svg @@ -0,0 +1,79 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/client/src/main/resources/html/pp196.png b/client/src/main/resources/html/pp196.png new file mode 100644 index 00000000..2037da6a Binary files /dev/null and b/client/src/main/resources/html/pp196.png differ diff --git a/client/src/main/resources/html/static/button-red.png b/client/src/main/resources/html/static/button-red.png new file mode 100644 index 00000000..cbf846bc Binary files /dev/null and b/client/src/main/resources/html/static/button-red.png differ diff --git a/client/src/main/resources/html/static/favicon.png b/client/src/main/resources/html/static/favicon.png new file mode 100644 index 00000000..11ec023d Binary files /dev/null and b/client/src/main/resources/html/static/favicon.png differ diff --git a/client/src/main/resources/html/static/freelancer.css b/client/src/main/resources/html/static/freelancer.css new file mode 100644 index 00000000..e626c028 --- /dev/null +++ b/client/src/main/resources/html/static/freelancer.css @@ -0,0 +1,382 @@ +body { + color: #2C3E50; + font-family: 'Lato'; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + color: #2C3E50; + font-weight: 700; + font-family: 'Montserrat'; +} + +hr.star-light, +hr.star-dark { + max-width: 15rem; + padding: 0; + text-align: center; + border: none; + border-top: solid 0.25rem; + margin-top: 2.5rem; + margin-bottom: 2.5rem; +} + +hr.star-light:after, +hr.star-dark:after { + position: relative; + top: -.8em; + display: inline-block; + padding: 0 0.25em; + content: '\f005'; + font-family: FontAwesome; + font-size: 2em; +} + +hr.star-light { + border-color: #fff; +} + +hr.star-light:after { + color: #fff; + background-color: #dc4444; +} + +hr.star-dark { + border-color: #2C3E50; +} + +hr.star-dark:after { + color: #2C3E50; + background-color: white; +} + +section { + padding: 9rem 6rem 0 6rem; +} + +section h2 { + font-size: 2.25rem; + line-height: 2rem; +} + +@media (min-width: 992px) { + section h2 { + font-size: 3rem; + line-height: 2.5rem; + } +} + +.btn-xl { + padding: 1rem 1.75rem; + font-size: 1.25rem; +} + +.btn-social { + width: 3.25rem; + height: 3.25rem; + font-size: 1.25rem; + line-height: 2rem; +} + +.scroll-to-top { + z-index: 1042; + right: 1rem; + bottom: 1rem; + display: none; +} + +.scroll-to-top a { + width: 3.5rem; + height: 3.5rem; + background-color: rgba(33, 37, 41, 0.5); + line-height: 3.1rem; +} + +#mainNav { + padding-top: 1rem; + padding-bottom: 1rem; + font-weight: 700; + font-family: 'Montserrat'; +} + +#mainNav .navbar-brand { + color: #fff; +} + +#mainNav .navbar-nav { + margin-top: 1rem; + letter-spacing: 0.0625rem; +} + +#mainNav .navbar-nav li.nav-item a.nav-link { + color: #fff; +} + +#mainNav .navbar-nav li.nav-item a.nav-link:hover { + color: #dc4444; +} + +#mainNav .navbar-nav li.nav-item a.nav-link:active, #mainNav .navbar-nav li.nav-item a.nav-link:focus { + color: #fff; +} + +#mainNav .navbar-nav li.nav-item a.nav-link.active { + color: #dc4444; +} + +#mainNav .navbar-toggler { + font-size: 80%; + padding: 0.8rem; +} + +@media (min-width: 992px) { + #mainNav { + padding-top: 1.5rem; + padding-bottom: 1.5rem; + -webkit-transition: padding-top 0.3s, padding-bottom 0.3s; + -moz-transition: padding-top 0.3s, padding-bottom 0.3s; + transition: padding-top 0.3s, padding-bottom 0.3s; + } + #mainNav .navbar-brand { + font-size: 2em; + -webkit-transition: font-size 0.3s; + -moz-transition: font-size 0.3s; + transition: font-size 0.3s; + } + #mainNav .navbar-nav { + margin-top: 0; + } + #mainNav .navbar-nav > li.nav-item > a.nav-link.active { + color: #fff; + background: #dc4444; + } + #mainNav .navbar-nav > li.nav-item > a.nav-link.active:active, #mainNav .navbar-nav > li.nav-item > a.nav-link.active:focus, #mainNav .navbar-nav > li.nav-item > a.nav-link.active:hover { + color: #fff; + background: #dc4444; + } + #mainNav.navbar-shrink { + padding-top: 0.5rem; + padding-bottom: 0.5rem; + } + #mainNav.navbar-shrink .navbar-brand { + font-size: 1.5em; + } +} + +header.masthead { + padding-top: calc(6rem + 72px); + padding-bottom: 6rem; +} + +header.masthead h1 { + font-size: 3rem; + line-height: 3rem; +} + +header.masthead h2 { + font-size: 1.3rem; + font-family: 'Lato'; +} + +@media (min-width: 992px) { + header.masthead { + padding-top: calc(6rem + 106px); + padding-bottom: 6rem; + } + header.masthead h1 { + font-size: 4.75em; + line-height: 4rem; + } + header.masthead h2 { + font-size: 1.75em; + } +} + +.portfolio { + margin-bottom: -15px; +} + +.portfolio .portfolio-item { + position: relative; + display: block; + max-width: 25rem; + margin-bottom: 15px; +} + +.portfolio .portfolio-item .portfolio-item-caption { + -webkit-transition: all ease 0.5s; + -moz-transition: all ease 0.5s; + transition: all ease 0.5s; + opacity: 0; + background-color: rgba(220, 68, 68, 0.9); +} + +.portfolio .portfolio-item .portfolio-item-caption:hover { + opacity: 1; +} + +.portfolio .portfolio-item .portfolio-item-caption .portfolio-item-caption-content { + font-size: 1.5rem; +} + +@media (min-width: 576px) { + .portfolio { + margin-bottom: -30px; + } + .portfolio .portfolio-item { + margin-bottom: 30px; + } +} + +.portfolio-modal .portfolio-modal-dialog { + padding: 3rem 1rem; + min-height: calc(100vh - 2rem); + margin: 1rem calc(1rem - 8px); + position: relative; + z-index: 2; + -moz-box-shadow: 0 0 3rem 1rem rgba(0, 0, 0, 0.5); + -webkit-box-shadow: 0 0 3rem 1rem rgba(0, 0, 0, 0.5); + box-shadow: 0 0 3rem 1rem rgba(0, 0, 0, 0.5); +} + +.portfolio-modal .portfolio-modal-dialog .close-button { + position: absolute; + top: 2rem; + right: 2rem; +} + +.portfolio-modal .portfolio-modal-dialog .close-button i { + line-height: 38px; +} + +.portfolio-modal .portfolio-modal-dialog h2 { + font-size: 2rem; +} + +@media (min-width: 768px) { + .portfolio-modal .portfolio-modal-dialog { + min-height: 100vh; + padding: 5rem; + margin: 3rem calc(3rem - 8px); + } + .portfolio-modal .portfolio-modal-dialog h2 { + font-size: 3rem; + } +} + +.floating-label-form-group { + position: relative; + border-bottom: 1px solid #e9ecef; +} + +.floating-label-form-group input, +.floating-label-form-group textarea { + font-size: 1.5em; + position: relative; + z-index: 1; + padding-right: 0; + padding-left: 0; + resize: none; + border: none; + border-radius: 0; + background: none; + box-shadow: none !important; +} + +.floating-label-form-group label { + font-size: 0.85em; + line-height: 1.764705882em; + position: relative; + z-index: 0; + top: 2em; + display: block; + margin: 0; + -webkit-transition: top 0.3s ease, opacity 0.3s ease; + -moz-transition: top 0.3s ease, opacity 0.3s ease; + -ms-transition: top 0.3s ease, opacity 0.3s ease; + transition: top 0.3s ease, opacity 0.3s ease; + vertical-align: middle; + vertical-align: baseline; + opacity: 0; +} + +.floating-label-form-group:not(:first-child) { + padding-left: 14px; + border-left: 1px solid #e9ecef; +} + +.floating-label-form-group-with-value label { + top: 0; + opacity: 1; +} + +.floating-label-form-group-with-focus label { + color: #dc4444; +} + +form .row:first-child .floating-label-form-group { + border-top: 1px solid #e9ecef; +} + +.footer { + padding-top: 5rem; + padding-bottom: 5rem; + background-color: #2C3E50; + color: #fff; +} + +.copyright { + background-color: #1a252f; +} + +a { + color: #dc4444; +} + +a:focus, a:hover, a:active { + color: #c82525; +} + +.btn { + border-width: 2px; +} + +.bg-primary { + background-color: #dc4444 !important; +} + +.bg-secondary { + background-color: #2C3E50 !important; +} + +.text-primary { + color: #dc4444 !important; +} + +.text-secondary { + color: #2C3E50 !important; +} + +.btn-primary { + background-color: #dc4444; + border-color: #dc4444; +} + +.btn-primary:hover, .btn-primary:focus, .btn-primary:active { + background-color: #c82525; + border-color: #c82525; +} + +.btn-secondary { + background-color: #2C3E50; + border-color: #2C3E50; +} + +.btn-secondary:hover, .btn-secondary:focus, .btn-secondary:active { + background-color: #1a252f; + border-color: #1a252f; +} diff --git a/client/src/main/resources/html/static/freelancer.min.js b/client/src/main/resources/html/static/freelancer.min.js new file mode 100644 index 00000000..8d2c92de --- /dev/null +++ b/client/src/main/resources/html/static/freelancer.min.js @@ -0,0 +1 @@ +!function(o){"use strict";o('a.js-scroll-trigger[href*="#"]:not([href="#"])').click(function(){if(location.pathname.replace(/^\//,"")==this.pathname.replace(/^\//,"")&&location.hostname==this.hostname){var t=o(this.hash);if((t=t.length?t:o("[name="+this.hash.slice(1)+"]")).length)return o("html, body").animate({scrollTop:t.offset().top-70},1e3,"easeInOutExpo"),!1}}),o(document).scroll(function(){o(this).scrollTop()>100?o(".scroll-to-top").fadeIn():o(".scroll-to-top").fadeOut()}),o(".js-scroll-trigger").click(function(){o(".navbar-collapse").collapse("hide")}),o("body").scrollspy({target:"#mainNav",offset:80});var t=function(){o("#mainNav").offset().top>100?o("#mainNav").addClass("navbar-shrink"):o("#mainNav").removeClass("navbar-shrink")};t(),o(window).scroll(t),o(".portfolio-item").magnificPopup({type:"inline",preloader:!1,focus:"#username",modal:!0}),o(document).on("click",".portfolio-modal-dismiss",function(t){t.preventDefault(),o.magnificPopup.close()}),o(function(){o("body").on("input propertychange",".floating-label-form-group",function(t){o(this).toggleClass("floating-label-form-group-with-value",!!o(t.target).val())}).on("focus",".floating-label-form-group",function(){o(this).addClass("floating-label-form-group-with-focus")}).on("blur",".floating-label-form-group",function(){o(this).removeClass("floating-label-form-group-with-focus")})})}(jQuery); \ No newline at end of file diff --git a/client/src/main/resources/html/static/icon64.png b/client/src/main/resources/html/static/icon64.png new file mode 100644 index 00000000..3622998e Binary files /dev/null and b/client/src/main/resources/html/static/icon64.png differ diff --git a/client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap-grid.css b/client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap-grid.css new file mode 100644 index 00000000..bcd4f334 --- /dev/null +++ b/client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap-grid.css @@ -0,0 +1,1567 @@ +/*! + * Bootstrap Grid v4.0.0-beta.2 (https://getbootstrap.com) + * Copyright 2011-2017 The Bootstrap Authors + * Copyright 2011-2017 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ +@-ms-viewport { + width: device-width; +} + +html { + box-sizing: border-box; + -ms-overflow-style: scrollbar; +} + +*, +*::before, +*::after { + box-sizing: inherit; +} + +.container { + width: 100%; + padding-right: 15px; + padding-left: 15px; + margin-right: auto; + margin-left: auto; +} + +@media (min-width: 576px) { + .container { + max-width: 540px; + } +} + +@media (min-width: 768px) { + .container { + max-width: 720px; + } +} + +@media (min-width: 992px) { + .container { + max-width: 960px; + } +} + +@media (min-width: 1200px) { + .container { + max-width: 1140px; + } +} + +.container-fluid { + width: 100%; + padding-right: 15px; + padding-left: 15px; + margin-right: auto; + margin-left: auto; +} + +.row { + display: -ms-flexbox; + display: flex; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + margin-right: -15px; + margin-left: -15px; +} + +.no-gutters { + margin-right: 0; + margin-left: 0; +} + +.no-gutters > .col, +.no-gutters > [class*="col-"] { + padding-right: 0; + padding-left: 0; +} + +.col-1, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-10, .col-11, .col-12, .col, +.col-auto, .col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12, .col-sm, +.col-sm-auto, .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12, .col-md, +.col-md-auto, .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12, .col-lg, +.col-lg-auto, .col-xl-1, .col-xl-2, .col-xl-3, .col-xl-4, .col-xl-5, .col-xl-6, .col-xl-7, .col-xl-8, .col-xl-9, .col-xl-10, .col-xl-11, .col-xl-12, .col-xl, +.col-xl-auto { + position: relative; + width: 100%; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; +} + +.col { + -ms-flex-preferred-size: 0; + flex-basis: 0; + -ms-flex-positive: 1; + flex-grow: 1; + max-width: 100%; +} + +.col-auto { + -ms-flex: 0 0 auto; + flex: 0 0 auto; + width: auto; + max-width: none; +} + +.col-1 { + -ms-flex: 0 0 8.333333%; + flex: 0 0 8.333333%; + max-width: 8.333333%; +} + +.col-2 { + -ms-flex: 0 0 16.666667%; + flex: 0 0 16.666667%; + max-width: 16.666667%; +} + +.col-3 { + -ms-flex: 0 0 25%; + flex: 0 0 25%; + max-width: 25%; +} + +.col-4 { + -ms-flex: 0 0 33.333333%; + flex: 0 0 33.333333%; + max-width: 33.333333%; +} + +.col-5 { + -ms-flex: 0 0 41.666667%; + flex: 0 0 41.666667%; + max-width: 41.666667%; +} + +.col-6 { + -ms-flex: 0 0 50%; + flex: 0 0 50%; + max-width: 50%; +} + +.col-7 { + -ms-flex: 0 0 58.333333%; + flex: 0 0 58.333333%; + max-width: 58.333333%; +} + +.col-8 { + -ms-flex: 0 0 66.666667%; + flex: 0 0 66.666667%; + max-width: 66.666667%; +} + +.col-9 { + -ms-flex: 0 0 75%; + flex: 0 0 75%; + max-width: 75%; +} + +.col-10 { + -ms-flex: 0 0 83.333333%; + flex: 0 0 83.333333%; + max-width: 83.333333%; +} + +.col-11 { + -ms-flex: 0 0 91.666667%; + flex: 0 0 91.666667%; + max-width: 91.666667%; +} + +.col-12 { + -ms-flex: 0 0 100%; + flex: 0 0 100%; + max-width: 100%; +} + +.order-first { + -ms-flex-order: -1; + order: -1; +} + +.order-1 { + -ms-flex-order: 1; + order: 1; +} + +.order-2 { + -ms-flex-order: 2; + order: 2; +} + +.order-3 { + -ms-flex-order: 3; + order: 3; +} + +.order-4 { + -ms-flex-order: 4; + order: 4; +} + +.order-5 { + -ms-flex-order: 5; + order: 5; +} + +.order-6 { + -ms-flex-order: 6; + order: 6; +} + +.order-7 { + -ms-flex-order: 7; + order: 7; +} + +.order-8 { + -ms-flex-order: 8; + order: 8; +} + +.order-9 { + -ms-flex-order: 9; + order: 9; +} + +.order-10 { + -ms-flex-order: 10; + order: 10; +} + +.order-11 { + -ms-flex-order: 11; + order: 11; +} + +.order-12 { + -ms-flex-order: 12; + order: 12; +} + +.offset-1 { + margin-left: 8.333333%; +} + +.offset-2 { + margin-left: 16.666667%; +} + +.offset-3 { + margin-left: 25%; +} + +.offset-4 { + margin-left: 33.333333%; +} + +.offset-5 { + margin-left: 41.666667%; +} + +.offset-6 { + margin-left: 50%; +} + +.offset-7 { + margin-left: 58.333333%; +} + +.offset-8 { + margin-left: 66.666667%; +} + +.offset-9 { + margin-left: 75%; +} + +.offset-10 { + margin-left: 83.333333%; +} + +.offset-11 { + margin-left: 91.666667%; +} + +@media (min-width: 576px) { + .col-sm { + -ms-flex-preferred-size: 0; + flex-basis: 0; + -ms-flex-positive: 1; + flex-grow: 1; + max-width: 100%; + } + .col-sm-auto { + -ms-flex: 0 0 auto; + flex: 0 0 auto; + width: auto; + max-width: none; + } + .col-sm-1 { + -ms-flex: 0 0 8.333333%; + flex: 0 0 8.333333%; + max-width: 8.333333%; + } + .col-sm-2 { + -ms-flex: 0 0 16.666667%; + flex: 0 0 16.666667%; + max-width: 16.666667%; + } + .col-sm-3 { + -ms-flex: 0 0 25%; + flex: 0 0 25%; + max-width: 25%; + } + .col-sm-4 { + -ms-flex: 0 0 33.333333%; + flex: 0 0 33.333333%; + max-width: 33.333333%; + } + .col-sm-5 { + -ms-flex: 0 0 41.666667%; + flex: 0 0 41.666667%; + max-width: 41.666667%; + } + .col-sm-6 { + -ms-flex: 0 0 50%; + flex: 0 0 50%; + max-width: 50%; + } + .col-sm-7 { + -ms-flex: 0 0 58.333333%; + flex: 0 0 58.333333%; + max-width: 58.333333%; + } + .col-sm-8 { + -ms-flex: 0 0 66.666667%; + flex: 0 0 66.666667%; + max-width: 66.666667%; + } + .col-sm-9 { + -ms-flex: 0 0 75%; + flex: 0 0 75%; + max-width: 75%; + } + .col-sm-10 { + -ms-flex: 0 0 83.333333%; + flex: 0 0 83.333333%; + max-width: 83.333333%; + } + .col-sm-11 { + -ms-flex: 0 0 91.666667%; + flex: 0 0 91.666667%; + max-width: 91.666667%; + } + .col-sm-12 { + -ms-flex: 0 0 100%; + flex: 0 0 100%; + max-width: 100%; + } + .order-sm-first { + -ms-flex-order: -1; + order: -1; + } + .order-sm-1 { + -ms-flex-order: 1; + order: 1; + } + .order-sm-2 { + -ms-flex-order: 2; + order: 2; + } + .order-sm-3 { + -ms-flex-order: 3; + order: 3; + } + .order-sm-4 { + -ms-flex-order: 4; + order: 4; + } + .order-sm-5 { + -ms-flex-order: 5; + order: 5; + } + .order-sm-6 { + -ms-flex-order: 6; + order: 6; + } + .order-sm-7 { + -ms-flex-order: 7; + order: 7; + } + .order-sm-8 { + -ms-flex-order: 8; + order: 8; + } + .order-sm-9 { + -ms-flex-order: 9; + order: 9; + } + .order-sm-10 { + -ms-flex-order: 10; + order: 10; + } + .order-sm-11 { + -ms-flex-order: 11; + order: 11; + } + .order-sm-12 { + -ms-flex-order: 12; + order: 12; + } + .offset-sm-0 { + margin-left: 0; + } + .offset-sm-1 { + margin-left: 8.333333%; + } + .offset-sm-2 { + margin-left: 16.666667%; + } + .offset-sm-3 { + margin-left: 25%; + } + .offset-sm-4 { + margin-left: 33.333333%; + } + .offset-sm-5 { + margin-left: 41.666667%; + } + .offset-sm-6 { + margin-left: 50%; + } + .offset-sm-7 { + margin-left: 58.333333%; + } + .offset-sm-8 { + margin-left: 66.666667%; + } + .offset-sm-9 { + margin-left: 75%; + } + .offset-sm-10 { + margin-left: 83.333333%; + } + .offset-sm-11 { + margin-left: 91.666667%; + } +} + +@media (min-width: 768px) { + .col-md { + -ms-flex-preferred-size: 0; + flex-basis: 0; + -ms-flex-positive: 1; + flex-grow: 1; + max-width: 100%; + } + .col-md-auto { + -ms-flex: 0 0 auto; + flex: 0 0 auto; + width: auto; + max-width: none; + } + .col-md-1 { + -ms-flex: 0 0 8.333333%; + flex: 0 0 8.333333%; + max-width: 8.333333%; + } + .col-md-2 { + -ms-flex: 0 0 16.666667%; + flex: 0 0 16.666667%; + max-width: 16.666667%; + } + .col-md-3 { + -ms-flex: 0 0 25%; + flex: 0 0 25%; + max-width: 25%; + } + .col-md-4 { + -ms-flex: 0 0 33.333333%; + flex: 0 0 33.333333%; + max-width: 33.333333%; + } + .col-md-5 { + -ms-flex: 0 0 41.666667%; + flex: 0 0 41.666667%; + max-width: 41.666667%; + } + .col-md-6 { + -ms-flex: 0 0 50%; + flex: 0 0 50%; + max-width: 50%; + } + .col-md-7 { + -ms-flex: 0 0 58.333333%; + flex: 0 0 58.333333%; + max-width: 58.333333%; + } + .col-md-8 { + -ms-flex: 0 0 66.666667%; + flex: 0 0 66.666667%; + max-width: 66.666667%; + } + .col-md-9 { + -ms-flex: 0 0 75%; + flex: 0 0 75%; + max-width: 75%; + } + .col-md-10 { + -ms-flex: 0 0 83.333333%; + flex: 0 0 83.333333%; + max-width: 83.333333%; + } + .col-md-11 { + -ms-flex: 0 0 91.666667%; + flex: 0 0 91.666667%; + max-width: 91.666667%; + } + .col-md-12 { + -ms-flex: 0 0 100%; + flex: 0 0 100%; + max-width: 100%; + } + .order-md-first { + -ms-flex-order: -1; + order: -1; + } + .order-md-1 { + -ms-flex-order: 1; + order: 1; + } + .order-md-2 { + -ms-flex-order: 2; + order: 2; + } + .order-md-3 { + -ms-flex-order: 3; + order: 3; + } + .order-md-4 { + -ms-flex-order: 4; + order: 4; + } + .order-md-5 { + -ms-flex-order: 5; + order: 5; + } + .order-md-6 { + -ms-flex-order: 6; + order: 6; + } + .order-md-7 { + -ms-flex-order: 7; + order: 7; + } + .order-md-8 { + -ms-flex-order: 8; + order: 8; + } + .order-md-9 { + -ms-flex-order: 9; + order: 9; + } + .order-md-10 { + -ms-flex-order: 10; + order: 10; + } + .order-md-11 { + -ms-flex-order: 11; + order: 11; + } + .order-md-12 { + -ms-flex-order: 12; + order: 12; + } + .offset-md-0 { + margin-left: 0; + } + .offset-md-1 { + margin-left: 8.333333%; + } + .offset-md-2 { + margin-left: 16.666667%; + } + .offset-md-3 { + margin-left: 25%; + } + .offset-md-4 { + margin-left: 33.333333%; + } + .offset-md-5 { + margin-left: 41.666667%; + } + .offset-md-6 { + margin-left: 50%; + } + .offset-md-7 { + margin-left: 58.333333%; + } + .offset-md-8 { + margin-left: 66.666667%; + } + .offset-md-9 { + margin-left: 75%; + } + .offset-md-10 { + margin-left: 83.333333%; + } + .offset-md-11 { + margin-left: 91.666667%; + } +} + +@media (min-width: 992px) { + .col-lg { + -ms-flex-preferred-size: 0; + flex-basis: 0; + -ms-flex-positive: 1; + flex-grow: 1; + max-width: 100%; + } + .col-lg-auto { + -ms-flex: 0 0 auto; + flex: 0 0 auto; + width: auto; + max-width: none; + } + .col-lg-1 { + -ms-flex: 0 0 8.333333%; + flex: 0 0 8.333333%; + max-width: 8.333333%; + } + .col-lg-2 { + -ms-flex: 0 0 16.666667%; + flex: 0 0 16.666667%; + max-width: 16.666667%; + } + .col-lg-3 { + -ms-flex: 0 0 25%; + flex: 0 0 25%; + max-width: 25%; + } + .col-lg-4 { + -ms-flex: 0 0 33.333333%; + flex: 0 0 33.333333%; + max-width: 33.333333%; + } + .col-lg-5 { + -ms-flex: 0 0 41.666667%; + flex: 0 0 41.666667%; + max-width: 41.666667%; + } + .col-lg-6 { + -ms-flex: 0 0 50%; + flex: 0 0 50%; + max-width: 50%; + } + .col-lg-7 { + -ms-flex: 0 0 58.333333%; + flex: 0 0 58.333333%; + max-width: 58.333333%; + } + .col-lg-8 { + -ms-flex: 0 0 66.666667%; + flex: 0 0 66.666667%; + max-width: 66.666667%; + } + .col-lg-9 { + -ms-flex: 0 0 75%; + flex: 0 0 75%; + max-width: 75%; + } + .col-lg-10 { + -ms-flex: 0 0 83.333333%; + flex: 0 0 83.333333%; + max-width: 83.333333%; + } + .col-lg-11 { + -ms-flex: 0 0 91.666667%; + flex: 0 0 91.666667%; + max-width: 91.666667%; + } + .col-lg-12 { + -ms-flex: 0 0 100%; + flex: 0 0 100%; + max-width: 100%; + } + .order-lg-first { + -ms-flex-order: -1; + order: -1; + } + .order-lg-1 { + -ms-flex-order: 1; + order: 1; + } + .order-lg-2 { + -ms-flex-order: 2; + order: 2; + } + .order-lg-3 { + -ms-flex-order: 3; + order: 3; + } + .order-lg-4 { + -ms-flex-order: 4; + order: 4; + } + .order-lg-5 { + -ms-flex-order: 5; + order: 5; + } + .order-lg-6 { + -ms-flex-order: 6; + order: 6; + } + .order-lg-7 { + -ms-flex-order: 7; + order: 7; + } + .order-lg-8 { + -ms-flex-order: 8; + order: 8; + } + .order-lg-9 { + -ms-flex-order: 9; + order: 9; + } + .order-lg-10 { + -ms-flex-order: 10; + order: 10; + } + .order-lg-11 { + -ms-flex-order: 11; + order: 11; + } + .order-lg-12 { + -ms-flex-order: 12; + order: 12; + } + .offset-lg-0 { + margin-left: 0; + } + .offset-lg-1 { + margin-left: 8.333333%; + } + .offset-lg-2 { + margin-left: 16.666667%; + } + .offset-lg-3 { + margin-left: 25%; + } + .offset-lg-4 { + margin-left: 33.333333%; + } + .offset-lg-5 { + margin-left: 41.666667%; + } + .offset-lg-6 { + margin-left: 50%; + } + .offset-lg-7 { + margin-left: 58.333333%; + } + .offset-lg-8 { + margin-left: 66.666667%; + } + .offset-lg-9 { + margin-left: 75%; + } + .offset-lg-10 { + margin-left: 83.333333%; + } + .offset-lg-11 { + margin-left: 91.666667%; + } +} + +@media (min-width: 1200px) { + .col-xl { + -ms-flex-preferred-size: 0; + flex-basis: 0; + -ms-flex-positive: 1; + flex-grow: 1; + max-width: 100%; + } + .col-xl-auto { + -ms-flex: 0 0 auto; + flex: 0 0 auto; + width: auto; + max-width: none; + } + .col-xl-1 { + -ms-flex: 0 0 8.333333%; + flex: 0 0 8.333333%; + max-width: 8.333333%; + } + .col-xl-2 { + -ms-flex: 0 0 16.666667%; + flex: 0 0 16.666667%; + max-width: 16.666667%; + } + .col-xl-3 { + -ms-flex: 0 0 25%; + flex: 0 0 25%; + max-width: 25%; + } + .col-xl-4 { + -ms-flex: 0 0 33.333333%; + flex: 0 0 33.333333%; + max-width: 33.333333%; + } + .col-xl-5 { + -ms-flex: 0 0 41.666667%; + flex: 0 0 41.666667%; + max-width: 41.666667%; + } + .col-xl-6 { + -ms-flex: 0 0 50%; + flex: 0 0 50%; + max-width: 50%; + } + .col-xl-7 { + -ms-flex: 0 0 58.333333%; + flex: 0 0 58.333333%; + max-width: 58.333333%; + } + .col-xl-8 { + -ms-flex: 0 0 66.666667%; + flex: 0 0 66.666667%; + max-width: 66.666667%; + } + .col-xl-9 { + -ms-flex: 0 0 75%; + flex: 0 0 75%; + max-width: 75%; + } + .col-xl-10 { + -ms-flex: 0 0 83.333333%; + flex: 0 0 83.333333%; + max-width: 83.333333%; + } + .col-xl-11 { + -ms-flex: 0 0 91.666667%; + flex: 0 0 91.666667%; + max-width: 91.666667%; + } + .col-xl-12 { + -ms-flex: 0 0 100%; + flex: 0 0 100%; + max-width: 100%; + } + .order-xl-first { + -ms-flex-order: -1; + order: -1; + } + .order-xl-1 { + -ms-flex-order: 1; + order: 1; + } + .order-xl-2 { + -ms-flex-order: 2; + order: 2; + } + .order-xl-3 { + -ms-flex-order: 3; + order: 3; + } + .order-xl-4 { + -ms-flex-order: 4; + order: 4; + } + .order-xl-5 { + -ms-flex-order: 5; + order: 5; + } + .order-xl-6 { + -ms-flex-order: 6; + order: 6; + } + .order-xl-7 { + -ms-flex-order: 7; + order: 7; + } + .order-xl-8 { + -ms-flex-order: 8; + order: 8; + } + .order-xl-9 { + -ms-flex-order: 9; + order: 9; + } + .order-xl-10 { + -ms-flex-order: 10; + order: 10; + } + .order-xl-11 { + -ms-flex-order: 11; + order: 11; + } + .order-xl-12 { + -ms-flex-order: 12; + order: 12; + } + .offset-xl-0 { + margin-left: 0; + } + .offset-xl-1 { + margin-left: 8.333333%; + } + .offset-xl-2 { + margin-left: 16.666667%; + } + .offset-xl-3 { + margin-left: 25%; + } + .offset-xl-4 { + margin-left: 33.333333%; + } + .offset-xl-5 { + margin-left: 41.666667%; + } + .offset-xl-6 { + margin-left: 50%; + } + .offset-xl-7 { + margin-left: 58.333333%; + } + .offset-xl-8 { + margin-left: 66.666667%; + } + .offset-xl-9 { + margin-left: 75%; + } + .offset-xl-10 { + margin-left: 83.333333%; + } + .offset-xl-11 { + margin-left: 91.666667%; + } +} + +.flex-row { + -ms-flex-direction: row !important; + flex-direction: row !important; +} + +.flex-column { + -ms-flex-direction: column !important; + flex-direction: column !important; +} + +.flex-row-reverse { + -ms-flex-direction: row-reverse !important; + flex-direction: row-reverse !important; +} + +.flex-column-reverse { + -ms-flex-direction: column-reverse !important; + flex-direction: column-reverse !important; +} + +.flex-wrap { + -ms-flex-wrap: wrap !important; + flex-wrap: wrap !important; +} + +.flex-nowrap { + -ms-flex-wrap: nowrap !important; + flex-wrap: nowrap !important; +} + +.flex-wrap-reverse { + -ms-flex-wrap: wrap-reverse !important; + flex-wrap: wrap-reverse !important; +} + +.justify-content-start { + -ms-flex-pack: start !important; + justify-content: flex-start !important; +} + +.justify-content-end { + -ms-flex-pack: end !important; + justify-content: flex-end !important; +} + +.justify-content-center { + -ms-flex-pack: center !important; + justify-content: center !important; +} + +.justify-content-between { + -ms-flex-pack: justify !important; + justify-content: space-between !important; +} + +.justify-content-around { + -ms-flex-pack: distribute !important; + justify-content: space-around !important; +} + +.align-items-start { + -ms-flex-align: start !important; + align-items: flex-start !important; +} + +.align-items-end { + -ms-flex-align: end !important; + align-items: flex-end !important; +} + +.align-items-center { + -ms-flex-align: center !important; + align-items: center !important; +} + +.align-items-baseline { + -ms-flex-align: baseline !important; + align-items: baseline !important; +} + +.align-items-stretch { + -ms-flex-align: stretch !important; + align-items: stretch !important; +} + +.align-content-start { + -ms-flex-line-pack: start !important; + align-content: flex-start !important; +} + +.align-content-end { + -ms-flex-line-pack: end !important; + align-content: flex-end !important; +} + +.align-content-center { + -ms-flex-line-pack: center !important; + align-content: center !important; +} + +.align-content-between { + -ms-flex-line-pack: justify !important; + align-content: space-between !important; +} + +.align-content-around { + -ms-flex-line-pack: distribute !important; + align-content: space-around !important; +} + +.align-content-stretch { + -ms-flex-line-pack: stretch !important; + align-content: stretch !important; +} + +.align-self-auto { + -ms-flex-item-align: auto !important; + align-self: auto !important; +} + +.align-self-start { + -ms-flex-item-align: start !important; + align-self: flex-start !important; +} + +.align-self-end { + -ms-flex-item-align: end !important; + align-self: flex-end !important; +} + +.align-self-center { + -ms-flex-item-align: center !important; + align-self: center !important; +} + +.align-self-baseline { + -ms-flex-item-align: baseline !important; + align-self: baseline !important; +} + +.align-self-stretch { + -ms-flex-item-align: stretch !important; + align-self: stretch !important; +} + +@media (min-width: 576px) { + .flex-sm-row { + -ms-flex-direction: row !important; + flex-direction: row !important; + } + .flex-sm-column { + -ms-flex-direction: column !important; + flex-direction: column !important; + } + .flex-sm-row-reverse { + -ms-flex-direction: row-reverse !important; + flex-direction: row-reverse !important; + } + .flex-sm-column-reverse { + -ms-flex-direction: column-reverse !important; + flex-direction: column-reverse !important; + } + .flex-sm-wrap { + -ms-flex-wrap: wrap !important; + flex-wrap: wrap !important; + } + .flex-sm-nowrap { + -ms-flex-wrap: nowrap !important; + flex-wrap: nowrap !important; + } + .flex-sm-wrap-reverse { + -ms-flex-wrap: wrap-reverse !important; + flex-wrap: wrap-reverse !important; + } + .justify-content-sm-start { + -ms-flex-pack: start !important; + justify-content: flex-start !important; + } + .justify-content-sm-end { + -ms-flex-pack: end !important; + justify-content: flex-end !important; + } + .justify-content-sm-center { + -ms-flex-pack: center !important; + justify-content: center !important; + } + .justify-content-sm-between { + -ms-flex-pack: justify !important; + justify-content: space-between !important; + } + .justify-content-sm-around { + -ms-flex-pack: distribute !important; + justify-content: space-around !important; + } + .align-items-sm-start { + -ms-flex-align: start !important; + align-items: flex-start !important; + } + .align-items-sm-end { + -ms-flex-align: end !important; + align-items: flex-end !important; + } + .align-items-sm-center { + -ms-flex-align: center !important; + align-items: center !important; + } + .align-items-sm-baseline { + -ms-flex-align: baseline !important; + align-items: baseline !important; + } + .align-items-sm-stretch { + -ms-flex-align: stretch !important; + align-items: stretch !important; + } + .align-content-sm-start { + -ms-flex-line-pack: start !important; + align-content: flex-start !important; + } + .align-content-sm-end { + -ms-flex-line-pack: end !important; + align-content: flex-end !important; + } + .align-content-sm-center { + -ms-flex-line-pack: center !important; + align-content: center !important; + } + .align-content-sm-between { + -ms-flex-line-pack: justify !important; + align-content: space-between !important; + } + .align-content-sm-around { + -ms-flex-line-pack: distribute !important; + align-content: space-around !important; + } + .align-content-sm-stretch { + -ms-flex-line-pack: stretch !important; + align-content: stretch !important; + } + .align-self-sm-auto { + -ms-flex-item-align: auto !important; + align-self: auto !important; + } + .align-self-sm-start { + -ms-flex-item-align: start !important; + align-self: flex-start !important; + } + .align-self-sm-end { + -ms-flex-item-align: end !important; + align-self: flex-end !important; + } + .align-self-sm-center { + -ms-flex-item-align: center !important; + align-self: center !important; + } + .align-self-sm-baseline { + -ms-flex-item-align: baseline !important; + align-self: baseline !important; + } + .align-self-sm-stretch { + -ms-flex-item-align: stretch !important; + align-self: stretch !important; + } +} + +@media (min-width: 768px) { + .flex-md-row { + -ms-flex-direction: row !important; + flex-direction: row !important; + } + .flex-md-column { + -ms-flex-direction: column !important; + flex-direction: column !important; + } + .flex-md-row-reverse { + -ms-flex-direction: row-reverse !important; + flex-direction: row-reverse !important; + } + .flex-md-column-reverse { + -ms-flex-direction: column-reverse !important; + flex-direction: column-reverse !important; + } + .flex-md-wrap { + -ms-flex-wrap: wrap !important; + flex-wrap: wrap !important; + } + .flex-md-nowrap { + -ms-flex-wrap: nowrap !important; + flex-wrap: nowrap !important; + } + .flex-md-wrap-reverse { + -ms-flex-wrap: wrap-reverse !important; + flex-wrap: wrap-reverse !important; + } + .justify-content-md-start { + -ms-flex-pack: start !important; + justify-content: flex-start !important; + } + .justify-content-md-end { + -ms-flex-pack: end !important; + justify-content: flex-end !important; + } + .justify-content-md-center { + -ms-flex-pack: center !important; + justify-content: center !important; + } + .justify-content-md-between { + -ms-flex-pack: justify !important; + justify-content: space-between !important; + } + .justify-content-md-around { + -ms-flex-pack: distribute !important; + justify-content: space-around !important; + } + .align-items-md-start { + -ms-flex-align: start !important; + align-items: flex-start !important; + } + .align-items-md-end { + -ms-flex-align: end !important; + align-items: flex-end !important; + } + .align-items-md-center { + -ms-flex-align: center !important; + align-items: center !important; + } + .align-items-md-baseline { + -ms-flex-align: baseline !important; + align-items: baseline !important; + } + .align-items-md-stretch { + -ms-flex-align: stretch !important; + align-items: stretch !important; + } + .align-content-md-start { + -ms-flex-line-pack: start !important; + align-content: flex-start !important; + } + .align-content-md-end { + -ms-flex-line-pack: end !important; + align-content: flex-end !important; + } + .align-content-md-center { + -ms-flex-line-pack: center !important; + align-content: center !important; + } + .align-content-md-between { + -ms-flex-line-pack: justify !important; + align-content: space-between !important; + } + .align-content-md-around { + -ms-flex-line-pack: distribute !important; + align-content: space-around !important; + } + .align-content-md-stretch { + -ms-flex-line-pack: stretch !important; + align-content: stretch !important; + } + .align-self-md-auto { + -ms-flex-item-align: auto !important; + align-self: auto !important; + } + .align-self-md-start { + -ms-flex-item-align: start !important; + align-self: flex-start !important; + } + .align-self-md-end { + -ms-flex-item-align: end !important; + align-self: flex-end !important; + } + .align-self-md-center { + -ms-flex-item-align: center !important; + align-self: center !important; + } + .align-self-md-baseline { + -ms-flex-item-align: baseline !important; + align-self: baseline !important; + } + .align-self-md-stretch { + -ms-flex-item-align: stretch !important; + align-self: stretch !important; + } +} + +@media (min-width: 992px) { + .flex-lg-row { + -ms-flex-direction: row !important; + flex-direction: row !important; + } + .flex-lg-column { + -ms-flex-direction: column !important; + flex-direction: column !important; + } + .flex-lg-row-reverse { + -ms-flex-direction: row-reverse !important; + flex-direction: row-reverse !important; + } + .flex-lg-column-reverse { + -ms-flex-direction: column-reverse !important; + flex-direction: column-reverse !important; + } + .flex-lg-wrap { + -ms-flex-wrap: wrap !important; + flex-wrap: wrap !important; + } + .flex-lg-nowrap { + -ms-flex-wrap: nowrap !important; + flex-wrap: nowrap !important; + } + .flex-lg-wrap-reverse { + -ms-flex-wrap: wrap-reverse !important; + flex-wrap: wrap-reverse !important; + } + .justify-content-lg-start { + -ms-flex-pack: start !important; + justify-content: flex-start !important; + } + .justify-content-lg-end { + -ms-flex-pack: end !important; + justify-content: flex-end !important; + } + .justify-content-lg-center { + -ms-flex-pack: center !important; + justify-content: center !important; + } + .justify-content-lg-between { + -ms-flex-pack: justify !important; + justify-content: space-between !important; + } + .justify-content-lg-around { + -ms-flex-pack: distribute !important; + justify-content: space-around !important; + } + .align-items-lg-start { + -ms-flex-align: start !important; + align-items: flex-start !important; + } + .align-items-lg-end { + -ms-flex-align: end !important; + align-items: flex-end !important; + } + .align-items-lg-center { + -ms-flex-align: center !important; + align-items: center !important; + } + .align-items-lg-baseline { + -ms-flex-align: baseline !important; + align-items: baseline !important; + } + .align-items-lg-stretch { + -ms-flex-align: stretch !important; + align-items: stretch !important; + } + .align-content-lg-start { + -ms-flex-line-pack: start !important; + align-content: flex-start !important; + } + .align-content-lg-end { + -ms-flex-line-pack: end !important; + align-content: flex-end !important; + } + .align-content-lg-center { + -ms-flex-line-pack: center !important; + align-content: center !important; + } + .align-content-lg-between { + -ms-flex-line-pack: justify !important; + align-content: space-between !important; + } + .align-content-lg-around { + -ms-flex-line-pack: distribute !important; + align-content: space-around !important; + } + .align-content-lg-stretch { + -ms-flex-line-pack: stretch !important; + align-content: stretch !important; + } + .align-self-lg-auto { + -ms-flex-item-align: auto !important; + align-self: auto !important; + } + .align-self-lg-start { + -ms-flex-item-align: start !important; + align-self: flex-start !important; + } + .align-self-lg-end { + -ms-flex-item-align: end !important; + align-self: flex-end !important; + } + .align-self-lg-center { + -ms-flex-item-align: center !important; + align-self: center !important; + } + .align-self-lg-baseline { + -ms-flex-item-align: baseline !important; + align-self: baseline !important; + } + .align-self-lg-stretch { + -ms-flex-item-align: stretch !important; + align-self: stretch !important; + } +} + +@media (min-width: 1200px) { + .flex-xl-row { + -ms-flex-direction: row !important; + flex-direction: row !important; + } + .flex-xl-column { + -ms-flex-direction: column !important; + flex-direction: column !important; + } + .flex-xl-row-reverse { + -ms-flex-direction: row-reverse !important; + flex-direction: row-reverse !important; + } + .flex-xl-column-reverse { + -ms-flex-direction: column-reverse !important; + flex-direction: column-reverse !important; + } + .flex-xl-wrap { + -ms-flex-wrap: wrap !important; + flex-wrap: wrap !important; + } + .flex-xl-nowrap { + -ms-flex-wrap: nowrap !important; + flex-wrap: nowrap !important; + } + .flex-xl-wrap-reverse { + -ms-flex-wrap: wrap-reverse !important; + flex-wrap: wrap-reverse !important; + } + .justify-content-xl-start { + -ms-flex-pack: start !important; + justify-content: flex-start !important; + } + .justify-content-xl-end { + -ms-flex-pack: end !important; + justify-content: flex-end !important; + } + .justify-content-xl-center { + -ms-flex-pack: center !important; + justify-content: center !important; + } + .justify-content-xl-between { + -ms-flex-pack: justify !important; + justify-content: space-between !important; + } + .justify-content-xl-around { + -ms-flex-pack: distribute !important; + justify-content: space-around !important; + } + .align-items-xl-start { + -ms-flex-align: start !important; + align-items: flex-start !important; + } + .align-items-xl-end { + -ms-flex-align: end !important; + align-items: flex-end !important; + } + .align-items-xl-center { + -ms-flex-align: center !important; + align-items: center !important; + } + .align-items-xl-baseline { + -ms-flex-align: baseline !important; + align-items: baseline !important; + } + .align-items-xl-stretch { + -ms-flex-align: stretch !important; + align-items: stretch !important; + } + .align-content-xl-start { + -ms-flex-line-pack: start !important; + align-content: flex-start !important; + } + .align-content-xl-end { + -ms-flex-line-pack: end !important; + align-content: flex-end !important; + } + .align-content-xl-center { + -ms-flex-line-pack: center !important; + align-content: center !important; + } + .align-content-xl-between { + -ms-flex-line-pack: justify !important; + align-content: space-between !important; + } + .align-content-xl-around { + -ms-flex-line-pack: distribute !important; + align-content: space-around !important; + } + .align-content-xl-stretch { + -ms-flex-line-pack: stretch !important; + align-content: stretch !important; + } + .align-self-xl-auto { + -ms-flex-item-align: auto !important; + align-self: auto !important; + } + .align-self-xl-start { + -ms-flex-item-align: start !important; + align-self: flex-start !important; + } + .align-self-xl-end { + -ms-flex-item-align: end !important; + align-self: flex-end !important; + } + .align-self-xl-center { + -ms-flex-item-align: center !important; + align-self: center !important; + } + .align-self-xl-baseline { + -ms-flex-item-align: baseline !important; + align-self: baseline !important; + } + .align-self-xl-stretch { + -ms-flex-item-align: stretch !important; + align-self: stretch !important; + } +} +/*# sourceMappingURL=bootstrap-grid.css.map */ \ No newline at end of file diff --git a/client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap-grid.min.css b/client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap-grid.min.css new file mode 100644 index 00000000..a7b220f5 --- /dev/null +++ b/client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap-grid.min.css @@ -0,0 +1,7 @@ +/*! + * Bootstrap Grid v4.0.0-beta.2 (https://getbootstrap.com) + * Copyright 2011-2017 The Bootstrap Authors + * Copyright 2011-2017 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */@-ms-viewport{width:device-width}html{box-sizing:border-box;-ms-overflow-style:scrollbar}*,::after,::before{box-sizing:inherit}.container{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:576px){.container{max-width:540px}}@media (min-width:768px){.container{max-width:720px}}@media (min-width:992px){.container{max-width:960px}}@media (min-width:1200px){.container{max-width:1140px}}.container-fluid{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-15px;margin-left:-15px}.no-gutters{margin-right:0;margin-left:0}.no-gutters>.col,.no-gutters>[class*=col-]{padding-right:0;padding-left:0}.col,.col-1,.col-10,.col-11,.col-12,.col-2,.col-3,.col-4,.col-5,.col-6,.col-7,.col-8,.col-9,.col-auto,.col-lg,.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-auto,.col-md,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-auto,.col-sm,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-auto,.col-xl,.col-xl-1,.col-xl-10,.col-xl-11,.col-xl-12,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-auto{position:relative;width:100%;min-height:1px;padding-right:15px;padding-left:15px}.col{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:none}.col-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-first{-ms-flex-order:-1;order:-1}.order-1{-ms-flex-order:1;order:1}.order-2{-ms-flex-order:2;order:2}.order-3{-ms-flex-order:3;order:3}.order-4{-ms-flex-order:4;order:4}.order-5{-ms-flex-order:5;order:5}.order-6{-ms-flex-order:6;order:6}.order-7{-ms-flex-order:7;order:7}.order-8{-ms-flex-order:8;order:8}.order-9{-ms-flex-order:9;order:9}.order-10{-ms-flex-order:10;order:10}.order-11{-ms-flex-order:11;order:11}.order-12{-ms-flex-order:12;order:12}.offset-1{margin-left:8.333333%}.offset-2{margin-left:16.666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.333333%}.offset-5{margin-left:41.666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.333333%}.offset-8{margin-left:66.666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.333333%}.offset-11{margin-left:91.666667%}@media (min-width:576px){.col-sm{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-sm-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:none}.col-sm-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-sm-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-sm-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-sm-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-sm-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-sm-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-sm-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-sm-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-sm-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-sm-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-sm-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-sm-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-sm-first{-ms-flex-order:-1;order:-1}.order-sm-1{-ms-flex-order:1;order:1}.order-sm-2{-ms-flex-order:2;order:2}.order-sm-3{-ms-flex-order:3;order:3}.order-sm-4{-ms-flex-order:4;order:4}.order-sm-5{-ms-flex-order:5;order:5}.order-sm-6{-ms-flex-order:6;order:6}.order-sm-7{-ms-flex-order:7;order:7}.order-sm-8{-ms-flex-order:8;order:8}.order-sm-9{-ms-flex-order:9;order:9}.order-sm-10{-ms-flex-order:10;order:10}.order-sm-11{-ms-flex-order:11;order:11}.order-sm-12{-ms-flex-order:12;order:12}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.333333%}.offset-sm-2{margin-left:16.666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.333333%}.offset-sm-5{margin-left:41.666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.333333%}.offset-sm-8{margin-left:66.666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.333333%}.offset-sm-11{margin-left:91.666667%}}@media (min-width:768px){.col-md{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-md-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:none}.col-md-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-md-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-md-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-md-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-md-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-md-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-md-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-md-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-md-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-md-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-md-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-md-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-md-first{-ms-flex-order:-1;order:-1}.order-md-1{-ms-flex-order:1;order:1}.order-md-2{-ms-flex-order:2;order:2}.order-md-3{-ms-flex-order:3;order:3}.order-md-4{-ms-flex-order:4;order:4}.order-md-5{-ms-flex-order:5;order:5}.order-md-6{-ms-flex-order:6;order:6}.order-md-7{-ms-flex-order:7;order:7}.order-md-8{-ms-flex-order:8;order:8}.order-md-9{-ms-flex-order:9;order:9}.order-md-10{-ms-flex-order:10;order:10}.order-md-11{-ms-flex-order:11;order:11}.order-md-12{-ms-flex-order:12;order:12}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.333333%}.offset-md-2{margin-left:16.666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.333333%}.offset-md-5{margin-left:41.666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.333333%}.offset-md-8{margin-left:66.666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.333333%}.offset-md-11{margin-left:91.666667%}}@media (min-width:992px){.col-lg{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-lg-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:none}.col-lg-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-lg-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-lg-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-lg-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-lg-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-lg-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-lg-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-lg-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-lg-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-lg-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-lg-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-lg-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-lg-first{-ms-flex-order:-1;order:-1}.order-lg-1{-ms-flex-order:1;order:1}.order-lg-2{-ms-flex-order:2;order:2}.order-lg-3{-ms-flex-order:3;order:3}.order-lg-4{-ms-flex-order:4;order:4}.order-lg-5{-ms-flex-order:5;order:5}.order-lg-6{-ms-flex-order:6;order:6}.order-lg-7{-ms-flex-order:7;order:7}.order-lg-8{-ms-flex-order:8;order:8}.order-lg-9{-ms-flex-order:9;order:9}.order-lg-10{-ms-flex-order:10;order:10}.order-lg-11{-ms-flex-order:11;order:11}.order-lg-12{-ms-flex-order:12;order:12}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.333333%}.offset-lg-2{margin-left:16.666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.333333%}.offset-lg-5{margin-left:41.666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.333333%}.offset-lg-8{margin-left:66.666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.333333%}.offset-lg-11{margin-left:91.666667%}}@media (min-width:1200px){.col-xl{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-xl-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:none}.col-xl-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-xl-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-xl-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-xl-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-xl-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-xl-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-xl-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-xl-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-xl-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-xl-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-xl-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-xl-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-xl-first{-ms-flex-order:-1;order:-1}.order-xl-1{-ms-flex-order:1;order:1}.order-xl-2{-ms-flex-order:2;order:2}.order-xl-3{-ms-flex-order:3;order:3}.order-xl-4{-ms-flex-order:4;order:4}.order-xl-5{-ms-flex-order:5;order:5}.order-xl-6{-ms-flex-order:6;order:6}.order-xl-7{-ms-flex-order:7;order:7}.order-xl-8{-ms-flex-order:8;order:8}.order-xl-9{-ms-flex-order:9;order:9}.order-xl-10{-ms-flex-order:10;order:10}.order-xl-11{-ms-flex-order:11;order:11}.order-xl-12{-ms-flex-order:12;order:12}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.333333%}.offset-xl-2{margin-left:16.666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.333333%}.offset-xl-5{margin-left:41.666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.333333%}.offset-xl-8{margin-left:66.666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.333333%}.offset-xl-11{margin-left:91.666667%}}.flex-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.justify-content-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-center{-ms-flex-align:center!important;align-items:center!important}.align-items-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}@media (min-width:576px){.flex-sm-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-sm-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-sm-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-sm-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-sm-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-sm-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-sm-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.justify-content-sm-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-sm-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-sm-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-sm-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-sm-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-sm-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-sm-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-sm-center{-ms-flex-align:center!important;align-items:center!important}.align-items-sm-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-sm-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-sm-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-sm-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-sm-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-sm-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-sm-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-sm-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-sm-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-sm-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-sm-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-sm-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-sm-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-sm-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:768px){.flex-md-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-md-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-md-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-md-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-md-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-md-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-md-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.justify-content-md-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-md-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-md-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-md-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-md-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-md-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-md-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-md-center{-ms-flex-align:center!important;align-items:center!important}.align-items-md-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-md-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-md-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-md-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-md-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-md-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-md-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-md-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-md-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-md-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-md-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-md-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-md-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-md-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:992px){.flex-lg-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-lg-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-lg-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-lg-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-lg-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-lg-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-lg-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.justify-content-lg-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-lg-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-lg-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-lg-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-lg-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-lg-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-lg-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-lg-center{-ms-flex-align:center!important;align-items:center!important}.align-items-lg-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-lg-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-lg-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-lg-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-lg-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-lg-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-lg-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-lg-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-lg-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-lg-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-lg-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-lg-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-lg-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-lg-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:1200px){.flex-xl-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-xl-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-xl-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-xl-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-xl-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-xl-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-xl-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.justify-content-xl-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-xl-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-xl-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-xl-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-xl-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-xl-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-xl-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-xl-center{-ms-flex-align:center!important;align-items:center!important}.align-items-xl-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-xl-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-xl-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-xl-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-xl-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-xl-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-xl-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-xl-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-xl-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-xl-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-xl-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-xl-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-xl-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-xl-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}} +/*# sourceMappingURL=bootstrap-grid.min.css.map */ \ No newline at end of file diff --git a/client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap-reboot.css b/client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap-reboot.css new file mode 100644 index 00000000..713196fc --- /dev/null +++ b/client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap-reboot.css @@ -0,0 +1,342 @@ +/*! + * Bootstrap Reboot v4.0.0-beta.2 (https://getbootstrap.com) + * Copyright 2011-2017 The Bootstrap Authors + * Copyright 2011-2017 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md) + */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + font-family: sans-serif; + line-height: 1.15; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + -ms-overflow-style: scrollbar; + -webkit-tap-highlight-color: transparent; +} + +@-ms-viewport { + width: device-width; +} + +article, aside, dialog, figcaption, figure, footer, header, hgroup, main, nav, section { + display: block; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + color: #212529; + text-align: left; + background-color: #fff; +} + +[tabindex="-1"]:focus { + outline: none !important; +} + +hr { + box-sizing: content-box; + height: 0; + overflow: visible; +} + +h1, h2, h3, h4, h5, h6 { + margin-top: 0; + margin-bottom: 0.5rem; +} + +p { + margin-top: 0; + margin-bottom: 1rem; +} + +abbr[title], +abbr[data-original-title] { + text-decoration: underline; + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + cursor: help; + border-bottom: 0; +} + +address { + margin-bottom: 1rem; + font-style: normal; + line-height: inherit; +} + +ol, +ul, +dl { + margin-top: 0; + margin-bottom: 1rem; +} + +ol ol, +ul ul, +ol ul, +ul ol { + margin-bottom: 0; +} + +dt { + font-weight: 700; +} + +dd { + margin-bottom: .5rem; + margin-left: 0; +} + +blockquote { + margin: 0 0 1rem; +} + +dfn { + font-style: italic; +} + +b, +strong { + font-weight: bolder; +} + +small { + font-size: 80%; +} + +sub, +sup { + position: relative; + font-size: 75%; + line-height: 0; + vertical-align: baseline; +} + +sub { + bottom: -.25em; +} + +sup { + top: -.5em; +} + +a { + color: #007bff; + text-decoration: none; + background-color: transparent; + -webkit-text-decoration-skip: objects; +} + +a:hover { + color: #0056b3; + text-decoration: underline; +} + +a:not([href]):not([tabindex]) { + color: inherit; + text-decoration: none; +} + +a:not([href]):not([tabindex]):focus, a:not([href]):not([tabindex]):hover { + color: inherit; + text-decoration: none; +} + +a:not([href]):not([tabindex]):focus { + outline: 0; +} + +pre, +code, +kbd, +samp { + font-family: monospace, monospace; + font-size: 1em; +} + +pre { + margin-top: 0; + margin-bottom: 1rem; + overflow: auto; + -ms-overflow-style: scrollbar; +} + +figure { + margin: 0 0 1rem; +} + +img { + vertical-align: middle; + border-style: none; +} + +svg:not(:root) { + overflow: hidden; +} + +a, +area, +button, +[role="button"], +input:not([type="range"]), +label, +select, +summary, +textarea { + -ms-touch-action: manipulation; + touch-action: manipulation; +} + +table { + border-collapse: collapse; +} + +caption { + padding-top: 0.75rem; + padding-bottom: 0.75rem; + color: #868e96; + text-align: left; + caption-side: bottom; +} + +th { + text-align: inherit; +} + +label { + display: inline-block; + margin-bottom: .5rem; +} + +button { + border-radius: 0; +} + +button:focus { + outline: 1px dotted; + outline: 5px auto -webkit-focus-ring-color; +} + +input, +button, +select, +optgroup, +textarea { + margin: 0; + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +button, +input { + overflow: visible; +} + +button, +select { + text-transform: none; +} + +button, +html [type="button"], +[type="reset"], +[type="submit"] { + -webkit-appearance: button; +} + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + padding: 0; + border-style: none; +} + +input[type="radio"], +input[type="checkbox"] { + box-sizing: border-box; + padding: 0; +} + +input[type="date"], +input[type="time"], +input[type="datetime-local"], +input[type="month"] { + -webkit-appearance: listbox; +} + +textarea { + overflow: auto; + resize: vertical; +} + +fieldset { + min-width: 0; + padding: 0; + margin: 0; + border: 0; +} + +legend { + display: block; + width: 100%; + max-width: 100%; + padding: 0; + margin-bottom: .5rem; + font-size: 1.5rem; + line-height: inherit; + color: inherit; + white-space: normal; +} + +progress { + vertical-align: baseline; +} + +[type="number"]::-webkit-inner-spin-button, +[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +[type="search"] { + outline-offset: -2px; + -webkit-appearance: none; +} + +[type="search"]::-webkit-search-cancel-button, +[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +::-webkit-file-upload-button { + font: inherit; + -webkit-appearance: button; +} + +output { + display: inline-block; +} + +summary { + display: list-item; +} + +template { + display: none; +} + +[hidden] { + display: none !important; +} +/*# sourceMappingURL=bootstrap-reboot.css.map */ \ No newline at end of file diff --git a/client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap-reboot.min.css b/client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap-reboot.min.css new file mode 100644 index 00000000..3f4f1a87 --- /dev/null +++ b/client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap-reboot.min.css @@ -0,0 +1,8 @@ +/*! + * Bootstrap Reboot v4.0.0-beta.2 (https://getbootstrap.com) + * Copyright 2011-2017 The Bootstrap Authors + * Copyright 2011-2017 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md) + */*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:transparent}@-ms-viewport{width:device-width}article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:left;background-color:#fff}[tabindex="-1"]:focus{outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0}address{margin-bottom:1rem;font-style:normal;line-height:inherit}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}dfn{font-style:italic}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#007bff;text-decoration:none;background-color:transparent;-webkit-text-decoration-skip:objects}a:hover{color:#0056b3;text-decoration:underline}a:not([href]):not([tabindex]){color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus,a:not([href]):not([tabindex]):hover{color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus{outline:0}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto;-ms-overflow-style:scrollbar}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg:not(:root){overflow:hidden}[role=button],a,area,button,input:not([type=range]),label,select,summary,textarea{-ms-touch-action:manipulation;touch-action:manipulation}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#868e96;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=date],input[type=datetime-local],input[type=month],input[type=time]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item}template{display:none}[hidden]{display:none!important} +/*# sourceMappingURL=bootstrap-reboot.min.css.map */ \ No newline at end of file diff --git a/client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap.css b/client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap.css new file mode 100644 index 00000000..7d43e1f1 --- /dev/null +++ b/client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap.css @@ -0,0 +1,8981 @@ +/*! + * Bootstrap v4.1.1 (https://getbootstrap.com/) + * Copyright 2011-2018 The Bootstrap Authors + * Copyright 2011-2018 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ +:root { + --blue: #007bff; + --indigo: #6610f2; + --purple: #6f42c1; + --pink: #e83e8c; + --red: #dc3545; + --orange: #fd7e14; + --yellow: #ffc107; + --green: #28a745; + --teal: #20c997; + --cyan: #17a2b8; + --white: #fff; + --gray: #6c757d; + --gray-dark: #343a40; + --primary: #007bff; + --secondary: #6c757d; + --success: #28a745; + --info: #17a2b8; + --warning: #ffc107; + --danger: #dc3545; + --light: #f8f9fa; + --dark: #343a40; + --breakpoint-xs: 0; + --breakpoint-sm: 576px; + --breakpoint-md: 768px; + --breakpoint-lg: 992px; + --breakpoint-xl: 1200px; + --font-family-sans-serif: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + --font-family-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + font-family: sans-serif; + line-height: 1.15; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + -ms-overflow-style: scrollbar; + -webkit-tap-highlight-color: transparent; +} + +@-ms-viewport { + width: device-width; +} + +article, aside, figcaption, figure, footer, header, hgroup, main, nav, section { + display: block; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + color: #212529; + text-align: left; + background-color: #fff; +} + +[tabindex="-1"]:focus { + outline: 0 !important; +} + +hr { + box-sizing: content-box; + height: 0; + overflow: visible; +} + +h1, h2, h3, h4, h5, h6 { + margin-top: 0; + margin-bottom: 0.5rem; +} + +p { + margin-top: 0; + margin-bottom: 1rem; +} + +abbr[title], +abbr[data-original-title] { + text-decoration: underline; + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + cursor: help; + border-bottom: 0; +} + +address { + margin-bottom: 1rem; + font-style: normal; + line-height: inherit; +} + +ol, +ul, +dl { + margin-top: 0; + margin-bottom: 1rem; +} + +ol ol, +ul ul, +ol ul, +ul ol { + margin-bottom: 0; +} + +dt { + font-weight: 700; +} + +dd { + margin-bottom: .5rem; + margin-left: 0; +} + +blockquote { + margin: 0 0 1rem; +} + +dfn { + font-style: italic; +} + +b, +strong { + font-weight: bolder; +} + +small { + font-size: 80%; +} + +sub, +sup { + position: relative; + font-size: 75%; + line-height: 0; + vertical-align: baseline; +} + +sub { + bottom: -.25em; +} + +sup { + top: -.5em; +} + +a { + color: #007bff; + text-decoration: none; + background-color: transparent; + -webkit-text-decoration-skip: objects; +} + +a:hover { + color: #0056b3; + text-decoration: underline; +} + +a:not([href]):not([tabindex]) { + color: inherit; + text-decoration: none; +} + +a:not([href]):not([tabindex]):hover, a:not([href]):not([tabindex]):focus { + color: inherit; + text-decoration: none; +} + +a:not([href]):not([tabindex]):focus { + outline: 0; +} + +pre, +code, +kbd, +samp { + font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 1em; +} + +pre { + margin-top: 0; + margin-bottom: 1rem; + overflow: auto; + -ms-overflow-style: scrollbar; +} + +figure { + margin: 0 0 1rem; +} + +img { + vertical-align: middle; + border-style: none; +} + +svg:not(:root) { + overflow: hidden; +} + +table { + border-collapse: collapse; +} + +caption { + padding-top: 0.75rem; + padding-bottom: 0.75rem; + color: #6c757d; + text-align: left; + caption-side: bottom; +} + +th { + text-align: inherit; +} + +label { + display: inline-block; + margin-bottom: 0.5rem; +} + +button { + border-radius: 0; +} + +button:focus { + outline: 1px dotted; + outline: 5px auto -webkit-focus-ring-color; +} + +input, +button, +select, +optgroup, +textarea { + margin: 0; + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +button, +input { + overflow: visible; +} + +button, +select { + text-transform: none; +} + +button, +html [type="button"], +[type="reset"], +[type="submit"] { + -webkit-appearance: button; +} + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + padding: 0; + border-style: none; +} + +input[type="radio"], +input[type="checkbox"] { + box-sizing: border-box; + padding: 0; +} + +input[type="date"], +input[type="time"], +input[type="datetime-local"], +input[type="month"] { + -webkit-appearance: listbox; +} + +textarea { + overflow: auto; + resize: vertical; +} + +fieldset { + min-width: 0; + padding: 0; + margin: 0; + border: 0; +} + +legend { + display: block; + width: 100%; + max-width: 100%; + padding: 0; + margin-bottom: .5rem; + font-size: 1.5rem; + line-height: inherit; + color: inherit; + white-space: normal; +} + +progress { + vertical-align: baseline; +} + +[type="number"]::-webkit-inner-spin-button, +[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +[type="search"] { + outline-offset: -2px; + -webkit-appearance: none; +} + +[type="search"]::-webkit-search-cancel-button, +[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +::-webkit-file-upload-button { + font: inherit; + -webkit-appearance: button; +} + +output { + display: inline-block; +} + +summary { + display: list-item; + cursor: pointer; +} + +template { + display: none; +} + +[hidden] { + display: none !important; +} + +h1, h2, h3, h4, h5, h6, +.h1, .h2, .h3, .h4, .h5, .h6 { + margin-bottom: 0.5rem; + font-family: inherit; + font-weight: 500; + line-height: 1.2; + color: inherit; +} + +h1, .h1 { + font-size: 2.5rem; +} + +h2, .h2 { + font-size: 2rem; +} + +h3, .h3 { + font-size: 1.75rem; +} + +h4, .h4 { + font-size: 1.5rem; +} + +h5, .h5 { + font-size: 1.25rem; +} + +h6, .h6 { + font-size: 1rem; +} + +.lead { + font-size: 1.25rem; + font-weight: 300; +} + +.display-1 { + font-size: 6rem; + font-weight: 300; + line-height: 1.2; +} + +.display-2 { + font-size: 5.5rem; + font-weight: 300; + line-height: 1.2; +} + +.display-3 { + font-size: 4.5rem; + font-weight: 300; + line-height: 1.2; +} + +.display-4 { + font-size: 3.5rem; + font-weight: 300; + line-height: 1.2; +} + +hr { + margin-top: 1rem; + margin-bottom: 1rem; + border: 0; + border-top: 1px solid rgba(0, 0, 0, 0.1); +} + +small, +.small { + font-size: 80%; + font-weight: 400; +} + +mark, +.mark { + padding: 0.2em; + background-color: #fcf8e3; +} + +.list-unstyled { + padding-left: 0; + list-style: none; +} + +.list-inline { + padding-left: 0; + list-style: none; +} + +.list-inline-item { + display: inline-block; +} + +.list-inline-item:not(:last-child) { + margin-right: 0.5rem; +} + +.initialism { + font-size: 90%; + text-transform: uppercase; +} + +.blockquote { + margin-bottom: 1rem; + font-size: 1.25rem; +} + +.blockquote-footer { + display: block; + font-size: 80%; + color: #6c757d; +} + +.blockquote-footer::before { + content: "\2014 \00A0"; +} + +.img-fluid { + max-width: 100%; + height: auto; +} + +.img-thumbnail { + padding: 0.25rem; + background-color: #fff; + border: 1px solid #dee2e6; + border-radius: 0.25rem; + max-width: 100%; + height: auto; +} + +.figure { + display: inline-block; +} + +.figure-img { + margin-bottom: 0.5rem; + line-height: 1; +} + +.figure-caption { + font-size: 90%; + color: #6c757d; +} + +code { + font-size: 87.5%; + color: #e83e8c; + word-break: break-word; +} + +a > code { + color: inherit; +} + +kbd { + padding: 0.2rem 0.4rem; + font-size: 87.5%; + color: #fff; + background-color: #212529; + border-radius: 0.2rem; +} + +kbd kbd { + padding: 0; + font-size: 100%; + font-weight: 700; +} + +pre { + display: block; + font-size: 87.5%; + color: #212529; +} + +pre code { + font-size: inherit; + color: inherit; + word-break: normal; +} + +.pre-scrollable { + max-height: 340px; + overflow-y: scroll; +} + +.container { + width: 100%; + padding-right: 15px; + padding-left: 15px; + margin-right: auto; + margin-left: auto; +} + +@media (min-width: 576px) { + .container { + max-width: 540px; + } +} + +@media (min-width: 768px) { + .container { + max-width: 720px; + } +} + +@media (min-width: 992px) { + .container { + max-width: 960px; + } +} + +@media (min-width: 1200px) { + .container { + max-width: 1140px; + } +} + +.container-fluid { + width: 100%; + padding-right: 15px; + padding-left: 15px; + margin-right: auto; + margin-left: auto; +} + +.row { + display: -ms-flexbox; + display: flex; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + margin-right: -15px; + margin-left: -15px; +} + +.no-gutters { + margin-right: 0; + margin-left: 0; +} + +.no-gutters > .col, +.no-gutters > [class*="col-"] { + padding-right: 0; + padding-left: 0; +} + +.col-1, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-10, .col-11, .col-12, .col, +.col-auto, .col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12, .col-sm, +.col-sm-auto, .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12, .col-md, +.col-md-auto, .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12, .col-lg, +.col-lg-auto, .col-xl-1, .col-xl-2, .col-xl-3, .col-xl-4, .col-xl-5, .col-xl-6, .col-xl-7, .col-xl-8, .col-xl-9, .col-xl-10, .col-xl-11, .col-xl-12, .col-xl, +.col-xl-auto { + position: relative; + width: 100%; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; +} + +.col { + -ms-flex-preferred-size: 0; + flex-basis: 0; + -ms-flex-positive: 1; + flex-grow: 1; + max-width: 100%; +} + +.col-auto { + -ms-flex: 0 0 auto; + flex: 0 0 auto; + width: auto; + max-width: none; +} + +.col-1 { + -ms-flex: 0 0 8.333333%; + flex: 0 0 8.333333%; + max-width: 8.333333%; +} + +.col-2 { + -ms-flex: 0 0 16.666667%; + flex: 0 0 16.666667%; + max-width: 16.666667%; +} + +.col-3 { + -ms-flex: 0 0 25%; + flex: 0 0 25%; + max-width: 25%; +} + +.col-4 { + -ms-flex: 0 0 33.333333%; + flex: 0 0 33.333333%; + max-width: 33.333333%; +} + +.col-5 { + -ms-flex: 0 0 41.666667%; + flex: 0 0 41.666667%; + max-width: 41.666667%; +} + +.col-6 { + -ms-flex: 0 0 50%; + flex: 0 0 50%; + max-width: 50%; +} + +.col-7 { + -ms-flex: 0 0 58.333333%; + flex: 0 0 58.333333%; + max-width: 58.333333%; +} + +.col-8 { + -ms-flex: 0 0 66.666667%; + flex: 0 0 66.666667%; + max-width: 66.666667%; +} + +.col-9 { + -ms-flex: 0 0 75%; + flex: 0 0 75%; + max-width: 75%; +} + +.col-10 { + -ms-flex: 0 0 83.333333%; + flex: 0 0 83.333333%; + max-width: 83.333333%; +} + +.col-11 { + -ms-flex: 0 0 91.666667%; + flex: 0 0 91.666667%; + max-width: 91.666667%; +} + +.col-12 { + -ms-flex: 0 0 100%; + flex: 0 0 100%; + max-width: 100%; +} + +.order-first { + -ms-flex-order: -1; + order: -1; +} + +.order-last { + -ms-flex-order: 13; + order: 13; +} + +.order-0 { + -ms-flex-order: 0; + order: 0; +} + +.order-1 { + -ms-flex-order: 1; + order: 1; +} + +.order-2 { + -ms-flex-order: 2; + order: 2; +} + +.order-3 { + -ms-flex-order: 3; + order: 3; +} + +.order-4 { + -ms-flex-order: 4; + order: 4; +} + +.order-5 { + -ms-flex-order: 5; + order: 5; +} + +.order-6 { + -ms-flex-order: 6; + order: 6; +} + +.order-7 { + -ms-flex-order: 7; + order: 7; +} + +.order-8 { + -ms-flex-order: 8; + order: 8; +} + +.order-9 { + -ms-flex-order: 9; + order: 9; +} + +.order-10 { + -ms-flex-order: 10; + order: 10; +} + +.order-11 { + -ms-flex-order: 11; + order: 11; +} + +.order-12 { + -ms-flex-order: 12; + order: 12; +} + +.offset-1 { + margin-left: 8.333333%; +} + +.offset-2 { + margin-left: 16.666667%; +} + +.offset-3 { + margin-left: 25%; +} + +.offset-4 { + margin-left: 33.333333%; +} + +.offset-5 { + margin-left: 41.666667%; +} + +.offset-6 { + margin-left: 50%; +} + +.offset-7 { + margin-left: 58.333333%; +} + +.offset-8 { + margin-left: 66.666667%; +} + +.offset-9 { + margin-left: 75%; +} + +.offset-10 { + margin-left: 83.333333%; +} + +.offset-11 { + margin-left: 91.666667%; +} + +@media (min-width: 576px) { + .col-sm { + -ms-flex-preferred-size: 0; + flex-basis: 0; + -ms-flex-positive: 1; + flex-grow: 1; + max-width: 100%; + } + .col-sm-auto { + -ms-flex: 0 0 auto; + flex: 0 0 auto; + width: auto; + max-width: none; + } + .col-sm-1 { + -ms-flex: 0 0 8.333333%; + flex: 0 0 8.333333%; + max-width: 8.333333%; + } + .col-sm-2 { + -ms-flex: 0 0 16.666667%; + flex: 0 0 16.666667%; + max-width: 16.666667%; + } + .col-sm-3 { + -ms-flex: 0 0 25%; + flex: 0 0 25%; + max-width: 25%; + } + .col-sm-4 { + -ms-flex: 0 0 33.333333%; + flex: 0 0 33.333333%; + max-width: 33.333333%; + } + .col-sm-5 { + -ms-flex: 0 0 41.666667%; + flex: 0 0 41.666667%; + max-width: 41.666667%; + } + .col-sm-6 { + -ms-flex: 0 0 50%; + flex: 0 0 50%; + max-width: 50%; + } + .col-sm-7 { + -ms-flex: 0 0 58.333333%; + flex: 0 0 58.333333%; + max-width: 58.333333%; + } + .col-sm-8 { + -ms-flex: 0 0 66.666667%; + flex: 0 0 66.666667%; + max-width: 66.666667%; + } + .col-sm-9 { + -ms-flex: 0 0 75%; + flex: 0 0 75%; + max-width: 75%; + } + .col-sm-10 { + -ms-flex: 0 0 83.333333%; + flex: 0 0 83.333333%; + max-width: 83.333333%; + } + .col-sm-11 { + -ms-flex: 0 0 91.666667%; + flex: 0 0 91.666667%; + max-width: 91.666667%; + } + .col-sm-12 { + -ms-flex: 0 0 100%; + flex: 0 0 100%; + max-width: 100%; + } + .order-sm-first { + -ms-flex-order: -1; + order: -1; + } + .order-sm-last { + -ms-flex-order: 13; + order: 13; + } + .order-sm-0 { + -ms-flex-order: 0; + order: 0; + } + .order-sm-1 { + -ms-flex-order: 1; + order: 1; + } + .order-sm-2 { + -ms-flex-order: 2; + order: 2; + } + .order-sm-3 { + -ms-flex-order: 3; + order: 3; + } + .order-sm-4 { + -ms-flex-order: 4; + order: 4; + } + .order-sm-5 { + -ms-flex-order: 5; + order: 5; + } + .order-sm-6 { + -ms-flex-order: 6; + order: 6; + } + .order-sm-7 { + -ms-flex-order: 7; + order: 7; + } + .order-sm-8 { + -ms-flex-order: 8; + order: 8; + } + .order-sm-9 { + -ms-flex-order: 9; + order: 9; + } + .order-sm-10 { + -ms-flex-order: 10; + order: 10; + } + .order-sm-11 { + -ms-flex-order: 11; + order: 11; + } + .order-sm-12 { + -ms-flex-order: 12; + order: 12; + } + .offset-sm-0 { + margin-left: 0; + } + .offset-sm-1 { + margin-left: 8.333333%; + } + .offset-sm-2 { + margin-left: 16.666667%; + } + .offset-sm-3 { + margin-left: 25%; + } + .offset-sm-4 { + margin-left: 33.333333%; + } + .offset-sm-5 { + margin-left: 41.666667%; + } + .offset-sm-6 { + margin-left: 50%; + } + .offset-sm-7 { + margin-left: 58.333333%; + } + .offset-sm-8 { + margin-left: 66.666667%; + } + .offset-sm-9 { + margin-left: 75%; + } + .offset-sm-10 { + margin-left: 83.333333%; + } + .offset-sm-11 { + margin-left: 91.666667%; + } +} + +@media (min-width: 768px) { + .col-md { + -ms-flex-preferred-size: 0; + flex-basis: 0; + -ms-flex-positive: 1; + flex-grow: 1; + max-width: 100%; + } + .col-md-auto { + -ms-flex: 0 0 auto; + flex: 0 0 auto; + width: auto; + max-width: none; + } + .col-md-1 { + -ms-flex: 0 0 8.333333%; + flex: 0 0 8.333333%; + max-width: 8.333333%; + } + .col-md-2 { + -ms-flex: 0 0 16.666667%; + flex: 0 0 16.666667%; + max-width: 16.666667%; + } + .col-md-3 { + -ms-flex: 0 0 25%; + flex: 0 0 25%; + max-width: 25%; + } + .col-md-4 { + -ms-flex: 0 0 33.333333%; + flex: 0 0 33.333333%; + max-width: 33.333333%; + } + .col-md-5 { + -ms-flex: 0 0 41.666667%; + flex: 0 0 41.666667%; + max-width: 41.666667%; + } + .col-md-6 { + -ms-flex: 0 0 50%; + flex: 0 0 50%; + max-width: 50%; + } + .col-md-7 { + -ms-flex: 0 0 58.333333%; + flex: 0 0 58.333333%; + max-width: 58.333333%; + } + .col-md-8 { + -ms-flex: 0 0 66.666667%; + flex: 0 0 66.666667%; + max-width: 66.666667%; + } + .col-md-9 { + -ms-flex: 0 0 75%; + flex: 0 0 75%; + max-width: 75%; + } + .col-md-10 { + -ms-flex: 0 0 83.333333%; + flex: 0 0 83.333333%; + max-width: 83.333333%; + } + .col-md-11 { + -ms-flex: 0 0 91.666667%; + flex: 0 0 91.666667%; + max-width: 91.666667%; + } + .col-md-12 { + -ms-flex: 0 0 100%; + flex: 0 0 100%; + max-width: 100%; + } + .order-md-first { + -ms-flex-order: -1; + order: -1; + } + .order-md-last { + -ms-flex-order: 13; + order: 13; + } + .order-md-0 { + -ms-flex-order: 0; + order: 0; + } + .order-md-1 { + -ms-flex-order: 1; + order: 1; + } + .order-md-2 { + -ms-flex-order: 2; + order: 2; + } + .order-md-3 { + -ms-flex-order: 3; + order: 3; + } + .order-md-4 { + -ms-flex-order: 4; + order: 4; + } + .order-md-5 { + -ms-flex-order: 5; + order: 5; + } + .order-md-6 { + -ms-flex-order: 6; + order: 6; + } + .order-md-7 { + -ms-flex-order: 7; + order: 7; + } + .order-md-8 { + -ms-flex-order: 8; + order: 8; + } + .order-md-9 { + -ms-flex-order: 9; + order: 9; + } + .order-md-10 { + -ms-flex-order: 10; + order: 10; + } + .order-md-11 { + -ms-flex-order: 11; + order: 11; + } + .order-md-12 { + -ms-flex-order: 12; + order: 12; + } + .offset-md-0 { + margin-left: 0; + } + .offset-md-1 { + margin-left: 8.333333%; + } + .offset-md-2 { + margin-left: 16.666667%; + } + .offset-md-3 { + margin-left: 25%; + } + .offset-md-4 { + margin-left: 33.333333%; + } + .offset-md-5 { + margin-left: 41.666667%; + } + .offset-md-6 { + margin-left: 50%; + } + .offset-md-7 { + margin-left: 58.333333%; + } + .offset-md-8 { + margin-left: 66.666667%; + } + .offset-md-9 { + margin-left: 75%; + } + .offset-md-10 { + margin-left: 83.333333%; + } + .offset-md-11 { + margin-left: 91.666667%; + } +} + +@media (min-width: 992px) { + .col-lg { + -ms-flex-preferred-size: 0; + flex-basis: 0; + -ms-flex-positive: 1; + flex-grow: 1; + max-width: 100%; + } + .col-lg-auto { + -ms-flex: 0 0 auto; + flex: 0 0 auto; + width: auto; + max-width: none; + } + .col-lg-1 { + -ms-flex: 0 0 8.333333%; + flex: 0 0 8.333333%; + max-width: 8.333333%; + } + .col-lg-2 { + -ms-flex: 0 0 16.666667%; + flex: 0 0 16.666667%; + max-width: 16.666667%; + } + .col-lg-3 { + -ms-flex: 0 0 25%; + flex: 0 0 25%; + max-width: 25%; + } + .col-lg-4 { + -ms-flex: 0 0 33.333333%; + flex: 0 0 33.333333%; + max-width: 33.333333%; + } + .col-lg-5 { + -ms-flex: 0 0 41.666667%; + flex: 0 0 41.666667%; + max-width: 41.666667%; + } + .col-lg-6 { + -ms-flex: 0 0 50%; + flex: 0 0 50%; + max-width: 50%; + } + .col-lg-7 { + -ms-flex: 0 0 58.333333%; + flex: 0 0 58.333333%; + max-width: 58.333333%; + } + .col-lg-8 { + -ms-flex: 0 0 66.666667%; + flex: 0 0 66.666667%; + max-width: 66.666667%; + } + .col-lg-9 { + -ms-flex: 0 0 75%; + flex: 0 0 75%; + max-width: 75%; + } + .col-lg-10 { + -ms-flex: 0 0 83.333333%; + flex: 0 0 83.333333%; + max-width: 83.333333%; + } + .col-lg-11 { + -ms-flex: 0 0 91.666667%; + flex: 0 0 91.666667%; + max-width: 91.666667%; + } + .col-lg-12 { + -ms-flex: 0 0 100%; + flex: 0 0 100%; + max-width: 100%; + } + .order-lg-first { + -ms-flex-order: -1; + order: -1; + } + .order-lg-last { + -ms-flex-order: 13; + order: 13; + } + .order-lg-0 { + -ms-flex-order: 0; + order: 0; + } + .order-lg-1 { + -ms-flex-order: 1; + order: 1; + } + .order-lg-2 { + -ms-flex-order: 2; + order: 2; + } + .order-lg-3 { + -ms-flex-order: 3; + order: 3; + } + .order-lg-4 { + -ms-flex-order: 4; + order: 4; + } + .order-lg-5 { + -ms-flex-order: 5; + order: 5; + } + .order-lg-6 { + -ms-flex-order: 6; + order: 6; + } + .order-lg-7 { + -ms-flex-order: 7; + order: 7; + } + .order-lg-8 { + -ms-flex-order: 8; + order: 8; + } + .order-lg-9 { + -ms-flex-order: 9; + order: 9; + } + .order-lg-10 { + -ms-flex-order: 10; + order: 10; + } + .order-lg-11 { + -ms-flex-order: 11; + order: 11; + } + .order-lg-12 { + -ms-flex-order: 12; + order: 12; + } + .offset-lg-0 { + margin-left: 0; + } + .offset-lg-1 { + margin-left: 8.333333%; + } + .offset-lg-2 { + margin-left: 16.666667%; + } + .offset-lg-3 { + margin-left: 25%; + } + .offset-lg-4 { + margin-left: 33.333333%; + } + .offset-lg-5 { + margin-left: 41.666667%; + } + .offset-lg-6 { + margin-left: 50%; + } + .offset-lg-7 { + margin-left: 58.333333%; + } + .offset-lg-8 { + margin-left: 66.666667%; + } + .offset-lg-9 { + margin-left: 75%; + } + .offset-lg-10 { + margin-left: 83.333333%; + } + .offset-lg-11 { + margin-left: 91.666667%; + } +} + +@media (min-width: 1200px) { + .col-xl { + -ms-flex-preferred-size: 0; + flex-basis: 0; + -ms-flex-positive: 1; + flex-grow: 1; + max-width: 100%; + } + .col-xl-auto { + -ms-flex: 0 0 auto; + flex: 0 0 auto; + width: auto; + max-width: none; + } + .col-xl-1 { + -ms-flex: 0 0 8.333333%; + flex: 0 0 8.333333%; + max-width: 8.333333%; + } + .col-xl-2 { + -ms-flex: 0 0 16.666667%; + flex: 0 0 16.666667%; + max-width: 16.666667%; + } + .col-xl-3 { + -ms-flex: 0 0 25%; + flex: 0 0 25%; + max-width: 25%; + } + .col-xl-4 { + -ms-flex: 0 0 33.333333%; + flex: 0 0 33.333333%; + max-width: 33.333333%; + } + .col-xl-5 { + -ms-flex: 0 0 41.666667%; + flex: 0 0 41.666667%; + max-width: 41.666667%; + } + .col-xl-6 { + -ms-flex: 0 0 50%; + flex: 0 0 50%; + max-width: 50%; + } + .col-xl-7 { + -ms-flex: 0 0 58.333333%; + flex: 0 0 58.333333%; + max-width: 58.333333%; + } + .col-xl-8 { + -ms-flex: 0 0 66.666667%; + flex: 0 0 66.666667%; + max-width: 66.666667%; + } + .col-xl-9 { + -ms-flex: 0 0 75%; + flex: 0 0 75%; + max-width: 75%; + } + .col-xl-10 { + -ms-flex: 0 0 83.333333%; + flex: 0 0 83.333333%; + max-width: 83.333333%; + } + .col-xl-11 { + -ms-flex: 0 0 91.666667%; + flex: 0 0 91.666667%; + max-width: 91.666667%; + } + .col-xl-12 { + -ms-flex: 0 0 100%; + flex: 0 0 100%; + max-width: 100%; + } + .order-xl-first { + -ms-flex-order: -1; + order: -1; + } + .order-xl-last { + -ms-flex-order: 13; + order: 13; + } + .order-xl-0 { + -ms-flex-order: 0; + order: 0; + } + .order-xl-1 { + -ms-flex-order: 1; + order: 1; + } + .order-xl-2 { + -ms-flex-order: 2; + order: 2; + } + .order-xl-3 { + -ms-flex-order: 3; + order: 3; + } + .order-xl-4 { + -ms-flex-order: 4; + order: 4; + } + .order-xl-5 { + -ms-flex-order: 5; + order: 5; + } + .order-xl-6 { + -ms-flex-order: 6; + order: 6; + } + .order-xl-7 { + -ms-flex-order: 7; + order: 7; + } + .order-xl-8 { + -ms-flex-order: 8; + order: 8; + } + .order-xl-9 { + -ms-flex-order: 9; + order: 9; + } + .order-xl-10 { + -ms-flex-order: 10; + order: 10; + } + .order-xl-11 { + -ms-flex-order: 11; + order: 11; + } + .order-xl-12 { + -ms-flex-order: 12; + order: 12; + } + .offset-xl-0 { + margin-left: 0; + } + .offset-xl-1 { + margin-left: 8.333333%; + } + .offset-xl-2 { + margin-left: 16.666667%; + } + .offset-xl-3 { + margin-left: 25%; + } + .offset-xl-4 { + margin-left: 33.333333%; + } + .offset-xl-5 { + margin-left: 41.666667%; + } + .offset-xl-6 { + margin-left: 50%; + } + .offset-xl-7 { + margin-left: 58.333333%; + } + .offset-xl-8 { + margin-left: 66.666667%; + } + .offset-xl-9 { + margin-left: 75%; + } + .offset-xl-10 { + margin-left: 83.333333%; + } + .offset-xl-11 { + margin-left: 91.666667%; + } +} + +.table { + width: 100%; + max-width: 100%; + margin-bottom: 1rem; + background-color: transparent; +} + +.table th, +.table td { + padding: 0.75rem; + vertical-align: top; + border-top: 1px solid #dee2e6; +} + +.table thead th { + vertical-align: bottom; + border-bottom: 2px solid #dee2e6; +} + +.table tbody + tbody { + border-top: 2px solid #dee2e6; +} + +.table .table { + background-color: #fff; +} + +.table-sm th, +.table-sm td { + padding: 0.3rem; +} + +.table-bordered { + border: 1px solid #dee2e6; +} + +.table-bordered th, +.table-bordered td { + border: 1px solid #dee2e6; +} + +.table-bordered thead th, +.table-bordered thead td { + border-bottom-width: 2px; +} + +.table-borderless th, +.table-borderless td, +.table-borderless thead th, +.table-borderless tbody + tbody { + border: 0; +} + +.table-striped tbody tr:nth-of-type(odd) { + background-color: rgba(0, 0, 0, 0.05); +} + +.table-hover tbody tr:hover { + background-color: rgba(0, 0, 0, 0.075); +} + +.table-primary, +.table-primary > th, +.table-primary > td { + background-color: #b8daff; +} + +.table-hover .table-primary:hover { + background-color: #9fcdff; +} + +.table-hover .table-primary:hover > td, +.table-hover .table-primary:hover > th { + background-color: #9fcdff; +} + +.table-secondary, +.table-secondary > th, +.table-secondary > td { + background-color: #d6d8db; +} + +.table-hover .table-secondary:hover { + background-color: #c8cbcf; +} + +.table-hover .table-secondary:hover > td, +.table-hover .table-secondary:hover > th { + background-color: #c8cbcf; +} + +.table-success, +.table-success > th, +.table-success > td { + background-color: #c3e6cb; +} + +.table-hover .table-success:hover { + background-color: #b1dfbb; +} + +.table-hover .table-success:hover > td, +.table-hover .table-success:hover > th { + background-color: #b1dfbb; +} + +.table-info, +.table-info > th, +.table-info > td { + background-color: #bee5eb; +} + +.table-hover .table-info:hover { + background-color: #abdde5; +} + +.table-hover .table-info:hover > td, +.table-hover .table-info:hover > th { + background-color: #abdde5; +} + +.table-warning, +.table-warning > th, +.table-warning > td { + background-color: #ffeeba; +} + +.table-hover .table-warning:hover { + background-color: #ffe8a1; +} + +.table-hover .table-warning:hover > td, +.table-hover .table-warning:hover > th { + background-color: #ffe8a1; +} + +.table-danger, +.table-danger > th, +.table-danger > td { + background-color: #f5c6cb; +} + +.table-hover .table-danger:hover { + background-color: #f1b0b7; +} + +.table-hover .table-danger:hover > td, +.table-hover .table-danger:hover > th { + background-color: #f1b0b7; +} + +.table-light, +.table-light > th, +.table-light > td { + background-color: #fdfdfe; +} + +.table-hover .table-light:hover { + background-color: #ececf6; +} + +.table-hover .table-light:hover > td, +.table-hover .table-light:hover > th { + background-color: #ececf6; +} + +.table-dark, +.table-dark > th, +.table-dark > td { + background-color: #c6c8ca; +} + +.table-hover .table-dark:hover { + background-color: #b9bbbe; +} + +.table-hover .table-dark:hover > td, +.table-hover .table-dark:hover > th { + background-color: #b9bbbe; +} + +.table-active, +.table-active > th, +.table-active > td { + background-color: rgba(0, 0, 0, 0.075); +} + +.table-hover .table-active:hover { + background-color: rgba(0, 0, 0, 0.075); +} + +.table-hover .table-active:hover > td, +.table-hover .table-active:hover > th { + background-color: rgba(0, 0, 0, 0.075); +} + +.table .thead-dark th { + color: #fff; + background-color: #212529; + border-color: #32383e; +} + +.table .thead-light th { + color: #495057; + background-color: #e9ecef; + border-color: #dee2e6; +} + +.table-dark { + color: #fff; + background-color: #212529; +} + +.table-dark th, +.table-dark td, +.table-dark thead th { + border-color: #32383e; +} + +.table-dark.table-bordered { + border: 0; +} + +.table-dark.table-striped tbody tr:nth-of-type(odd) { + background-color: rgba(255, 255, 255, 0.05); +} + +.table-dark.table-hover tbody tr:hover { + background-color: rgba(255, 255, 255, 0.075); +} + +@media (max-width: 575.98px) { + .table-responsive-sm { + display: block; + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + -ms-overflow-style: -ms-autohiding-scrollbar; + } + .table-responsive-sm > .table-bordered { + border: 0; + } +} + +@media (max-width: 767.98px) { + .table-responsive-md { + display: block; + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + -ms-overflow-style: -ms-autohiding-scrollbar; + } + .table-responsive-md > .table-bordered { + border: 0; + } +} + +@media (max-width: 991.98px) { + .table-responsive-lg { + display: block; + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + -ms-overflow-style: -ms-autohiding-scrollbar; + } + .table-responsive-lg > .table-bordered { + border: 0; + } +} + +@media (max-width: 1199.98px) { + .table-responsive-xl { + display: block; + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + -ms-overflow-style: -ms-autohiding-scrollbar; + } + .table-responsive-xl > .table-bordered { + border: 0; + } +} + +.table-responsive { + display: block; + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + -ms-overflow-style: -ms-autohiding-scrollbar; +} + +.table-responsive > .table-bordered { + border: 0; +} + +.form-control { + display: block; + width: 100%; + padding: 0.375rem 0.75rem; + font-size: 1rem; + line-height: 1.5; + color: #495057; + background-color: #fff; + background-clip: padding-box; + border: 1px solid #ced4da; + border-radius: 0.25rem; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} + +@media screen and (prefers-reduced-motion: reduce) { + .form-control { + transition: none; + } +} + +.form-control::-ms-expand { + background-color: transparent; + border: 0; +} + +.form-control:focus { + color: #495057; + background-color: #fff; + border-color: #80bdff; + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +.form-control::-webkit-input-placeholder { + color: #6c757d; + opacity: 1; +} + +.form-control::-moz-placeholder { + color: #6c757d; + opacity: 1; +} + +.form-control:-ms-input-placeholder { + color: #6c757d; + opacity: 1; +} + +.form-control::-ms-input-placeholder { + color: #6c757d; + opacity: 1; +} + +.form-control::placeholder { + color: #6c757d; + opacity: 1; +} + +.form-control:disabled, .form-control[readonly] { + background-color: #e9ecef; + opacity: 1; +} + +select.form-control:not([size]):not([multiple]) { + height: calc(2.25rem + 2px); +} + +select.form-control:focus::-ms-value { + color: #495057; + background-color: #fff; +} + +.form-control-file, +.form-control-range { + display: block; + width: 100%; +} + +.col-form-label { + padding-top: calc(0.375rem + 1px); + padding-bottom: calc(0.375rem + 1px); + margin-bottom: 0; + font-size: inherit; + line-height: 1.5; +} + +.col-form-label-lg { + padding-top: calc(0.5rem + 1px); + padding-bottom: calc(0.5rem + 1px); + font-size: 1.25rem; + line-height: 1.5; +} + +.col-form-label-sm { + padding-top: calc(0.25rem + 1px); + padding-bottom: calc(0.25rem + 1px); + font-size: 0.875rem; + line-height: 1.5; +} + +.form-control-plaintext { + display: block; + width: 100%; + padding-top: 0.375rem; + padding-bottom: 0.375rem; + margin-bottom: 0; + line-height: 1.5; + color: #212529; + background-color: transparent; + border: solid transparent; + border-width: 1px 0; +} + +.form-control-plaintext.form-control-sm, .input-group-sm > .form-control-plaintext.form-control, +.input-group-sm > .input-group-prepend > .form-control-plaintext.input-group-text, +.input-group-sm > .input-group-append > .form-control-plaintext.input-group-text, +.input-group-sm > .input-group-prepend > .form-control-plaintext.btn, +.input-group-sm > .input-group-append > .form-control-plaintext.btn, .form-control-plaintext.form-control-lg, .input-group-lg > .form-control-plaintext.form-control, +.input-group-lg > .input-group-prepend > .form-control-plaintext.input-group-text, +.input-group-lg > .input-group-append > .form-control-plaintext.input-group-text, +.input-group-lg > .input-group-prepend > .form-control-plaintext.btn, +.input-group-lg > .input-group-append > .form-control-plaintext.btn { + padding-right: 0; + padding-left: 0; +} + +.form-control-sm, .input-group-sm > .form-control, +.input-group-sm > .input-group-prepend > .input-group-text, +.input-group-sm > .input-group-append > .input-group-text, +.input-group-sm > .input-group-prepend > .btn, +.input-group-sm > .input-group-append > .btn { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + line-height: 1.5; + border-radius: 0.2rem; +} + +select.form-control-sm:not([size]):not([multiple]), .input-group-sm > select.form-control:not([size]):not([multiple]), +.input-group-sm > .input-group-prepend > select.input-group-text:not([size]):not([multiple]), +.input-group-sm > .input-group-append > select.input-group-text:not([size]):not([multiple]), +.input-group-sm > .input-group-prepend > select.btn:not([size]):not([multiple]), +.input-group-sm > .input-group-append > select.btn:not([size]):not([multiple]) { + height: calc(1.8125rem + 2px); +} + +.form-control-lg, .input-group-lg > .form-control, +.input-group-lg > .input-group-prepend > .input-group-text, +.input-group-lg > .input-group-append > .input-group-text, +.input-group-lg > .input-group-prepend > .btn, +.input-group-lg > .input-group-append > .btn { + padding: 0.5rem 1rem; + font-size: 1.25rem; + line-height: 1.5; + border-radius: 0.3rem; +} + +select.form-control-lg:not([size]):not([multiple]), .input-group-lg > select.form-control:not([size]):not([multiple]), +.input-group-lg > .input-group-prepend > select.input-group-text:not([size]):not([multiple]), +.input-group-lg > .input-group-append > select.input-group-text:not([size]):not([multiple]), +.input-group-lg > .input-group-prepend > select.btn:not([size]):not([multiple]), +.input-group-lg > .input-group-append > select.btn:not([size]):not([multiple]) { + height: calc(2.875rem + 2px); +} + +.form-group { + margin-bottom: 1rem; +} + +.form-text { + display: block; + margin-top: 0.25rem; +} + +.form-row { + display: -ms-flexbox; + display: flex; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + margin-right: -5px; + margin-left: -5px; +} + +.form-row > .col, +.form-row > [class*="col-"] { + padding-right: 5px; + padding-left: 5px; +} + +.form-check { + position: relative; + display: block; + padding-left: 1.25rem; +} + +.form-check-input { + position: absolute; + margin-top: 0.3rem; + margin-left: -1.25rem; +} + +.form-check-input:disabled ~ .form-check-label { + color: #6c757d; +} + +.form-check-label { + margin-bottom: 0; +} + +.form-check-inline { + display: -ms-inline-flexbox; + display: inline-flex; + -ms-flex-align: center; + align-items: center; + padding-left: 0; + margin-right: 0.75rem; +} + +.form-check-inline .form-check-input { + position: static; + margin-top: 0; + margin-right: 0.3125rem; + margin-left: 0; +} + +.valid-feedback { + display: none; + width: 100%; + margin-top: 0.25rem; + font-size: 80%; + color: #28a745; +} + +.valid-tooltip { + position: absolute; + top: 100%; + z-index: 5; + display: none; + max-width: 100%; + padding: .5rem; + margin-top: .1rem; + font-size: .875rem; + line-height: 1; + color: #fff; + background-color: rgba(40, 167, 69, 0.8); + border-radius: .2rem; +} + +.was-validated .form-control:valid, .form-control.is-valid, .was-validated +.custom-select:valid, +.custom-select.is-valid { + border-color: #28a745; +} + +.was-validated .form-control:valid:focus, .form-control.is-valid:focus, .was-validated +.custom-select:valid:focus, +.custom-select.is-valid:focus { + border-color: #28a745; + box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25); +} + +.was-validated .form-control:valid ~ .valid-feedback, +.was-validated .form-control:valid ~ .valid-tooltip, .form-control.is-valid ~ .valid-feedback, +.form-control.is-valid ~ .valid-tooltip, .was-validated +.custom-select:valid ~ .valid-feedback, +.was-validated +.custom-select:valid ~ .valid-tooltip, +.custom-select.is-valid ~ .valid-feedback, +.custom-select.is-valid ~ .valid-tooltip { + display: block; +} + +.was-validated .form-control-file:valid ~ .valid-feedback, +.was-validated .form-control-file:valid ~ .valid-tooltip, .form-control-file.is-valid ~ .valid-feedback, +.form-control-file.is-valid ~ .valid-tooltip { + display: block; +} + +.was-validated .form-check-input:valid ~ .form-check-label, .form-check-input.is-valid ~ .form-check-label { + color: #28a745; +} + +.was-validated .form-check-input:valid ~ .valid-feedback, +.was-validated .form-check-input:valid ~ .valid-tooltip, .form-check-input.is-valid ~ .valid-feedback, +.form-check-input.is-valid ~ .valid-tooltip { + display: block; +} + +.was-validated .custom-control-input:valid ~ .custom-control-label, .custom-control-input.is-valid ~ .custom-control-label { + color: #28a745; +} + +.was-validated .custom-control-input:valid ~ .custom-control-label::before, .custom-control-input.is-valid ~ .custom-control-label::before { + background-color: #71dd8a; +} + +.was-validated .custom-control-input:valid ~ .valid-feedback, +.was-validated .custom-control-input:valid ~ .valid-tooltip, .custom-control-input.is-valid ~ .valid-feedback, +.custom-control-input.is-valid ~ .valid-tooltip { + display: block; +} + +.was-validated .custom-control-input:valid:checked ~ .custom-control-label::before, .custom-control-input.is-valid:checked ~ .custom-control-label::before { + background-color: #34ce57; +} + +.was-validated .custom-control-input:valid:focus ~ .custom-control-label::before, .custom-control-input.is-valid:focus ~ .custom-control-label::before { + box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(40, 167, 69, 0.25); +} + +.was-validated .custom-file-input:valid ~ .custom-file-label, .custom-file-input.is-valid ~ .custom-file-label { + border-color: #28a745; +} + +.was-validated .custom-file-input:valid ~ .custom-file-label::before, .custom-file-input.is-valid ~ .custom-file-label::before { + border-color: inherit; +} + +.was-validated .custom-file-input:valid ~ .valid-feedback, +.was-validated .custom-file-input:valid ~ .valid-tooltip, .custom-file-input.is-valid ~ .valid-feedback, +.custom-file-input.is-valid ~ .valid-tooltip { + display: block; +} + +.was-validated .custom-file-input:valid:focus ~ .custom-file-label, .custom-file-input.is-valid:focus ~ .custom-file-label { + box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25); +} + +.invalid-feedback { + display: none; + width: 100%; + margin-top: 0.25rem; + font-size: 80%; + color: #dc3545; +} + +.invalid-tooltip { + position: absolute; + top: 100%; + z-index: 5; + display: none; + max-width: 100%; + padding: .5rem; + margin-top: .1rem; + font-size: .875rem; + line-height: 1; + color: #fff; + background-color: rgba(220, 53, 69, 0.8); + border-radius: .2rem; +} + +.was-validated .form-control:invalid, .form-control.is-invalid, .was-validated +.custom-select:invalid, +.custom-select.is-invalid { + border-color: #dc3545; +} + +.was-validated .form-control:invalid:focus, .form-control.is-invalid:focus, .was-validated +.custom-select:invalid:focus, +.custom-select.is-invalid:focus { + border-color: #dc3545; + box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25); +} + +.was-validated .form-control:invalid ~ .invalid-feedback, +.was-validated .form-control:invalid ~ .invalid-tooltip, .form-control.is-invalid ~ .invalid-feedback, +.form-control.is-invalid ~ .invalid-tooltip, .was-validated +.custom-select:invalid ~ .invalid-feedback, +.was-validated +.custom-select:invalid ~ .invalid-tooltip, +.custom-select.is-invalid ~ .invalid-feedback, +.custom-select.is-invalid ~ .invalid-tooltip { + display: block; +} + +.was-validated .form-control-file:invalid ~ .invalid-feedback, +.was-validated .form-control-file:invalid ~ .invalid-tooltip, .form-control-file.is-invalid ~ .invalid-feedback, +.form-control-file.is-invalid ~ .invalid-tooltip { + display: block; +} + +.was-validated .form-check-input:invalid ~ .form-check-label, .form-check-input.is-invalid ~ .form-check-label { + color: #dc3545; +} + +.was-validated .form-check-input:invalid ~ .invalid-feedback, +.was-validated .form-check-input:invalid ~ .invalid-tooltip, .form-check-input.is-invalid ~ .invalid-feedback, +.form-check-input.is-invalid ~ .invalid-tooltip { + display: block; +} + +.was-validated .custom-control-input:invalid ~ .custom-control-label, .custom-control-input.is-invalid ~ .custom-control-label { + color: #dc3545; +} + +.was-validated .custom-control-input:invalid ~ .custom-control-label::before, .custom-control-input.is-invalid ~ .custom-control-label::before { + background-color: #efa2a9; +} + +.was-validated .custom-control-input:invalid ~ .invalid-feedback, +.was-validated .custom-control-input:invalid ~ .invalid-tooltip, .custom-control-input.is-invalid ~ .invalid-feedback, +.custom-control-input.is-invalid ~ .invalid-tooltip { + display: block; +} + +.was-validated .custom-control-input:invalid:checked ~ .custom-control-label::before, .custom-control-input.is-invalid:checked ~ .custom-control-label::before { + background-color: #e4606d; +} + +.was-validated .custom-control-input:invalid:focus ~ .custom-control-label::before, .custom-control-input.is-invalid:focus ~ .custom-control-label::before { + box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(220, 53, 69, 0.25); +} + +.was-validated .custom-file-input:invalid ~ .custom-file-label, .custom-file-input.is-invalid ~ .custom-file-label { + border-color: #dc3545; +} + +.was-validated .custom-file-input:invalid ~ .custom-file-label::before, .custom-file-input.is-invalid ~ .custom-file-label::before { + border-color: inherit; +} + +.was-validated .custom-file-input:invalid ~ .invalid-feedback, +.was-validated .custom-file-input:invalid ~ .invalid-tooltip, .custom-file-input.is-invalid ~ .invalid-feedback, +.custom-file-input.is-invalid ~ .invalid-tooltip { + display: block; +} + +.was-validated .custom-file-input:invalid:focus ~ .custom-file-label, .custom-file-input.is-invalid:focus ~ .custom-file-label { + box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25); +} + +.form-inline { + display: -ms-flexbox; + display: flex; + -ms-flex-flow: row wrap; + flex-flow: row wrap; + -ms-flex-align: center; + align-items: center; +} + +.form-inline .form-check { + width: 100%; +} + +@media (min-width: 576px) { + .form-inline label { + display: -ms-flexbox; + display: flex; + -ms-flex-align: center; + align-items: center; + -ms-flex-pack: center; + justify-content: center; + margin-bottom: 0; + } + .form-inline .form-group { + display: -ms-flexbox; + display: flex; + -ms-flex: 0 0 auto; + flex: 0 0 auto; + -ms-flex-flow: row wrap; + flex-flow: row wrap; + -ms-flex-align: center; + align-items: center; + margin-bottom: 0; + } + .form-inline .form-control { + display: inline-block; + width: auto; + vertical-align: middle; + } + .form-inline .form-control-plaintext { + display: inline-block; + } + .form-inline .input-group, + .form-inline .custom-select { + width: auto; + } + .form-inline .form-check { + display: -ms-flexbox; + display: flex; + -ms-flex-align: center; + align-items: center; + -ms-flex-pack: center; + justify-content: center; + width: auto; + padding-left: 0; + } + .form-inline .form-check-input { + position: relative; + margin-top: 0; + margin-right: 0.25rem; + margin-left: 0; + } + .form-inline .custom-control { + -ms-flex-align: center; + align-items: center; + -ms-flex-pack: center; + justify-content: center; + } + .form-inline .custom-control-label { + margin-bottom: 0; + } +} + +.btn { + display: inline-block; + font-weight: 400; + text-align: center; + white-space: nowrap; + vertical-align: middle; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + border: 1px solid transparent; + padding: 0.375rem 0.75rem; + font-size: 1rem; + line-height: 1.5; + border-radius: 0.25rem; + transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} + +@media screen and (prefers-reduced-motion: reduce) { + .btn { + transition: none; + } +} + +.btn:hover, .btn:focus { + text-decoration: none; +} + +.btn:focus, .btn.focus { + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +.btn.disabled, .btn:disabled { + opacity: 0.65; +} + +.btn:not(:disabled):not(.disabled) { + cursor: pointer; +} + +.btn:not(:disabled):not(.disabled):active, .btn:not(:disabled):not(.disabled).active { + background-image: none; +} + +a.btn.disabled, +fieldset:disabled a.btn { + pointer-events: none; +} + +.btn-primary { + color: #fff; + background-color: #007bff; + border-color: #007bff; +} + +.btn-primary:hover { + color: #fff; + background-color: #0069d9; + border-color: #0062cc; +} + +.btn-primary:focus, .btn-primary.focus { + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.5); +} + +.btn-primary.disabled, .btn-primary:disabled { + color: #fff; + background-color: #007bff; + border-color: #007bff; +} + +.btn-primary:not(:disabled):not(.disabled):active, .btn-primary:not(:disabled):not(.disabled).active, +.show > .btn-primary.dropdown-toggle { + color: #fff; + background-color: #0062cc; + border-color: #005cbf; +} + +.btn-primary:not(:disabled):not(.disabled):active:focus, .btn-primary:not(:disabled):not(.disabled).active:focus, +.show > .btn-primary.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.5); +} + +.btn-secondary { + color: #fff; + background-color: #6c757d; + border-color: #6c757d; +} + +.btn-secondary:hover { + color: #fff; + background-color: #5a6268; + border-color: #545b62; +} + +.btn-secondary:focus, .btn-secondary.focus { + box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5); +} + +.btn-secondary.disabled, .btn-secondary:disabled { + color: #fff; + background-color: #6c757d; + border-color: #6c757d; +} + +.btn-secondary:not(:disabled):not(.disabled):active, .btn-secondary:not(:disabled):not(.disabled).active, +.show > .btn-secondary.dropdown-toggle { + color: #fff; + background-color: #545b62; + border-color: #4e555b; +} + +.btn-secondary:not(:disabled):not(.disabled):active:focus, .btn-secondary:not(:disabled):not(.disabled).active:focus, +.show > .btn-secondary.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5); +} + +.btn-success { + color: #fff; + background-color: #28a745; + border-color: #28a745; +} + +.btn-success:hover { + color: #fff; + background-color: #218838; + border-color: #1e7e34; +} + +.btn-success:focus, .btn-success.focus { + box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.5); +} + +.btn-success.disabled, .btn-success:disabled { + color: #fff; + background-color: #28a745; + border-color: #28a745; +} + +.btn-success:not(:disabled):not(.disabled):active, .btn-success:not(:disabled):not(.disabled).active, +.show > .btn-success.dropdown-toggle { + color: #fff; + background-color: #1e7e34; + border-color: #1c7430; +} + +.btn-success:not(:disabled):not(.disabled):active:focus, .btn-success:not(:disabled):not(.disabled).active:focus, +.show > .btn-success.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.5); +} + +.btn-info { + color: #fff; + background-color: #17a2b8; + border-color: #17a2b8; +} + +.btn-info:hover { + color: #fff; + background-color: #138496; + border-color: #117a8b; +} + +.btn-info:focus, .btn-info.focus { + box-shadow: 0 0 0 0.2rem rgba(23, 162, 184, 0.5); +} + +.btn-info.disabled, .btn-info:disabled { + color: #fff; + background-color: #17a2b8; + border-color: #17a2b8; +} + +.btn-info:not(:disabled):not(.disabled):active, .btn-info:not(:disabled):not(.disabled).active, +.show > .btn-info.dropdown-toggle { + color: #fff; + background-color: #117a8b; + border-color: #10707f; +} + +.btn-info:not(:disabled):not(.disabled):active:focus, .btn-info:not(:disabled):not(.disabled).active:focus, +.show > .btn-info.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(23, 162, 184, 0.5); +} + +.btn-warning { + color: #212529; + background-color: #ffc107; + border-color: #ffc107; +} + +.btn-warning:hover { + color: #212529; + background-color: #e0a800; + border-color: #d39e00; +} + +.btn-warning:focus, .btn-warning.focus { + box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.5); +} + +.btn-warning.disabled, .btn-warning:disabled { + color: #212529; + background-color: #ffc107; + border-color: #ffc107; +} + +.btn-warning:not(:disabled):not(.disabled):active, .btn-warning:not(:disabled):not(.disabled).active, +.show > .btn-warning.dropdown-toggle { + color: #212529; + background-color: #d39e00; + border-color: #c69500; +} + +.btn-warning:not(:disabled):not(.disabled):active:focus, .btn-warning:not(:disabled):not(.disabled).active:focus, +.show > .btn-warning.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.5); +} + +.btn-danger { + color: #fff; + background-color: #dc3545; + border-color: #dc3545; +} + +.btn-danger:hover { + color: #fff; + background-color: #c82333; + border-color: #bd2130; +} + +.btn-danger:focus, .btn-danger.focus { + box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.5); +} + +.btn-danger.disabled, .btn-danger:disabled { + color: #fff; + background-color: #dc3545; + border-color: #dc3545; +} + +.btn-danger:not(:disabled):not(.disabled):active, .btn-danger:not(:disabled):not(.disabled).active, +.show > .btn-danger.dropdown-toggle { + color: #fff; + background-color: #bd2130; + border-color: #b21f2d; +} + +.btn-danger:not(:disabled):not(.disabled):active:focus, .btn-danger:not(:disabled):not(.disabled).active:focus, +.show > .btn-danger.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.5); +} + +.btn-light { + color: #212529; + background-color: #f8f9fa; + border-color: #f8f9fa; +} + +.btn-light:hover { + color: #212529; + background-color: #e2e6ea; + border-color: #dae0e5; +} + +.btn-light:focus, .btn-light.focus { + box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5); +} + +.btn-light.disabled, .btn-light:disabled { + color: #212529; + background-color: #f8f9fa; + border-color: #f8f9fa; +} + +.btn-light:not(:disabled):not(.disabled):active, .btn-light:not(:disabled):not(.disabled).active, +.show > .btn-light.dropdown-toggle { + color: #212529; + background-color: #dae0e5; + border-color: #d3d9df; +} + +.btn-light:not(:disabled):not(.disabled):active:focus, .btn-light:not(:disabled):not(.disabled).active:focus, +.show > .btn-light.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5); +} + +.btn-dark { + color: #fff; + background-color: #343a40; + border-color: #343a40; +} + +.btn-dark:hover { + color: #fff; + background-color: #23272b; + border-color: #1d2124; +} + +.btn-dark:focus, .btn-dark.focus { + box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5); +} + +.btn-dark.disabled, .btn-dark:disabled { + color: #fff; + background-color: #343a40; + border-color: #343a40; +} + +.btn-dark:not(:disabled):not(.disabled):active, .btn-dark:not(:disabled):not(.disabled).active, +.show > .btn-dark.dropdown-toggle { + color: #fff; + background-color: #1d2124; + border-color: #171a1d; +} + +.btn-dark:not(:disabled):not(.disabled):active:focus, .btn-dark:not(:disabled):not(.disabled).active:focus, +.show > .btn-dark.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5); +} + +.btn-outline-primary { + color: #007bff; + background-color: transparent; + background-image: none; + border-color: #007bff; +} + +.btn-outline-primary:hover { + color: #fff; + background-color: #007bff; + border-color: #007bff; +} + +.btn-outline-primary:focus, .btn-outline-primary.focus { + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.5); +} + +.btn-outline-primary.disabled, .btn-outline-primary:disabled { + color: #007bff; + background-color: transparent; +} + +.btn-outline-primary:not(:disabled):not(.disabled):active, .btn-outline-primary:not(:disabled):not(.disabled).active, +.show > .btn-outline-primary.dropdown-toggle { + color: #fff; + background-color: #007bff; + border-color: #007bff; +} + +.btn-outline-primary:not(:disabled):not(.disabled):active:focus, .btn-outline-primary:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-primary.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.5); +} + +.btn-outline-secondary { + color: #6c757d; + background-color: transparent; + background-image: none; + border-color: #6c757d; +} + +.btn-outline-secondary:hover { + color: #fff; + background-color: #6c757d; + border-color: #6c757d; +} + +.btn-outline-secondary:focus, .btn-outline-secondary.focus { + box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5); +} + +.btn-outline-secondary.disabled, .btn-outline-secondary:disabled { + color: #6c757d; + background-color: transparent; +} + +.btn-outline-secondary:not(:disabled):not(.disabled):active, .btn-outline-secondary:not(:disabled):not(.disabled).active, +.show > .btn-outline-secondary.dropdown-toggle { + color: #fff; + background-color: #6c757d; + border-color: #6c757d; +} + +.btn-outline-secondary:not(:disabled):not(.disabled):active:focus, .btn-outline-secondary:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-secondary.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5); +} + +.btn-outline-success { + color: #28a745; + background-color: transparent; + background-image: none; + border-color: #28a745; +} + +.btn-outline-success:hover { + color: #fff; + background-color: #28a745; + border-color: #28a745; +} + +.btn-outline-success:focus, .btn-outline-success.focus { + box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.5); +} + +.btn-outline-success.disabled, .btn-outline-success:disabled { + color: #28a745; + background-color: transparent; +} + +.btn-outline-success:not(:disabled):not(.disabled):active, .btn-outline-success:not(:disabled):not(.disabled).active, +.show > .btn-outline-success.dropdown-toggle { + color: #fff; + background-color: #28a745; + border-color: #28a745; +} + +.btn-outline-success:not(:disabled):not(.disabled):active:focus, .btn-outline-success:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-success.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.5); +} + +.btn-outline-info { + color: #17a2b8; + background-color: transparent; + background-image: none; + border-color: #17a2b8; +} + +.btn-outline-info:hover { + color: #fff; + background-color: #17a2b8; + border-color: #17a2b8; +} + +.btn-outline-info:focus, .btn-outline-info.focus { + box-shadow: 0 0 0 0.2rem rgba(23, 162, 184, 0.5); +} + +.btn-outline-info.disabled, .btn-outline-info:disabled { + color: #17a2b8; + background-color: transparent; +} + +.btn-outline-info:not(:disabled):not(.disabled):active, .btn-outline-info:not(:disabled):not(.disabled).active, +.show > .btn-outline-info.dropdown-toggle { + color: #fff; + background-color: #17a2b8; + border-color: #17a2b8; +} + +.btn-outline-info:not(:disabled):not(.disabled):active:focus, .btn-outline-info:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-info.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(23, 162, 184, 0.5); +} + +.btn-outline-warning { + color: #ffc107; + background-color: transparent; + background-image: none; + border-color: #ffc107; +} + +.btn-outline-warning:hover { + color: #212529; + background-color: #ffc107; + border-color: #ffc107; +} + +.btn-outline-warning:focus, .btn-outline-warning.focus { + box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.5); +} + +.btn-outline-warning.disabled, .btn-outline-warning:disabled { + color: #ffc107; + background-color: transparent; +} + +.btn-outline-warning:not(:disabled):not(.disabled):active, .btn-outline-warning:not(:disabled):not(.disabled).active, +.show > .btn-outline-warning.dropdown-toggle { + color: #212529; + background-color: #ffc107; + border-color: #ffc107; +} + +.btn-outline-warning:not(:disabled):not(.disabled):active:focus, .btn-outline-warning:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-warning.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.5); +} + +.btn-outline-danger { + color: #dc3545; + background-color: transparent; + background-image: none; + border-color: #dc3545; +} + +.btn-outline-danger:hover { + color: #fff; + background-color: #dc3545; + border-color: #dc3545; +} + +.btn-outline-danger:focus, .btn-outline-danger.focus { + box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.5); +} + +.btn-outline-danger.disabled, .btn-outline-danger:disabled { + color: #dc3545; + background-color: transparent; +} + +.btn-outline-danger:not(:disabled):not(.disabled):active, .btn-outline-danger:not(:disabled):not(.disabled).active, +.show > .btn-outline-danger.dropdown-toggle { + color: #fff; + background-color: #dc3545; + border-color: #dc3545; +} + +.btn-outline-danger:not(:disabled):not(.disabled):active:focus, .btn-outline-danger:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-danger.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.5); +} + +.btn-outline-light { + color: #f8f9fa; + background-color: transparent; + background-image: none; + border-color: #f8f9fa; +} + +.btn-outline-light:hover { + color: #212529; + background-color: #f8f9fa; + border-color: #f8f9fa; +} + +.btn-outline-light:focus, .btn-outline-light.focus { + box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5); +} + +.btn-outline-light.disabled, .btn-outline-light:disabled { + color: #f8f9fa; + background-color: transparent; +} + +.btn-outline-light:not(:disabled):not(.disabled):active, .btn-outline-light:not(:disabled):not(.disabled).active, +.show > .btn-outline-light.dropdown-toggle { + color: #212529; + background-color: #f8f9fa; + border-color: #f8f9fa; +} + +.btn-outline-light:not(:disabled):not(.disabled):active:focus, .btn-outline-light:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-light.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5); +} + +.btn-outline-dark { + color: #343a40; + background-color: transparent; + background-image: none; + border-color: #343a40; +} + +.btn-outline-dark:hover { + color: #fff; + background-color: #343a40; + border-color: #343a40; +} + +.btn-outline-dark:focus, .btn-outline-dark.focus { + box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5); +} + +.btn-outline-dark.disabled, .btn-outline-dark:disabled { + color: #343a40; + background-color: transparent; +} + +.btn-outline-dark:not(:disabled):not(.disabled):active, .btn-outline-dark:not(:disabled):not(.disabled).active, +.show > .btn-outline-dark.dropdown-toggle { + color: #fff; + background-color: #343a40; + border-color: #343a40; +} + +.btn-outline-dark:not(:disabled):not(.disabled):active:focus, .btn-outline-dark:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-dark.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5); +} + +.btn-link { + font-weight: 400; + color: #007bff; + background-color: transparent; +} + +.btn-link:hover { + color: #0056b3; + text-decoration: underline; + background-color: transparent; + border-color: transparent; +} + +.btn-link:focus, .btn-link.focus { + text-decoration: underline; + border-color: transparent; + box-shadow: none; +} + +.btn-link:disabled, .btn-link.disabled { + color: #6c757d; + pointer-events: none; +} + +.btn-lg, .btn-group-lg > .btn { + padding: 0.5rem 1rem; + font-size: 1.25rem; + line-height: 1.5; + border-radius: 0.3rem; +} + +.btn-sm, .btn-group-sm > .btn { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + line-height: 1.5; + border-radius: 0.2rem; +} + +.btn-block { + display: block; + width: 100%; +} + +.btn-block + .btn-block { + margin-top: 0.5rem; +} + +input[type="submit"].btn-block, +input[type="reset"].btn-block, +input[type="button"].btn-block { + width: 100%; +} + +.fade { + transition: opacity 0.15s linear; +} + +@media screen and (prefers-reduced-motion: reduce) { + .fade { + transition: none; + } +} + +.fade:not(.show) { + opacity: 0; +} + +.collapse:not(.show) { + display: none; +} + +.collapsing { + position: relative; + height: 0; + overflow: hidden; + transition: height 0.35s ease; +} + +@media screen and (prefers-reduced-motion: reduce) { + .collapsing { + transition: none; + } +} + +.dropup, +.dropright, +.dropdown, +.dropleft { + position: relative; +} + +.dropdown-toggle::after { + display: inline-block; + width: 0; + height: 0; + margin-left: 0.255em; + vertical-align: 0.255em; + content: ""; + border-top: 0.3em solid; + border-right: 0.3em solid transparent; + border-bottom: 0; + border-left: 0.3em solid transparent; +} + +.dropdown-toggle:empty::after { + margin-left: 0; +} + +.dropdown-menu { + position: absolute; + top: 100%; + left: 0; + z-index: 1000; + display: none; + float: left; + min-width: 10rem; + padding: 0.5rem 0; + margin: 0.125rem 0 0; + font-size: 1rem; + color: #212529; + text-align: left; + list-style: none; + background-color: #fff; + background-clip: padding-box; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 0.25rem; +} + +.dropdown-menu-right { + right: 0; + left: auto; +} + +.dropup .dropdown-menu { + top: auto; + bottom: 100%; + margin-top: 0; + margin-bottom: 0.125rem; +} + +.dropup .dropdown-toggle::after { + display: inline-block; + width: 0; + height: 0; + margin-left: 0.255em; + vertical-align: 0.255em; + content: ""; + border-top: 0; + border-right: 0.3em solid transparent; + border-bottom: 0.3em solid; + border-left: 0.3em solid transparent; +} + +.dropup .dropdown-toggle:empty::after { + margin-left: 0; +} + +.dropright .dropdown-menu { + top: 0; + right: auto; + left: 100%; + margin-top: 0; + margin-left: 0.125rem; +} + +.dropright .dropdown-toggle::after { + display: inline-block; + width: 0; + height: 0; + margin-left: 0.255em; + vertical-align: 0.255em; + content: ""; + border-top: 0.3em solid transparent; + border-right: 0; + border-bottom: 0.3em solid transparent; + border-left: 0.3em solid; +} + +.dropright .dropdown-toggle:empty::after { + margin-left: 0; +} + +.dropright .dropdown-toggle::after { + vertical-align: 0; +} + +.dropleft .dropdown-menu { + top: 0; + right: 100%; + left: auto; + margin-top: 0; + margin-right: 0.125rem; +} + +.dropleft .dropdown-toggle::after { + display: inline-block; + width: 0; + height: 0; + margin-left: 0.255em; + vertical-align: 0.255em; + content: ""; +} + +.dropleft .dropdown-toggle::after { + display: none; +} + +.dropleft .dropdown-toggle::before { + display: inline-block; + width: 0; + height: 0; + margin-right: 0.255em; + vertical-align: 0.255em; + content: ""; + border-top: 0.3em solid transparent; + border-right: 0.3em solid; + border-bottom: 0.3em solid transparent; +} + +.dropleft .dropdown-toggle:empty::after { + margin-left: 0; +} + +.dropleft .dropdown-toggle::before { + vertical-align: 0; +} + +.dropdown-menu[x-placement^="top"], .dropdown-menu[x-placement^="right"], .dropdown-menu[x-placement^="bottom"], .dropdown-menu[x-placement^="left"] { + right: auto; + bottom: auto; +} + +.dropdown-divider { + height: 0; + margin: 0.5rem 0; + overflow: hidden; + border-top: 1px solid #e9ecef; +} + +.dropdown-item { + display: block; + width: 100%; + padding: 0.25rem 1.5rem; + clear: both; + font-weight: 400; + color: #212529; + text-align: inherit; + white-space: nowrap; + background-color: transparent; + border: 0; +} + +.dropdown-item:hover, .dropdown-item:focus { + color: #16181b; + text-decoration: none; + background-color: #f8f9fa; +} + +.dropdown-item.active, .dropdown-item:active { + color: #fff; + text-decoration: none; + background-color: #007bff; +} + +.dropdown-item.disabled, .dropdown-item:disabled { + color: #6c757d; + background-color: transparent; +} + +.dropdown-menu.show { + display: block; +} + +.dropdown-header { + display: block; + padding: 0.5rem 1.5rem; + margin-bottom: 0; + font-size: 0.875rem; + color: #6c757d; + white-space: nowrap; +} + +.dropdown-item-text { + display: block; + padding: 0.25rem 1.5rem; + color: #212529; +} + +.btn-group, +.btn-group-vertical { + position: relative; + display: -ms-inline-flexbox; + display: inline-flex; + vertical-align: middle; +} + +.btn-group > .btn, +.btn-group-vertical > .btn { + position: relative; + -ms-flex: 0 1 auto; + flex: 0 1 auto; +} + +.btn-group > .btn:hover, +.btn-group-vertical > .btn:hover { + z-index: 1; +} + +.btn-group > .btn:focus, .btn-group > .btn:active, .btn-group > .btn.active, +.btn-group-vertical > .btn:focus, +.btn-group-vertical > .btn:active, +.btn-group-vertical > .btn.active { + z-index: 1; +} + +.btn-group .btn + .btn, +.btn-group .btn + .btn-group, +.btn-group .btn-group + .btn, +.btn-group .btn-group + .btn-group, +.btn-group-vertical .btn + .btn, +.btn-group-vertical .btn + .btn-group, +.btn-group-vertical .btn-group + .btn, +.btn-group-vertical .btn-group + .btn-group { + margin-left: -1px; +} + +.btn-toolbar { + display: -ms-flexbox; + display: flex; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + -ms-flex-pack: start; + justify-content: flex-start; +} + +.btn-toolbar .input-group { + width: auto; +} + +.btn-group > .btn:first-child { + margin-left: 0; +} + +.btn-group > .btn:not(:last-child):not(.dropdown-toggle), +.btn-group > .btn-group:not(:last-child) > .btn { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.btn-group > .btn:not(:first-child), +.btn-group > .btn-group:not(:first-child) > .btn { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.dropdown-toggle-split { + padding-right: 0.5625rem; + padding-left: 0.5625rem; +} + +.dropdown-toggle-split::after, +.dropup .dropdown-toggle-split::after, +.dropright .dropdown-toggle-split::after { + margin-left: 0; +} + +.dropleft .dropdown-toggle-split::before { + margin-right: 0; +} + +.btn-sm + .dropdown-toggle-split, .btn-group-sm > .btn + .dropdown-toggle-split { + padding-right: 0.375rem; + padding-left: 0.375rem; +} + +.btn-lg + .dropdown-toggle-split, .btn-group-lg > .btn + .dropdown-toggle-split { + padding-right: 0.75rem; + padding-left: 0.75rem; +} + +.btn-group-vertical { + -ms-flex-direction: column; + flex-direction: column; + -ms-flex-align: start; + align-items: flex-start; + -ms-flex-pack: center; + justify-content: center; +} + +.btn-group-vertical .btn, +.btn-group-vertical .btn-group { + width: 100%; +} + +.btn-group-vertical > .btn + .btn, +.btn-group-vertical > .btn + .btn-group, +.btn-group-vertical > .btn-group + .btn, +.btn-group-vertical > .btn-group + .btn-group { + margin-top: -1px; + margin-left: 0; +} + +.btn-group-vertical > .btn:not(:last-child):not(.dropdown-toggle), +.btn-group-vertical > .btn-group:not(:last-child) > .btn { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.btn-group-vertical > .btn:not(:first-child), +.btn-group-vertical > .btn-group:not(:first-child) > .btn { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.btn-group-toggle > .btn, +.btn-group-toggle > .btn-group > .btn { + margin-bottom: 0; +} + +.btn-group-toggle > .btn input[type="radio"], +.btn-group-toggle > .btn input[type="checkbox"], +.btn-group-toggle > .btn-group > .btn input[type="radio"], +.btn-group-toggle > .btn-group > .btn input[type="checkbox"] { + position: absolute; + clip: rect(0, 0, 0, 0); + pointer-events: none; +} + +.input-group { + position: relative; + display: -ms-flexbox; + display: flex; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + -ms-flex-align: stretch; + align-items: stretch; + width: 100%; +} + +.input-group > .form-control, +.input-group > .custom-select, +.input-group > .custom-file { + position: relative; + -ms-flex: 1 1 auto; + flex: 1 1 auto; + width: 1%; + margin-bottom: 0; +} + +.input-group > .form-control:focus, +.input-group > .custom-select:focus, +.input-group > .custom-file:focus { + z-index: 3; +} + +.input-group > .form-control + .form-control, +.input-group > .form-control + .custom-select, +.input-group > .form-control + .custom-file, +.input-group > .custom-select + .form-control, +.input-group > .custom-select + .custom-select, +.input-group > .custom-select + .custom-file, +.input-group > .custom-file + .form-control, +.input-group > .custom-file + .custom-select, +.input-group > .custom-file + .custom-file { + margin-left: -1px; +} + +.input-group > .form-control:not(:last-child), +.input-group > .custom-select:not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.input-group > .form-control:not(:first-child), +.input-group > .custom-select:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.input-group > .custom-file { + display: -ms-flexbox; + display: flex; + -ms-flex-align: center; + align-items: center; +} + +.input-group > .custom-file:not(:last-child) .custom-file-label, +.input-group > .custom-file:not(:last-child) .custom-file-label::after { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.input-group > .custom-file:not(:first-child) .custom-file-label { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.input-group-prepend, +.input-group-append { + display: -ms-flexbox; + display: flex; +} + +.input-group-prepend .btn, +.input-group-append .btn { + position: relative; + z-index: 2; +} + +.input-group-prepend .btn + .btn, +.input-group-prepend .btn + .input-group-text, +.input-group-prepend .input-group-text + .input-group-text, +.input-group-prepend .input-group-text + .btn, +.input-group-append .btn + .btn, +.input-group-append .btn + .input-group-text, +.input-group-append .input-group-text + .input-group-text, +.input-group-append .input-group-text + .btn { + margin-left: -1px; +} + +.input-group-prepend { + margin-right: -1px; +} + +.input-group-append { + margin-left: -1px; +} + +.input-group-text { + display: -ms-flexbox; + display: flex; + -ms-flex-align: center; + align-items: center; + padding: 0.375rem 0.75rem; + margin-bottom: 0; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + color: #495057; + text-align: center; + white-space: nowrap; + background-color: #e9ecef; + border: 1px solid #ced4da; + border-radius: 0.25rem; +} + +.input-group-text input[type="radio"], +.input-group-text input[type="checkbox"] { + margin-top: 0; +} + +.input-group > .input-group-prepend > .btn, +.input-group > .input-group-prepend > .input-group-text, +.input-group > .input-group-append:not(:last-child) > .btn, +.input-group > .input-group-append:not(:last-child) > .input-group-text, +.input-group > .input-group-append:last-child > .btn:not(:last-child):not(.dropdown-toggle), +.input-group > .input-group-append:last-child > .input-group-text:not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.input-group > .input-group-append > .btn, +.input-group > .input-group-append > .input-group-text, +.input-group > .input-group-prepend:not(:first-child) > .btn, +.input-group > .input-group-prepend:not(:first-child) > .input-group-text, +.input-group > .input-group-prepend:first-child > .btn:not(:first-child), +.input-group > .input-group-prepend:first-child > .input-group-text:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.custom-control { + position: relative; + display: block; + min-height: 1.5rem; + padding-left: 1.5rem; +} + +.custom-control-inline { + display: -ms-inline-flexbox; + display: inline-flex; + margin-right: 1rem; +} + +.custom-control-input { + position: absolute; + z-index: -1; + opacity: 0; +} + +.custom-control-input:checked ~ .custom-control-label::before { + color: #fff; + background-color: #007bff; +} + +.custom-control-input:focus ~ .custom-control-label::before { + box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +.custom-control-input:active ~ .custom-control-label::before { + color: #fff; + background-color: #b3d7ff; +} + +.custom-control-input:disabled ~ .custom-control-label { + color: #6c757d; +} + +.custom-control-input:disabled ~ .custom-control-label::before { + background-color: #e9ecef; +} + +.custom-control-label { + position: relative; + margin-bottom: 0; +} + +.custom-control-label::before { + position: absolute; + top: 0.25rem; + left: -1.5rem; + display: block; + width: 1rem; + height: 1rem; + pointer-events: none; + content: ""; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + background-color: #dee2e6; +} + +.custom-control-label::after { + position: absolute; + top: 0.25rem; + left: -1.5rem; + display: block; + width: 1rem; + height: 1rem; + content: ""; + background-repeat: no-repeat; + background-position: center center; + background-size: 50% 50%; +} + +.custom-checkbox .custom-control-label::before { + border-radius: 0.25rem; +} + +.custom-checkbox .custom-control-input:checked ~ .custom-control-label::before { + background-color: #007bff; +} + +.custom-checkbox .custom-control-input:checked ~ .custom-control-label::after { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26 2.974 7.25 8 2.193z'/%3E%3C/svg%3E"); +} + +.custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::before { + background-color: #007bff; +} + +.custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::after { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 4'%3E%3Cpath stroke='%23fff' d='M0 2h4'/%3E%3C/svg%3E"); +} + +.custom-checkbox .custom-control-input:disabled:checked ~ .custom-control-label::before { + background-color: rgba(0, 123, 255, 0.5); +} + +.custom-checkbox .custom-control-input:disabled:indeterminate ~ .custom-control-label::before { + background-color: rgba(0, 123, 255, 0.5); +} + +.custom-radio .custom-control-label::before { + border-radius: 50%; +} + +.custom-radio .custom-control-input:checked ~ .custom-control-label::before { + background-color: #007bff; +} + +.custom-radio .custom-control-input:checked ~ .custom-control-label::after { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3E%3Ccircle r='3' fill='%23fff'/%3E%3C/svg%3E"); +} + +.custom-radio .custom-control-input:disabled:checked ~ .custom-control-label::before { + background-color: rgba(0, 123, 255, 0.5); +} + +.custom-select { + display: inline-block; + width: 100%; + height: calc(2.25rem + 2px); + padding: 0.375rem 1.75rem 0.375rem 0.75rem; + line-height: 1.5; + color: #495057; + vertical-align: middle; + background: #fff url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3E%3Cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3E%3C/svg%3E") no-repeat right 0.75rem center; + background-size: 8px 10px; + border: 1px solid #ced4da; + border-radius: 0.25rem; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +} + +.custom-select:focus { + border-color: #80bdff; + outline: 0; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.075), 0 0 5px rgba(128, 189, 255, 0.5); +} + +.custom-select:focus::-ms-value { + color: #495057; + background-color: #fff; +} + +.custom-select[multiple], .custom-select[size]:not([size="1"]) { + height: auto; + padding-right: 0.75rem; + background-image: none; +} + +.custom-select:disabled { + color: #6c757d; + background-color: #e9ecef; +} + +.custom-select::-ms-expand { + opacity: 0; +} + +.custom-select-sm { + height: calc(1.8125rem + 2px); + padding-top: 0.375rem; + padding-bottom: 0.375rem; + font-size: 75%; +} + +.custom-select-lg { + height: calc(2.875rem + 2px); + padding-top: 0.375rem; + padding-bottom: 0.375rem; + font-size: 125%; +} + +.custom-file { + position: relative; + display: inline-block; + width: 100%; + height: calc(2.25rem + 2px); + margin-bottom: 0; +} + +.custom-file-input { + position: relative; + z-index: 2; + width: 100%; + height: calc(2.25rem + 2px); + margin: 0; + opacity: 0; +} + +.custom-file-input:focus ~ .custom-file-label { + border-color: #80bdff; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +.custom-file-input:focus ~ .custom-file-label::after { + border-color: #80bdff; +} + +.custom-file-input:lang(en) ~ .custom-file-label::after { + content: "Browse"; +} + +.custom-file-label { + position: absolute; + top: 0; + right: 0; + left: 0; + z-index: 1; + height: calc(2.25rem + 2px); + padding: 0.375rem 0.75rem; + line-height: 1.5; + color: #495057; + background-color: #fff; + border: 1px solid #ced4da; + border-radius: 0.25rem; +} + +.custom-file-label::after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 3; + display: block; + height: 2.25rem; + padding: 0.375rem 0.75rem; + line-height: 1.5; + color: #495057; + content: "Browse"; + background-color: #e9ecef; + border-left: 1px solid #ced4da; + border-radius: 0 0.25rem 0.25rem 0; +} + +.custom-range { + width: 100%; + padding-left: 0; + background-color: transparent; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +} + +.custom-range:focus { + outline: none; +} + +.custom-range::-moz-focus-outer { + border: 0; +} + +.custom-range::-webkit-slider-thumb { + width: 1rem; + height: 1rem; + margin-top: -0.25rem; + background-color: #007bff; + border: 0; + border-radius: 1rem; + -webkit-appearance: none; + appearance: none; +} + +.custom-range::-webkit-slider-thumb:focus { + outline: none; + box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +.custom-range::-webkit-slider-thumb:active { + background-color: #b3d7ff; +} + +.custom-range::-webkit-slider-runnable-track { + width: 100%; + height: 0.5rem; + color: transparent; + cursor: pointer; + background-color: #dee2e6; + border-color: transparent; + border-radius: 1rem; +} + +.custom-range::-moz-range-thumb { + width: 1rem; + height: 1rem; + background-color: #007bff; + border: 0; + border-radius: 1rem; + -moz-appearance: none; + appearance: none; +} + +.custom-range::-moz-range-thumb:focus { + outline: none; + box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +.custom-range::-moz-range-thumb:active { + background-color: #b3d7ff; +} + +.custom-range::-moz-range-track { + width: 100%; + height: 0.5rem; + color: transparent; + cursor: pointer; + background-color: #dee2e6; + border-color: transparent; + border-radius: 1rem; +} + +.custom-range::-ms-thumb { + width: 1rem; + height: 1rem; + background-color: #007bff; + border: 0; + border-radius: 1rem; + appearance: none; +} + +.custom-range::-ms-thumb:focus { + outline: none; + box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +.custom-range::-ms-thumb:active { + background-color: #b3d7ff; +} + +.custom-range::-ms-track { + width: 100%; + height: 0.5rem; + color: transparent; + cursor: pointer; + background-color: transparent; + border-color: transparent; + border-width: 0.5rem; +} + +.custom-range::-ms-fill-lower { + background-color: #dee2e6; + border-radius: 1rem; +} + +.custom-range::-ms-fill-upper { + margin-right: 15px; + background-color: #dee2e6; + border-radius: 1rem; +} + +.nav { + display: -ms-flexbox; + display: flex; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + padding-left: 0; + margin-bottom: 0; + list-style: none; +} + +.nav-link { + display: block; + padding: 0.5rem 1rem; +} + +.nav-link:hover, .nav-link:focus { + text-decoration: none; +} + +.nav-link.disabled { + color: #6c757d; +} + +.nav-tabs { + border-bottom: 1px solid #dee2e6; +} + +.nav-tabs .nav-item { + margin-bottom: -1px; +} + +.nav-tabs .nav-link { + border: 1px solid transparent; + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; +} + +.nav-tabs .nav-link:hover, .nav-tabs .nav-link:focus { + border-color: #e9ecef #e9ecef #dee2e6; +} + +.nav-tabs .nav-link.disabled { + color: #6c757d; + background-color: transparent; + border-color: transparent; +} + +.nav-tabs .nav-link.active, +.nav-tabs .nav-item.show .nav-link { + color: #495057; + background-color: #fff; + border-color: #dee2e6 #dee2e6 #fff; +} + +.nav-tabs .dropdown-menu { + margin-top: -1px; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.nav-pills .nav-link { + border-radius: 0.25rem; +} + +.nav-pills .nav-link.active, +.nav-pills .show > .nav-link { + color: #fff; + background-color: #007bff; +} + +.nav-fill .nav-item { + -ms-flex: 1 1 auto; + flex: 1 1 auto; + text-align: center; +} + +.nav-justified .nav-item { + -ms-flex-preferred-size: 0; + flex-basis: 0; + -ms-flex-positive: 1; + flex-grow: 1; + text-align: center; +} + +.tab-content > .tab-pane { + display: none; +} + +.tab-content > .active { + display: block; +} + +.navbar { + position: relative; + display: -ms-flexbox; + display: flex; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + -ms-flex-align: center; + align-items: center; + -ms-flex-pack: justify; + justify-content: space-between; + padding: 0.5rem 1rem; +} + +.navbar > .container, +.navbar > .container-fluid { + display: -ms-flexbox; + display: flex; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + -ms-flex-align: center; + align-items: center; + -ms-flex-pack: justify; + justify-content: space-between; +} + +.navbar-brand { + display: inline-block; + padding-top: 0.3125rem; + padding-bottom: 0.3125rem; + margin-right: 1rem; + font-size: 1.25rem; + line-height: inherit; + white-space: nowrap; +} + +.navbar-brand:hover, .navbar-brand:focus { + text-decoration: none; +} + +.navbar-nav { + display: -ms-flexbox; + display: flex; + -ms-flex-direction: column; + flex-direction: column; + padding-left: 0; + margin-bottom: 0; + list-style: none; +} + +.navbar-nav .nav-link { + padding-right: 0; + padding-left: 0; +} + +.navbar-nav .dropdown-menu { + position: static; + float: none; +} + +.navbar-text { + display: inline-block; + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.navbar-collapse { + -ms-flex-preferred-size: 100%; + flex-basis: 100%; + -ms-flex-positive: 1; + flex-grow: 1; + -ms-flex-align: center; + align-items: center; +} + +.navbar-toggler { + padding: 0.25rem 0.75rem; + font-size: 1.25rem; + line-height: 1; + background-color: transparent; + border: 1px solid transparent; + border-radius: 0.25rem; +} + +.navbar-toggler:hover, .navbar-toggler:focus { + text-decoration: none; +} + +.navbar-toggler:not(:disabled):not(.disabled) { + cursor: pointer; +} + +.navbar-toggler-icon { + display: inline-block; + width: 1.5em; + height: 1.5em; + vertical-align: middle; + content: ""; + background: no-repeat center center; + background-size: 100% 100%; +} + +@media (max-width: 575.98px) { + .navbar-expand-sm > .container, + .navbar-expand-sm > .container-fluid { + padding-right: 0; + padding-left: 0; + } +} + +@media (min-width: 576px) { + .navbar-expand-sm { + -ms-flex-flow: row nowrap; + flex-flow: row nowrap; + -ms-flex-pack: start; + justify-content: flex-start; + } + .navbar-expand-sm .navbar-nav { + -ms-flex-direction: row; + flex-direction: row; + } + .navbar-expand-sm .navbar-nav .dropdown-menu { + position: absolute; + } + .navbar-expand-sm .navbar-nav .nav-link { + padding-right: 0.5rem; + padding-left: 0.5rem; + } + .navbar-expand-sm > .container, + .navbar-expand-sm > .container-fluid { + -ms-flex-wrap: nowrap; + flex-wrap: nowrap; + } + .navbar-expand-sm .navbar-collapse { + display: -ms-flexbox !important; + display: flex !important; + -ms-flex-preferred-size: auto; + flex-basis: auto; + } + .navbar-expand-sm .navbar-toggler { + display: none; + } +} + +@media (max-width: 767.98px) { + .navbar-expand-md > .container, + .navbar-expand-md > .container-fluid { + padding-right: 0; + padding-left: 0; + } +} + +@media (min-width: 768px) { + .navbar-expand-md { + -ms-flex-flow: row nowrap; + flex-flow: row nowrap; + -ms-flex-pack: start; + justify-content: flex-start; + } + .navbar-expand-md .navbar-nav { + -ms-flex-direction: row; + flex-direction: row; + } + .navbar-expand-md .navbar-nav .dropdown-menu { + position: absolute; + } + .navbar-expand-md .navbar-nav .nav-link { + padding-right: 0.5rem; + padding-left: 0.5rem; + } + .navbar-expand-md > .container, + .navbar-expand-md > .container-fluid { + -ms-flex-wrap: nowrap; + flex-wrap: nowrap; + } + .navbar-expand-md .navbar-collapse { + display: -ms-flexbox !important; + display: flex !important; + -ms-flex-preferred-size: auto; + flex-basis: auto; + } + .navbar-expand-md .navbar-toggler { + display: none; + } +} + +@media (max-width: 991.98px) { + .navbar-expand-lg > .container, + .navbar-expand-lg > .container-fluid { + padding-right: 0; + padding-left: 0; + } +} + +@media (min-width: 992px) { + .navbar-expand-lg { + -ms-flex-flow: row nowrap; + flex-flow: row nowrap; + -ms-flex-pack: start; + justify-content: flex-start; + } + .navbar-expand-lg .navbar-nav { + -ms-flex-direction: row; + flex-direction: row; + } + .navbar-expand-lg .navbar-nav .dropdown-menu { + position: absolute; + } + .navbar-expand-lg .navbar-nav .nav-link { + padding-right: 0.5rem; + padding-left: 0.5rem; + } + .navbar-expand-lg > .container, + .navbar-expand-lg > .container-fluid { + -ms-flex-wrap: nowrap; + flex-wrap: nowrap; + } + .navbar-expand-lg .navbar-collapse { + display: -ms-flexbox !important; + display: flex !important; + -ms-flex-preferred-size: auto; + flex-basis: auto; + } + .navbar-expand-lg .navbar-toggler { + display: none; + } +} + +@media (max-width: 1199.98px) { + .navbar-expand-xl > .container, + .navbar-expand-xl > .container-fluid { + padding-right: 0; + padding-left: 0; + } +} + +@media (min-width: 1200px) { + .navbar-expand-xl { + -ms-flex-flow: row nowrap; + flex-flow: row nowrap; + -ms-flex-pack: start; + justify-content: flex-start; + } + .navbar-expand-xl .navbar-nav { + -ms-flex-direction: row; + flex-direction: row; + } + .navbar-expand-xl .navbar-nav .dropdown-menu { + position: absolute; + } + .navbar-expand-xl .navbar-nav .nav-link { + padding-right: 0.5rem; + padding-left: 0.5rem; + } + .navbar-expand-xl > .container, + .navbar-expand-xl > .container-fluid { + -ms-flex-wrap: nowrap; + flex-wrap: nowrap; + } + .navbar-expand-xl .navbar-collapse { + display: -ms-flexbox !important; + display: flex !important; + -ms-flex-preferred-size: auto; + flex-basis: auto; + } + .navbar-expand-xl .navbar-toggler { + display: none; + } +} + +.navbar-expand { + -ms-flex-flow: row nowrap; + flex-flow: row nowrap; + -ms-flex-pack: start; + justify-content: flex-start; +} + +.navbar-expand > .container, +.navbar-expand > .container-fluid { + padding-right: 0; + padding-left: 0; +} + +.navbar-expand .navbar-nav { + -ms-flex-direction: row; + flex-direction: row; +} + +.navbar-expand .navbar-nav .dropdown-menu { + position: absolute; +} + +.navbar-expand .navbar-nav .nav-link { + padding-right: 0.5rem; + padding-left: 0.5rem; +} + +.navbar-expand > .container, +.navbar-expand > .container-fluid { + -ms-flex-wrap: nowrap; + flex-wrap: nowrap; +} + +.navbar-expand .navbar-collapse { + display: -ms-flexbox !important; + display: flex !important; + -ms-flex-preferred-size: auto; + flex-basis: auto; +} + +.navbar-expand .navbar-toggler { + display: none; +} + +.navbar-light .navbar-brand { + color: rgba(0, 0, 0, 0.9); +} + +.navbar-light .navbar-brand:hover, .navbar-light .navbar-brand:focus { + color: rgba(0, 0, 0, 0.9); +} + +.navbar-light .navbar-nav .nav-link { + color: rgba(0, 0, 0, 0.5); +} + +.navbar-light .navbar-nav .nav-link:hover, .navbar-light .navbar-nav .nav-link:focus { + color: rgba(0, 0, 0, 0.7); +} + +.navbar-light .navbar-nav .nav-link.disabled { + color: rgba(0, 0, 0, 0.3); +} + +.navbar-light .navbar-nav .show > .nav-link, +.navbar-light .navbar-nav .active > .nav-link, +.navbar-light .navbar-nav .nav-link.show, +.navbar-light .navbar-nav .nav-link.active { + color: rgba(0, 0, 0, 0.9); +} + +.navbar-light .navbar-toggler { + color: rgba(0, 0, 0, 0.5); + border-color: rgba(0, 0, 0, 0.1); +} + +.navbar-light .navbar-toggler-icon { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(0, 0, 0, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E"); +} + +.navbar-light .navbar-text { + color: rgba(0, 0, 0, 0.5); +} + +.navbar-light .navbar-text a { + color: rgba(0, 0, 0, 0.9); +} + +.navbar-light .navbar-text a:hover, .navbar-light .navbar-text a:focus { + color: rgba(0, 0, 0, 0.9); +} + +.navbar-dark .navbar-brand { + color: #fff; +} + +.navbar-dark .navbar-brand:hover, .navbar-dark .navbar-brand:focus { + color: #fff; +} + +.navbar-dark .navbar-nav .nav-link { + color: rgba(255, 255, 255, 0.5); +} + +.navbar-dark .navbar-nav .nav-link:hover, .navbar-dark .navbar-nav .nav-link:focus { + color: rgba(255, 255, 255, 0.75); +} + +.navbar-dark .navbar-nav .nav-link.disabled { + color: rgba(255, 255, 255, 0.25); +} + +.navbar-dark .navbar-nav .show > .nav-link, +.navbar-dark .navbar-nav .active > .nav-link, +.navbar-dark .navbar-nav .nav-link.show, +.navbar-dark .navbar-nav .nav-link.active { + color: #fff; +} + +.navbar-dark .navbar-toggler { + color: rgba(255, 255, 255, 0.5); + border-color: rgba(255, 255, 255, 0.1); +} + +.navbar-dark .navbar-toggler-icon { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(255, 255, 255, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E"); +} + +.navbar-dark .navbar-text { + color: rgba(255, 255, 255, 0.5); +} + +.navbar-dark .navbar-text a { + color: #fff; +} + +.navbar-dark .navbar-text a:hover, .navbar-dark .navbar-text a:focus { + color: #fff; +} + +.card { + position: relative; + display: -ms-flexbox; + display: flex; + -ms-flex-direction: column; + flex-direction: column; + min-width: 0; + word-wrap: break-word; + background-color: #fff; + background-clip: border-box; + border: 1px solid rgba(0, 0, 0, 0.125); + border-radius: 0.25rem; +} + +.card > hr { + margin-right: 0; + margin-left: 0; +} + +.card > .list-group:first-child .list-group-item:first-child { + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; +} + +.card > .list-group:last-child .list-group-item:last-child { + border-bottom-right-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; +} + +.card-body { + -ms-flex: 1 1 auto; + flex: 1 1 auto; + padding: 1.25rem; +} + +.card-title { + margin-bottom: 0.75rem; +} + +.card-subtitle { + margin-top: -0.375rem; + margin-bottom: 0; +} + +.card-text:last-child { + margin-bottom: 0; +} + +.card-link:hover { + text-decoration: none; +} + +.card-link + .card-link { + margin-left: 1.25rem; +} + +.card-header { + padding: 0.75rem 1.25rem; + margin-bottom: 0; + background-color: rgba(0, 0, 0, 0.03); + border-bottom: 1px solid rgba(0, 0, 0, 0.125); +} + +.card-header:first-child { + border-radius: calc(0.25rem - 1px) calc(0.25rem - 1px) 0 0; +} + +.card-header + .list-group .list-group-item:first-child { + border-top: 0; +} + +.card-footer { + padding: 0.75rem 1.25rem; + background-color: rgba(0, 0, 0, 0.03); + border-top: 1px solid rgba(0, 0, 0, 0.125); +} + +.card-footer:last-child { + border-radius: 0 0 calc(0.25rem - 1px) calc(0.25rem - 1px); +} + +.card-header-tabs { + margin-right: -0.625rem; + margin-bottom: -0.75rem; + margin-left: -0.625rem; + border-bottom: 0; +} + +.card-header-pills { + margin-right: -0.625rem; + margin-left: -0.625rem; +} + +.card-img-overlay { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + padding: 1.25rem; +} + +.card-img { + width: 100%; + border-radius: calc(0.25rem - 1px); +} + +.card-img-top { + width: 100%; + border-top-left-radius: calc(0.25rem - 1px); + border-top-right-radius: calc(0.25rem - 1px); +} + +.card-img-bottom { + width: 100%; + border-bottom-right-radius: calc(0.25rem - 1px); + border-bottom-left-radius: calc(0.25rem - 1px); +} + +.card-deck { + display: -ms-flexbox; + display: flex; + -ms-flex-direction: column; + flex-direction: column; +} + +.card-deck .card { + margin-bottom: 15px; +} + +@media (min-width: 576px) { + .card-deck { + -ms-flex-flow: row wrap; + flex-flow: row wrap; + margin-right: -15px; + margin-left: -15px; + } + .card-deck .card { + display: -ms-flexbox; + display: flex; + -ms-flex: 1 0 0%; + flex: 1 0 0%; + -ms-flex-direction: column; + flex-direction: column; + margin-right: 15px; + margin-bottom: 0; + margin-left: 15px; + } +} + +.card-group { + display: -ms-flexbox; + display: flex; + -ms-flex-direction: column; + flex-direction: column; +} + +.card-group > .card { + margin-bottom: 15px; +} + +@media (min-width: 576px) { + .card-group { + -ms-flex-flow: row wrap; + flex-flow: row wrap; + } + .card-group > .card { + -ms-flex: 1 0 0%; + flex: 1 0 0%; + margin-bottom: 0; + } + .card-group > .card + .card { + margin-left: 0; + border-left: 0; + } + .card-group > .card:first-child { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + .card-group > .card:first-child .card-img-top, + .card-group > .card:first-child .card-header { + border-top-right-radius: 0; + } + .card-group > .card:first-child .card-img-bottom, + .card-group > .card:first-child .card-footer { + border-bottom-right-radius: 0; + } + .card-group > .card:last-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + .card-group > .card:last-child .card-img-top, + .card-group > .card:last-child .card-header { + border-top-left-radius: 0; + } + .card-group > .card:last-child .card-img-bottom, + .card-group > .card:last-child .card-footer { + border-bottom-left-radius: 0; + } + .card-group > .card:only-child { + border-radius: 0.25rem; + } + .card-group > .card:only-child .card-img-top, + .card-group > .card:only-child .card-header { + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; + } + .card-group > .card:only-child .card-img-bottom, + .card-group > .card:only-child .card-footer { + border-bottom-right-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; + } + .card-group > .card:not(:first-child):not(:last-child):not(:only-child) { + border-radius: 0; + } + .card-group > .card:not(:first-child):not(:last-child):not(:only-child) .card-img-top, + .card-group > .card:not(:first-child):not(:last-child):not(:only-child) .card-img-bottom, + .card-group > .card:not(:first-child):not(:last-child):not(:only-child) .card-header, + .card-group > .card:not(:first-child):not(:last-child):not(:only-child) .card-footer { + border-radius: 0; + } +} + +.card-columns .card { + margin-bottom: 0.75rem; +} + +@media (min-width: 576px) { + .card-columns { + -webkit-column-count: 3; + -moz-column-count: 3; + column-count: 3; + -webkit-column-gap: 1.25rem; + -moz-column-gap: 1.25rem; + column-gap: 1.25rem; + orphans: 1; + widows: 1; + } + .card-columns .card { + display: inline-block; + width: 100%; + } +} + +.accordion .card:not(:first-of-type):not(:last-of-type) { + border-bottom: 0; + border-radius: 0; +} + +.accordion .card:not(:first-of-type) .card-header:first-child { + border-radius: 0; +} + +.accordion .card:first-of-type { + border-bottom: 0; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.accordion .card:last-of-type { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.breadcrumb { + display: -ms-flexbox; + display: flex; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + padding: 0.75rem 1rem; + margin-bottom: 1rem; + list-style: none; + background-color: #e9ecef; + border-radius: 0.25rem; +} + +.breadcrumb-item + .breadcrumb-item { + padding-left: 0.5rem; +} + +.breadcrumb-item + .breadcrumb-item::before { + display: inline-block; + padding-right: 0.5rem; + color: #6c757d; + content: "/"; +} + +.breadcrumb-item + .breadcrumb-item:hover::before { + text-decoration: underline; +} + +.breadcrumb-item + .breadcrumb-item:hover::before { + text-decoration: none; +} + +.breadcrumb-item.active { + color: #6c757d; +} + +.pagination { + display: -ms-flexbox; + display: flex; + padding-left: 0; + list-style: none; + border-radius: 0.25rem; +} + +.page-link { + position: relative; + display: block; + padding: 0.5rem 0.75rem; + margin-left: -1px; + line-height: 1.25; + color: #007bff; + background-color: #fff; + border: 1px solid #dee2e6; +} + +.page-link:hover { + z-index: 2; + color: #0056b3; + text-decoration: none; + background-color: #e9ecef; + border-color: #dee2e6; +} + +.page-link:focus { + z-index: 2; + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +.page-link:not(:disabled):not(.disabled) { + cursor: pointer; +} + +.page-item:first-child .page-link { + margin-left: 0; + border-top-left-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; +} + +.page-item:last-child .page-link { + border-top-right-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; +} + +.page-item.active .page-link { + z-index: 1; + color: #fff; + background-color: #007bff; + border-color: #007bff; +} + +.page-item.disabled .page-link { + color: #6c757d; + pointer-events: none; + cursor: auto; + background-color: #fff; + border-color: #dee2e6; +} + +.pagination-lg .page-link { + padding: 0.75rem 1.5rem; + font-size: 1.25rem; + line-height: 1.5; +} + +.pagination-lg .page-item:first-child .page-link { + border-top-left-radius: 0.3rem; + border-bottom-left-radius: 0.3rem; +} + +.pagination-lg .page-item:last-child .page-link { + border-top-right-radius: 0.3rem; + border-bottom-right-radius: 0.3rem; +} + +.pagination-sm .page-link { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + line-height: 1.5; +} + +.pagination-sm .page-item:first-child .page-link { + border-top-left-radius: 0.2rem; + border-bottom-left-radius: 0.2rem; +} + +.pagination-sm .page-item:last-child .page-link { + border-top-right-radius: 0.2rem; + border-bottom-right-radius: 0.2rem; +} + +.badge { + display: inline-block; + padding: 0.25em 0.4em; + font-size: 75%; + font-weight: 700; + line-height: 1; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: 0.25rem; +} + +.badge:empty { + display: none; +} + +.btn .badge { + position: relative; + top: -1px; +} + +.badge-pill { + padding-right: 0.6em; + padding-left: 0.6em; + border-radius: 10rem; +} + +.badge-primary { + color: #fff; + background-color: #007bff; +} + +.badge-primary[href]:hover, .badge-primary[href]:focus { + color: #fff; + text-decoration: none; + background-color: #0062cc; +} + +.badge-secondary { + color: #fff; + background-color: #6c757d; +} + +.badge-secondary[href]:hover, .badge-secondary[href]:focus { + color: #fff; + text-decoration: none; + background-color: #545b62; +} + +.badge-success { + color: #fff; + background-color: #28a745; +} + +.badge-success[href]:hover, .badge-success[href]:focus { + color: #fff; + text-decoration: none; + background-color: #1e7e34; +} + +.badge-info { + color: #fff; + background-color: #17a2b8; +} + +.badge-info[href]:hover, .badge-info[href]:focus { + color: #fff; + text-decoration: none; + background-color: #117a8b; +} + +.badge-warning { + color: #212529; + background-color: #ffc107; +} + +.badge-warning[href]:hover, .badge-warning[href]:focus { + color: #212529; + text-decoration: none; + background-color: #d39e00; +} + +.badge-danger { + color: #fff; + background-color: #dc3545; +} + +.badge-danger[href]:hover, .badge-danger[href]:focus { + color: #fff; + text-decoration: none; + background-color: #bd2130; +} + +.badge-light { + color: #212529; + background-color: #f8f9fa; +} + +.badge-light[href]:hover, .badge-light[href]:focus { + color: #212529; + text-decoration: none; + background-color: #dae0e5; +} + +.badge-dark { + color: #fff; + background-color: #343a40; +} + +.badge-dark[href]:hover, .badge-dark[href]:focus { + color: #fff; + text-decoration: none; + background-color: #1d2124; +} + +.jumbotron { + padding: 2rem 1rem; + margin-bottom: 2rem; + background-color: #e9ecef; + border-radius: 0.3rem; +} + +@media (min-width: 576px) { + .jumbotron { + padding: 4rem 2rem; + } +} + +.jumbotron-fluid { + padding-right: 0; + padding-left: 0; + border-radius: 0; +} + +.alert { + position: relative; + padding: 0.75rem 1.25rem; + margin-bottom: 1rem; + border: 1px solid transparent; + border-radius: 0.25rem; +} + +.alert-heading { + color: inherit; +} + +.alert-link { + font-weight: 700; +} + +.alert-dismissible { + padding-right: 4rem; +} + +.alert-dismissible .close { + position: absolute; + top: 0; + right: 0; + padding: 0.75rem 1.25rem; + color: inherit; +} + +.alert-primary { + color: #004085; + background-color: #cce5ff; + border-color: #b8daff; +} + +.alert-primary hr { + border-top-color: #9fcdff; +} + +.alert-primary .alert-link { + color: #002752; +} + +.alert-secondary { + color: #383d41; + background-color: #e2e3e5; + border-color: #d6d8db; +} + +.alert-secondary hr { + border-top-color: #c8cbcf; +} + +.alert-secondary .alert-link { + color: #202326; +} + +.alert-success { + color: #155724; + background-color: #d4edda; + border-color: #c3e6cb; +} + +.alert-success hr { + border-top-color: #b1dfbb; +} + +.alert-success .alert-link { + color: #0b2e13; +} + +.alert-info { + color: #0c5460; + background-color: #d1ecf1; + border-color: #bee5eb; +} + +.alert-info hr { + border-top-color: #abdde5; +} + +.alert-info .alert-link { + color: #062c33; +} + +.alert-warning { + color: #856404; + background-color: #fff3cd; + border-color: #ffeeba; +} + +.alert-warning hr { + border-top-color: #ffe8a1; +} + +.alert-warning .alert-link { + color: #533f03; +} + +.alert-danger { + color: #721c24; + background-color: #f8d7da; + border-color: #f5c6cb; +} + +.alert-danger hr { + border-top-color: #f1b0b7; +} + +.alert-danger .alert-link { + color: #491217; +} + +.alert-light { + color: #818182; + background-color: #fefefe; + border-color: #fdfdfe; +} + +.alert-light hr { + border-top-color: #ececf6; +} + +.alert-light .alert-link { + color: #686868; +} + +.alert-dark { + color: #1b1e21; + background-color: #d6d8d9; + border-color: #c6c8ca; +} + +.alert-dark hr { + border-top-color: #b9bbbe; +} + +.alert-dark .alert-link { + color: #040505; +} + +@-webkit-keyframes progress-bar-stripes { + from { + background-position: 1rem 0; + } + to { + background-position: 0 0; + } +} + +@keyframes progress-bar-stripes { + from { + background-position: 1rem 0; + } + to { + background-position: 0 0; + } +} + +.progress { + display: -ms-flexbox; + display: flex; + height: 1rem; + overflow: hidden; + font-size: 0.75rem; + background-color: #e9ecef; + border-radius: 0.25rem; +} + +.progress-bar { + display: -ms-flexbox; + display: flex; + -ms-flex-direction: column; + flex-direction: column; + -ms-flex-pack: center; + justify-content: center; + color: #fff; + text-align: center; + white-space: nowrap; + background-color: #007bff; + transition: width 0.6s ease; +} + +@media screen and (prefers-reduced-motion: reduce) { + .progress-bar { + transition: none; + } +} + +.progress-bar-striped { + background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); + background-size: 1rem 1rem; +} + +.progress-bar-animated { + -webkit-animation: progress-bar-stripes 1s linear infinite; + animation: progress-bar-stripes 1s linear infinite; +} + +.media { + display: -ms-flexbox; + display: flex; + -ms-flex-align: start; + align-items: flex-start; +} + +.media-body { + -ms-flex: 1; + flex: 1; +} + +.list-group { + display: -ms-flexbox; + display: flex; + -ms-flex-direction: column; + flex-direction: column; + padding-left: 0; + margin-bottom: 0; +} + +.list-group-item-action { + width: 100%; + color: #495057; + text-align: inherit; +} + +.list-group-item-action:hover, .list-group-item-action:focus { + color: #495057; + text-decoration: none; + background-color: #f8f9fa; +} + +.list-group-item-action:active { + color: #212529; + background-color: #e9ecef; +} + +.list-group-item { + position: relative; + display: block; + padding: 0.75rem 1.25rem; + margin-bottom: -1px; + background-color: #fff; + border: 1px solid rgba(0, 0, 0, 0.125); +} + +.list-group-item:first-child { + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; +} + +.list-group-item:last-child { + margin-bottom: 0; + border-bottom-right-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; +} + +.list-group-item:hover, .list-group-item:focus { + z-index: 1; + text-decoration: none; +} + +.list-group-item.disabled, .list-group-item:disabled { + color: #6c757d; + background-color: #fff; +} + +.list-group-item.active { + z-index: 2; + color: #fff; + background-color: #007bff; + border-color: #007bff; +} + +.list-group-flush .list-group-item { + border-right: 0; + border-left: 0; + border-radius: 0; +} + +.list-group-flush:first-child .list-group-item:first-child { + border-top: 0; +} + +.list-group-flush:last-child .list-group-item:last-child { + border-bottom: 0; +} + +.list-group-item-primary { + color: #004085; + background-color: #b8daff; +} + +.list-group-item-primary.list-group-item-action:hover, .list-group-item-primary.list-group-item-action:focus { + color: #004085; + background-color: #9fcdff; +} + +.list-group-item-primary.list-group-item-action.active { + color: #fff; + background-color: #004085; + border-color: #004085; +} + +.list-group-item-secondary { + color: #383d41; + background-color: #d6d8db; +} + +.list-group-item-secondary.list-group-item-action:hover, .list-group-item-secondary.list-group-item-action:focus { + color: #383d41; + background-color: #c8cbcf; +} + +.list-group-item-secondary.list-group-item-action.active { + color: #fff; + background-color: #383d41; + border-color: #383d41; +} + +.list-group-item-success { + color: #155724; + background-color: #c3e6cb; +} + +.list-group-item-success.list-group-item-action:hover, .list-group-item-success.list-group-item-action:focus { + color: #155724; + background-color: #b1dfbb; +} + +.list-group-item-success.list-group-item-action.active { + color: #fff; + background-color: #155724; + border-color: #155724; +} + +.list-group-item-info { + color: #0c5460; + background-color: #bee5eb; +} + +.list-group-item-info.list-group-item-action:hover, .list-group-item-info.list-group-item-action:focus { + color: #0c5460; + background-color: #abdde5; +} + +.list-group-item-info.list-group-item-action.active { + color: #fff; + background-color: #0c5460; + border-color: #0c5460; +} + +.list-group-item-warning { + color: #856404; + background-color: #ffeeba; +} + +.list-group-item-warning.list-group-item-action:hover, .list-group-item-warning.list-group-item-action:focus { + color: #856404; + background-color: #ffe8a1; +} + +.list-group-item-warning.list-group-item-action.active { + color: #fff; + background-color: #856404; + border-color: #856404; +} + +.list-group-item-danger { + color: #721c24; + background-color: #f5c6cb; +} + +.list-group-item-danger.list-group-item-action:hover, .list-group-item-danger.list-group-item-action:focus { + color: #721c24; + background-color: #f1b0b7; +} + +.list-group-item-danger.list-group-item-action.active { + color: #fff; + background-color: #721c24; + border-color: #721c24; +} + +.list-group-item-light { + color: #818182; + background-color: #fdfdfe; +} + +.list-group-item-light.list-group-item-action:hover, .list-group-item-light.list-group-item-action:focus { + color: #818182; + background-color: #ececf6; +} + +.list-group-item-light.list-group-item-action.active { + color: #fff; + background-color: #818182; + border-color: #818182; +} + +.list-group-item-dark { + color: #1b1e21; + background-color: #c6c8ca; +} + +.list-group-item-dark.list-group-item-action:hover, .list-group-item-dark.list-group-item-action:focus { + color: #1b1e21; + background-color: #b9bbbe; +} + +.list-group-item-dark.list-group-item-action.active { + color: #fff; + background-color: #1b1e21; + border-color: #1b1e21; +} + +.close { + float: right; + font-size: 1.5rem; + font-weight: 700; + line-height: 1; + color: #000; + text-shadow: 0 1px 0 #fff; + opacity: .5; +} + +.close:hover, .close:focus { + color: #000; + text-decoration: none; + opacity: .75; +} + +.close:not(:disabled):not(.disabled) { + cursor: pointer; +} + +button.close { + padding: 0; + background-color: transparent; + border: 0; + -webkit-appearance: none; +} + +.modal-open { + overflow: hidden; +} + +.modal { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1050; + display: none; + overflow: hidden; + outline: 0; +} + +.modal-open .modal { + overflow-x: hidden; + overflow-y: auto; +} + +.modal-dialog { + position: relative; + width: auto; + margin: 0.5rem; + pointer-events: none; +} + +.modal.fade .modal-dialog { + transition: -webkit-transform 0.3s ease-out; + transition: transform 0.3s ease-out; + transition: transform 0.3s ease-out, -webkit-transform 0.3s ease-out; + -webkit-transform: translate(0, -25%); + transform: translate(0, -25%); +} + +@media screen and (prefers-reduced-motion: reduce) { + .modal.fade .modal-dialog { + transition: none; + } +} + +.modal.show .modal-dialog { + -webkit-transform: translate(0, 0); + transform: translate(0, 0); +} + +.modal-dialog-centered { + display: -ms-flexbox; + display: flex; + -ms-flex-align: center; + align-items: center; + min-height: calc(100% - (0.5rem * 2)); +} + +.modal-content { + position: relative; + display: -ms-flexbox; + display: flex; + -ms-flex-direction: column; + flex-direction: column; + width: 100%; + pointer-events: auto; + background-color: #fff; + background-clip: padding-box; + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 0.3rem; + outline: 0; +} + +.modal-backdrop { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1040; + background-color: #000; +} + +.modal-backdrop.fade { + opacity: 0; +} + +.modal-backdrop.show { + opacity: 0.5; +} + +.modal-header { + display: -ms-flexbox; + display: flex; + -ms-flex-align: start; + align-items: flex-start; + -ms-flex-pack: justify; + justify-content: space-between; + padding: 1rem; + border-bottom: 1px solid #e9ecef; + border-top-left-radius: 0.3rem; + border-top-right-radius: 0.3rem; +} + +.modal-header .close { + padding: 1rem; + margin: -1rem -1rem -1rem auto; +} + +.modal-title { + margin-bottom: 0; + line-height: 1.5; +} + +.modal-body { + position: relative; + -ms-flex: 1 1 auto; + flex: 1 1 auto; + padding: 1rem; +} + +.modal-footer { + display: -ms-flexbox; + display: flex; + -ms-flex-align: center; + align-items: center; + -ms-flex-pack: end; + justify-content: flex-end; + padding: 1rem; + border-top: 1px solid #e9ecef; +} + +.modal-footer > :not(:first-child) { + margin-left: .25rem; +} + +.modal-footer > :not(:last-child) { + margin-right: .25rem; +} + +.modal-scrollbar-measure { + position: absolute; + top: -9999px; + width: 50px; + height: 50px; + overflow: scroll; +} + +@media (min-width: 576px) { + .modal-dialog { + max-width: 500px; + margin: 1.75rem auto; + } + .modal-dialog-centered { + min-height: calc(100% - (1.75rem * 2)); + } + .modal-sm { + max-width: 300px; + } +} + +@media (min-width: 992px) { + .modal-lg { + max-width: 800px; + } +} + +.tooltip { + position: absolute; + z-index: 1070; + display: block; + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + font-style: normal; + font-weight: 400; + line-height: 1.5; + text-align: left; + text-align: start; + text-decoration: none; + text-shadow: none; + text-transform: none; + letter-spacing: normal; + word-break: normal; + word-spacing: normal; + white-space: normal; + line-break: auto; + font-size: 0.875rem; + word-wrap: break-word; + opacity: 0; +} + +.tooltip.show { + opacity: 0.9; +} + +.tooltip .arrow { + position: absolute; + display: block; + width: 0.8rem; + height: 0.4rem; +} + +.tooltip .arrow::before { + position: absolute; + content: ""; + border-color: transparent; + border-style: solid; +} + +.bs-tooltip-top, .bs-tooltip-auto[x-placement^="top"] { + padding: 0.4rem 0; +} + +.bs-tooltip-top .arrow, .bs-tooltip-auto[x-placement^="top"] .arrow { + bottom: 0; +} + +.bs-tooltip-top .arrow::before, .bs-tooltip-auto[x-placement^="top"] .arrow::before { + top: 0; + border-width: 0.4rem 0.4rem 0; + border-top-color: #000; +} + +.bs-tooltip-right, .bs-tooltip-auto[x-placement^="right"] { + padding: 0 0.4rem; +} + +.bs-tooltip-right .arrow, .bs-tooltip-auto[x-placement^="right"] .arrow { + left: 0; + width: 0.4rem; + height: 0.8rem; +} + +.bs-tooltip-right .arrow::before, .bs-tooltip-auto[x-placement^="right"] .arrow::before { + right: 0; + border-width: 0.4rem 0.4rem 0.4rem 0; + border-right-color: #000; +} + +.bs-tooltip-bottom, .bs-tooltip-auto[x-placement^="bottom"] { + padding: 0.4rem 0; +} + +.bs-tooltip-bottom .arrow, .bs-tooltip-auto[x-placement^="bottom"] .arrow { + top: 0; +} + +.bs-tooltip-bottom .arrow::before, .bs-tooltip-auto[x-placement^="bottom"] .arrow::before { + bottom: 0; + border-width: 0 0.4rem 0.4rem; + border-bottom-color: #000; +} + +.bs-tooltip-left, .bs-tooltip-auto[x-placement^="left"] { + padding: 0 0.4rem; +} + +.bs-tooltip-left .arrow, .bs-tooltip-auto[x-placement^="left"] .arrow { + right: 0; + width: 0.4rem; + height: 0.8rem; +} + +.bs-tooltip-left .arrow::before, .bs-tooltip-auto[x-placement^="left"] .arrow::before { + left: 0; + border-width: 0.4rem 0 0.4rem 0.4rem; + border-left-color: #000; +} + +.tooltip-inner { + max-width: 200px; + padding: 0.25rem 0.5rem; + color: #fff; + text-align: center; + background-color: #000; + border-radius: 0.25rem; +} + +.popover { + position: absolute; + top: 0; + left: 0; + z-index: 1060; + display: block; + max-width: 276px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + font-style: normal; + font-weight: 400; + line-height: 1.5; + text-align: left; + text-align: start; + text-decoration: none; + text-shadow: none; + text-transform: none; + letter-spacing: normal; + word-break: normal; + word-spacing: normal; + white-space: normal; + line-break: auto; + font-size: 0.875rem; + word-wrap: break-word; + background-color: #fff; + background-clip: padding-box; + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 0.3rem; +} + +.popover .arrow { + position: absolute; + display: block; + width: 1rem; + height: 0.5rem; + margin: 0 0.3rem; +} + +.popover .arrow::before, .popover .arrow::after { + position: absolute; + display: block; + content: ""; + border-color: transparent; + border-style: solid; +} + +.bs-popover-top, .bs-popover-auto[x-placement^="top"] { + margin-bottom: 0.5rem; +} + +.bs-popover-top .arrow, .bs-popover-auto[x-placement^="top"] .arrow { + bottom: calc((0.5rem + 1px) * -1); +} + +.bs-popover-top .arrow::before, .bs-popover-auto[x-placement^="top"] .arrow::before, +.bs-popover-top .arrow::after, +.bs-popover-auto[x-placement^="top"] .arrow::after { + border-width: 0.5rem 0.5rem 0; +} + +.bs-popover-top .arrow::before, .bs-popover-auto[x-placement^="top"] .arrow::before { + bottom: 0; + border-top-color: rgba(0, 0, 0, 0.25); +} + + +.bs-popover-top .arrow::after, +.bs-popover-auto[x-placement^="top"] .arrow::after { + bottom: 1px; + border-top-color: #fff; +} + +.bs-popover-right, .bs-popover-auto[x-placement^="right"] { + margin-left: 0.5rem; +} + +.bs-popover-right .arrow, .bs-popover-auto[x-placement^="right"] .arrow { + left: calc((0.5rem + 1px) * -1); + width: 0.5rem; + height: 1rem; + margin: 0.3rem 0; +} + +.bs-popover-right .arrow::before, .bs-popover-auto[x-placement^="right"] .arrow::before, +.bs-popover-right .arrow::after, +.bs-popover-auto[x-placement^="right"] .arrow::after { + border-width: 0.5rem 0.5rem 0.5rem 0; +} + +.bs-popover-right .arrow::before, .bs-popover-auto[x-placement^="right"] .arrow::before { + left: 0; + border-right-color: rgba(0, 0, 0, 0.25); +} + + +.bs-popover-right .arrow::after, +.bs-popover-auto[x-placement^="right"] .arrow::after { + left: 1px; + border-right-color: #fff; +} + +.bs-popover-bottom, .bs-popover-auto[x-placement^="bottom"] { + margin-top: 0.5rem; +} + +.bs-popover-bottom .arrow, .bs-popover-auto[x-placement^="bottom"] .arrow { + top: calc((0.5rem + 1px) * -1); +} + +.bs-popover-bottom .arrow::before, .bs-popover-auto[x-placement^="bottom"] .arrow::before, +.bs-popover-bottom .arrow::after, +.bs-popover-auto[x-placement^="bottom"] .arrow::after { + border-width: 0 0.5rem 0.5rem 0.5rem; +} + +.bs-popover-bottom .arrow::before, .bs-popover-auto[x-placement^="bottom"] .arrow::before { + top: 0; + border-bottom-color: rgba(0, 0, 0, 0.25); +} + + +.bs-popover-bottom .arrow::after, +.bs-popover-auto[x-placement^="bottom"] .arrow::after { + top: 1px; + border-bottom-color: #fff; +} + +.bs-popover-bottom .popover-header::before, .bs-popover-auto[x-placement^="bottom"] .popover-header::before { + position: absolute; + top: 0; + left: 50%; + display: block; + width: 1rem; + margin-left: -0.5rem; + content: ""; + border-bottom: 1px solid #f7f7f7; +} + +.bs-popover-left, .bs-popover-auto[x-placement^="left"] { + margin-right: 0.5rem; +} + +.bs-popover-left .arrow, .bs-popover-auto[x-placement^="left"] .arrow { + right: calc((0.5rem + 1px) * -1); + width: 0.5rem; + height: 1rem; + margin: 0.3rem 0; +} + +.bs-popover-left .arrow::before, .bs-popover-auto[x-placement^="left"] .arrow::before, +.bs-popover-left .arrow::after, +.bs-popover-auto[x-placement^="left"] .arrow::after { + border-width: 0.5rem 0 0.5rem 0.5rem; +} + +.bs-popover-left .arrow::before, .bs-popover-auto[x-placement^="left"] .arrow::before { + right: 0; + border-left-color: rgba(0, 0, 0, 0.25); +} + + +.bs-popover-left .arrow::after, +.bs-popover-auto[x-placement^="left"] .arrow::after { + right: 1px; + border-left-color: #fff; +} + +.popover-header { + padding: 0.5rem 0.75rem; + margin-bottom: 0; + font-size: 1rem; + color: inherit; + background-color: #f7f7f7; + border-bottom: 1px solid #ebebeb; + border-top-left-radius: calc(0.3rem - 1px); + border-top-right-radius: calc(0.3rem - 1px); +} + +.popover-header:empty { + display: none; +} + +.popover-body { + padding: 0.5rem 0.75rem; + color: #212529; +} + +.carousel { + position: relative; +} + +.carousel-inner { + position: relative; + width: 100%; + overflow: hidden; +} + +.carousel-item { + position: relative; + display: none; + -ms-flex-align: center; + align-items: center; + width: 100%; + transition: -webkit-transform 0.6s ease; + transition: transform 0.6s ease; + transition: transform 0.6s ease, -webkit-transform 0.6s ease; + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + -webkit-perspective: 1000px; + perspective: 1000px; +} + +@media screen and (prefers-reduced-motion: reduce) { + .carousel-item { + transition: none; + } +} + +.carousel-item.active, +.carousel-item-next, +.carousel-item-prev { + display: block; +} + +.carousel-item-next, +.carousel-item-prev { + position: absolute; + top: 0; +} + +.carousel-item-next.carousel-item-left, +.carousel-item-prev.carousel-item-right { + -webkit-transform: translateX(0); + transform: translateX(0); +} + +@supports ((-webkit-transform-style: preserve-3d) or (transform-style: preserve-3d)) { + .carousel-item-next.carousel-item-left, + .carousel-item-prev.carousel-item-right { + -webkit-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + } +} + +.carousel-item-next, +.active.carousel-item-right { + -webkit-transform: translateX(100%); + transform: translateX(100%); +} + +@supports ((-webkit-transform-style: preserve-3d) or (transform-style: preserve-3d)) { + .carousel-item-next, + .active.carousel-item-right { + -webkit-transform: translate3d(100%, 0, 0); + transform: translate3d(100%, 0, 0); + } +} + +.carousel-item-prev, +.active.carousel-item-left { + -webkit-transform: translateX(-100%); + transform: translateX(-100%); +} + +@supports ((-webkit-transform-style: preserve-3d) or (transform-style: preserve-3d)) { + .carousel-item-prev, + .active.carousel-item-left { + -webkit-transform: translate3d(-100%, 0, 0); + transform: translate3d(-100%, 0, 0); + } +} + +.carousel-fade .carousel-item { + opacity: 0; + transition-duration: .6s; + transition-property: opacity; +} + +.carousel-fade .carousel-item.active, +.carousel-fade .carousel-item-next.carousel-item-left, +.carousel-fade .carousel-item-prev.carousel-item-right { + opacity: 1; +} + +.carousel-fade .active.carousel-item-left, +.carousel-fade .active.carousel-item-right { + opacity: 0; +} + +.carousel-fade .carousel-item-next, +.carousel-fade .carousel-item-prev, +.carousel-fade .carousel-item.active, +.carousel-fade .active.carousel-item-left, +.carousel-fade .active.carousel-item-prev { + -webkit-transform: translateX(0); + transform: translateX(0); +} + +@supports ((-webkit-transform-style: preserve-3d) or (transform-style: preserve-3d)) { + .carousel-fade .carousel-item-next, + .carousel-fade .carousel-item-prev, + .carousel-fade .carousel-item.active, + .carousel-fade .active.carousel-item-left, + .carousel-fade .active.carousel-item-prev { + -webkit-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + } +} + +.carousel-control-prev, +.carousel-control-next { + position: absolute; + top: 0; + bottom: 0; + display: -ms-flexbox; + display: flex; + -ms-flex-align: center; + align-items: center; + -ms-flex-pack: center; + justify-content: center; + width: 15%; + color: #fff; + text-align: center; + opacity: 0.5; +} + +.carousel-control-prev:hover, .carousel-control-prev:focus, +.carousel-control-next:hover, +.carousel-control-next:focus { + color: #fff; + text-decoration: none; + outline: 0; + opacity: .9; +} + +.carousel-control-prev { + left: 0; +} + +.carousel-control-next { + right: 0; +} + +.carousel-control-prev-icon, +.carousel-control-next-icon { + display: inline-block; + width: 20px; + height: 20px; + background: transparent no-repeat center center; + background-size: 100% 100%; +} + +.carousel-control-prev-icon { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 8 8'%3E%3Cpath d='M5.25 0l-4 4 4 4 1.5-1.5-2.5-2.5 2.5-2.5-1.5-1.5z'/%3E%3C/svg%3E"); +} + +.carousel-control-next-icon { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 8 8'%3E%3Cpath d='M2.75 0l-1.5 1.5 2.5 2.5-2.5 2.5 1.5 1.5 4-4-4-4z'/%3E%3C/svg%3E"); +} + +.carousel-indicators { + position: absolute; + right: 0; + bottom: 10px; + left: 0; + z-index: 15; + display: -ms-flexbox; + display: flex; + -ms-flex-pack: center; + justify-content: center; + padding-left: 0; + margin-right: 15%; + margin-left: 15%; + list-style: none; +} + +.carousel-indicators li { + position: relative; + -ms-flex: 0 1 auto; + flex: 0 1 auto; + width: 30px; + height: 3px; + margin-right: 3px; + margin-left: 3px; + text-indent: -999px; + cursor: pointer; + background-color: rgba(255, 255, 255, 0.5); +} + +.carousel-indicators li::before { + position: absolute; + top: -10px; + left: 0; + display: inline-block; + width: 100%; + height: 10px; + content: ""; +} + +.carousel-indicators li::after { + position: absolute; + bottom: -10px; + left: 0; + display: inline-block; + width: 100%; + height: 10px; + content: ""; +} + +.carousel-indicators .active { + background-color: #fff; +} + +.carousel-caption { + position: absolute; + right: 15%; + bottom: 20px; + left: 15%; + z-index: 10; + padding-top: 20px; + padding-bottom: 20px; + color: #fff; + text-align: center; +} + +.align-baseline { + vertical-align: baseline !important; +} + +.align-top { + vertical-align: top !important; +} + +.align-middle { + vertical-align: middle !important; +} + +.align-bottom { + vertical-align: bottom !important; +} + +.align-text-bottom { + vertical-align: text-bottom !important; +} + +.align-text-top { + vertical-align: text-top !important; +} + +.bg-primary { + background-color: #007bff !important; +} + +a.bg-primary:hover, a.bg-primary:focus, +button.bg-primary:hover, +button.bg-primary:focus { + background-color: #0062cc !important; +} + +.bg-secondary { + background-color: #6c757d !important; +} + +a.bg-secondary:hover, a.bg-secondary:focus, +button.bg-secondary:hover, +button.bg-secondary:focus { + background-color: #545b62 !important; +} + +.bg-success { + background-color: #28a745 !important; +} + +a.bg-success:hover, a.bg-success:focus, +button.bg-success:hover, +button.bg-success:focus { + background-color: #1e7e34 !important; +} + +.bg-info { + background-color: #17a2b8 !important; +} + +a.bg-info:hover, a.bg-info:focus, +button.bg-info:hover, +button.bg-info:focus { + background-color: #117a8b !important; +} + +.bg-warning { + background-color: #ffc107 !important; +} + +a.bg-warning:hover, a.bg-warning:focus, +button.bg-warning:hover, +button.bg-warning:focus { + background-color: #d39e00 !important; +} + +.bg-danger { + background-color: #dc3545 !important; +} + +a.bg-danger:hover, a.bg-danger:focus, +button.bg-danger:hover, +button.bg-danger:focus { + background-color: #bd2130 !important; +} + +.bg-light { + background-color: #f8f9fa !important; +} + +a.bg-light:hover, a.bg-light:focus, +button.bg-light:hover, +button.bg-light:focus { + background-color: #dae0e5 !important; +} + +.bg-dark { + background-color: #343a40 !important; +} + +a.bg-dark:hover, a.bg-dark:focus, +button.bg-dark:hover, +button.bg-dark:focus { + background-color: #1d2124 !important; +} + +.bg-white { + background-color: #fff !important; +} + +.bg-transparent { + background-color: transparent !important; +} + +.border { + border: 1px solid #dee2e6 !important; +} + +.border-top { + border-top: 1px solid #dee2e6 !important; +} + +.border-right { + border-right: 1px solid #dee2e6 !important; +} + +.border-bottom { + border-bottom: 1px solid #dee2e6 !important; +} + +.border-left { + border-left: 1px solid #dee2e6 !important; +} + +.border-0 { + border: 0 !important; +} + +.border-top-0 { + border-top: 0 !important; +} + +.border-right-0 { + border-right: 0 !important; +} + +.border-bottom-0 { + border-bottom: 0 !important; +} + +.border-left-0 { + border-left: 0 !important; +} + +.border-primary { + border-color: #007bff !important; +} + +.border-secondary { + border-color: #6c757d !important; +} + +.border-success { + border-color: #28a745 !important; +} + +.border-info { + border-color: #17a2b8 !important; +} + +.border-warning { + border-color: #ffc107 !important; +} + +.border-danger { + border-color: #dc3545 !important; +} + +.border-light { + border-color: #f8f9fa !important; +} + +.border-dark { + border-color: #343a40 !important; +} + +.border-white { + border-color: #fff !important; +} + +.rounded { + border-radius: 0.25rem !important; +} + +.rounded-top { + border-top-left-radius: 0.25rem !important; + border-top-right-radius: 0.25rem !important; +} + +.rounded-right { + border-top-right-radius: 0.25rem !important; + border-bottom-right-radius: 0.25rem !important; +} + +.rounded-bottom { + border-bottom-right-radius: 0.25rem !important; + border-bottom-left-radius: 0.25rem !important; +} + +.rounded-left { + border-top-left-radius: 0.25rem !important; + border-bottom-left-radius: 0.25rem !important; +} + +.rounded-circle { + border-radius: 50% !important; +} + +.rounded-0 { + border-radius: 0 !important; +} + +.clearfix::after { + display: block; + clear: both; + content: ""; +} + +.d-none { + display: none !important; +} + +.d-inline { + display: inline !important; +} + +.d-inline-block { + display: inline-block !important; +} + +.d-block { + display: block !important; +} + +.d-table { + display: table !important; +} + +.d-table-row { + display: table-row !important; +} + +.d-table-cell { + display: table-cell !important; +} + +.d-flex { + display: -ms-flexbox !important; + display: flex !important; +} + +.d-inline-flex { + display: -ms-inline-flexbox !important; + display: inline-flex !important; +} + +@media (min-width: 576px) { + .d-sm-none { + display: none !important; + } + .d-sm-inline { + display: inline !important; + } + .d-sm-inline-block { + display: inline-block !important; + } + .d-sm-block { + display: block !important; + } + .d-sm-table { + display: table !important; + } + .d-sm-table-row { + display: table-row !important; + } + .d-sm-table-cell { + display: table-cell !important; + } + .d-sm-flex { + display: -ms-flexbox !important; + display: flex !important; + } + .d-sm-inline-flex { + display: -ms-inline-flexbox !important; + display: inline-flex !important; + } +} + +@media (min-width: 768px) { + .d-md-none { + display: none !important; + } + .d-md-inline { + display: inline !important; + } + .d-md-inline-block { + display: inline-block !important; + } + .d-md-block { + display: block !important; + } + .d-md-table { + display: table !important; + } + .d-md-table-row { + display: table-row !important; + } + .d-md-table-cell { + display: table-cell !important; + } + .d-md-flex { + display: -ms-flexbox !important; + display: flex !important; + } + .d-md-inline-flex { + display: -ms-inline-flexbox !important; + display: inline-flex !important; + } +} + +@media (min-width: 992px) { + .d-lg-none { + display: none !important; + } + .d-lg-inline { + display: inline !important; + } + .d-lg-inline-block { + display: inline-block !important; + } + .d-lg-block { + display: block !important; + } + .d-lg-table { + display: table !important; + } + .d-lg-table-row { + display: table-row !important; + } + .d-lg-table-cell { + display: table-cell !important; + } + .d-lg-flex { + display: -ms-flexbox !important; + display: flex !important; + } + .d-lg-inline-flex { + display: -ms-inline-flexbox !important; + display: inline-flex !important; + } +} + +@media (min-width: 1200px) { + .d-xl-none { + display: none !important; + } + .d-xl-inline { + display: inline !important; + } + .d-xl-inline-block { + display: inline-block !important; + } + .d-xl-block { + display: block !important; + } + .d-xl-table { + display: table !important; + } + .d-xl-table-row { + display: table-row !important; + } + .d-xl-table-cell { + display: table-cell !important; + } + .d-xl-flex { + display: -ms-flexbox !important; + display: flex !important; + } + .d-xl-inline-flex { + display: -ms-inline-flexbox !important; + display: inline-flex !important; + } +} + +@media print { + .d-print-none { + display: none !important; + } + .d-print-inline { + display: inline !important; + } + .d-print-inline-block { + display: inline-block !important; + } + .d-print-block { + display: block !important; + } + .d-print-table { + display: table !important; + } + .d-print-table-row { + display: table-row !important; + } + .d-print-table-cell { + display: table-cell !important; + } + .d-print-flex { + display: -ms-flexbox !important; + display: flex !important; + } + .d-print-inline-flex { + display: -ms-inline-flexbox !important; + display: inline-flex !important; + } +} + +.embed-responsive { + position: relative; + display: block; + width: 100%; + padding: 0; + overflow: hidden; +} + +.embed-responsive::before { + display: block; + content: ""; +} + +.embed-responsive .embed-responsive-item, +.embed-responsive iframe, +.embed-responsive embed, +.embed-responsive object, +.embed-responsive video { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 100%; + height: 100%; + border: 0; +} + +.embed-responsive-21by9::before { + padding-top: 42.857143%; +} + +.embed-responsive-16by9::before { + padding-top: 56.25%; +} + +.embed-responsive-4by3::before { + padding-top: 75%; +} + +.embed-responsive-1by1::before { + padding-top: 100%; +} + +.flex-row { + -ms-flex-direction: row !important; + flex-direction: row !important; +} + +.flex-column { + -ms-flex-direction: column !important; + flex-direction: column !important; +} + +.flex-row-reverse { + -ms-flex-direction: row-reverse !important; + flex-direction: row-reverse !important; +} + +.flex-column-reverse { + -ms-flex-direction: column-reverse !important; + flex-direction: column-reverse !important; +} + +.flex-wrap { + -ms-flex-wrap: wrap !important; + flex-wrap: wrap !important; +} + +.flex-nowrap { + -ms-flex-wrap: nowrap !important; + flex-wrap: nowrap !important; +} + +.flex-wrap-reverse { + -ms-flex-wrap: wrap-reverse !important; + flex-wrap: wrap-reverse !important; +} + +.flex-fill { + -ms-flex: 1 1 auto !important; + flex: 1 1 auto !important; +} + +.flex-grow-0 { + -ms-flex-positive: 0 !important; + flex-grow: 0 !important; +} + +.flex-grow-1 { + -ms-flex-positive: 1 !important; + flex-grow: 1 !important; +} + +.flex-shrink-0 { + -ms-flex-negative: 0 !important; + flex-shrink: 0 !important; +} + +.flex-shrink-1 { + -ms-flex-negative: 1 !important; + flex-shrink: 1 !important; +} + +.justify-content-start { + -ms-flex-pack: start !important; + justify-content: flex-start !important; +} + +.justify-content-end { + -ms-flex-pack: end !important; + justify-content: flex-end !important; +} + +.justify-content-center { + -ms-flex-pack: center !important; + justify-content: center !important; +} + +.justify-content-between { + -ms-flex-pack: justify !important; + justify-content: space-between !important; +} + +.justify-content-around { + -ms-flex-pack: distribute !important; + justify-content: space-around !important; +} + +.align-items-start { + -ms-flex-align: start !important; + align-items: flex-start !important; +} + +.align-items-end { + -ms-flex-align: end !important; + align-items: flex-end !important; +} + +.align-items-center { + -ms-flex-align: center !important; + align-items: center !important; +} + +.align-items-baseline { + -ms-flex-align: baseline !important; + align-items: baseline !important; +} + +.align-items-stretch { + -ms-flex-align: stretch !important; + align-items: stretch !important; +} + +.align-content-start { + -ms-flex-line-pack: start !important; + align-content: flex-start !important; +} + +.align-content-end { + -ms-flex-line-pack: end !important; + align-content: flex-end !important; +} + +.align-content-center { + -ms-flex-line-pack: center !important; + align-content: center !important; +} + +.align-content-between { + -ms-flex-line-pack: justify !important; + align-content: space-between !important; +} + +.align-content-around { + -ms-flex-line-pack: distribute !important; + align-content: space-around !important; +} + +.align-content-stretch { + -ms-flex-line-pack: stretch !important; + align-content: stretch !important; +} + +.align-self-auto { + -ms-flex-item-align: auto !important; + align-self: auto !important; +} + +.align-self-start { + -ms-flex-item-align: start !important; + align-self: flex-start !important; +} + +.align-self-end { + -ms-flex-item-align: end !important; + align-self: flex-end !important; +} + +.align-self-center { + -ms-flex-item-align: center !important; + align-self: center !important; +} + +.align-self-baseline { + -ms-flex-item-align: baseline !important; + align-self: baseline !important; +} + +.align-self-stretch { + -ms-flex-item-align: stretch !important; + align-self: stretch !important; +} + +@media (min-width: 576px) { + .flex-sm-row { + -ms-flex-direction: row !important; + flex-direction: row !important; + } + .flex-sm-column { + -ms-flex-direction: column !important; + flex-direction: column !important; + } + .flex-sm-row-reverse { + -ms-flex-direction: row-reverse !important; + flex-direction: row-reverse !important; + } + .flex-sm-column-reverse { + -ms-flex-direction: column-reverse !important; + flex-direction: column-reverse !important; + } + .flex-sm-wrap { + -ms-flex-wrap: wrap !important; + flex-wrap: wrap !important; + } + .flex-sm-nowrap { + -ms-flex-wrap: nowrap !important; + flex-wrap: nowrap !important; + } + .flex-sm-wrap-reverse { + -ms-flex-wrap: wrap-reverse !important; + flex-wrap: wrap-reverse !important; + } + .flex-sm-fill { + -ms-flex: 1 1 auto !important; + flex: 1 1 auto !important; + } + .flex-sm-grow-0 { + -ms-flex-positive: 0 !important; + flex-grow: 0 !important; + } + .flex-sm-grow-1 { + -ms-flex-positive: 1 !important; + flex-grow: 1 !important; + } + .flex-sm-shrink-0 { + -ms-flex-negative: 0 !important; + flex-shrink: 0 !important; + } + .flex-sm-shrink-1 { + -ms-flex-negative: 1 !important; + flex-shrink: 1 !important; + } + .justify-content-sm-start { + -ms-flex-pack: start !important; + justify-content: flex-start !important; + } + .justify-content-sm-end { + -ms-flex-pack: end !important; + justify-content: flex-end !important; + } + .justify-content-sm-center { + -ms-flex-pack: center !important; + justify-content: center !important; + } + .justify-content-sm-between { + -ms-flex-pack: justify !important; + justify-content: space-between !important; + } + .justify-content-sm-around { + -ms-flex-pack: distribute !important; + justify-content: space-around !important; + } + .align-items-sm-start { + -ms-flex-align: start !important; + align-items: flex-start !important; + } + .align-items-sm-end { + -ms-flex-align: end !important; + align-items: flex-end !important; + } + .align-items-sm-center { + -ms-flex-align: center !important; + align-items: center !important; + } + .align-items-sm-baseline { + -ms-flex-align: baseline !important; + align-items: baseline !important; + } + .align-items-sm-stretch { + -ms-flex-align: stretch !important; + align-items: stretch !important; + } + .align-content-sm-start { + -ms-flex-line-pack: start !important; + align-content: flex-start !important; + } + .align-content-sm-end { + -ms-flex-line-pack: end !important; + align-content: flex-end !important; + } + .align-content-sm-center { + -ms-flex-line-pack: center !important; + align-content: center !important; + } + .align-content-sm-between { + -ms-flex-line-pack: justify !important; + align-content: space-between !important; + } + .align-content-sm-around { + -ms-flex-line-pack: distribute !important; + align-content: space-around !important; + } + .align-content-sm-stretch { + -ms-flex-line-pack: stretch !important; + align-content: stretch !important; + } + .align-self-sm-auto { + -ms-flex-item-align: auto !important; + align-self: auto !important; + } + .align-self-sm-start { + -ms-flex-item-align: start !important; + align-self: flex-start !important; + } + .align-self-sm-end { + -ms-flex-item-align: end !important; + align-self: flex-end !important; + } + .align-self-sm-center { + -ms-flex-item-align: center !important; + align-self: center !important; + } + .align-self-sm-baseline { + -ms-flex-item-align: baseline !important; + align-self: baseline !important; + } + .align-self-sm-stretch { + -ms-flex-item-align: stretch !important; + align-self: stretch !important; + } +} + +@media (min-width: 768px) { + .flex-md-row { + -ms-flex-direction: row !important; + flex-direction: row !important; + } + .flex-md-column { + -ms-flex-direction: column !important; + flex-direction: column !important; + } + .flex-md-row-reverse { + -ms-flex-direction: row-reverse !important; + flex-direction: row-reverse !important; + } + .flex-md-column-reverse { + -ms-flex-direction: column-reverse !important; + flex-direction: column-reverse !important; + } + .flex-md-wrap { + -ms-flex-wrap: wrap !important; + flex-wrap: wrap !important; + } + .flex-md-nowrap { + -ms-flex-wrap: nowrap !important; + flex-wrap: nowrap !important; + } + .flex-md-wrap-reverse { + -ms-flex-wrap: wrap-reverse !important; + flex-wrap: wrap-reverse !important; + } + .flex-md-fill { + -ms-flex: 1 1 auto !important; + flex: 1 1 auto !important; + } + .flex-md-grow-0 { + -ms-flex-positive: 0 !important; + flex-grow: 0 !important; + } + .flex-md-grow-1 { + -ms-flex-positive: 1 !important; + flex-grow: 1 !important; + } + .flex-md-shrink-0 { + -ms-flex-negative: 0 !important; + flex-shrink: 0 !important; + } + .flex-md-shrink-1 { + -ms-flex-negative: 1 !important; + flex-shrink: 1 !important; + } + .justify-content-md-start { + -ms-flex-pack: start !important; + justify-content: flex-start !important; + } + .justify-content-md-end { + -ms-flex-pack: end !important; + justify-content: flex-end !important; + } + .justify-content-md-center { + -ms-flex-pack: center !important; + justify-content: center !important; + } + .justify-content-md-between { + -ms-flex-pack: justify !important; + justify-content: space-between !important; + } + .justify-content-md-around { + -ms-flex-pack: distribute !important; + justify-content: space-around !important; + } + .align-items-md-start { + -ms-flex-align: start !important; + align-items: flex-start !important; + } + .align-items-md-end { + -ms-flex-align: end !important; + align-items: flex-end !important; + } + .align-items-md-center { + -ms-flex-align: center !important; + align-items: center !important; + } + .align-items-md-baseline { + -ms-flex-align: baseline !important; + align-items: baseline !important; + } + .align-items-md-stretch { + -ms-flex-align: stretch !important; + align-items: stretch !important; + } + .align-content-md-start { + -ms-flex-line-pack: start !important; + align-content: flex-start !important; + } + .align-content-md-end { + -ms-flex-line-pack: end !important; + align-content: flex-end !important; + } + .align-content-md-center { + -ms-flex-line-pack: center !important; + align-content: center !important; + } + .align-content-md-between { + -ms-flex-line-pack: justify !important; + align-content: space-between !important; + } + .align-content-md-around { + -ms-flex-line-pack: distribute !important; + align-content: space-around !important; + } + .align-content-md-stretch { + -ms-flex-line-pack: stretch !important; + align-content: stretch !important; + } + .align-self-md-auto { + -ms-flex-item-align: auto !important; + align-self: auto !important; + } + .align-self-md-start { + -ms-flex-item-align: start !important; + align-self: flex-start !important; + } + .align-self-md-end { + -ms-flex-item-align: end !important; + align-self: flex-end !important; + } + .align-self-md-center { + -ms-flex-item-align: center !important; + align-self: center !important; + } + .align-self-md-baseline { + -ms-flex-item-align: baseline !important; + align-self: baseline !important; + } + .align-self-md-stretch { + -ms-flex-item-align: stretch !important; + align-self: stretch !important; + } +} + +@media (min-width: 992px) { + .flex-lg-row { + -ms-flex-direction: row !important; + flex-direction: row !important; + } + .flex-lg-column { + -ms-flex-direction: column !important; + flex-direction: column !important; + } + .flex-lg-row-reverse { + -ms-flex-direction: row-reverse !important; + flex-direction: row-reverse !important; + } + .flex-lg-column-reverse { + -ms-flex-direction: column-reverse !important; + flex-direction: column-reverse !important; + } + .flex-lg-wrap { + -ms-flex-wrap: wrap !important; + flex-wrap: wrap !important; + } + .flex-lg-nowrap { + -ms-flex-wrap: nowrap !important; + flex-wrap: nowrap !important; + } + .flex-lg-wrap-reverse { + -ms-flex-wrap: wrap-reverse !important; + flex-wrap: wrap-reverse !important; + } + .flex-lg-fill { + -ms-flex: 1 1 auto !important; + flex: 1 1 auto !important; + } + .flex-lg-grow-0 { + -ms-flex-positive: 0 !important; + flex-grow: 0 !important; + } + .flex-lg-grow-1 { + -ms-flex-positive: 1 !important; + flex-grow: 1 !important; + } + .flex-lg-shrink-0 { + -ms-flex-negative: 0 !important; + flex-shrink: 0 !important; + } + .flex-lg-shrink-1 { + -ms-flex-negative: 1 !important; + flex-shrink: 1 !important; + } + .justify-content-lg-start { + -ms-flex-pack: start !important; + justify-content: flex-start !important; + } + .justify-content-lg-end { + -ms-flex-pack: end !important; + justify-content: flex-end !important; + } + .justify-content-lg-center { + -ms-flex-pack: center !important; + justify-content: center !important; + } + .justify-content-lg-between { + -ms-flex-pack: justify !important; + justify-content: space-between !important; + } + .justify-content-lg-around { + -ms-flex-pack: distribute !important; + justify-content: space-around !important; + } + .align-items-lg-start { + -ms-flex-align: start !important; + align-items: flex-start !important; + } + .align-items-lg-end { + -ms-flex-align: end !important; + align-items: flex-end !important; + } + .align-items-lg-center { + -ms-flex-align: center !important; + align-items: center !important; + } + .align-items-lg-baseline { + -ms-flex-align: baseline !important; + align-items: baseline !important; + } + .align-items-lg-stretch { + -ms-flex-align: stretch !important; + align-items: stretch !important; + } + .align-content-lg-start { + -ms-flex-line-pack: start !important; + align-content: flex-start !important; + } + .align-content-lg-end { + -ms-flex-line-pack: end !important; + align-content: flex-end !important; + } + .align-content-lg-center { + -ms-flex-line-pack: center !important; + align-content: center !important; + } + .align-content-lg-between { + -ms-flex-line-pack: justify !important; + align-content: space-between !important; + } + .align-content-lg-around { + -ms-flex-line-pack: distribute !important; + align-content: space-around !important; + } + .align-content-lg-stretch { + -ms-flex-line-pack: stretch !important; + align-content: stretch !important; + } + .align-self-lg-auto { + -ms-flex-item-align: auto !important; + align-self: auto !important; + } + .align-self-lg-start { + -ms-flex-item-align: start !important; + align-self: flex-start !important; + } + .align-self-lg-end { + -ms-flex-item-align: end !important; + align-self: flex-end !important; + } + .align-self-lg-center { + -ms-flex-item-align: center !important; + align-self: center !important; + } + .align-self-lg-baseline { + -ms-flex-item-align: baseline !important; + align-self: baseline !important; + } + .align-self-lg-stretch { + -ms-flex-item-align: stretch !important; + align-self: stretch !important; + } +} + +@media (min-width: 1200px) { + .flex-xl-row { + -ms-flex-direction: row !important; + flex-direction: row !important; + } + .flex-xl-column { + -ms-flex-direction: column !important; + flex-direction: column !important; + } + .flex-xl-row-reverse { + -ms-flex-direction: row-reverse !important; + flex-direction: row-reverse !important; + } + .flex-xl-column-reverse { + -ms-flex-direction: column-reverse !important; + flex-direction: column-reverse !important; + } + .flex-xl-wrap { + -ms-flex-wrap: wrap !important; + flex-wrap: wrap !important; + } + .flex-xl-nowrap { + -ms-flex-wrap: nowrap !important; + flex-wrap: nowrap !important; + } + .flex-xl-wrap-reverse { + -ms-flex-wrap: wrap-reverse !important; + flex-wrap: wrap-reverse !important; + } + .flex-xl-fill { + -ms-flex: 1 1 auto !important; + flex: 1 1 auto !important; + } + .flex-xl-grow-0 { + -ms-flex-positive: 0 !important; + flex-grow: 0 !important; + } + .flex-xl-grow-1 { + -ms-flex-positive: 1 !important; + flex-grow: 1 !important; + } + .flex-xl-shrink-0 { + -ms-flex-negative: 0 !important; + flex-shrink: 0 !important; + } + .flex-xl-shrink-1 { + -ms-flex-negative: 1 !important; + flex-shrink: 1 !important; + } + .justify-content-xl-start { + -ms-flex-pack: start !important; + justify-content: flex-start !important; + } + .justify-content-xl-end { + -ms-flex-pack: end !important; + justify-content: flex-end !important; + } + .justify-content-xl-center { + -ms-flex-pack: center !important; + justify-content: center !important; + } + .justify-content-xl-between { + -ms-flex-pack: justify !important; + justify-content: space-between !important; + } + .justify-content-xl-around { + -ms-flex-pack: distribute !important; + justify-content: space-around !important; + } + .align-items-xl-start { + -ms-flex-align: start !important; + align-items: flex-start !important; + } + .align-items-xl-end { + -ms-flex-align: end !important; + align-items: flex-end !important; + } + .align-items-xl-center { + -ms-flex-align: center !important; + align-items: center !important; + } + .align-items-xl-baseline { + -ms-flex-align: baseline !important; + align-items: baseline !important; + } + .align-items-xl-stretch { + -ms-flex-align: stretch !important; + align-items: stretch !important; + } + .align-content-xl-start { + -ms-flex-line-pack: start !important; + align-content: flex-start !important; + } + .align-content-xl-end { + -ms-flex-line-pack: end !important; + align-content: flex-end !important; + } + .align-content-xl-center { + -ms-flex-line-pack: center !important; + align-content: center !important; + } + .align-content-xl-between { + -ms-flex-line-pack: justify !important; + align-content: space-between !important; + } + .align-content-xl-around { + -ms-flex-line-pack: distribute !important; + align-content: space-around !important; + } + .align-content-xl-stretch { + -ms-flex-line-pack: stretch !important; + align-content: stretch !important; + } + .align-self-xl-auto { + -ms-flex-item-align: auto !important; + align-self: auto !important; + } + .align-self-xl-start { + -ms-flex-item-align: start !important; + align-self: flex-start !important; + } + .align-self-xl-end { + -ms-flex-item-align: end !important; + align-self: flex-end !important; + } + .align-self-xl-center { + -ms-flex-item-align: center !important; + align-self: center !important; + } + .align-self-xl-baseline { + -ms-flex-item-align: baseline !important; + align-self: baseline !important; + } + .align-self-xl-stretch { + -ms-flex-item-align: stretch !important; + align-self: stretch !important; + } +} + +.float-left { + float: left !important; +} + +.float-right { + float: right !important; +} + +.float-none { + float: none !important; +} + +@media (min-width: 576px) { + .float-sm-left { + float: left !important; + } + .float-sm-right { + float: right !important; + } + .float-sm-none { + float: none !important; + } +} + +@media (min-width: 768px) { + .float-md-left { + float: left !important; + } + .float-md-right { + float: right !important; + } + .float-md-none { + float: none !important; + } +} + +@media (min-width: 992px) { + .float-lg-left { + float: left !important; + } + .float-lg-right { + float: right !important; + } + .float-lg-none { + float: none !important; + } +} + +@media (min-width: 1200px) { + .float-xl-left { + float: left !important; + } + .float-xl-right { + float: right !important; + } + .float-xl-none { + float: none !important; + } +} + +.position-static { + position: static !important; +} + +.position-relative { + position: relative !important; +} + +.position-absolute { + position: absolute !important; +} + +.position-fixed { + position: fixed !important; +} + +.position-sticky { + position: -webkit-sticky !important; + position: sticky !important; +} + +.fixed-top { + position: fixed; + top: 0; + right: 0; + left: 0; + z-index: 1030; +} + +.fixed-bottom { + position: fixed; + right: 0; + bottom: 0; + left: 0; + z-index: 1030; +} + +@supports ((position: -webkit-sticky) or (position: sticky)) { + .sticky-top { + position: -webkit-sticky; + position: sticky; + top: 0; + z-index: 1020; + } +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.sr-only-focusable:active, .sr-only-focusable:focus { + position: static; + width: auto; + height: auto; + overflow: visible; + clip: auto; + white-space: normal; +} + +.shadow-sm { + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075) !important; +} + +.shadow { + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important; +} + +.shadow-lg { + box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.175) !important; +} + +.shadow-none { + box-shadow: none !important; +} + +.w-25 { + width: 25% !important; +} + +.w-50 { + width: 50% !important; +} + +.w-75 { + width: 75% !important; +} + +.w-100 { + width: 100% !important; +} + +.w-auto { + width: auto !important; +} + +.h-25 { + height: 25% !important; +} + +.h-50 { + height: 50% !important; +} + +.h-75 { + height: 75% !important; +} + +.h-100 { + height: 100% !important; +} + +.h-auto { + height: auto !important; +} + +.mw-100 { + max-width: 100% !important; +} + +.mh-100 { + max-height: 100% !important; +} + +.m-0 { + margin: 0 !important; +} + +.mt-0, +.my-0 { + margin-top: 0 !important; +} + +.mr-0, +.mx-0 { + margin-right: 0 !important; +} + +.mb-0, +.my-0 { + margin-bottom: 0 !important; +} + +.ml-0, +.mx-0 { + margin-left: 0 !important; +} + +.m-1 { + margin: 0.25rem !important; +} + +.mt-1, +.my-1 { + margin-top: 0.25rem !important; +} + +.mr-1, +.mx-1 { + margin-right: 0.25rem !important; +} + +.mb-1, +.my-1 { + margin-bottom: 0.25rem !important; +} + +.ml-1, +.mx-1 { + margin-left: 0.25rem !important; +} + +.m-2 { + margin: 0.5rem !important; +} + +.mt-2, +.my-2 { + margin-top: 0.5rem !important; +} + +.mr-2, +.mx-2 { + margin-right: 0.5rem !important; +} + +.mb-2, +.my-2 { + margin-bottom: 0.5rem !important; +} + +.ml-2, +.mx-2 { + margin-left: 0.5rem !important; +} + +.m-3 { + margin: 1rem !important; +} + +.mt-3, +.my-3 { + margin-top: 1rem !important; +} + +.mr-3, +.mx-3 { + margin-right: 1rem !important; +} + +.mb-3, +.my-3 { + margin-bottom: 1rem !important; +} + +.ml-3, +.mx-3 { + margin-left: 1rem !important; +} + +.m-4 { + margin: 1.5rem !important; +} + +.mt-4, +.my-4 { + margin-top: 1.5rem !important; +} + +.mr-4, +.mx-4 { + margin-right: 1.5rem !important; +} + +.mb-4, +.my-4 { + margin-bottom: 1.5rem !important; +} + +.ml-4, +.mx-4 { + margin-left: 1.5rem !important; +} + +.m-5 { + margin: 3rem !important; +} + +.mt-5, +.my-5 { + margin-top: 3rem !important; +} + +.mr-5, +.mx-5 { + margin-right: 3rem !important; +} + +.mb-5, +.my-5 { + margin-bottom: 3rem !important; +} + +.ml-5, +.mx-5 { + margin-left: 3rem !important; +} + +.p-0 { + padding: 0 !important; +} + +.pt-0, +.py-0 { + padding-top: 0 !important; +} + +.pr-0, +.px-0 { + padding-right: 0 !important; +} + +.pb-0, +.py-0 { + padding-bottom: 0 !important; +} + +.pl-0, +.px-0 { + padding-left: 0 !important; +} + +.p-1 { + padding: 0.25rem !important; +} + +.pt-1, +.py-1 { + padding-top: 0.25rem !important; +} + +.pr-1, +.px-1 { + padding-right: 0.25rem !important; +} + +.pb-1, +.py-1 { + padding-bottom: 0.25rem !important; +} + +.pl-1, +.px-1 { + padding-left: 0.25rem !important; +} + +.p-2 { + padding: 0.5rem !important; +} + +.pt-2, +.py-2 { + padding-top: 0.5rem !important; +} + +.pr-2, +.px-2 { + padding-right: 0.5rem !important; +} + +.pb-2, +.py-2 { + padding-bottom: 0.5rem !important; +} + +.pl-2, +.px-2 { + padding-left: 0.5rem !important; +} + +.p-3 { + padding: 1rem !important; +} + +.pt-3, +.py-3 { + padding-top: 1rem !important; +} + +.pr-3, +.px-3 { + padding-right: 1rem !important; +} + +.pb-3, +.py-3 { + padding-bottom: 1rem !important; +} + +.pl-3, +.px-3 { + padding-left: 1rem !important; +} + +.p-4 { + padding: 1.5rem !important; +} + +.pt-4, +.py-4 { + padding-top: 1.5rem !important; +} + +.pr-4, +.px-4 { + padding-right: 1.5rem !important; +} + +.pb-4, +.py-4 { + padding-bottom: 1.5rem !important; +} + +.pl-4, +.px-4 { + padding-left: 1.5rem !important; +} + +.p-5 { + padding: 3rem !important; +} + +.pt-5, +.py-5 { + padding-top: 3rem !important; +} + +.pr-5, +.px-5 { + padding-right: 3rem !important; +} + +.pb-5, +.py-5 { + padding-bottom: 3rem !important; +} + +.pl-5, +.px-5 { + padding-left: 3rem !important; +} + +.m-auto { + margin: auto !important; +} + +.mt-auto, +.my-auto { + margin-top: auto !important; +} + +.mr-auto, +.mx-auto { + margin-right: auto !important; +} + +.mb-auto, +.my-auto { + margin-bottom: auto !important; +} + +.ml-auto, +.mx-auto { + margin-left: auto !important; +} + +@media (min-width: 576px) { + .m-sm-0 { + margin: 0 !important; + } + .mt-sm-0, + .my-sm-0 { + margin-top: 0 !important; + } + .mr-sm-0, + .mx-sm-0 { + margin-right: 0 !important; + } + .mb-sm-0, + .my-sm-0 { + margin-bottom: 0 !important; + } + .ml-sm-0, + .mx-sm-0 { + margin-left: 0 !important; + } + .m-sm-1 { + margin: 0.25rem !important; + } + .mt-sm-1, + .my-sm-1 { + margin-top: 0.25rem !important; + } + .mr-sm-1, + .mx-sm-1 { + margin-right: 0.25rem !important; + } + .mb-sm-1, + .my-sm-1 { + margin-bottom: 0.25rem !important; + } + .ml-sm-1, + .mx-sm-1 { + margin-left: 0.25rem !important; + } + .m-sm-2 { + margin: 0.5rem !important; + } + .mt-sm-2, + .my-sm-2 { + margin-top: 0.5rem !important; + } + .mr-sm-2, + .mx-sm-2 { + margin-right: 0.5rem !important; + } + .mb-sm-2, + .my-sm-2 { + margin-bottom: 0.5rem !important; + } + .ml-sm-2, + .mx-sm-2 { + margin-left: 0.5rem !important; + } + .m-sm-3 { + margin: 1rem !important; + } + .mt-sm-3, + .my-sm-3 { + margin-top: 1rem !important; + } + .mr-sm-3, + .mx-sm-3 { + margin-right: 1rem !important; + } + .mb-sm-3, + .my-sm-3 { + margin-bottom: 1rem !important; + } + .ml-sm-3, + .mx-sm-3 { + margin-left: 1rem !important; + } + .m-sm-4 { + margin: 1.5rem !important; + } + .mt-sm-4, + .my-sm-4 { + margin-top: 1.5rem !important; + } + .mr-sm-4, + .mx-sm-4 { + margin-right: 1.5rem !important; + } + .mb-sm-4, + .my-sm-4 { + margin-bottom: 1.5rem !important; + } + .ml-sm-4, + .mx-sm-4 { + margin-left: 1.5rem !important; + } + .m-sm-5 { + margin: 3rem !important; + } + .mt-sm-5, + .my-sm-5 { + margin-top: 3rem !important; + } + .mr-sm-5, + .mx-sm-5 { + margin-right: 3rem !important; + } + .mb-sm-5, + .my-sm-5 { + margin-bottom: 3rem !important; + } + .ml-sm-5, + .mx-sm-5 { + margin-left: 3rem !important; + } + .p-sm-0 { + padding: 0 !important; + } + .pt-sm-0, + .py-sm-0 { + padding-top: 0 !important; + } + .pr-sm-0, + .px-sm-0 { + padding-right: 0 !important; + } + .pb-sm-0, + .py-sm-0 { + padding-bottom: 0 !important; + } + .pl-sm-0, + .px-sm-0 { + padding-left: 0 !important; + } + .p-sm-1 { + padding: 0.25rem !important; + } + .pt-sm-1, + .py-sm-1 { + padding-top: 0.25rem !important; + } + .pr-sm-1, + .px-sm-1 { + padding-right: 0.25rem !important; + } + .pb-sm-1, + .py-sm-1 { + padding-bottom: 0.25rem !important; + } + .pl-sm-1, + .px-sm-1 { + padding-left: 0.25rem !important; + } + .p-sm-2 { + padding: 0.5rem !important; + } + .pt-sm-2, + .py-sm-2 { + padding-top: 0.5rem !important; + } + .pr-sm-2, + .px-sm-2 { + padding-right: 0.5rem !important; + } + .pb-sm-2, + .py-sm-2 { + padding-bottom: 0.5rem !important; + } + .pl-sm-2, + .px-sm-2 { + padding-left: 0.5rem !important; + } + .p-sm-3 { + padding: 1rem !important; + } + .pt-sm-3, + .py-sm-3 { + padding-top: 1rem !important; + } + .pr-sm-3, + .px-sm-3 { + padding-right: 1rem !important; + } + .pb-sm-3, + .py-sm-3 { + padding-bottom: 1rem !important; + } + .pl-sm-3, + .px-sm-3 { + padding-left: 1rem !important; + } + .p-sm-4 { + padding: 1.5rem !important; + } + .pt-sm-4, + .py-sm-4 { + padding-top: 1.5rem !important; + } + .pr-sm-4, + .px-sm-4 { + padding-right: 1.5rem !important; + } + .pb-sm-4, + .py-sm-4 { + padding-bottom: 1.5rem !important; + } + .pl-sm-4, + .px-sm-4 { + padding-left: 1.5rem !important; + } + .p-sm-5 { + padding: 3rem !important; + } + .pt-sm-5, + .py-sm-5 { + padding-top: 3rem !important; + } + .pr-sm-5, + .px-sm-5 { + padding-right: 3rem !important; + } + .pb-sm-5, + .py-sm-5 { + padding-bottom: 3rem !important; + } + .pl-sm-5, + .px-sm-5 { + padding-left: 3rem !important; + } + .m-sm-auto { + margin: auto !important; + } + .mt-sm-auto, + .my-sm-auto { + margin-top: auto !important; + } + .mr-sm-auto, + .mx-sm-auto { + margin-right: auto !important; + } + .mb-sm-auto, + .my-sm-auto { + margin-bottom: auto !important; + } + .ml-sm-auto, + .mx-sm-auto { + margin-left: auto !important; + } +} + +@media (min-width: 768px) { + .m-md-0 { + margin: 0 !important; + } + .mt-md-0, + .my-md-0 { + margin-top: 0 !important; + } + .mr-md-0, + .mx-md-0 { + margin-right: 0 !important; + } + .mb-md-0, + .my-md-0 { + margin-bottom: 0 !important; + } + .ml-md-0, + .mx-md-0 { + margin-left: 0 !important; + } + .m-md-1 { + margin: 0.25rem !important; + } + .mt-md-1, + .my-md-1 { + margin-top: 0.25rem !important; + } + .mr-md-1, + .mx-md-1 { + margin-right: 0.25rem !important; + } + .mb-md-1, + .my-md-1 { + margin-bottom: 0.25rem !important; + } + .ml-md-1, + .mx-md-1 { + margin-left: 0.25rem !important; + } + .m-md-2 { + margin: 0.5rem !important; + } + .mt-md-2, + .my-md-2 { + margin-top: 0.5rem !important; + } + .mr-md-2, + .mx-md-2 { + margin-right: 0.5rem !important; + } + .mb-md-2, + .my-md-2 { + margin-bottom: 0.5rem !important; + } + .ml-md-2, + .mx-md-2 { + margin-left: 0.5rem !important; + } + .m-md-3 { + margin: 1rem !important; + } + .mt-md-3, + .my-md-3 { + margin-top: 1rem !important; + } + .mr-md-3, + .mx-md-3 { + margin-right: 1rem !important; + } + .mb-md-3, + .my-md-3 { + margin-bottom: 1rem !important; + } + .ml-md-3, + .mx-md-3 { + margin-left: 1rem !important; + } + .m-md-4 { + margin: 1.5rem !important; + } + .mt-md-4, + .my-md-4 { + margin-top: 1.5rem !important; + } + .mr-md-4, + .mx-md-4 { + margin-right: 1.5rem !important; + } + .mb-md-4, + .my-md-4 { + margin-bottom: 1.5rem !important; + } + .ml-md-4, + .mx-md-4 { + margin-left: 1.5rem !important; + } + .m-md-5 { + margin: 3rem !important; + } + .mt-md-5, + .my-md-5 { + margin-top: 3rem !important; + } + .mr-md-5, + .mx-md-5 { + margin-right: 3rem !important; + } + .mb-md-5, + .my-md-5 { + margin-bottom: 3rem !important; + } + .ml-md-5, + .mx-md-5 { + margin-left: 3rem !important; + } + .p-md-0 { + padding: 0 !important; + } + .pt-md-0, + .py-md-0 { + padding-top: 0 !important; + } + .pr-md-0, + .px-md-0 { + padding-right: 0 !important; + } + .pb-md-0, + .py-md-0 { + padding-bottom: 0 !important; + } + .pl-md-0, + .px-md-0 { + padding-left: 0 !important; + } + .p-md-1 { + padding: 0.25rem !important; + } + .pt-md-1, + .py-md-1 { + padding-top: 0.25rem !important; + } + .pr-md-1, + .px-md-1 { + padding-right: 0.25rem !important; + } + .pb-md-1, + .py-md-1 { + padding-bottom: 0.25rem !important; + } + .pl-md-1, + .px-md-1 { + padding-left: 0.25rem !important; + } + .p-md-2 { + padding: 0.5rem !important; + } + .pt-md-2, + .py-md-2 { + padding-top: 0.5rem !important; + } + .pr-md-2, + .px-md-2 { + padding-right: 0.5rem !important; + } + .pb-md-2, + .py-md-2 { + padding-bottom: 0.5rem !important; + } + .pl-md-2, + .px-md-2 { + padding-left: 0.5rem !important; + } + .p-md-3 { + padding: 1rem !important; + } + .pt-md-3, + .py-md-3 { + padding-top: 1rem !important; + } + .pr-md-3, + .px-md-3 { + padding-right: 1rem !important; + } + .pb-md-3, + .py-md-3 { + padding-bottom: 1rem !important; + } + .pl-md-3, + .px-md-3 { + padding-left: 1rem !important; + } + .p-md-4 { + padding: 1.5rem !important; + } + .pt-md-4, + .py-md-4 { + padding-top: 1.5rem !important; + } + .pr-md-4, + .px-md-4 { + padding-right: 1.5rem !important; + } + .pb-md-4, + .py-md-4 { + padding-bottom: 1.5rem !important; + } + .pl-md-4, + .px-md-4 { + padding-left: 1.5rem !important; + } + .p-md-5 { + padding: 3rem !important; + } + .pt-md-5, + .py-md-5 { + padding-top: 3rem !important; + } + .pr-md-5, + .px-md-5 { + padding-right: 3rem !important; + } + .pb-md-5, + .py-md-5 { + padding-bottom: 3rem !important; + } + .pl-md-5, + .px-md-5 { + padding-left: 3rem !important; + } + .m-md-auto { + margin: auto !important; + } + .mt-md-auto, + .my-md-auto { + margin-top: auto !important; + } + .mr-md-auto, + .mx-md-auto { + margin-right: auto !important; + } + .mb-md-auto, + .my-md-auto { + margin-bottom: auto !important; + } + .ml-md-auto, + .mx-md-auto { + margin-left: auto !important; + } +} + +@media (min-width: 992px) { + .m-lg-0 { + margin: 0 !important; + } + .mt-lg-0, + .my-lg-0 { + margin-top: 0 !important; + } + .mr-lg-0, + .mx-lg-0 { + margin-right: 0 !important; + } + .mb-lg-0, + .my-lg-0 { + margin-bottom: 0 !important; + } + .ml-lg-0, + .mx-lg-0 { + margin-left: 0 !important; + } + .m-lg-1 { + margin: 0.25rem !important; + } + .mt-lg-1, + .my-lg-1 { + margin-top: 0.25rem !important; + } + .mr-lg-1, + .mx-lg-1 { + margin-right: 0.25rem !important; + } + .mb-lg-1, + .my-lg-1 { + margin-bottom: 0.25rem !important; + } + .ml-lg-1, + .mx-lg-1 { + margin-left: 0.25rem !important; + } + .m-lg-2 { + margin: 0.5rem !important; + } + .mt-lg-2, + .my-lg-2 { + margin-top: 0.5rem !important; + } + .mr-lg-2, + .mx-lg-2 { + margin-right: 0.5rem !important; + } + .mb-lg-2, + .my-lg-2 { + margin-bottom: 0.5rem !important; + } + .ml-lg-2, + .mx-lg-2 { + margin-left: 0.5rem !important; + } + .m-lg-3 { + margin: 1rem !important; + } + .mt-lg-3, + .my-lg-3 { + margin-top: 1rem !important; + } + .mr-lg-3, + .mx-lg-3 { + margin-right: 1rem !important; + } + .mb-lg-3, + .my-lg-3 { + margin-bottom: 1rem !important; + } + .ml-lg-3, + .mx-lg-3 { + margin-left: 1rem !important; + } + .m-lg-4 { + margin: 1.5rem !important; + } + .mt-lg-4, + .my-lg-4 { + margin-top: 1.5rem !important; + } + .mr-lg-4, + .mx-lg-4 { + margin-right: 1.5rem !important; + } + .mb-lg-4, + .my-lg-4 { + margin-bottom: 1.5rem !important; + } + .ml-lg-4, + .mx-lg-4 { + margin-left: 1.5rem !important; + } + .m-lg-5 { + margin: 3rem !important; + } + .mt-lg-5, + .my-lg-5 { + margin-top: 3rem !important; + } + .mr-lg-5, + .mx-lg-5 { + margin-right: 3rem !important; + } + .mb-lg-5, + .my-lg-5 { + margin-bottom: 3rem !important; + } + .ml-lg-5, + .mx-lg-5 { + margin-left: 3rem !important; + } + .p-lg-0 { + padding: 0 !important; + } + .pt-lg-0, + .py-lg-0 { + padding-top: 0 !important; + } + .pr-lg-0, + .px-lg-0 { + padding-right: 0 !important; + } + .pb-lg-0, + .py-lg-0 { + padding-bottom: 0 !important; + } + .pl-lg-0, + .px-lg-0 { + padding-left: 0 !important; + } + .p-lg-1 { + padding: 0.25rem !important; + } + .pt-lg-1, + .py-lg-1 { + padding-top: 0.25rem !important; + } + .pr-lg-1, + .px-lg-1 { + padding-right: 0.25rem !important; + } + .pb-lg-1, + .py-lg-1 { + padding-bottom: 0.25rem !important; + } + .pl-lg-1, + .px-lg-1 { + padding-left: 0.25rem !important; + } + .p-lg-2 { + padding: 0.5rem !important; + } + .pt-lg-2, + .py-lg-2 { + padding-top: 0.5rem !important; + } + .pr-lg-2, + .px-lg-2 { + padding-right: 0.5rem !important; + } + .pb-lg-2, + .py-lg-2 { + padding-bottom: 0.5rem !important; + } + .pl-lg-2, + .px-lg-2 { + padding-left: 0.5rem !important; + } + .p-lg-3 { + padding: 1rem !important; + } + .pt-lg-3, + .py-lg-3 { + padding-top: 1rem !important; + } + .pr-lg-3, + .px-lg-3 { + padding-right: 1rem !important; + } + .pb-lg-3, + .py-lg-3 { + padding-bottom: 1rem !important; + } + .pl-lg-3, + .px-lg-3 { + padding-left: 1rem !important; + } + .p-lg-4 { + padding: 1.5rem !important; + } + .pt-lg-4, + .py-lg-4 { + padding-top: 1.5rem !important; + } + .pr-lg-4, + .px-lg-4 { + padding-right: 1.5rem !important; + } + .pb-lg-4, + .py-lg-4 { + padding-bottom: 1.5rem !important; + } + .pl-lg-4, + .px-lg-4 { + padding-left: 1.5rem !important; + } + .p-lg-5 { + padding: 3rem !important; + } + .pt-lg-5, + .py-lg-5 { + padding-top: 3rem !important; + } + .pr-lg-5, + .px-lg-5 { + padding-right: 3rem !important; + } + .pb-lg-5, + .py-lg-5 { + padding-bottom: 3rem !important; + } + .pl-lg-5, + .px-lg-5 { + padding-left: 3rem !important; + } + .m-lg-auto { + margin: auto !important; + } + .mt-lg-auto, + .my-lg-auto { + margin-top: auto !important; + } + .mr-lg-auto, + .mx-lg-auto { + margin-right: auto !important; + } + .mb-lg-auto, + .my-lg-auto { + margin-bottom: auto !important; + } + .ml-lg-auto, + .mx-lg-auto { + margin-left: auto !important; + } +} + +@media (min-width: 1200px) { + .m-xl-0 { + margin: 0 !important; + } + .mt-xl-0, + .my-xl-0 { + margin-top: 0 !important; + } + .mr-xl-0, + .mx-xl-0 { + margin-right: 0 !important; + } + .mb-xl-0, + .my-xl-0 { + margin-bottom: 0 !important; + } + .ml-xl-0, + .mx-xl-0 { + margin-left: 0 !important; + } + .m-xl-1 { + margin: 0.25rem !important; + } + .mt-xl-1, + .my-xl-1 { + margin-top: 0.25rem !important; + } + .mr-xl-1, + .mx-xl-1 { + margin-right: 0.25rem !important; + } + .mb-xl-1, + .my-xl-1 { + margin-bottom: 0.25rem !important; + } + .ml-xl-1, + .mx-xl-1 { + margin-left: 0.25rem !important; + } + .m-xl-2 { + margin: 0.5rem !important; + } + .mt-xl-2, + .my-xl-2 { + margin-top: 0.5rem !important; + } + .mr-xl-2, + .mx-xl-2 { + margin-right: 0.5rem !important; + } + .mb-xl-2, + .my-xl-2 { + margin-bottom: 0.5rem !important; + } + .ml-xl-2, + .mx-xl-2 { + margin-left: 0.5rem !important; + } + .m-xl-3 { + margin: 1rem !important; + } + .mt-xl-3, + .my-xl-3 { + margin-top: 1rem !important; + } + .mr-xl-3, + .mx-xl-3 { + margin-right: 1rem !important; + } + .mb-xl-3, + .my-xl-3 { + margin-bottom: 1rem !important; + } + .ml-xl-3, + .mx-xl-3 { + margin-left: 1rem !important; + } + .m-xl-4 { + margin: 1.5rem !important; + } + .mt-xl-4, + .my-xl-4 { + margin-top: 1.5rem !important; + } + .mr-xl-4, + .mx-xl-4 { + margin-right: 1.5rem !important; + } + .mb-xl-4, + .my-xl-4 { + margin-bottom: 1.5rem !important; + } + .ml-xl-4, + .mx-xl-4 { + margin-left: 1.5rem !important; + } + .m-xl-5 { + margin: 3rem !important; + } + .mt-xl-5, + .my-xl-5 { + margin-top: 3rem !important; + } + .mr-xl-5, + .mx-xl-5 { + margin-right: 3rem !important; + } + .mb-xl-5, + .my-xl-5 { + margin-bottom: 3rem !important; + } + .ml-xl-5, + .mx-xl-5 { + margin-left: 3rem !important; + } + .p-xl-0 { + padding: 0 !important; + } + .pt-xl-0, + .py-xl-0 { + padding-top: 0 !important; + } + .pr-xl-0, + .px-xl-0 { + padding-right: 0 !important; + } + .pb-xl-0, + .py-xl-0 { + padding-bottom: 0 !important; + } + .pl-xl-0, + .px-xl-0 { + padding-left: 0 !important; + } + .p-xl-1 { + padding: 0.25rem !important; + } + .pt-xl-1, + .py-xl-1 { + padding-top: 0.25rem !important; + } + .pr-xl-1, + .px-xl-1 { + padding-right: 0.25rem !important; + } + .pb-xl-1, + .py-xl-1 { + padding-bottom: 0.25rem !important; + } + .pl-xl-1, + .px-xl-1 { + padding-left: 0.25rem !important; + } + .p-xl-2 { + padding: 0.5rem !important; + } + .pt-xl-2, + .py-xl-2 { + padding-top: 0.5rem !important; + } + .pr-xl-2, + .px-xl-2 { + padding-right: 0.5rem !important; + } + .pb-xl-2, + .py-xl-2 { + padding-bottom: 0.5rem !important; + } + .pl-xl-2, + .px-xl-2 { + padding-left: 0.5rem !important; + } + .p-xl-3 { + padding: 1rem !important; + } + .pt-xl-3, + .py-xl-3 { + padding-top: 1rem !important; + } + .pr-xl-3, + .px-xl-3 { + padding-right: 1rem !important; + } + .pb-xl-3, + .py-xl-3 { + padding-bottom: 1rem !important; + } + .pl-xl-3, + .px-xl-3 { + padding-left: 1rem !important; + } + .p-xl-4 { + padding: 1.5rem !important; + } + .pt-xl-4, + .py-xl-4 { + padding-top: 1.5rem !important; + } + .pr-xl-4, + .px-xl-4 { + padding-right: 1.5rem !important; + } + .pb-xl-4, + .py-xl-4 { + padding-bottom: 1.5rem !important; + } + .pl-xl-4, + .px-xl-4 { + padding-left: 1.5rem !important; + } + .p-xl-5 { + padding: 3rem !important; + } + .pt-xl-5, + .py-xl-5 { + padding-top: 3rem !important; + } + .pr-xl-5, + .px-xl-5 { + padding-right: 3rem !important; + } + .pb-xl-5, + .py-xl-5 { + padding-bottom: 3rem !important; + } + .pl-xl-5, + .px-xl-5 { + padding-left: 3rem !important; + } + .m-xl-auto { + margin: auto !important; + } + .mt-xl-auto, + .my-xl-auto { + margin-top: auto !important; + } + .mr-xl-auto, + .mx-xl-auto { + margin-right: auto !important; + } + .mb-xl-auto, + .my-xl-auto { + margin-bottom: auto !important; + } + .ml-xl-auto, + .mx-xl-auto { + margin-left: auto !important; + } +} + +.text-monospace { + font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} + +.text-justify { + text-align: justify !important; +} + +.text-nowrap { + white-space: nowrap !important; +} + +.text-truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.text-left { + text-align: left !important; +} + +.text-right { + text-align: right !important; +} + +.text-center { + text-align: center !important; +} + +@media (min-width: 576px) { + .text-sm-left { + text-align: left !important; + } + .text-sm-right { + text-align: right !important; + } + .text-sm-center { + text-align: center !important; + } +} + +@media (min-width: 768px) { + .text-md-left { + text-align: left !important; + } + .text-md-right { + text-align: right !important; + } + .text-md-center { + text-align: center !important; + } +} + +@media (min-width: 992px) { + .text-lg-left { + text-align: left !important; + } + .text-lg-right { + text-align: right !important; + } + .text-lg-center { + text-align: center !important; + } +} + +@media (min-width: 1200px) { + .text-xl-left { + text-align: left !important; + } + .text-xl-right { + text-align: right !important; + } + .text-xl-center { + text-align: center !important; + } +} + +.text-lowercase { + text-transform: lowercase !important; +} + +.text-uppercase { + text-transform: uppercase !important; +} + +.text-capitalize { + text-transform: capitalize !important; +} + +.font-weight-light { + font-weight: 300 !important; +} + +.font-weight-normal { + font-weight: 400 !important; +} + +.font-weight-bold { + font-weight: 700 !important; +} + +.font-italic { + font-style: italic !important; +} + +.text-white { + color: #fff !important; +} + +.text-primary { + color: #007bff !important; +} + +a.text-primary:hover, a.text-primary:focus { + color: #0062cc !important; +} + +.text-secondary { + color: #6c757d !important; +} + +a.text-secondary:hover, a.text-secondary:focus { + color: #545b62 !important; +} + +.text-success { + color: #28a745 !important; +} + +a.text-success:hover, a.text-success:focus { + color: #1e7e34 !important; +} + +.text-info { + color: #17a2b8 !important; +} + +a.text-info:hover, a.text-info:focus { + color: #117a8b !important; +} + +.text-warning { + color: #ffc107 !important; +} + +a.text-warning:hover, a.text-warning:focus { + color: #d39e00 !important; +} + +.text-danger { + color: #dc3545 !important; +} + +a.text-danger:hover, a.text-danger:focus { + color: #bd2130 !important; +} + +.text-light { + color: #f8f9fa !important; +} + +a.text-light:hover, a.text-light:focus { + color: #dae0e5 !important; +} + +.text-dark { + color: #343a40 !important; +} + +a.text-dark:hover, a.text-dark:focus { + color: #1d2124 !important; +} + +.text-body { + color: #212529 !important; +} + +.text-muted { + color: #6c757d !important; +} + +.text-black-50 { + color: rgba(0, 0, 0, 0.5) !important; +} + +.text-white-50 { + color: rgba(255, 255, 255, 0.5) !important; +} + +.text-hide { + font: 0/0 a; + color: transparent; + text-shadow: none; + background-color: transparent; + border: 0; +} + +.visible { + visibility: visible !important; +} + +.invisible { + visibility: hidden !important; +} + +@media print { + *, + *::before, + *::after { + text-shadow: none !important; + box-shadow: none !important; + } + a:not(.btn) { + text-decoration: underline; + } + abbr[title]::after { + content: " (" attr(title) ")"; + } + pre { + white-space: pre-wrap !important; + } + pre, + blockquote { + border: 1px solid #adb5bd; + page-break-inside: avoid; + } + thead { + display: table-header-group; + } + tr, + img { + page-break-inside: avoid; + } + p, + h2, + h3 { + orphans: 3; + widows: 3; + } + h2, + h3 { + page-break-after: avoid; + } + @page { + size: a3; + } + body { + min-width: 992px !important; + } + .container { + min-width: 992px !important; + } + .navbar { + display: none; + } + .badge { + border: 1px solid #000; + } + .table { + border-collapse: collapse !important; + } + .table td, + .table th { + background-color: #fff !important; + } + .table-bordered th, + .table-bordered td { + border: 1px solid #dee2e6 !important; + } + .table-dark { + color: inherit; + } + .table-dark th, + .table-dark td, + .table-dark thead th, + .table-dark tbody + tbody { + border-color: #dee2e6; + } + .table .thead-dark th { + color: inherit; + border-color: #dee2e6; + } +} +/*# sourceMappingURL=bootstrap.css.map */ \ No newline at end of file diff --git a/client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap.css.map b/client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap.css.map new file mode 100644 index 00000000..28d6241c --- /dev/null +++ b/client/src/main/resources/html/static/vendor/bootstrap/css/bootstrap.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../scss/bootstrap.scss","../../scss/_root.scss","../../scss/_reboot.scss","../../scss/_variables.scss","bootstrap.css","../../scss/mixins/_hover.scss","../../scss/_type.scss","../../scss/mixins/_lists.scss","../../scss/_images.scss","../../scss/mixins/_image.scss","../../scss/mixins/_border-radius.scss","../../scss/_code.scss","../../scss/_grid.scss","../../scss/mixins/_grid.scss","../../scss/mixins/_breakpoints.scss","../../scss/mixins/_grid-framework.scss","../../scss/_tables.scss","../../scss/mixins/_table-row.scss","../../scss/_functions.scss","../../scss/_forms.scss","../../scss/mixins/_transition.scss","../../scss/mixins/_forms.scss","../../scss/mixins/_gradients.scss","../../scss/_buttons.scss","../../scss/mixins/_buttons.scss","../../scss/_transitions.scss","../../scss/_dropdown.scss","../../scss/mixins/_caret.scss","../../scss/mixins/_nav-divider.scss","../../scss/_button-group.scss","../../scss/_input-group.scss","../../scss/_custom-forms.scss","../../scss/_nav.scss","../../scss/_navbar.scss","../../scss/_card.scss","../../scss/_breadcrumb.scss","../../scss/_pagination.scss","../../scss/mixins/_pagination.scss","../../scss/_badge.scss","../../scss/mixins/_badge.scss","../../scss/_jumbotron.scss","../../scss/_alert.scss","../../scss/mixins/_alert.scss","../../scss/_progress.scss","../../scss/_media.scss","../../scss/_list-group.scss","../../scss/mixins/_list-group.scss","../../scss/_close.scss","../../scss/_modal.scss","../../scss/_tooltip.scss","../../scss/mixins/_reset-text.scss","../../scss/_popover.scss","../../scss/_carousel.scss","../../scss/utilities/_align.scss","../../scss/mixins/_background-variant.scss","../../scss/utilities/_background.scss","../../scss/utilities/_borders.scss","../../scss/mixins/_clearfix.scss","../../scss/utilities/_display.scss","../../scss/utilities/_embed.scss","../../scss/utilities/_flex.scss","../../scss/utilities/_float.scss","../../scss/mixins/_float.scss","../../scss/utilities/_position.scss","../../scss/utilities/_screenreaders.scss","../../scss/mixins/_screen-reader.scss","../../scss/utilities/_shadows.scss","../../scss/utilities/_sizing.scss","../../scss/utilities/_spacing.scss","../../scss/utilities/_text.scss","../../scss/mixins/_text-truncate.scss","../../scss/mixins/_text-emphasis.scss","../../scss/mixins/_text-hide.scss","../../scss/utilities/_visibility.scss","../../scss/mixins/_visibility.scss","../../scss/_print.scss"],"names":[],"mappings":"AAAA;;;;;GAKG;ACLH;EAGI,gBAAc;EAAd,kBAAc;EAAd,kBAAc;EAAd,gBAAc;EAAd,eAAc;EAAd,kBAAc;EAAd,kBAAc;EAAd,iBAAc;EAAd,gBAAc;EAAd,gBAAc;EAAd,cAAc;EAAd,gBAAc;EAAd,qBAAc;EAId,mBAAc;EAAd,qBAAc;EAAd,mBAAc;EAAd,gBAAc;EAAd,mBAAc;EAAd,kBAAc;EAAd,iBAAc;EAAd,gBAAc;EAId,mBAAiC;EAAjC,uBAAiC;EAAjC,uBAAiC;EAAjC,uBAAiC;EAAjC,wBAAiC;EAKnC,+KAAyB;EACzB,8GAAwB;CACzB;;ACED;;;EAGE,uBAAsB;CACvB;;AAED;EACE,wBAAuB;EACvB,kBAAiB;EACjB,+BAA8B;EAC9B,2BAA0B;EAC1B,8BAA6B;EAC7B,yCCXa;CDYd;;AAIC;EACE,oBAAmB;CEgBtB;;AFVD;EACE,eAAc;CACf;;AAUD;EACE,UAAS;EACT,kKC+KgL;ED9KhL,gBCmLgC;EDlLhC,iBCuL+B;EDtL/B,iBC0L+B;EDzL/B,eC1CgB;ED2ChB,iBAAgB;EAChB,uBCrDa;CDsDd;;AEMD;EFEE,sBAAqB;CACtB;;AAQD;EACE,wBAAuB;EACvB,UAAS;EACT,kBAAiB;CAClB;;AAYD;EACE,cAAa;EACb,sBC4JyC;CD3J1C;;AAOD;EACE,cAAa;EACb,oBCiD8B;CDhD/B;;AASD;;EAEE,2BAA0B;EAC1B,0CAAiC;EAAjC,kCAAiC;EACjC,aAAY;EACZ,iBAAgB;CACjB;;AAED;EACE,oBAAmB;EACnB,mBAAkB;EAClB,qBAAoB;CACrB;;AAED;;;EAGE,cAAa;EACb,oBAAmB;CACpB;;AAED;;;;EAIE,iBAAgB;CACjB;;AAED;EACE,iBC+F+B;CD9FhC;;AAED;EACE,qBAAoB;EACpB,eAAc;CACf;;AAED;EACE,iBAAgB;CACjB;;AAED;EACE,mBAAkB;CACnB;;AAGD;;EAEE,oBAAmB;CACpB;;AAGD;EACE,eAAc;CACf;;AAOD;;EAEE,mBAAkB;EAClB,eAAc;EACd,eAAc;EACd,yBAAwB;CACzB;;AAED;EAAM,eAAc;CAAI;;AACxB;EAAM,WAAU;CAAI;;AAOpB;EACE,eClKe;EDmKf,sBChD8B;EDiD9B,8BAA6B;EAC7B,sCAAqC;CAMtC;;AGnMC;EHgME,eCpDgD;EDqDhD,2BCpDiC;CE7Ib;;AH2MxB;EACE,eAAc;EACd,sBAAqB;CAUtB;;AGnNC;EH4ME,eAAc;EACd,sBAAqB;CG1MtB;;AHoMH;EAUI,WAAU;CACX;;AAQH;;;;EAIE,kGCJgH;EDKhH,eAAc;CACf;;AAED;EAEE,cAAa;EAEb,oBAAmB;EAEnB,eAAc;EAGd,8BAA6B;CAC9B;;AAOD;EAEE,iBAAgB;CACjB;;AAOD;EACE,uBAAsB;EACtB,mBAAkB;CACnB;;AAED;EACE,iBAAgB;CACjB;;AAOD;EACE,0BAAyB;CAC1B;;AAED;EACE,qBCgBkC;EDflC,wBCekC;EDdlC,eCjRgB;EDkRhB,iBAAgB;EAChB,qBAAoB;CACrB;;AAED;EAGE,oBAAmB;CACpB;;AAOD;EAEE,sBAAqB;EACrB,sBCiF2C;CDhF5C;;AAKD;EACE,iBAAgB;CACjB;;AAMD;EACE,oBAAmB;EACnB,2CAA0C;CAC3C;;AAED;;;;;EAKE,UAAS;EACT,qBAAoB;EACpB,mBAAkB;EAClB,qBAAoB;CACrB;;AAED;;EAEE,kBAAiB;CAClB;;AAED;;EAEE,qBAAoB;CACrB;;AAKD;;;;EAIE,2BAA0B;CAC3B;;AAGD;;;;EAIE,WAAU;EACV,mBAAkB;CACnB;;AAED;;EAEE,uBAAsB;EACtB,WAAU;CACX;;AAGD;;;;EASE,4BAA2B;CAC5B;;AAED;EACE,eAAc;EAEd,iBAAgB;CACjB;;AAED;EAME,aAAY;EAEZ,WAAU;EACV,UAAS;EACT,UAAS;CACV;;AAID;EACE,eAAc;EACd,YAAW;EACX,gBAAe;EACf,WAAU;EACV,qBAAoB;EACpB,kBAAiB;EACjB,qBAAoB;EACpB,eAAc;EACd,oBAAmB;CACpB;;AAED;EACE,yBAAwB;CACzB;;AEpGD;;EFyGE,aAAY;CACb;;AErGD;EF4GE,qBAAoB;EACpB,yBAAwB;CACzB;;AEzGD;;EFiHE,yBAAwB;CACzB;;AAOD;EACE,cAAa;EACb,2BAA0B;CAC3B;;AAMD;EACE,sBAAqB;CACtB;;AAED;EACE,mBAAkB;EAClB,gBAAe;CAChB;;AAED;EACE,cAAa;CACd;;AEtHD;EF2HE,yBAAwB;CACzB;;AIzdD;;EAEE,sBHwPyC;EGvPzC,qBHwPmC;EGvPnC,iBHwP+B;EGvP/B,iBHwP+B;EGvP/B,eHwPmC;CGvPpC;;AAED;EAAU,kBH0OyC;CG1Ob;;AACtC;EAAU,gBH0OuC;CG1OX;;AACtC;EAAU,mBH0O0C;CG1Od;;AACtC;EAAU,kBH0OyC;CG1Ob;;AACtC;EAAU,mBH0O0C;CG1Od;;AACtC;EAAU,gBH0NwB;CG1NI;;AAEtC;EACE,mBH0PoD;EGzPpD,iBH0P+B;CGzPhC;;AAGD;EACE,gBHyOgC;EGxOhC,iBH6O+B;EG5O/B,iBHoO+B;CGnOhC;;AACD;EACE,kBHqOkC;EGpOlC,iBHyO+B;EGxO/B,iBH+N+B;CG9NhC;;AACD;EACE,kBHiOkC;EGhOlC,iBHqO+B;EGpO/B,iBH0N+B;CGzNhC;;AACD;EACE,kBH6NkC;EG5NlC,iBHiO+B;EGhO/B,iBHqN+B;CGpNhC;;AJmCD;EI3BE,iBH8DW;EG7DX,oBH6DW;EG5DX,UAAS;EACT,yCHrCa;CGsCd;;AAOD;;EAEE,eHgN+B;EG/M/B,iBH8K+B;CG7KhC;;AAED;;EAEE,eHoNgC;EGnNhC,0BH4NmC;CG3NpC;;AAOD;EC/EE,gBAAe;EACf,iBAAgB;CDgFjB;;AAGD;ECpFE,gBAAe;EACf,iBAAgB;CDqFjB;;AACD;EACE,sBAAqB;CAKtB;;AAND;EAII,qBHsM+B;CGrMhC;;AASH;EACE,eAAc;EACd,0BAAyB;CAC1B;;AAGD;EACE,oBHKW;EGJX,mBHwKoD;CGvKrD;;AAED;EACE,eAAc;EACd,eAAc;EACd,eHtGgB;CG2GjB;;AARD;EAMI,uBAAsB;CACvB;;AEpHH;ECIE,gBAAe;EAGf,aAAY;CDLb;;AAID;EACE,iBLs0BwC;EKr0BxC,uBLJa;EKKb,0BLFgB;EOVd,uBP8MgC;EMvMlC,gBAAe;EAGf,aAAY;CDQb;;AAMD;EAEE,sBAAqB;CACtB;;AAED;EACE,sBAA4B;EAC5B,eAAc;CACf;;AAED;EACE,eLuzBqC;EKtzBrC,eLvBgB;CKwBjB;;AGxCD;EACE,iBR+4BuC;EQ94BvC,eRoCe;EQnCf,uBAAsB;CAMvB;;AAHC;EACE,eAAc;CACf;;AAIH;EACE,uBRu4BuC;EQt4BvC,iBRk4BuC;EQj4BvC,YRLa;EQMb,0BRGgB;EOhBd,sBPgN+B;CQzLlC;;AAdD;EASI,WAAU;EACV,gBAAe;EACf,iBR4N6B;CQ1N9B;;ATwNH;ESnNE,eAAc;EACd,iBRi3BuC;EQh3BvC,eRbgB;CQqBjB;;AAXD;EAOI,mBAAkB;EAClB,eAAc;EACd,mBAAkB;CACnB;;AAIH;EACE,kBR82BuC;EQ72BvC,mBAAkB;CACnB;;AC1CC;ECAA,YAAW;EACX,oBAAuC;EACvC,mBAAsC;EACtC,mBAAkB;EAClB,kBAAiB;CDDhB;;AEoDC;EFvDF;ICYI,iBVuKK;GShLR;CRuiBF;;AUnfG;EFvDF;ICYI,iBVwKK;GSjLR;CR6iBF;;AUzfG;EFvDF;ICYI,iBVyKK;GSlLR;CRmjBF;;AU/fG;EFvDF;ICYI,kBV0KM;GSnLT;CRyjBF;;AQhjBC;ECZA,YAAW;EACX,oBAAuC;EACvC,mBAAsC;EACtC,mBAAkB;EAClB,kBAAiB;CDUhB;;AAQD;ECJA,qBAAa;EAAb,cAAa;EACb,oBAAe;EAAf,gBAAe;EACf,oBAAuC;EACvC,mBAAsC;CDGrC;;AAID;EACE,gBAAe;EACf,eAAc;CAOf;;AATD;;EAMI,iBAAgB;EAChB,gBAAe;CAChB;;AGlCH;;;;;;EACE,mBAAkB;EAClB,YAAW;EACX,gBAAe;EACf,oBAA4B;EAC5B,mBAA2B;CAC5B;;AAkBG;EACE,2BAAa;EAAb,cAAa;EACb,qBAAY;EAAZ,aAAY;EACZ,gBAAe;CAChB;;AACD;EACE,mBAAc;EAAd,eAAc;EACd,YAAW;EACX,gBAAe;CAChB;;AAGC;EFFN,wBAAsC;EAAtC,oBAAsC;EAItC,qBAAuC;CEAhC;;AAFD;EFFN,yBAAsC;EAAtC,qBAAsC;EAItC,sBAAuC;CEAhC;;AAFD;EFFN,kBAAsC;EAAtC,cAAsC;EAItC,eAAuC;CEAhC;;AAFD;EFFN,yBAAsC;EAAtC,qBAAsC;EAItC,sBAAuC;CEAhC;;AAFD;EFFN,yBAAsC;EAAtC,qBAAsC;EAItC,sBAAuC;CEAhC;;AAFD;EFFN,kBAAsC;EAAtC,cAAsC;EAItC,eAAuC;CEAhC;;AAFD;EFFN,yBAAsC;EAAtC,qBAAsC;EAItC,sBAAuC;CEAhC;;AAFD;EFFN,yBAAsC;EAAtC,qBAAsC;EAItC,sBAAuC;CEAhC;;AAFD;EFFN,kBAAsC;EAAtC,cAAsC;EAItC,eAAuC;CEAhC;;AAFD;EFFN,yBAAsC;EAAtC,qBAAsC;EAItC,sBAAuC;CEAhC;;AAFD;EFFN,yBAAsC;EAAtC,qBAAsC;EAItC,sBAAuC;CEAhC;;AAFD;EFFN,mBAAsC;EAAtC,eAAsC;EAItC,gBAAuC;CEAhC;;AAGH;EAAwB,mBAAS;EAAT,UAAS;CAAI;;AAErC;EAAuB,mBZmJG;EYnJH,UZmJG;CYnJoB;;AAG5C;EAAwB,kBADZ;EACY,SADZ;CACyB;;AAArC;EAAwB,kBADZ;EACY,SADZ;CACyB;;AAArC;EAAwB,kBADZ;EACY,SADZ;CACyB;;AAArC;EAAwB,kBADZ;EACY,SADZ;CACyB;;AAArC;EAAwB,kBADZ;EACY,SADZ;CACyB;;AAArC;EAAwB,kBADZ;EACY,SADZ;CACyB;;AAArC;EAAwB,kBADZ;EACY,SADZ;CACyB;;AAArC;EAAwB,kBADZ;EACY,SADZ;CACyB;;AAArC;EAAwB,kBADZ;EACY,SADZ;CACyB;;AAArC;EAAwB,kBADZ;EACY,SADZ;CACyB;;AAArC;EAAwB,mBADZ;EACY,UADZ;CACyB;;AAArC;EAAwB,mBADZ;EACY,UADZ;CACyB;;AAArC;EAAwB,mBADZ;EACY,UADZ;CACyB;;AAMnC;EFTR,uBAA8C;CEWrC;;AAFD;EFTR,wBAA8C;CEWrC;;AAFD;EFTR,iBAA8C;CEWrC;;AAFD;EFTR,wBAA8C;CEWrC;;AAFD;EFTR,wBAA8C;CEWrC;;AAFD;EFTR,iBAA8C;CEWrC;;AAFD;EFTR,wBAA8C;CEWrC;;AAFD;EFTR,wBAA8C;CEWrC;;AAFD;EFTR,iBAA8C;CEWrC;;AAFD;EFTR,wBAA8C;CEWrC;;AAFD;EFTR,wBAA8C;CEWrC;;ADDP;EC7BE;IACE,2BAAa;IAAb,cAAa;IACb,qBAAY;IAAZ,aAAY;IACZ,gBAAe;GAChB;EACD;IACE,mBAAc;IAAd,eAAc;IACd,YAAW;IACX,gBAAe;GAChB;EAGC;IFFN,wBAAsC;IAAtC,oBAAsC;IAItC,qBAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,kBAAsC;IAAtC,cAAsC;IAItC,eAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,kBAAsC;IAAtC,cAAsC;IAItC,eAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,kBAAsC;IAAtC,cAAsC;IAItC,eAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,mBAAsC;IAAtC,eAAsC;IAItC,gBAAuC;GEAhC;EAGH;IAAwB,mBAAS;IAAT,UAAS;GAAI;EAErC;IAAuB,mBZmJG;IYnJH,UZmJG;GYnJoB;EAG5C;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,mBADZ;IACY,UADZ;GACyB;EAArC;IAAwB,mBADZ;IACY,UADZ;GACyB;EAArC;IAAwB,mBADZ;IACY,UADZ;GACyB;EAMnC;IFTR,eAA4B;GEWnB;EAFD;IFTR,uBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;EAFD;IFTR,iBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;EAFD;IFTR,iBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;EAFD;IFTR,iBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;CXy2BV;;AU12BG;EC7BE;IACE,2BAAa;IAAb,cAAa;IACb,qBAAY;IAAZ,aAAY;IACZ,gBAAe;GAChB;EACD;IACE,mBAAc;IAAd,eAAc;IACd,YAAW;IACX,gBAAe;GAChB;EAGC;IFFN,wBAAsC;IAAtC,oBAAsC;IAItC,qBAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,kBAAsC;IAAtC,cAAsC;IAItC,eAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,kBAAsC;IAAtC,cAAsC;IAItC,eAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,kBAAsC;IAAtC,cAAsC;IAItC,eAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,mBAAsC;IAAtC,eAAsC;IAItC,gBAAuC;GEAhC;EAGH;IAAwB,mBAAS;IAAT,UAAS;GAAI;EAErC;IAAuB,mBZmJG;IYnJH,UZmJG;GYnJoB;EAG5C;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,mBADZ;IACY,UADZ;GACyB;EAArC;IAAwB,mBADZ;IACY,UADZ;GACyB;EAArC;IAAwB,mBADZ;IACY,UADZ;GACyB;EAMnC;IFTR,eAA4B;GEWnB;EAFD;IFTR,uBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;EAFD;IFTR,iBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;EAFD;IFTR,iBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;EAFD;IFTR,iBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;CXu/BV;;AUx/BG;EC7BE;IACE,2BAAa;IAAb,cAAa;IACb,qBAAY;IAAZ,aAAY;IACZ,gBAAe;GAChB;EACD;IACE,mBAAc;IAAd,eAAc;IACd,YAAW;IACX,gBAAe;GAChB;EAGC;IFFN,wBAAsC;IAAtC,oBAAsC;IAItC,qBAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,kBAAsC;IAAtC,cAAsC;IAItC,eAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,kBAAsC;IAAtC,cAAsC;IAItC,eAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,kBAAsC;IAAtC,cAAsC;IAItC,eAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,mBAAsC;IAAtC,eAAsC;IAItC,gBAAuC;GEAhC;EAGH;IAAwB,mBAAS;IAAT,UAAS;GAAI;EAErC;IAAuB,mBZmJG;IYnJH,UZmJG;GYnJoB;EAG5C;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,mBADZ;IACY,UADZ;GACyB;EAArC;IAAwB,mBADZ;IACY,UADZ;GACyB;EAArC;IAAwB,mBADZ;IACY,UADZ;GACyB;EAMnC;IFTR,eAA4B;GEWnB;EAFD;IFTR,uBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;EAFD;IFTR,iBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;EAFD;IFTR,iBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;EAFD;IFTR,iBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;CXqoCV;;AUtoCG;EC7BE;IACE,2BAAa;IAAb,cAAa;IACb,qBAAY;IAAZ,aAAY;IACZ,gBAAe;GAChB;EACD;IACE,mBAAc;IAAd,eAAc;IACd,YAAW;IACX,gBAAe;GAChB;EAGC;IFFN,wBAAsC;IAAtC,oBAAsC;IAItC,qBAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,kBAAsC;IAAtC,cAAsC;IAItC,eAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,kBAAsC;IAAtC,cAAsC;IAItC,eAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,kBAAsC;IAAtC,cAAsC;IAItC,eAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,yBAAsC;IAAtC,qBAAsC;IAItC,sBAAuC;GEAhC;EAFD;IFFN,mBAAsC;IAAtC,eAAsC;IAItC,gBAAuC;GEAhC;EAGH;IAAwB,mBAAS;IAAT,UAAS;GAAI;EAErC;IAAuB,mBZmJG;IYnJH,UZmJG;GYnJoB;EAG5C;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,kBADZ;IACY,SADZ;GACyB;EAArC;IAAwB,mBADZ;IACY,UADZ;GACyB;EAArC;IAAwB,mBADZ;IACY,UADZ;GACyB;EAArC;IAAwB,mBADZ;IACY,UADZ;GACyB;EAMnC;IFTR,eAA4B;GEWnB;EAFD;IFTR,uBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;EAFD;IFTR,iBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;EAFD;IFTR,iBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;EAFD;IFTR,iBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;EAFD;IFTR,wBAA8C;GEWrC;CXmxCV;;AY50CD;EACE,YAAW;EACX,gBAAe;EACf,oBb8GW;Ea7GX,8Bb2SuC;CatRxC;;AAzBD;;EAQI,iBboSgC;EanShC,oBAAmB;EACnB,8BbAc;CaCf;;AAXH;EAcI,uBAAsB;EACtB,iCbLc;CaMf;;AAhBH;EAmBI,8BbTc;CaUf;;AApBH;EAuBI,uBbhBW;CaiBZ;;AAQH;;EAGI,gBb0Q+B;CazQhC;;AAQH;EACE,0BbnCgB;CagDjB;;AAdD;;EAKI,0BbvCc;CawCf;;AANH;;EAWM,yBAA8C;CAC/C;;AAIL;;;;EAKI,UAAS;CACV;;AAOH;EAEI,sCb1DW;Ca2DZ;;AXpED;EW+EI,uCbtES;CETS;;AYPtB;;;EAII,0BC2E4D;CD1E7D;;AZEH;EYQM,0BAJsC;CZJtB;;AYGtB;;EASQ,0BARoC;CASrC;;AApBP;;;EAII,0BC2E4D;CD1E7D;;AZEH;EYQM,0BAJsC;CZJtB;;AYGtB;;EASQ,0BARoC;CASrC;;AApBP;;;EAII,0BC2E4D;CD1E7D;;AZEH;EYQM,0BAJsC;CZJtB;;AYGtB;;EASQ,0BARoC;CASrC;;AApBP;;;EAII,0BC2E4D;CD1E7D;;AZEH;EYQM,0BAJsC;CZJtB;;AYGtB;;EASQ,0BARoC;CASrC;;AApBP;;;EAII,0BC2E4D;CD1E7D;;AZEH;EYQM,0BAJsC;CZJtB;;AYGtB;;EASQ,0BARoC;CASrC;;AApBP;;;EAII,0BC2E4D;CD1E7D;;AZEH;EYQM,0BAJsC;CZJtB;;AYGtB;;EASQ,0BARoC;CASrC;;AApBP;;;EAII,0BC2E4D;CD1E7D;;AZEH;EYQM,0BAJsC;CZJtB;;AYGtB;;EASQ,0BARoC;CASrC;;AApBP;;;EAII,0BC2E4D;CD1E7D;;AZEH;EYQM,0BAJsC;CZJtB;;AYGtB;;EASQ,0BARoC;CASrC;;AApBP;;;EAII,uCdYS;CcXV;;AZEH;EYQM,uCAJsC;CZJtB;;AYGtB;;EASQ,uCARoC;CASrC;;ADyFT;EAGM,Yb1GS;Ea2GT,0BblGY;EamGZ,sBb0MgD;CazMjD;;AANL;EAWM,eb3GY;Ea4GZ,0BbjHY;EakHZ,sBbjHY;CakHb;;AAIL;EACE,Yb1Ha;Ea2Hb,0BblHgB;Ca2IjB;;AA3BD;;;EAOI,sBbsLkD;CarLnD;;AARH;EAWI,UAAS;CACV;;AAZH;EAgBM,4CbzIS;Ca0IV;;AXzIH;EW+IM,6CbhJO;CECS;;AS6DpB;EEmGA;IAEI,eAAc;IACd,YAAW;IACX,iBAAgB;IAChB,kCAAiC;IACjC,6CAA4C;GAO/C;EAbA;IAUK,UAAS;GACV;CZ64CR;;AU3/CG;EEmGA;IAEI,eAAc;IACd,YAAW;IACX,iBAAgB;IAChB,kCAAiC;IACjC,6CAA4C;GAO/C;EAbA;IAUK,UAAS;GACV;CZ05CR;;AUxgDG;EEmGA;IAEI,eAAc;IACd,YAAW;IACX,iBAAgB;IAChB,kCAAiC;IACjC,6CAA4C;GAO/C;EAbA;IAUK,UAAS;GACV;CZu6CR;;AUrhDG;EEmGA;IAEI,eAAc;IACd,YAAW;IACX,iBAAgB;IAChB,kCAAiC;IACjC,6CAA4C;GAO/C;EAbA;IAUK,UAAS;GACV;CZo7CR;;AYp8CD;EAOQ,eAAc;EACd,YAAW;EACX,iBAAgB;EAChB,kCAAiC;EACjC,6CAA4C;CAO/C;;AAlBL;EAeU,UAAS;CACV;;AGjLT;EACE,eAAc;EACd,YAAW;EACX,0BhBoUkC;EgBnUlC,gBhBoOgC;EgBnOhC,iBhB4O+B;EgB3O/B,ehBMgB;EgBLhB,uBhBFa;EgBGb,6BAA4B;EAC5B,0BhBAgB;EgBKd,uBhB8LgC;EiB7M9B,yEjB+a4F;CgB5XjG;;AC/CC;EDHF;ICII,iBAAgB;GD8CnB;CfmlDA;;AeroDD;EAyBI,8BAA6B;EAC7B,UAAS;CACV;;AEpBD;EACE,elBIc;EkBHd,uBlBJW;EkBKX,sBlBiZsE;EkBhZtE,WAAU;EAKR,iDlBcW;CkBZd;;AFlBH;EAkCI,ehBvBc;EgByBd,WAAU;CACX;;AArCH;EAkCI,ehBvBc;EgByBd,WAAU;CACX;;AArCH;EAkCI,ehBvBc;EgByBd,WAAU;CACX;;AArCH;EAkCI,ehBvBc;EgByBd,WAAU;CACX;;AArCH;EAkCI,ehBvBc;EgByBd,WAAU;CACX;;AArCH;EA8CI,0BhBvCc;EgByCd,WAAU;CACX;;AAGH;EAEI,4BhBgX0F;CgB/W3F;;AAHH;EAWI,ehBnDc;EgBoDd,uBhB3DW;CgB4DZ;;AAIH;;EAEE,eAAc;EACd,YAAW;CACZ;;AASD;EACE,kCAA+D;EAC/D,qCAAkE;EAClE,iBAAgB;EAChB,mBAAkB;EAClB,iBhB0J+B;CgBzJhC;;AAED;EACE,gCAAkE;EAClE,mCAAqE;EACrE,mBhB4IoD;EgB3IpD,iBhBwG+B;CgBvGhC;;AAED;EACE,iCAAkE;EAClE,oCAAqE;EACrE,oBhBsIoD;EgBrIpD,iBhBkG+B;CgBjGhC;;AAQD;EACE,eAAc;EACd,YAAW;EACX,sBhBqNmC;EgBpNnC,yBhBoNmC;EgBnNnC,iBAAgB;EAChB,iBhB6H+B;EgB5H/B,ehBvGgB;EgBwGhB,8BAA6B;EAC7B,0BAAyB;EACzB,oBAAmC;CAOpC;;AAjBD;;;;;;;;;EAcI,iBAAgB;EAChB,gBAAe;CAChB;;AAYH;;;;;EACE,wBhBoMiC;EgBnMjC,oBhB8FoD;EgB7FpD,iBhB0D+B;EOzM7B,sBPgN+B;CgB/DlC;;AAED;;;;;EAEI,8BhBsR6F;CgBrR9F;;AAGH;;;;;EACE,qBhB2LgC;EgB1LhC,mBhBgFoD;EgB/EpD,iBhB4C+B;EOxM7B,sBP+M+B;CgBjDlC;;AAED;;;;;EAEI,6BhB4Q6F;CgB3Q9F;;AASH;EACE,oBhB8Q0C;CgB7Q3C;;AAED;EACE,eAAc;EACd,oBhBgQ4C;CgB/P7C;;AAOD;EACE,qBAAa;EAAb,cAAa;EACb,oBAAe;EAAf,gBAAe;EACf,mBAAkB;EAClB,kBAAiB;CAOlB;;AAXD;;EAQI,mBAAkB;EAClB,kBAAiB;CAClB;;AAQH;EACE,mBAAkB;EAClB,eAAc;EACd,sBhBqO6C;CgBpO9C;;AAED;EACE,mBAAkB;EAClB,mBhBiO2C;EgBhO3C,sBhB+N6C;CgB1N9C;;AARD;EAMI,ehB3Mc;CgB4Mf;;AAGH;EACE,iBAAgB;CACjB;;AAED;EACE,4BAAoB;EAApB,qBAAoB;EACpB,uBAAmB;EAAnB,oBAAmB;EACnB,gBAAe;EACf,sBhBoN4C;CgB3M7C;;AAbD;EAQI,iBAAgB;EAChB,cAAa;EACb,wBhB+M4C;EgB9M5C,eAAc;CACf;;AEnND;EACE,cAAa;EACb,YAAW;EACX,oBlBsZ0C;EkBrZ1C,elBmP6B;EkBlP7B,elBSa;CkBRd;;AAED;EACE,mBAAkB;EAClB,UAAS;EACT,WAAU;EACV,cAAa;EACb,gBAAe;EACf,eAAc;EACd,kBAAiB;EACjB,mBAAkB;EAClB,eAAc;EACd,YlBpCW;EkBqCX,yClBLa;EkBMb,qBAAoB;CACrB;;AAIC;;;EAEE,sBlBbW;CkBwBZ;;AAbD;;;EAKI,sBlBhBS;EkBiBT,iDlBjBS;CkBkBV;;AAPH;;;;;;;;EAWI,eAAc;CACf;;AAKH;;;EAII,eAAc;CACf;;AAKH;EAGI,elBzCS;CkB0CV;;AAJH;;;EAQI,eAAc;CACf;;AAKH;EAGI,elBvDS;CkB4DV;;AARH;EAMM,0BAAsC;CACvC;;AAPL;;;EAYI,eAAc;CACf;;AAbH;ECzFA,0BD0G+C;CAC1C;;AAlBL;EAuBM,iElB3EO;CkB4ER;;AAOL;EAGI,sBlBtFS;CkByFV;;AANH;EAKgB,sBAAqB;CAAI;;AALzC;;;EAUI,eAAc;CACf;;AAXH;EAeM,iDlBlGO;CkBmGR;;AAjHP;EACE,cAAa;EACb,YAAW;EACX,oBlBsZ0C;EkBrZ1C,elBmP6B;EkBlP7B,elBMa;CkBLd;;AAED;EACE,mBAAkB;EAClB,UAAS;EACT,WAAU;EACV,cAAa;EACb,gBAAe;EACf,eAAc;EACd,kBAAiB;EACjB,mBAAkB;EAClB,eAAc;EACd,YlBpCW;EkBqCX,yClBRa;EkBSb,qBAAoB;CACrB;;AAIC;;;EAEE,sBlBhBW;CkB2BZ;;AAbD;;;EAKI,sBlBnBS;EkBoBT,iDlBpBS;CkBqBV;;AAPH;;;;;;;;EAWI,eAAc;CACf;;AAKH;;;EAII,eAAc;CACf;;AAKH;EAGI,elB5CS;CkB6CV;;AAJH;;;EAQI,eAAc;CACf;;AAKH;EAGI,elB1DS;CkB+DV;;AARH;EAMM,0BAAsC;CACvC;;AAPL;;;EAYI,eAAc;CACf;;AAbH;ECzFA,0BD0G+C;CAC1C;;AAlBL;EAuBM,iElB9EO;CkB+ER;;AAOL;EAGI,sBlBzFS;CkB4FV;;AANH;EAKgB,sBAAqB;CAAI;;AALzC;;;EAUI,eAAc;CACf;;AAXH;EAeM,iDlBrGO;CkBsGR;;AFyHT;EACE,qBAAa;EAAb,cAAa;EACb,wBAAmB;EAAnB,oBAAmB;EACnB,uBAAmB;EAAnB,oBAAmB;CAoEpB;;AAvED;EASI,YAAW;CACZ;;ALrNC;EK2MJ;IAeM,qBAAa;IAAb,cAAa;IACb,uBAAmB;IAAnB,oBAAmB;IACnB,sBAAuB;IAAvB,wBAAuB;IACvB,iBAAgB;GACjB;EAnBL;IAuBM,qBAAa;IAAb,cAAa;IACb,mBAAc;IAAd,eAAc;IACd,wBAAmB;IAAnB,oBAAmB;IACnB,uBAAmB;IAAnB,oBAAmB;IACnB,iBAAgB;GACjB;EA5BL;IAgCM,sBAAqB;IACrB,YAAW;IACX,uBAAsB;GACvB;EAnCL;IAuCM,sBAAqB;GACtB;EAxCL;;IA4CM,YAAW;GACZ;EA7CL;IAkDM,qBAAa;IAAb,cAAa;IACb,uBAAmB;IAAnB,oBAAmB;IACnB,sBAAuB;IAAvB,wBAAuB;IACvB,YAAW;IACX,gBAAe;GAChB;EAvDL;IAyDM,mBAAkB;IAClB,cAAa;IACb,sBhBwHwC;IgBvHxC,eAAc;GACf;EA7DL;IAgEM,uBAAmB;IAAnB,oBAAmB;IACnB,sBAAuB;IAAvB,wBAAuB;GACxB;EAlEL;IAoEM,iBAAgB;GACjB;Cf2vDJ;;AmBjkED;EACE,sBAAqB;EACrB,iBpB2O+B;EoB1O/B,mBAAkB;EAClB,oBAAmB;EACnB,uBAAsB;EACtB,0BAAiB;EAAjB,uBAAiB;EAAjB,sBAAiB;EAAjB,kBAAiB;EACjB,8BAA2C;ECsF3C,0BrB0OkC;EqBzOlC,gBrB0IgC;EqBzIhC,iBrBkJ+B;EqB/I7B,uBrB0GgC;EiB7M9B,sIjB4X6I;CoBhVlJ;;AHxCC;EGHF;IHII,iBAAgB;GGuCnB;CnB2iEA;;AC5kEC;EkBGE,sBAAqB;ClBAtB;;AkBbH;EAkBI,WAAU;EACV,iDpBWa;CoBVd;;AApBH;EAyBI,cpBsV6B;CoBpV9B;;AA3BH;EA+BI,gBAAe;CAChB;;AAhCH;EAoCI,uBAAsB;CAMvB;;AAIH;;EAEE,qBAAoB;CACrB;;AAQC;ECzDA,YrBKa;EmBLX,0BnB8Ba;EqB5Bf,sBrB4Be;CoB6Bd;;AlBrDD;EmBAE,YrBDW;EmBLX,0BEDoF;EASpF,sBATyH;CnBOrG;;AmBKtB;EAMI,gDrBaW;CqBXd;;AAGD;EAEE,YrBnBW;EqBoBX,0BrBKa;EqBJb,sBrBIa;CqBHd;;AAED;;EAGE,YrB3BW;EqB4BX,0BAlCuK;EAsCvK,sBAtC+M;CAgDhN;;AARC;;EAKI,gDrBdS;CqBgBZ;;ADWH;ECzDA,YrBKa;EmBLX,0BnBWc;EqBThB,sBrBSgB;CoBgDf;;AlBrDD;EmBAE,YrBDW;EmBLX,0BEDoF;EASpF,sBATyH;CnBOrG;;AmBKtB;EAMI,kDrBNY;CqBQf;;AAGD;EAEE,YrBnBW;EqBoBX,0BrBdc;EqBed,sBrBfc;CqBgBf;;AAED;;EAGE,YrB3BW;EqB4BX,0BAlCuK;EAsCvK,sBAtC+M;CAgDhN;;AARC;;EAKI,kDrBjCU;CqBmCb;;ADWH;ECzDA,YrBKa;EmBLX,0BnBqCa;EqBnCf,sBrBmCe;CoBsBd;;AlBrDD;EmBAE,YrBDW;EmBLX,0BEDoF;EASpF,sBATyH;CnBOrG;;AmBKtB;EAMI,gDrBoBW;CqBlBd;;AAGD;EAEE,YrBnBW;EqBoBX,0BrBYa;EqBXb,sBrBWa;CqBVd;;AAED;;EAGE,YrB3BW;EqB4BX,0BAlCuK;EAsCvK,sBAtC+M;CAgDhN;;AARC;;EAKI,gDrBPS;CqBSZ;;ADWH;ECzDA,YrBKa;EmBLX,0BnBuCa;EqBrCf,sBrBqCe;CoBoBd;;AlBrDD;EmBAE,YrBDW;EmBLX,0BEDoF;EASpF,sBATyH;CnBOrG;;AmBKtB;EAMI,iDrBsBW;CqBpBd;;AAGD;EAEE,YrBnBW;EqBoBX,0BrBca;EqBbb,sBrBaa;CqBZd;;AAED;;EAGE,YrB3BW;EqB4BX,0BAlCuK;EAsCvK,sBAtC+M;CAgDhN;;AARC;;EAKI,iDrBLS;CqBOZ;;ADWH;ECzDA,erBcgB;EmBdd,0BnBoCa;EqBlCf,sBrBkCe;CoBuBd;;AlBrDD;EmBAE,erBQc;EmBdd,0BEDoF;EASpF,sBATyH;CnBOrG;;AmBKtB;EAMI,gDrBmBW;CqBjBd;;AAGD;EAEE,erBVc;EqBWd,0BrBWa;EqBVb,sBrBUa;CqBTd;;AAED;;EAGE,erBlBc;EqBmBd,0BAlCuK;EAsCvK,sBAtC+M;CAgDhN;;AARC;;EAKI,gDrBRS;CqBUZ;;ADWH;ECzDA,YrBKa;EmBLX,0BnBkCa;EqBhCf,sBrBgCe;CoByBd;;AlBrDD;EmBAE,YrBDW;EmBLX,0BEDoF;EASpF,sBATyH;CnBOrG;;AmBKtB;EAMI,gDrBiBW;CqBfd;;AAGD;EAEE,YrBnBW;EqBoBX,0BrBSa;EqBRb,sBrBQa;CqBPd;;AAED;;EAGE,YrB3BW;EqB4BX,0BAlCuK;EAsCvK,sBAtC+M;CAgDhN;;AARC;;EAKI,gDrBVS;CqBYZ;;ADWH;ECzDA,erBcgB;EmBdd,0BnBMc;EqBJhB,sBrBIgB;CoBqDf;;AlBrDD;EmBAE,erBQc;EmBdd,0BEDoF;EASpF,sBATyH;CnBOrG;;AmBKtB;EAMI,kDrBXY;CqBaf;;AAGD;EAEE,erBVc;EqBWd,0BrBnBc;EqBoBd,sBrBpBc;CqBqBf;;AAED;;EAGE,erBlBc;EqBmBd,0BAlCuK;EAsCvK,sBAtC+M;CAgDhN;;AARC;;EAKI,kDrBtCU;CqBwCb;;ADWH;ECzDA,YrBKa;EmBLX,0BnBac;EqBXhB,sBrBWgB;CoB8Cf;;AlBrDD;EmBAE,YrBDW;EmBLX,0BEDoF;EASpF,sBATyH;CnBOrG;;AmBKtB;EAMI,+CrBJY;CqBMf;;AAGD;EAEE,YrBnBW;EqBoBX,0BrBZc;EqBad,sBrBbc;CqBcf;;AAED;;EAGE,YrB3BW;EqB4BX,0BAlCuK;EAsCvK,sBAtC+M;CAgDhN;;AARC;;EAKI,+CrB/BU;CqBiCb;;ADiBH;ECZA,erBrBe;EqBsBf,8BAA6B;EAC7B,uBAAsB;EACtB,sBrBxBe;CoBmCd;;ACTD;EACE,YrBpDW;EqBqDX,0BrB5Ba;EqB6Bb,sBrB7Ba;CqB8Bd;;AAED;EAEE,gDrBlCa;CqBmCd;;AAED;EAEE,erBvCa;EqBwCb,8BAA6B;CAC9B;;AAED;;EAGE,YrBvEW;EqBwEX,0BrB/Ca;EqBgDb,sBrBhDa;CqB0Dd;;AARC;;EAKI,gDrBvDS;CqByDZ;;ADxBH;ECZA,erBxCgB;EqByChB,8BAA6B;EAC7B,uBAAsB;EACtB,sBrB3CgB;CoBsDf;;ACTD;EACE,YrBpDW;EqBqDX,0BrB/Cc;EqBgDd,sBrBhDc;CqBiDf;;AAED;EAEE,kDrBrDc;CqBsDf;;AAED;EAEE,erB1Dc;EqB2Dd,8BAA6B;CAC9B;;AAED;;EAGE,YrBvEW;EqBwEX,0BrBlEc;EqBmEd,sBrBnEc;CqB6Ef;;AARC;;EAKI,kDrB1EU;CqB4Eb;;ADxBH;ECZA,erBde;EqBef,8BAA6B;EAC7B,uBAAsB;EACtB,sBrBjBe;CoB4Bd;;ACTD;EACE,YrBpDW;EqBqDX,0BrBrBa;EqBsBb,sBrBtBa;CqBuBd;;AAED;EAEE,gDrB3Ba;CqB4Bd;;AAED;EAEE,erBhCa;EqBiCb,8BAA6B;CAC9B;;AAED;;EAGE,YrBvEW;EqBwEX,0BrBxCa;EqByCb,sBrBzCa;CqBmDd;;AARC;;EAKI,gDrBhDS;CqBkDZ;;ADxBH;ECZA,erBZe;EqBaf,8BAA6B;EAC7B,uBAAsB;EACtB,sBrBfe;CoB0Bd;;ACTD;EACE,YrBpDW;EqBqDX,0BrBnBa;EqBoBb,sBrBpBa;CqBqBd;;AAED;EAEE,iDrBzBa;CqB0Bd;;AAED;EAEE,erB9Ba;EqB+Bb,8BAA6B;CAC9B;;AAED;;EAGE,YrBvEW;EqBwEX,0BrBtCa;EqBuCb,sBrBvCa;CqBiDd;;AARC;;EAKI,iDrB9CS;CqBgDZ;;ADxBH;ECZA,erBfe;EqBgBf,8BAA6B;EAC7B,uBAAsB;EACtB,sBrBlBe;CoB6Bd;;ACTD;EACE,erB3Cc;EqB4Cd,0BrBtBa;EqBuBb,sBrBvBa;CqBwBd;;AAED;EAEE,gDrB5Ba;CqB6Bd;;AAED;EAEE,erBjCa;EqBkCb,8BAA6B;CAC9B;;AAED;;EAGE,erB9Dc;EqB+Dd,0BrBzCa;EqB0Cb,sBrB1Ca;CqBoDd;;AARC;;EAKI,gDrBjDS;CqBmDZ;;ADxBH;ECZA,erBjBe;EqBkBf,8BAA6B;EAC7B,uBAAsB;EACtB,sBrBpBe;CoB+Bd;;ACTD;EACE,YrBpDW;EqBqDX,0BrBxBa;EqByBb,sBrBzBa;CqB0Bd;;AAED;EAEE,gDrB9Ba;CqB+Bd;;AAED;EAEE,erBnCa;EqBoCb,8BAA6B;CAC9B;;AAED;;EAGE,YrBvEW;EqBwEX,0BrB3Ca;EqB4Cb,sBrB5Ca;CqBsDd;;AARC;;EAKI,gDrBnDS;CqBqDZ;;ADxBH;ECZA,erB7CgB;EqB8ChB,8BAA6B;EAC7B,uBAAsB;EACtB,sBrBhDgB;CoB2Df;;ACTD;EACE,erB3Cc;EqB4Cd,0BrBpDc;EqBqDd,sBrBrDc;CqBsDf;;AAED;EAEE,kDrB1Dc;CqB2Df;;AAED;EAEE,erB/Dc;EqBgEd,8BAA6B;CAC9B;;AAED;;EAGE,erB9Dc;EqB+Dd,0BrBvEc;EqBwEd,sBrBxEc;CqBkFf;;AARC;;EAKI,kDrB/EU;CqBiFb;;ADxBH;ECZA,erBtCgB;EqBuChB,8BAA6B;EAC7B,uBAAsB;EACtB,sBrBzCgB;CoBoDf;;ACTD;EACE,YrBpDW;EqBqDX,0BrB7Cc;EqB8Cd,sBrB9Cc;CqB+Cf;;AAED;EAEE,+CrBnDc;CqBoDf;;AAED;EAEE,erBxDc;EqByDd,8BAA6B;CAC9B;;AAED;;EAGE,YrBvEW;EqBwEX,0BrBhEc;EqBiEd,sBrBjEc;CqB2Ef;;AARC;;EAKI,+CrBxEU;CqB0Eb;;ADbL;EACE,iBpBkK+B;EoBjK/B,epB9Ce;EoB+Cf,8BAA6B;CAuB9B;;AlB9FC;EkB0EE,epBkEgD;EoBjEhD,2BpBkEiC;EoBjEjC,8BAA6B;EAC7B,0BAAyB;ClB7EL;;AkBoExB;EAcI,2BpB2DiC;EoB1DjC,0BAAyB;EACzB,iBAAgB;CACjB;;AAjBH;EAqBI,epBpFc;EoBqFd,qBAAoB;CACrB;;AAUH;ECdE,qBrBsPgC;EqBrPhC,mBrB2IoD;EqB1IpD,iBrBuG+B;EqBpG7B,sBrB2G+B;CoBhGlC;;AAED;EClBE,wBrBkPiC;EqBjPjC,oBrB4IoD;EqB3IpD,iBrBwG+B;EqBrG7B,sBrB4G+B;CoB7FlC;;AAOD;EACE,eAAc;EACd,YAAW;CAMZ;;AARD;EAMI,mBpBsP+B;CoBrPhC;;AAIH;;;EAII,YAAW;CACZ;;AE5IH;ELGM,iCjB2N2C;CsBxNhD;;ALCC;EKPF;ILQI,iBAAgB;GKFnB;CrB6sFA;;AqBntFD;EAII,WAAU;CACX;;AAGH;EAEI,cAAa;CACd;;AAGH;EACE,mBAAkB;EAClB,UAAS;EACT,iBAAgB;ELdZ,8BjB4NwC;CsB5M7C;;ALZC;EKOF;ILNI,iBAAgB;GKWnB;CrBqtFA;;AsBzuFD;;;;EAIE,mBAAkB;CACnB;;ACuBG;EACE,sBAAqB;EACrB,SAAQ;EACR,UAAS;EACT,qBAA+B;EAC/B,wBAAkC;EAClC,YAAW;EAlCf,wBAA8B;EAC9B,sCAA4C;EAC5C,iBAAgB;EAChB,qCAA2C;CAuCxC;;AAkBD;EACE,eAAc;CACf;;ADjDL;EACE,mBAAkB;EAClB,UAAS;EACT,QAAO;EACP,cvB2jBsC;EuB1jBtC,cAAa;EACb,YAAW;EACX,iBvB0hBuC;EuBzhBvC,kBAA8B;EAC9B,qBAA4B;EAC5B,gBvBsNgC;EuBrNhC,evBLgB;EuBMhB,iBAAgB;EAChB,iBAAgB;EAChB,uBvBjBa;EuBkBb,6BAA4B;EAC5B,sCvBTa;EOjBX,uBP8MgC;CuBjLnC;;AAED;EACE,SAAQ;EACR,WAAU;CACX;;AAID;EAEI,UAAS;EACT,aAAY;EACZ,cAAa;EACb,wBvBkgBuC;CuBjgBxC;;ACnBC;EACE,sBAAqB;EACrB,SAAQ;EACR,UAAS;EACT,qBAA+B;EAC/B,wBAAkC;EAClC,YAAW;EA3Bf,cAAa;EACb,sCAA4C;EAC5C,2BAAiC;EACjC,qCAA2C;CAgCxC;;AAkBD;EACE,eAAc;CACf;;ADRL;EAEI,OAAM;EACN,YAAW;EACX,WAAU;EACV,cAAa;EACb,sBvBofuC;CuBnfxC;;ACjCC;EACE,sBAAqB;EACrB,SAAQ;EACR,UAAS;EACT,qBAA+B;EAC/B,wBAAkC;EAClC,YAAW;EApBf,oCAA0C;EAC1C,gBAAe;EACf,uCAA6C;EAC7C,yBAA+B;CAyB5B;;AAkBD;EACE,eAAc;CACf;;AAlCD;EDsCE,kBAAiB;CAClB;;AAIL;EAEI,OAAM;EACN,YAAW;EACX,WAAU;EACV,cAAa;EACb,uBvBmeuC;CuBlexC;;AClDC;EACE,sBAAqB;EACrB,SAAQ;EACR,UAAS;EACT,qBAA+B;EAC/B,wBAAkC;EAClC,YAAW;CAQZ;;AAdD;EAkBI,cAAa;CACd;;AAED;EACE,sBAAqB;EACrB,SAAQ;EACR,UAAS;EACT,sBAAgC;EAChC,wBAAkC;EAClC,YAAW;EAlCjB,oCAA0C;EAC1C,0BAAgC;EAChC,uCAA6C;CAkCxC;;AAGH;EACE,eAAc;CACf;;AAbC;EDkCA,kBAAiB;CAClB;;AAML;EAKI,YAAW;EACX,aAAY;CACb;;AAKH;EElGE,UAAS;EACT,iBAAmB;EACnB,iBAAgB;EAChB,8BzBKgB;CuB4FjB;;AAKD;EACE,eAAc;EACd,YAAW;EACX,wBvBkdwC;EuBjdxC,YAAW;EACX,iBvBgI+B;EuB/H/B,evBhGgB;EuBiGhB,oBAAmB;EACnB,oBAAmB;EACnB,8BAA6B;EAC7B,UAAS;CAwBV;;ArBhIC;EqB2GE,evB+bqD;EuB9brD,sBAAqB;EJtHrB,0BnBMc;CEOf;;AqB2FH;EAoBI,YvBvHW;EuBwHX,sBAAqB;EJ7HrB,0BnB8Ba;CuBiGd;;AAvBH;EA2BI,evBxHc;EuByHd,8BAA6B;CAK9B;;AAGH;EACE,eAAc;CACf;;AAGD;EACE,eAAc;EACd,uBvB0awC;EuBzaxC,iBAAgB;EAChB,oBvBqFoD;EuBpFpD,evB3IgB;EuB4IhB,oBAAmB;CACpB;;AAGD;EACE,eAAc;EACd,wBvBgawC;EuB/ZxC,evBhJgB;CuBiJjB;;AGlKD;;EAEE,mBAAkB;EAClB,4BAAoB;EAApB,qBAAoB;EACpB,uBAAsB;CAyBvB;;AA7BD;;EAOI,mBAAkB;EAClB,mBAAc;EAAd,eAAc;CAYf;;AxBXD;;EwBII,WAAU;CxBJQ;;AwBTxB;;;;EAkBM,WAAU;CACX;;AAnBL;;;;;;;;EA2BI,kB1BiL6B;C0BhL9B;;AAIH;EACE,qBAAa;EAAb,cAAa;EACb,oBAAe;EAAf,gBAAe;EACf,qBAA2B;EAA3B,4BAA2B;CAK5B;;AARD;EAMI,YAAW;CACZ;;AAGH;EAEI,eAAc;CACf;;AAHH;;EnB5BI,2BmBoC8B;EnBnC9B,8BmBmC8B;CAC/B;;AATH;;EnBdI,0BmB2B6B;EnB1B7B,6BmB0B6B;CAC9B;;AAeH;EACE,yBAAmC;EACnC,wBAAkC;CAWnC;;AAbD;;;EAOI,eAAc;CACf;;AAED;EACE,gBAAe;CAChB;;AAGH;EACE,wBAAsC;EACtC,uBAAqC;CACtC;;AAED;EACE,uBAAsC;EACtC,sBAAqC;CACtC;;AAmBD;EACE,2BAAsB;EAAtB,uBAAsB;EACtB,sBAAuB;EAAvB,wBAAuB;EACvB,sBAAuB;EAAvB,wBAAuB;CAyBxB;;AA5BD;;EAOI,YAAW;CACZ;;AARH;;;;EAcI,iB1B6E6B;E0B5E7B,eAAc;CACf;;AAhBH;;EnB5FI,8BmBiH+B;EnBhH/B,6BmBgH+B;CAChC;;AAtBH;;EnB1GI,0BmBoI4B;EnBnI5B,2BmBmI4B;CAC7B;;AAgBH;;EAGI,iBAAgB;CAQjB;;AAXH;;;;EAOM,mBAAkB;EAClB,uBAAsB;EACtB,qBAAoB;CACrB;;ACnKL;EACE,mBAAkB;EAClB,qBAAa;EAAb,cAAa;EACb,oBAAe;EAAf,gBAAe;EACf,wBAAoB;EAApB,qBAAoB;EACpB,YAAW;CAwCZ;;AA7CD;;;EAUI,mBAAkB;EAClB,mBAAc;EAAd,eAAc;EAGd,UAAS;EACT,iBAAgB;CAYjB;;AA3BH;;;EAmBM,WAAU;CACX;;AApBL;;;;;;;;;EAyBM,kB3BgL2B;C2B/K5B;;AA1BL;;EpBWI,2BoBoBmD;EpBnBnD,8BoBmBmD;CAAK;;AA/B5D;;EpByBI,0BoBOmD;EpBNnD,6BoBMmD;CAAK;;AAhC5D;EAsCI,qBAAa;EAAb,cAAa;EACb,uBAAmB;EAAnB,oBAAmB;CAKpB;;AA5CH;;EpBWI,2BoB+B6E;EpB9B7E,8BoB8B6E;CAAK;;AA1CtF;EpByBI,0BoBkBsE;EpBjBtE,6BoBiBsE;CAAK;;AAW/E;;EAEE,qBAAa;EAAb,cAAa;CAgBd;;AAlBD;;EAQI,mBAAkB;EAClB,WAAU;CACX;;AAVH;;;;;;;;EAgBI,kB3BmI6B;C2BlI9B;;AAGH;EAAuB,mB3B+HU;C2B/H4B;;AAC7D;EAAsB,kB3B8HW;C2B9H0B;;AAQ3D;EACE,qBAAa;EAAb,cAAa;EACb,uBAAmB;EAAnB,oBAAmB;EACnB,0B3BiPkC;E2BhPlC,iBAAgB;EAChB,gB3BgJgC;E2B/IhC,iB3BoJ+B;E2BnJ/B,iB3BuJ+B;E2BtJ/B,e3B/EgB;E2BgFhB,mBAAkB;EAClB,oBAAmB;EACnB,0B3BvFgB;E2BwFhB,0B3BtFgB;EOXd,uBP8MgC;C2BrGnC;;AApBD;;EAkBI,cAAa;CACd;;AAiCH;;;;;;EpB5HI,2BoBkI4B;EpBjI5B,8BoBiI4B;CAC/B;;AAED;;;;;;EpBvHI,0BoB6H2B;EpB5H3B,6BoB4H2B;CAC9B;;ACpJD;EACE,mBAAkB;EAClB,eAAc;EACd,mBAAsC;EACtC,qB5Bwb4C;C4Bvb7C;;AAED;EACE,4BAAoB;EAApB,qBAAoB;EACpB,mB5Bob0C;C4Bnb3C;;AAED;EACE,mBAAkB;EAClB,YAAW;EACX,WAAU;CA4BX;;AA/BD;EAMI,Y5BhBW;EmBLX,0BnB8Ba;C4BNd;;AATH;EAaI,iE5BEa;C4BDd;;AAdH;EAiBI,Y5B3BW;E4B4BX,0B5Bib8E;C4B/a/E;;AApBH;EAwBM,e5B5BY;C4BiCb;;AA7BL;EA2BQ,0B5BnCU;C4BoCX;;AASP;EACE,mBAAkB;EAClB,iBAAgB;CA8BjB;;AAhCD;EAMI,mBAAkB;EAClB,aAA+D;EAC/D,c5BmY0C;E4BlY1C,eAAc;EACd,Y5BoYwC;E4BnYxC,a5BmYwC;E4BlYxC,qBAAoB;EACpB,YAAW;EACX,0BAAiB;EAAjB,uBAAiB;EAAjB,sBAAiB;EAAjB,kBAAiB;EACjB,0B5B3Dc;C4B6Df;;AAjBH;EAqBI,mBAAkB;EAClB,aAA+D;EAC/D,c5BoX0C;E4BnX1C,eAAc;EACd,Y5BqXwC;E4BpXxC,a5BoXwC;E4BnXxC,YAAW;EACX,6BAA4B;EAC5B,mCAAkC;EAClC,yB5BkX2C;C4BjX5C;;AAQH;ErB7FI,uBP8MgC;C4B9GjC;;AAHH;ET3FI,0BnB8Ba;C4BqEZ;;AARL;EAUM,2Nb/DqI;CagEtI;;AAXL;ET3FI,0BnB8Ba;C4B+EZ;;AAlBL;EAoBM,wKbzEqI;Ca0EtI;;AArBL;EA0BM,yC5BvFW;C4BwFZ;;AA3BL;EA6BM,yC5B1FW;C4B2FZ;;AAQL;EAEI,mB5B0V+C;C4BzVhD;;AAHH;ETjII,0BnB8Ba;C4B2GZ;;AARL;EAUM,qKbrGqI;CasGtI;;AAXL;EAgBM,yC5BnHW;C4BoHZ;;AAWL;EACE,sBAAqB;EACrB,YAAW;EACX,4B5BsQ4F;E4BrQ5F,2C5BgUwC;E4B/TxC,iB5B+E+B;E4B9E/B,e5BvJgB;E4BwJhB,uBAAsB;EACtB,uNAAsG;EACtG,0B5BmU0C;E4BlU1C,0B5B9JgB;E4BgKd,uB5BmCgC;E4B/BlC,yBAAgB;EAAhB,sBAAgB;EAAhB,iBAAgB;CAkCjB;;AAlDD;EAmBI,sB5B2OsE;E4B1OtE,WAAU;EACV,mF5ByOsE;C4B9NvE;;AAhCH;EA6BM,e5B9KY;E4B+KZ,uB5BtLS;C4BuLV;;AA/BL;EAoCI,aAAY;EACZ,uB5B+RsC;E4B9RtC,uBAAsB;CACvB;;AAvCH;EA0CI,e5B5Lc;E4B6Ld,0B5BjMc;C4BkMf;;AA5CH;EAgDI,WAAU;CACX;;AAGH;EACE,8B5BuN+F;E4BtN/F,sB5B6QyC;E4B5QzC,yB5B4QyC;E4B3QzC,e5B8RqC;C4B7RtC;;AAED;EACE,6B5BmN+F;E4BlN/F,sB5BsQyC;E4BrQzC,yB5BqQyC;E4BpQzC,gB5B0RsC;C4BzRvC;;AAOD;EACE,mBAAkB;EAClB,sBAAqB;EACrB,YAAW;EACX,4B5B8L4F;E4B7L5F,iBAAgB;CACjB;;AAED;EACE,mBAAkB;EAClB,WAAU;EACV,YAAW;EACX,4B5BsL4F;E4BrL5F,UAAS;EACT,WAAU;CAgBX;;AAtBD;EASI,sB5BsKsE;E4BrKtE,iD5BxNa;C4B6Nd;;AAfH;EAaM,sB5BkKoE;C4BjKrE;;AAdL;EAmBM,kB5B2RQ;C4B1RT;;AAIL;EACE,mBAAkB;EAClB,OAAM;EACN,SAAQ;EACR,QAAO;EACP,WAAU;EACV,4B5B4J4F;E4B3J5F,0B5B4DkC;E4B3DlC,iB5B3B+B;E4B4B/B,e5BjQgB;E4BkQhB,uB5BzQa;E4B0Qb,0B5BtQgB;EOXd,uBP8MgC;C4BuFnC;;AA/BD;EAgBI,mBAAkB;EAClB,OAAM;EACN,SAAQ;EACR,UAAS;EACT,WAAU;EACV,eAAc;EACd,gB5B2I2G;E4B1I3G,0B5B4CgC;E4B3ChC,iB5B3C6B;E4B4C7B,e5BjRc;E4BkRd,kBAAiB;ET9RjB,0BnBOc;E4ByRd,+B5BvRc;EOXd,mCqBmSgF;CACjF;;AASH;EACE,YAAW;EACX,gBAAe;EACf,8BAA6B;EAC7B,yBAAgB;EAAhB,sBAAgB;EAAhB,iBAAgB;CA+GjB;;AAnHD;EAOI,cAAa;CACd;;AARH;EAWI,UAAS;CACV;;AAZH;EAeI,Y5BsMsC;E4BrMtC,a5BqMsC;E4BpMtC,qBAA6C;ET5T7C,0BnB8Ba;E4BgSb,U5BqMmC;EOrgBnC,oBPsgBsC;E4BnMtC,yBAAgB;EAAhB,iBAAgB;CAUjB;;AAhCH;EAyBM,cAAa;EACb,iE5BvSW;C4BwSZ;;AA3BL;ET3SI,0BnBugBoE;C4B7LnE;;AA/BL;EAmCI,Y5B2KoC;E4B1KpC,e5B2KqC;E4B1KrC,mBAAkB;EAClB,gB5B0KuC;E4BzKvC,0B5B1Uc;E4B2Ud,0BAAyB;ErBrVzB,oBP+foC;C4BvKrC;;AA3CH;EA8CI,Y5BuKsC;E4BtKtC,a5BsKsC;EmBhgBtC,0BnB8Ba;E4B8Tb,U5BuKmC;EOrgBnC,oBPsgBsC;E4BrKtC,sBAAgB;EAAhB,iBAAgB;CAUjB;;AA9DH;EAuDM,cAAa;EACb,iE5BrUW;C4BsUZ;;AAzDL;ET3SI,0BnBugBoE;C4B/JnE;;AA7DL;EAiEI,Y5B6IoC;E4B5IpC,e5B6IqC;E4B5IrC,mBAAkB;EAClB,gB5B4IuC;E4B3IvC,0B5BxWc;E4ByWd,0BAAyB;ErBnXzB,oBP+foC;C4BzIrC;;AAzEH;EA4EI,Y5ByIsC;E4BxItC,a5BwIsC;EmBhgBtC,0BnB8Ba;E4B4Vb,U5ByImC;EOrgBnC,oBPsgBsC;E4BvItC,iBAAgB;CAUjB;;AA5FH;EAqFM,cAAa;EACb,iE5BnWW;C4BoWZ;;AAvFL;ET3SI,0BnBugBoE;C4BjInE;;AA3FL;EA+FI,Y5B+GoC;E4B9GpC,e5B+GqC;E4B9GrC,mBAAkB;EAClB,gB5B8GuC;E4B7GvC,8BAA6B;EAC7B,0BAAyB;EACzB,qBAA+C;CAEhD;;AAvGH;EA0GI,0B5B7Yc;EOVd,oBP+foC;C4BtGrC;;AA5GH;EA+GI,mBAAkB;EAClB,0B5BnZc;EOVd,oBP+foC;C4BhGrC;;AC9ZH;EACE,qBAAa;EAAb,cAAa;EACb,oBAAe;EAAf,gBAAe;EACf,gBAAe;EACf,iBAAgB;EAChB,iBAAgB;CACjB;;AAED;EACE,eAAc;EACd,qB7BykBsC;C6B/jBvC;;A3BTC;E2BEE,sBAAqB;C3BCtB;;A2BNH;EAUI,e7BNc;C6BOf;;AAOH;EACE,iC7BlBgB;C6BoDjB;;AAnCD;EAII,oB7B4K6B;C6B3K9B;;AALH;EAQI,8BAAgD;EtB7BhD,gCPwMgC;EOvMhC,iCPuMgC;C6B/JjC;;A3BnCD;E2B2BI,sC7B7BY;CEKf;;A2BYH;EAgBM,e7B9BY;E6B+BZ,8BAA6B;EAC7B,0BAAyB;CAC1B;;AAnBL;;EAwBI,e7BrCc;E6BsCd,uB7B7CW;E6B8CX,mC7B9CW;C6B+CZ;;AA3BH;EA+BI,iB7BiJ6B;EOrM7B,0BsBsD4B;EtBrD5B,2BsBqD4B;CAC7B;;AAQH;EtBrEI,uBP8MgC;C6BtIjC;;AAHH;;EAOI,Y7BrEW;E6BsEX,0B7B7Ca;C6B8Cd;;AAQH;EAEI,mBAAc;EAAd,eAAc;EACd,mBAAkB;CACnB;;AAGH;EAEI,2BAAa;EAAb,cAAa;EACb,qBAAY;EAAZ,aAAY;EACZ,mBAAkB;CACnB;;AAQH;EAEI,cAAa;CACd;;AAHH;EAKI,eAAc;CACf;;ACnGH;EACE,mBAAkB;EAClB,qBAAa;EAAb,cAAa;EACb,oBAAe;EAAf,gBAAe;EACf,uBAAmB;EAAnB,oBAAmB;EACnB,uBAA8B;EAA9B,+BAA8B;EAC9B,qB9B8FW;C8BnFZ;;AAjBD;;EAYI,qBAAa;EAAb,cAAa;EACb,oBAAe;EAAf,gBAAe;EACf,uBAAmB;EAAnB,oBAAmB;EACnB,uBAA8B;EAA9B,+BAA8B;CAC/B;;AAQH;EACE,sBAAqB;EACrB,uB9B0kB+E;E8BzkB/E,0B9BykB+E;E8BxkB/E,mB9BwEW;E8BvEX,mB9BiMoD;E8BhMpD,qBAAoB;EACpB,oBAAmB;CAKpB;;A5BrCC;E4BmCE,sBAAqB;C5BhCtB;;A4ByCH;EACE,qBAAa;EAAb,cAAa;EACb,2BAAsB;EAAtB,uBAAsB;EACtB,gBAAe;EACf,iBAAgB;EAChB,iBAAgB;CAWjB;;AAhBD;EAQI,iBAAgB;EAChB,gBAAe;CAChB;;AAVH;EAaI,iBAAgB;EAChB,YAAW;CACZ;;AAQH;EACE,sBAAqB;EACrB,oB9BkgBuC;E8BjgBvC,uB9BigBuC;C8BhgBxC;;AAWD;EACE,8BAAgB;EAAhB,iBAAgB;EAChB,qBAAY;EAAZ,aAAY;EAGZ,uBAAmB;EAAnB,oBAAmB;CACpB;;AAGD;EACE,yB9B4gBwC;E8B3gBxC,mB9BkIoD;E8BjIpD,eAAc;EACd,8BAA6B;EAC7B,8BAAuC;EvB5GrC,uBP8MgC;C8BvFnC;;A5B3GC;E4BoGE,sBAAqB;C5BjGtB;;A4BwFH;EAcI,gBAAe;CAChB;;AAKH;EACE,sBAAqB;EACrB,aAAY;EACZ,cAAa;EACb,uBAAsB;EACtB,YAAW;EACX,oCAAmC;EACnC,2BAA0B;CAC3B;;AnB9DG;EmBuEC;;IAIK,iBAAgB;IAChB,gBAAe;GAChB;C7BwjHR;;AUlpHG;EmBoFA;IAUI,0BAAqB;IAArB,sBAAqB;IACrB,qBAA2B;IAA3B,4BAA2B;GAgC9B;EA3CA;IAcK,wBAAmB;IAAnB,oBAAmB;GAUpB;EAxBJ;IAiBO,mBAAkB;GACnB;EAlBN;IAqBO,sB9B0c6B;I8Bzc7B,qB9Byc6B;G8Bxc9B;EAvBN;;IA6BK,sBAAiB;IAAjB,kBAAiB;GAClB;EA9BJ;IAiCK,gCAAwB;IAAxB,yBAAwB;IAGxB,8BAAgB;IAAhB,iBAAgB;GACjB;EArCJ;IAwCK,cAAa;GACd;C7BijHR;;AUjqHG;EmBuEC;;IAIK,iBAAgB;IAChB,gBAAe;GAChB;C7B4lHR;;AUtrHG;EmBoFA;IAUI,0BAAqB;IAArB,sBAAqB;IACrB,qBAA2B;IAA3B,4BAA2B;GAgC9B;EA3CA;IAcK,wBAAmB;IAAnB,oBAAmB;GAUpB;EAxBJ;IAiBO,mBAAkB;GACnB;EAlBN;IAqBO,sB9B0c6B;I8Bzc7B,qB9Byc6B;G8Bxc9B;EAvBN;;IA6BK,sBAAiB;IAAjB,kBAAiB;GAClB;EA9BJ;IAiCK,gCAAwB;IAAxB,yBAAwB;IAGxB,8BAAgB;IAAhB,iBAAgB;GACjB;EArCJ;IAwCK,cAAa;GACd;C7BqlHR;;AUrsHG;EmBuEC;;IAIK,iBAAgB;IAChB,gBAAe;GAChB;C7BgoHR;;AU1tHG;EmBoFA;IAUI,0BAAqB;IAArB,sBAAqB;IACrB,qBAA2B;IAA3B,4BAA2B;GAgC9B;EA3CA;IAcK,wBAAmB;IAAnB,oBAAmB;GAUpB;EAxBJ;IAiBO,mBAAkB;GACnB;EAlBN;IAqBO,sB9B0c6B;I8Bzc7B,qB9Byc6B;G8Bxc9B;EAvBN;;IA6BK,sBAAiB;IAAjB,kBAAiB;GAClB;EA9BJ;IAiCK,gCAAwB;IAAxB,yBAAwB;IAGxB,8BAAgB;IAAhB,iBAAgB;GACjB;EArCJ;IAwCK,cAAa;GACd;C7BynHR;;AUzuHG;EmBuEC;;IAIK,iBAAgB;IAChB,gBAAe;GAChB;C7BoqHR;;AU9vHG;EmBoFA;IAUI,0BAAqB;IAArB,sBAAqB;IACrB,qBAA2B;IAA3B,4BAA2B;GAgC9B;EA3CA;IAcK,wBAAmB;IAAnB,oBAAmB;GAUpB;EAxBJ;IAiBO,mBAAkB;GACnB;EAlBN;IAqBO,sB9B0c6B;I8Bzc7B,qB9Byc6B;G8Bxc9B;EAvBN;;IA6BK,sBAAiB;IAAjB,kBAAiB;GAClB;EA9BJ;IAiCK,gCAAwB;IAAxB,yBAAwB;IAGxB,8BAAgB;IAAhB,iBAAgB;GACjB;EArCJ;IAwCK,cAAa;GACd;C7B6pHR;;A6B3sHD;EAeQ,0BAAqB;EAArB,sBAAqB;EACrB,qBAA2B;EAA3B,4BAA2B;CAgC9B;;AAhDL;;EASU,iBAAgB;EAChB,gBAAe;CAChB;;AAXT;EAmBU,wBAAmB;EAAnB,oBAAmB;CAUpB;;AA7BT;EAsBY,mBAAkB;CACnB;;AAvBX;EA0BY,sB9B0c6B;E8Bzc7B,qB9Byc6B;C8Bxc9B;;AA5BX;;EAkCU,sBAAiB;EAAjB,kBAAiB;CAClB;;AAnCT;EAsCU,gCAAwB;EAAxB,yBAAwB;EAGxB,8BAAgB;EAAhB,iBAAgB;CACjB;;AA1CT;EA6CU,cAAa;CACd;;AAYT;EAEI,0B9BlLW;C8BuLZ;;A5B5LD;E4B0LI,0B9BrLS;CEFZ;;A4BkLH;EAWM,0B9B3LS;C8BoMV;;A5BzMH;E4BmMM,0B9B9LO;CEFZ;;A4BkLH;EAkBQ,0B9BlMO;C8BmMR;;AAnBP;;;;EA0BM,0B9B1MS;C8B2MV;;AA3BL;EA+BI,0B9B/MW;E8BgNX,iC9BhNW;C8BiNZ;;AAjCH;EAoCI,sQ9B8ZmS;C8B7ZpS;;AArCH;EAwCI,0B9BxNW;C8BgOZ;;AAhDH;EA0CM,0B9B1NS;C8B+NV;;A5BpOH;E4BkOM,0B9B7NO;CEFZ;;A4BsOH;EAEI,Y9BhPW;C8BqPZ;;A5BhPD;E4B8OI,Y9BnPS;CEQZ;;A4BsOH;EAWM,gC9BzPS;C8BkQV;;A5B7PH;E4BuPM,iC9B5PO;CEQZ;;A4BsOH;EAkBQ,iC9BhQO;C8BiQR;;AAnBP;;;;EA0BM,Y9BxQS;C8ByQV;;AA3BL;EA+BI,gC9B7QW;E8B8QX,uC9B9QW;C8B+QZ;;AAjCH;EAoCI,4Q9BmWkS;C8BlWnS;;AArCH;EAwCI,gC9BtRW;C8B8RZ;;AAhDH;EA0CM,Y9BxRS;C8B6RV;;A5BxRH;E4BsRM,Y9B3RO;CEQZ;;A6BfH;EACE,mBAAkB;EAClB,qBAAa;EAAb,cAAa;EACb,2BAAsB;EAAtB,uBAAsB;EACtB,aAAY;EACZ,sBAAqB;EACrB,uB/BCa;E+BAb,4BAA2B;EAC3B,uC/BSa;EOjBX,uBP8MgC;C+BnLnC;;AA3BD;EAYI,gBAAe;EACf,eAAc;CACf;;AAdH;ExBMI,gCPwMgC;EOvMhC,iCPuMgC;C+B3L/B;;AAnBL;ExBoBI,oCP0LgC;EOzLhC,mCPyLgC;C+BrL/B;;AAIL;EAGE,mBAAc;EAAd,eAAc;EACd,iB/B6oByC;C+B5oB1C;;AAED;EACE,uB/BwoBwC;C+BvoBzC;;AAED;EACE,sBAAgC;EAChC,iBAAgB;CACjB;;AAED;EACE,iBAAgB;CACjB;;A7BvCC;E6B2CE,sBAAqB;C7B3CD;;A6ByCxB;EAMI,qB/BunBuC;C+BtnBxC;;AAOH;EACE,yB/B8mByC;E+B7mBzC,iBAAgB;EAChB,sC/BjDa;E+BkDb,8C/BlDa;C+B6Dd;;AAfD;ExB/DI,2DwBsE8E;CAC/E;;AARH;EAYM,cAAa;CACd;;AAIL;EACE,yB/B6lByC;E+B5lBzC,sC/BjEa;E+BkEb,2C/BlEa;C+BuEd;;AARD;ExBhFI,2DPkrBoF;C+B3lBrF;;AAQH;EACE,wBAAkC;EAClC,wB/B4kBwC;E+B3kBxC,uBAAiC;EACjC,iBAAgB;CACjB;;AAED;EACE,wBAAkC;EAClC,uBAAiC;CAClC;;AAGD;EACE,mBAAkB;EAClB,OAAM;EACN,SAAQ;EACR,UAAS;EACT,QAAO;EACP,iB/BokByC;C+BnkB1C;;AAED;EACE,YAAW;ExBtHT,mCPkrBoF;C+B1jBvF;;AAGD;EACE,YAAW;ExBtHT,4CP4qBoF;EO3qBpF,6CP2qBoF;C+BpjBvF;;AAED;EACE,YAAW;ExB7GT,gDP8pBoF;EO7pBpF,+CP6pBoF;C+B/iBvF;;AAKD;EACE,qBAAa;EAAb,cAAa;EACb,2BAAsB;EAAtB,uBAAsB;CAqBvB;;AAvBD;EAKI,oB/B2iBwD;C+B1iBzD;;ApBtFC;EoBgFJ;IASI,wBAAmB;IAAnB,oBAAmB;IACnB,oB/BsiBwD;I+BriBxD,mB/BqiBwD;G+BzhB3D;EAvBD;IAcM,qBAAa;IAAb,cAAa;IAEb,iBAAY;IAAZ,aAAY;IACZ,2BAAsB;IAAtB,uBAAsB;IACtB,mB/B8hBsD;I+B7hBtD,iBAAgB;IAChB,kB/B4hBsD;G+B3hBvD;C9Bw8HJ;;A8B/7HD;EACE,qBAAa;EAAb,cAAa;EACb,2BAAsB;EAAtB,uBAAsB;CA4EvB;;AA9ED;EAOI,oB/B2gBwD;C+B1gBzD;;ApBtHC;EoB8GJ;IAWI,wBAAmB;IAAnB,oBAAmB;GAmEtB;EA9ED;IAgBM,iBAAY;IAAZ,aAAY;IACZ,iBAAgB;GA2DjB;EA5EL;IAoBQ,eAAc;IACd,eAAc;GACf;EAtBP;IxBzJI,2BwBoLoC;IxBnLpC,8BwBmLoC;GAU/B;EArCT;;IA+BY,2BAA0B;GAC3B;EAhCX;;IAmCY,8BAA6B;GAC9B;EApCX;IxB3II,0BwBmLmC;IxBlLnC,6BwBkLmC;GAU9B;EAlDT;;IA4CY,0BAAyB;GAC1B;EA7CX;;IAgDY,6BAA4B;GAC7B;EAjDX;IxBtKI,uBP8MgC;G+BuB3B;EA/DT;;IxBhKI,gCPwMgC;IOvMhC,iCPuMgC;G+BkBzB;EA1DX;;IxBlJI,oCP0LgC;IOzLhC,mCPyLgC;G+BsBzB;EA9DX;IxBtKI,iBwBwO8B;GAQzB;EA1ET;;;;IxBtKI,iBwB8OgC;GACzB;C9B27HV;;A8B/6HD;EAEI,uB/BgbsC;C+B/avC;;ApBtMC;EoBmMJ;IAMI,wB/B0biC;I+B1bjC,qB/B0biC;I+B1bjC,gB/B0biC;I+BzbjC,4B/B0buC;I+B1bvC,yB/B0buC;I+B1bvC,oB/B0buC;I+BzbvC,WAAU;IACV,UAAS;GAOZ;EAhBD;IAYM,sBAAqB;IACrB,YAAW;GACZ;C9Bk7HJ;;A8Bz6HD;EAEI,iBAAgB;EAChB,iBAAgB;CACjB;;AAJH;EAQM,iBAAgB;CACjB;;AATL;EAaI,iBAAgB;EAChB,8BAA6B;EAC7B,6BAA4B;CAC7B;;AAhBH;EAmBI,0BAAyB;EACzB,2BAA0B;CAC3B;;AC3SH;EACE,qBAAa;EAAb,cAAa;EACb,oBAAe;EAAf,gBAAe;EACf,sBhCk2BsC;EgCj2BtC,oBhCo2BsC;EgCn2BtC,iBAAgB;EAChB,0BhCOgB;EOTd,uBP8MgC;CgC1MnC;;AAED;EAGI,qBhCy1BqC;CgCj1BtC;;AAXH;EAMM,sBAAqB;EACrB,sBhCq1BmC;EgCp1BnC,ehCDY;EgCEZ,ahC01BuC;CgCz1BxC;;AAVL;EAoBI,2BAA0B;CAC3B;;AArBH;EAwBI,sBAAqB;CACtB;;AAzBH;EA4BI,ehCrBc;CgCsBf;;ACvCH;EACE,qBAAa;EAAb,cAAa;E7BGb,gBAAe;EACf,iBAAgB;EGDd,uBP8MgC;CiC9MnC;;AAED;EACE,mBAAkB;EAClB,eAAc;EACd,wBjCooBwC;EiCnoBxC,kBjCqM+B;EiCpM/B,kBjCuoBsC;EiCtoBtC,ejCwBe;EiCvBf,uBjCFa;EiCGb,0BjCAgB;CiCoBjB;;AA5BD;EAWI,WAAU;EACV,ejCsIgD;EiCrIhD,sBAAqB;EACrB,0BjCPc;EiCQd,sBjCPc;CiCQf;;AAhBH;EAmBI,WAAU;EACV,WjCgoBiC;EiC/nBjC,iDjCSa;CiCRd;;AAtBH;EA0BI,gBAAe;CAChB;;AAGH;EAGM,eAAc;E1BRhB,gCPmLgC;EOlLhC,mCPkLgC;CiCzK/B;;AALL;E1BnBI,iCPiMgC;EOhMhC,oCPgMgC;CiCpK/B;;AAVL;EAcI,WAAU;EACV,YjCxCW;EiCyCX,0BjChBa;EiCiBb,sBjCjBa;CiCkBd;;AAlBH;EAqBI,ejCxCc;EiCyCd,qBAAoB;EAEpB,aAAY;EACZ,uBjClDW;EiCmDX,sBjChDc;CiCiDf;;AC5DD;EACE,wBlC6oBsC;EkC5oBtC,mBlC0OkD;EkCzOlD,iBlCsM6B;CkCrM9B;;AAIG;E3BoBF,+BPoL+B;EOnL/B,kCPmL+B;CkCtM5B;;AAGD;E3BCF,gCPkM+B;EOjM/B,mCPiM+B;CkCjM5B;;AAfL;EACE,wBlC2oBqC;EkC1oBrC,oBlC2OkD;EkC1OlD,iBlCuM6B;CkCtM9B;;AAIG;E3BoBF,+BPqL+B;EOpL/B,kCPoL+B;CkCvM5B;;AAGD;E3BCF,gCPmM+B;EOlM/B,mCPkM+B;CkClM5B;;ACbP;EACE,sBAAqB;EACrB,sBnC6uBsC;EmC5uBtC,enCyuBqC;EmCxuBrC,iBnC2O+B;EmC1O/B,eAAc;EACd,mBAAkB;EAClB,oBAAmB;EACnB,yBAAwB;E5BTtB,uBP8MgC;CmC9LnC;;AAfD;EAaI,cAAa;CACd;;AAIH;EACE,mBAAkB;EAClB,UAAS;CACV;;AAMD;EACE,qBnCstBsC;EmCrtBtC,oBnCqtBsC;EOnvBpC,qBPsvBqC;CmCttBxC;;AAOC;EC1CA,YpCUa;EoCTb,0BpCkCe;CmCSd;;AjC7BD;EkCVI,YpCKS;EoCJT,sBAAqB;EACrB,0BAAkC;ClCWrC;;AiCwBD;EC1CA,YpCUa;EoCTb,0BpCegB;CmC4Bf;;AjC7BD;EkCVI,YpCKS;EoCJT,sBAAqB;EACrB,0BAAkC;ClCWrC;;AiCwBD;EC1CA,YpCUa;EoCTb,0BpCyCe;CmCEd;;AjC7BD;EkCVI,YpCKS;EoCJT,sBAAqB;EACrB,0BAAkC;ClCWrC;;AiCwBD;EC1CA,YpCUa;EoCTb,0BpC2Ce;CmCAd;;AjC7BD;EkCVI,YpCKS;EoCJT,sBAAqB;EACrB,0BAAkC;ClCWrC;;AiCwBD;EC1CA,epCmBgB;EoClBhB,0BpCwCe;CmCGd;;AjC7BD;EkCVI,epCcY;EoCbZ,sBAAqB;EACrB,0BAAkC;ClCWrC;;AiCwBD;EC1CA,YpCUa;EoCTb,0BpCsCe;CmCKd;;AjC7BD;EkCVI,YpCKS;EoCJT,sBAAqB;EACrB,0BAAkC;ClCWrC;;AiCwBD;EC1CA,epCmBgB;EoClBhB,0BpCUgB;CmCiCf;;AjC7BD;EkCVI,epCcY;EoCbZ,sBAAqB;EACrB,0BAAkC;ClCWrC;;AiCwBD;EC1CA,YpCUa;EoCTb,0BpCiBgB;CmC0Bf;;AjC7BD;EkCVI,YpCKS;EoCJT,sBAAqB;EACrB,0BAAkC;ClCWrC;;AmCnBH;EACE,mBAAoD;EACpD,oBrCyqBsC;EqCxqBtC,0BrCUgB;EOTd,sBP+M+B;CqC1MlC;;A1BmDG;E0B5DJ;IAOI,mBrCoqBoC;GqClqBvC;CpCy8IA;;AoCv8ID;EACE,iBAAgB;EAChB,gBAAe;E9BTb,iB8BUsB;CACzB;;ACXD;EACE,mBAAkB;EAClB,yBtC4xByC;EsC3xBzC,oBtC4xBsC;EsC3xBtC,8BAA6C;E/BJ3C,uBP8MgC;CsCxMnC;;AAGD;EAEE,eAAc;CACf;;AAGD;EACE,iBtCgO+B;CsC/NhC;;AAOD;EACE,oBAAwD;CAUzD;;AAXD;EAKI,mBAAkB;EAClB,OAAM;EACN,SAAQ;EACR,yBtC8vBuC;EsC7vBvC,eAAc;CACf;;AASD;EC9CA,exBmFgE;EI9E9D,0BJ8E8D;EwBjFhE,sBxBiFgE;CuBnC/D;;AC5CD;EACE,0BAAqC;CACtC;;AAED;EACE,eAA0B;CAC3B;;ADoCD;EC9CA,exBmFgE;EI9E9D,0BJ8E8D;EwBjFhE,sBxBiFgE;CuBnC/D;;AC5CD;EACE,0BAAqC;CACtC;;AAED;EACE,eAA0B;CAC3B;;ADoCD;EC9CA,exBmFgE;EI9E9D,0BJ8E8D;EwBjFhE,sBxBiFgE;CuBnC/D;;AC5CD;EACE,0BAAqC;CACtC;;AAED;EACE,eAA0B;CAC3B;;ADoCD;EC9CA,exBmFgE;EI9E9D,0BJ8E8D;EwBjFhE,sBxBiFgE;CuBnC/D;;AC5CD;EACE,0BAAqC;CACtC;;AAED;EACE,eAA0B;CAC3B;;ADoCD;EC9CA,exBmFgE;EI9E9D,0BJ8E8D;EwBjFhE,sBxBiFgE;CuBnC/D;;AC5CD;EACE,0BAAqC;CACtC;;AAED;EACE,eAA0B;CAC3B;;ADoCD;EC9CA,exBmFgE;EI9E9D,0BJ8E8D;EwBjFhE,sBxBiFgE;CuBnC/D;;AC5CD;EACE,0BAAqC;CACtC;;AAED;EACE,eAA0B;CAC3B;;ADoCD;EC9CA,exBmFgE;EI9E9D,0BJ8E8D;EwBjFhE,sBxBiFgE;CuBnC/D;;AC5CD;EACE,0BAAqC;CACtC;;AAED;EACE,eAA0B;CAC3B;;ADoCD;EC9CA,exBmFgE;EI9E9D,0BJ8E8D;EwBjFhE,sBxBiFgE;CuBnC/D;;AC5CD;EACE,0BAAqC;CACtC;;AAED;EACE,eAA0B;CAC3B;;ACXH;EACE;IAAO,4BAAuC;GvCwmJ7C;EuCvmJD;IAAK,yBAAwB;GvC0mJ5B;CACF;;AuC7mJD;EACE;IAAO,4BAAuC;GvCwmJ7C;EuCvmJD;IAAK,yBAAwB;GvC0mJ5B;CACF;;AuCxmJD;EACE,qBAAa;EAAb,cAAa;EACb,axCwyBsC;EwCvyBtC,iBAAgB;EAChB,mBxCuyByD;EwCtyBzD,0BxCGgB;EOTd,uBP8MgC;CwCrMnC;;AAED;EACE,qBAAa;EAAb,cAAa;EACb,2BAAsB;EAAtB,uBAAsB;EACtB,sBAAuB;EAAvB,wBAAuB;EACvB,YxCRa;EwCSb,mBAAkB;EAClB,oBAAmB;EACnB,0BxCce;EiB/BX,4BjBkzB4C;CwC/xBjD;;AvBfC;EuBMF;IvBLI,iBAAgB;GuBcnB;CvC+mJA;;AuC7mJD;ErBiBE,sMAA6I;EqBf7I,2BxCmxBsC;CwClxBvC;;AAED;EACE,2DxCsxBoD;EwCtxBpD,mDxCsxBoD;CwCrxBrD;;ACjCD;EACE,qBAAa;EAAb,cAAa;EACb,sBAAuB;EAAvB,wBAAuB;CACxB;;AAED;EACE,YAAO;EAAP,QAAO;CACR;;ACHD;EACE,qBAAa;EAAb,cAAa;EACb,2BAAsB;EAAtB,uBAAsB;EAGtB,gBAAe;EACf,iBAAgB;CACjB;;AAQD;EACE,YAAW;EACX,e1CHgB;E0CIhB,oBAAmB;CAapB;;AxCnBC;EwCUE,e1CRc;E0CSd,sBAAqB;EACrB,0B1ChBc;CEOf;;AwCAH;EAaI,e1CZc;E0Cad,0B1CpBc;C0CqBf;;AAQH;EACE,mBAAkB;EAClB,eAAc;EACd,yB1CoxByC;E0ClxBzC,oB1CgK+B;E0C/J/B,uB1CrCa;E0CsCb,uC1C5Ba;C0CyDd;;AApCD;EnChCI,gCPwMgC;EOvMhC,iCPuMgC;C0C7JjC;;AAXH;EAcI,iBAAgB;EnChChB,oCP0LgC;EOzLhC,mCPyLgC;C0CxJjC;;AxC1CD;EwC6CE,WAAU;EACV,sBAAqB;CxC3CtB;;AwCuBH;EAyBI,e1ClDc;E0CmDd,uB1CzDW;C0C0DZ;;AA3BH;EA+BI,WAAU;EACV,Y1C/DW;E0CgEX,0B1CvCa;E0CwCb,sB1CxCa;C0CyCd;;AASH;EAEI,gBAAe;EACf,eAAc;EnCrFd,iBmCsFwB;CACzB;;AALH;EASM,cAAa;CACd;;AAVL;EAeM,iBAAgB;CACjB;;ACnGH;EACE,e5BgF8D;E4B/E9D,0B5B+E8D;C4BjE/D;;AzCHD;EyCPM,e5B2E0D;E4B1E1D,0BAAyC;CzCS9C;;AyChBD;EAWM,Y3CHO;E2CIP,0B5BqE0D;E4BpE1D,sB5BoE0D;C4BnE3D;;AAdL;EACE,e5BgF8D;E4B/E9D,0B5B+E8D;C4BjE/D;;AzCHD;EyCPM,e5B2E0D;E4B1E1D,0BAAyC;CzCS9C;;AyChBD;EAWM,Y3CHO;E2CIP,0B5BqE0D;E4BpE1D,sB5BoE0D;C4BnE3D;;AAdL;EACE,e5BgF8D;E4B/E9D,0B5B+E8D;C4BjE/D;;AzCHD;EyCPM,e5B2E0D;E4B1E1D,0BAAyC;CzCS9C;;AyChBD;EAWM,Y3CHO;E2CIP,0B5BqE0D;E4BpE1D,sB5BoE0D;C4BnE3D;;AAdL;EACE,e5BgF8D;E4B/E9D,0B5B+E8D;C4BjE/D;;AzCHD;EyCPM,e5B2E0D;E4B1E1D,0BAAyC;CzCS9C;;AyChBD;EAWM,Y3CHO;E2CIP,0B5BqE0D;E4BpE1D,sB5BoE0D;C4BnE3D;;AAdL;EACE,e5BgF8D;E4B/E9D,0B5B+E8D;C4BjE/D;;AzCHD;EyCPM,e5B2E0D;E4B1E1D,0BAAyC;CzCS9C;;AyChBD;EAWM,Y3CHO;E2CIP,0B5BqE0D;E4BpE1D,sB5BoE0D;C4BnE3D;;AAdL;EACE,e5BgF8D;E4B/E9D,0B5B+E8D;C4BjE/D;;AzCHD;EyCPM,e5B2E0D;E4B1E1D,0BAAyC;CzCS9C;;AyChBD;EAWM,Y3CHO;E2CIP,0B5BqE0D;E4BpE1D,sB5BoE0D;C4BnE3D;;AAdL;EACE,e5BgF8D;E4B/E9D,0B5B+E8D;C4BjE/D;;AzCHD;EyCPM,e5B2E0D;E4B1E1D,0BAAyC;CzCS9C;;AyChBD;EAWM,Y3CHO;E2CIP,0B5BqE0D;E4BpE1D,sB5BoE0D;C4BnE3D;;AAdL;EACE,e5BgF8D;E4B/E9D,0B5B+E8D;C4BjE/D;;AzCHD;EyCPM,e5B2E0D;E4B1E1D,0BAAyC;CzCS9C;;AyChBD;EAWM,Y3CHO;E2CIP,0B5BqE0D;E4BpE1D,sB5BoE0D;C4BnE3D;;ACjBP;EACE,aAAY;EACZ,kB5Cw4BuD;E4Cv4BvD,iB5CiP+B;E4ChP/B,eAAc;EACd,Y5CgBa;E4Cfb,0B5CKa;E4CJb,YAAW;CAYZ;;A1CHC;E0CNE,Y5CWW;E4CVX,sBAAqB;EACrB,aAAY;C1COb;;A0CnBH;EAiBI,gBAAe;CAChB;;AASH;EACE,WAAU;EACV,8BAA6B;EAC7B,UAAS;EACT,yBAAwB;CACzB;;ACzBD;EACE,iBAAgB;CACjB;;AAGD;EACE,gBAAe;EACf,OAAM;EACN,SAAQ;EACR,UAAS;EACT,QAAO;EACP,c7C+jBsC;E6C9jBtC,cAAa;EACb,iBAAgB;EAGhB,WAAU;CASX;;AAJC;EACE,mBAAkB;EAClB,iBAAgB;CACjB;;AAIH;EACE,mBAAkB;EAClB,YAAW;EACX,e7C4tBuC;E6C1tBvC,qBAAoB;CAUrB;;AAPC;E5BtCI,4CjBqxBoD;EiBrxBpD,oCjBqxBoD;EiBrxBpD,qEjBqxBoD;E6C7uBtD,sCAA6B;EAA7B,8BAA6B;CAC9B;;A5BrCD;E4BkCA;I5BjCE,iBAAgB;G4BoCjB;C5Cw3JF;;A4Cv3JC;EACE,mCAA0B;EAA1B,2BAA0B;CAC3B;;AAGH;EACE,qBAAa;EAAb,cAAa;EACb,uBAAmB;EAAnB,oBAAmB;EACnB,sCAAsD;CACvD;;AAGD;EACE,mBAAkB;EAClB,qBAAa;EAAb,cAAa;EACb,2BAAsB;EAAtB,uBAAsB;EACtB,YAAW;EAEX,qBAAoB;EACpB,uB7CvDa;E6CwDb,6BAA4B;EAC5B,qC7C/Ca;EOjBX,sBP+M+B;E6C3IjC,WAAU;CACX;;AAGD;EACE,gBAAe;EACf,OAAM;EACN,SAAQ;EACR,UAAS;EACT,QAAO;EACP,c7C8fsC;E6C7ftC,uB7C9Da;C6CmEd;;AAZD;EAUW,WAAU;CAAI;;AAVzB;EAWW,a7CwrB2B;C6CxrBS;;AAK/C;EACE,qBAAa;EAAb,cAAa;EACb,sBAAuB;EAAvB,wBAAuB;EACvB,uBAA8B;EAA9B,+BAA8B;EAC9B,c7CorBsC;E6CnrBtC,iC7CpFgB;EOHd,+BPyM+B;EOxM/B,gCPwM+B;C6C1GlC;;AAbD;EASI,c7C+qBoC;E6C7qBpC,+BAAuF;CACxF;;AAIH;EACE,iBAAgB;EAChB,iB7CyI+B;C6CxIhC;;AAID;EACE,mBAAkB;EAGlB,mBAAc;EAAd,eAAc;EACd,c7CwoBsC;C6CvoBvC;;AAGD;EACE,qBAAa;EAAb,cAAa;EACb,uBAAmB;EAAnB,oBAAmB;EACnB,mBAAyB;EAAzB,0BAAyB;EACzB,c7CgoBsC;E6C/nBtC,8B7CpHgB;C6CyHjB;;AAVD;EAQyB,oBAAmB;CAAI;;AARhD;EASwB,qBAAoB;CAAI;;AAIhD;EACE,mBAAkB;EAClB,aAAY;EACZ,YAAW;EACX,aAAY;EACZ,iBAAgB;CACjB;;AlCnFG;EkCzBJ;IAkHI,iB7CkoBqC;I6CjoBrC,qBAAyC;GAC1C;EAnGH;IAsGI,uCAA8D;GAC/D;EAMD;IAAY,iB7CunB2B;G6CvnBH;C5C62JrC;;AUl9JG;EkC0GF;IAAY,iB7CgnB2B;G6ChnBH;C5C82JrC;;A6CnhKD;EACE,mBAAkB;EAClB,c9CglBsC;E8C/kBtC,eAAc;EACd,U9CysBmC;E+C7sBnC,kK/CwOgL;E+CtOhL,mBAAkB;EAClB,iB/C+O+B;E+C9O/B,iB/CkP+B;E+CjP/B,iBAAgB;EAChB,kBAAiB;EACjB,sBAAqB;EACrB,kBAAiB;EACjB,qBAAoB;EACpB,uBAAsB;EACtB,mBAAkB;EAClB,qBAAoB;EACpB,oBAAmB;EACnB,iBAAgB;EDNhB,oB9CuOoD;E8CrOpD,sBAAqB;EACrB,WAAU;CAiBX;;AA5BD;EAaW,a9C6rB2B;C8C7rBE;;AAbxC;EAgBI,mBAAkB;EAClB,eAAc;EACd,c9C6rBqC;E8C5rBrC,e9C6rBqC;C8CrrBtC;;AA3BH;EAsBM,mBAAkB;EAClB,YAAW;EACX,0BAAyB;EACzB,oBAAmB;CACpB;;AAIL;EACE,kBAAgC;CAWjC;;AAZD;EAII,UAAS;CAOV;;AAXH;EAOM,OAAM;EACN,8BAAgE;EAChE,uB9CnBS;C8CoBV;;AAIL;EACE,kB9CmqBuC;C8CtpBxC;;AAdD;EAII,QAAO;EACP,c9C+pBqC;E8C9pBrC,e9C6pBqC;C8CtpBtC;;AAbH;EASM,SAAQ;EACR,qCAA2F;EAC3F,yB9CnCS;C8CoCV;;AAIL;EACE,kBAAgC;CAWjC;;AAZD;EAII,OAAM;CAOP;;AAXH;EAOM,UAAS;EACT,8B9C4oBmC;E8C3oBnC,0B9CjDS;C8CkDV;;AAIL;EACE,kB9CqoBuC;C8CxnBxC;;AAdD;EAII,SAAQ;EACR,c9CioBqC;E8ChoBrC,e9C+nBqC;C8CxnBtC;;AAbH;EASM,QAAO;EACP,qC9C4nBmC;E8C3nBnC,wB9CjES;C8CkEV;;AAoBL;EACE,iB9C2lBuC;E8C1lBvC,wB9CgmBuC;E8C/lBvC,Y9CnGa;E8CoGb,mBAAkB;EAClB,uB9C3Fa;EOjBX,uBP8MgC;C8ChGnC;;AElHD;EACE,mBAAkB;EAClB,OAAM;EACN,QAAO;EACP,chD8kBsC;EgD7kBtC,eAAc;EACd,iBhDmtBuC;E+CxtBvC,kK/CwOgL;E+CtOhL,mBAAkB;EAClB,iB/C+O+B;E+C9O/B,iB/CkP+B;E+CjP/B,iBAAgB;EAChB,kBAAiB;EACjB,sBAAqB;EACrB,kBAAiB;EACjB,qBAAoB;EACpB,uBAAsB;EACtB,mBAAkB;EAClB,qBAAoB;EACpB,oBAAmB;EACnB,iBAAgB;ECLhB,oBhDsOoD;EgDpOpD,sBAAqB;EACrB,uBhDFa;EgDGb,6BAA4B;EAC5B,qChDMa;EOjBX,sBP+M+B;CgDhLlC;;AAnCD;EAoBI,mBAAkB;EAClB,eAAc;EACd,YhDktBoC;EgDjtBpC,ehDktBqC;EgDjtBrC,iBhD2L+B;CgDjLhC;;AAlCH;EA4BM,mBAAkB;EAClB,eAAc;EACd,YAAW;EACX,0BAAyB;EACzB,oBAAmB;CACpB;;AAIL;EACE,sBhDmsBuC;CgD/qBxC;;AArBD;EAII,kCAAwE;CACzE;;AALH;;;EASI,8BAAgE;CACjE;;AAVH;EAaI,UAAS;EACT,sChDyrBmE;CgDxrBpE;;;AAfH;;EAkBI,YhDwJ6B;EgDvJ7B,uBhD7CW;CgD8CZ;;AAGH;EACE,oBhD4qBuC;CgDrpBxC;;AAxBD;EAII,gCAAsE;EACtE,chDwqBqC;EgDvqBrC,ahDsqBoC;EgDrqBpC,iBAA2B;CAC5B;;AARH;;;EAYI,qCAA2F;CAC5F;;AAbH;EAgBI,QAAO;EACP,wChD+pBmE;CgD9pBpE;;;AAlBH;;EAqBI,UhD8H6B;EgD7H7B,yBhDvEW;CgDwEZ;;AAGH;EACE,mBhDkpBuC;CgDlnBxC;;AAjCD;EAII,+BAAqE;CACtE;;AALH;;;EASI,qCAA2F;CAC5F;;AAVH;EAaI,OAAM;EACN,yChDwoBmE;CgDvoBpE;;;AAfH;;EAkBI,ShDuG6B;EgDtG7B,0BhD9FW;CgD+FZ;;AApBH;EAwBI,mBAAkB;EAClB,OAAM;EACN,UAAS;EACT,eAAc;EACd,YhDsnBoC;EgDrnBpC,qBAAwC;EACxC,YAAW;EACX,iChD0mBuD;CgDzmBxD;;AAGH;EACE,qBhD+mBuC;CgDxlBxC;;AAxBD;EAII,iCAAuE;EACvE,chD2mBqC;EgD1mBrC,ahDymBoC;EgDxmBpC,iBAA2B;CAC5B;;AARH;;;EAYI,qChDomBqC;CgDnmBtC;;AAbH;EAgBI,SAAQ;EACR,uChDkmBmE;CgDjmBpE;;;AAlBH;;EAqBI,WhDiE6B;EgDhE7B,wBhDpIW;CgDqIZ;;AAoBH;EACE,wBhD6jBwC;EgD5jBxC,iBAAgB;EAChB,gBhDuEgC;EgDtEhC,ehD4FmC;EgD3FnC,0BhDsjByD;EgDrjBzD,iCAAyE;EzChKvE,2CyCiKyE;EzChKzE,4CyCgKyE;CAM5E;;AAbD;EAWI,cAAa;CACd;;AAGH;EACE,wBhD8iBwC;EgD7iBxC,ehDjKgB;CgDkKjB;;AC5KD;EACE,mBAAkB;CACnB;;AAED;EACE,mBAAkB;EAClB,YAAW;EACX,iBAAgB;CACjB;;AAED;EACE,mBAAkB;EAClB,cAAa;EACb,uBAAmB;EAAnB,oBAAmB;EACnB,YAAW;EhCnBP,wCjBg4BgD;EiBh4BhD,gCjBg4BgD;EiBh4BhD,6DjBg4BgD;EiD32BpD,oCAA2B;EAA3B,4BAA2B;EAC3B,4BAAmB;EAAnB,oBAAmB;CACpB;;AhCnBC;EgCWF;IhCVI,iBAAgB;GgCkBnB;ChD2zKA;;AgDzzKD;;;EAGE,eAAc;CACf;;AAED;;EAEE,mBAAkB;EAClB,OAAM;CACP;;AAED;;EAEE,iCAAwB;EAAxB,yBAAwB;CAKzB;;AAHyC;EAJ1C;;IAKI,wCAA+B;IAA/B,gCAA+B;GAElC;ChD8zKA;;AgD5zKD;;EAEE,oCAA2B;EAA3B,4BAA2B;CAK5B;;AAHyC;EAJ1C;;IAKI,2CAAkC;IAAlC,mCAAkC;GAErC;ChDi0KA;;AgD/zKD;;EAEE,qCAA4B;EAA5B,6BAA4B;CAK7B;;AAHyC;EAJ1C;;IAKI,4CAAmC;IAAnC,oCAAmC;GAEtC;ChDo0KA;;AgD7zKD;EAEI,WAAU;EACV,yBAAwB;EACxB,6BAA4B;CAC7B;;AALH;;;EAUI,WAAU;CACX;;AAXH;;EAeI,WAAU;CACX;;AAhBH;;;;;EAuBI,iCAAwB;EAAxB,yBAAwB;CAKzB;;AAHyC;EAzB5C;;;;;IA0BM,wCAA+B;IAA/B,gCAA+B;GAElC;ChDo0KF;;AgD5zKD;;EAEE,mBAAkB;EAClB,OAAM;EACN,UAAS;EAET,qBAAa;EAAb,cAAa;EACb,uBAAmB;EAAnB,oBAAmB;EACnB,sBAAuB;EAAvB,wBAAuB;EACvB,WjD8vBqC;EiD7vBrC,YjD7Ga;EiD8Gb,mBAAkB;EAClB,ajD4vBoC;CiDjvBrC;;A/CrHC;;;E+CgHE,YjDrHW;EiDsHX,sBAAqB;EACrB,WAAU;EACV,YAAW;C/ChHZ;;A+CmHH;EACE,QAAO;CAIR;;AACD;EACE,SAAQ;CAIT;;AAGD;;EAEE,sBAAqB;EACrB,YjDyuBsC;EiDxuBtC,ajDwuBsC;EiDvuBtC,gDAA+C;EAC/C,2BAA0B;CAC3B;;AACD;EACE,iNlCjHyI;CkCkH1I;;AACD;EACE,iNlCpHyI;CkCqH1I;;AAQD;EACE,mBAAkB;EAClB,SAAQ;EACR,aAAY;EACZ,QAAO;EACP,YAAW;EACX,qBAAa;EAAb,cAAa;EACb,sBAAuB;EAAvB,wBAAuB;EACvB,gBAAe;EAEf,kBjDksBqC;EiDjsBrC,iBjDisBqC;EiDhsBrC,iBAAgB;CAqCjB;;AAjDD;EAeI,mBAAkB;EAClB,mBAAc;EAAd,eAAc;EACd,YjD8rBoC;EiD7rBpC,YjD8rBmC;EiD7rBnC,kBjD8rBmC;EiD7rBnC,iBjD6rBmC;EiD5rBnC,oBAAmB;EACnB,gBAAe;EACf,2CjDrLW;CiD0MZ;;AA5CH;EA2BM,mBAAkB;EAClB,WAAU;EACV,QAAO;EACP,sBAAqB;EACrB,YAAW;EACX,aAAY;EACZ,YAAW;CACZ;;AAlCL;EAoCM,mBAAkB;EAClB,cAAa;EACb,QAAO;EACP,sBAAqB;EACrB,YAAW;EACX,aAAY;EACZ,YAAW;CACZ;;AA3CL;EA+CI,uBjD7MW;CiD8MZ;;AAQH;EACE,mBAAkB;EAClB,WAA6C;EAC7C,aAAY;EACZ,UAA4C;EAC5C,YAAW;EACX,kBAAiB;EACjB,qBAAoB;EACpB,YjD9Na;EiD+Nb,mBAAkB;CACnB;;ACzOD;EAAqB,oCAAmC;CAAI;;AAC5D;EAAqB,+BAA8B;CAAI;;AACvD;EAAqB,kCAAiC;CAAI;;AAC1D;EAAqB,kCAAiC;CAAI;;AAC1D;EAAqB,uCAAsC;CAAI;;AAC/D;EAAqB,oCAAmC;CAAI;;ACF1D;EACE,qCAAmC;CACpC;;AjDSD;;;EiDLI,qCAAgD;CjDQnD;;AiDdD;EACE,qCAAmC;CACpC;;AjDSD;;;EiDLI,qCAAgD;CjDQnD;;AiDdD;EACE,qCAAmC;CACpC;;AjDSD;;;EiDLI,qCAAgD;CjDQnD;;AiDdD;EACE,qCAAmC;CACpC;;AjDSD;;;EiDLI,qCAAgD;CjDQnD;;AiDdD;EACE,qCAAmC;CACpC;;AjDSD;;;EiDLI,qCAAgD;CjDQnD;;AiDdD;EACE,qCAAmC;CACpC;;AjDSD;;;EiDLI,qCAAgD;CjDQnD;;AiDdD;EACE,qCAAmC;CACpC;;AjDSD;;;EiDLI,qCAAgD;CjDQnD;;AiDdD;EACE,qCAAmC;CACpC;;AjDSD;;;EiDLI,qCAAgD;CjDQnD;;AkDPH;EACE,kCAAmC;CACpC;;AAED;EACE,yCAAwC;CACzC;;ACZD;EAAkB,qCAAoD;CAAI;;AAC1E;EAAkB,yCAAwD;CAAI;;AAC9E;EAAkB,2CAA0D;CAAI;;AAChF;EAAkB,4CAA2D;CAAI;;AACjF;EAAkB,0CAAyD;CAAI;;AAE/E;EAAmB,qBAAoB;CAAI;;AAC3C;EAAmB,yBAAwB;CAAI;;AAC/C;EAAmB,2BAA0B;CAAI;;AACjD;EAAmB,4BAA2B;CAAI;;AAClD;EAAmB,0BAAyB;CAAI;;AAG9C;EACE,iCAA+B;CAChC;;AAFD;EACE,iCAA+B;CAChC;;AAFD;EACE,iCAA+B;CAChC;;AAFD;EACE,iCAA+B;CAChC;;AAFD;EACE,iCAA+B;CAChC;;AAFD;EACE,iCAA+B;CAChC;;AAFD;EACE,iCAA+B;CAChC;;AAFD;EACE,iCAA+B;CAChC;;AAGH;EACE,8BAA+B;CAChC;;AAMD;EACE,kCAAwC;CACzC;;AACD;EACE,2CAAiD;EACjD,4CAAkD;CACnD;;AACD;EACE,4CAAkD;EAClD,+CAAqD;CACtD;;AACD;EACE,+CAAqD;EACrD,8CAAoD;CACrD;;AACD;EACE,2CAAiD;EACjD,8CAAoD;CACrD;;AAED;EACE,8BAA6B;CAC9B;;AAED;EACE,4BAA2B;CAC5B;;ACzDC;EACE,eAAc;EACd,YAAW;EACX,YAAW;CACZ;;ACKC;EAA2B,yBAAwB;CAAI;;AACvD;EAA2B,2BAA0B;CAAI;;AACzD;EAA2B,iCAAgC;CAAI;;AAC/D;EAA2B,0BAAyB;CAAI;;AACxD;EAA2B,0BAAyB;CAAI;;AACxD;EAA2B,8BAA6B;CAAI;;AAC5D;EAA2B,+BAA8B;CAAI;;AAC7D;EAA2B,gCAAwB;EAAxB,yBAAwB;CAAI;;AACvD;EAA2B,uCAA+B;EAA/B,gCAA+B;CAAI;;A5C0C9D;E4ClDA;IAA2B,yBAAwB;GAAI;EACvD;IAA2B,2BAA0B;GAAI;EACzD;IAA2B,iCAAgC;GAAI;EAC/D;IAA2B,0BAAyB;GAAI;EACxD;IAA2B,0BAAyB;GAAI;EACxD;IAA2B,8BAA6B;GAAI;EAC5D;IAA2B,+BAA8B;GAAI;EAC7D;IAA2B,gCAAwB;IAAxB,yBAAwB;GAAI;EACvD;IAA2B,uCAA+B;IAA/B,gCAA+B;GAAI;CtD0yLjE;;AUhwLG;E4ClDA;IAA2B,yBAAwB;GAAI;EACvD;IAA2B,2BAA0B;GAAI;EACzD;IAA2B,iCAAgC;GAAI;EAC/D;IAA2B,0BAAyB;GAAI;EACxD;IAA2B,0BAAyB;GAAI;EACxD;IAA2B,8BAA6B;GAAI;EAC5D;IAA2B,+BAA8B;GAAI;EAC7D;IAA2B,gCAAwB;IAAxB,yBAAwB;GAAI;EACvD;IAA2B,uCAA+B;IAA/B,gCAA+B;GAAI;CtDw0LjE;;AU9xLG;E4ClDA;IAA2B,yBAAwB;GAAI;EACvD;IAA2B,2BAA0B;GAAI;EACzD;IAA2B,iCAAgC;GAAI;EAC/D;IAA2B,0BAAyB;GAAI;EACxD;IAA2B,0BAAyB;GAAI;EACxD;IAA2B,8BAA6B;GAAI;EAC5D;IAA2B,+BAA8B;GAAI;EAC7D;IAA2B,gCAAwB;IAAxB,yBAAwB;GAAI;EACvD;IAA2B,uCAA+B;IAA/B,gCAA+B;GAAI;CtDs2LjE;;AU5zLG;E4ClDA;IAA2B,yBAAwB;GAAI;EACvD;IAA2B,2BAA0B;GAAI;EACzD;IAA2B,iCAAgC;GAAI;EAC/D;IAA2B,0BAAyB;GAAI;EACxD;IAA2B,0BAAyB;GAAI;EACxD;IAA2B,8BAA6B;GAAI;EAC5D;IAA2B,+BAA8B;GAAI;EAC7D;IAA2B,gCAAwB;IAAxB,yBAAwB;GAAI;EACvD;IAA2B,uCAA+B;IAA/B,gCAA+B;GAAI;CtDo4LjE;;AsD33LD;EACE;IAAwB,yBAAwB;GAAI;EACpD;IAAwB,2BAA0B;GAAI;EACtD;IAAwB,iCAAgC;GAAI;EAC5D;IAAwB,0BAAyB;GAAI;EACrD;IAAwB,0BAAyB;GAAI;EACrD;IAAwB,8BAA6B;GAAI;EACzD;IAAwB,+BAA8B;GAAI;EAC1D;IAAwB,gCAAwB;IAAxB,yBAAwB;GAAI;EACpD;IAAwB,uCAA+B;IAA/B,gCAA+B;GAAI;CtDg5L5D;;AuDl7LD;EACE,mBAAkB;EAClB,eAAc;EACd,YAAW;EACX,WAAU;EACV,iBAAgB;CAoBjB;;AAzBD;EAQI,eAAc;EACd,YAAW;CACZ;;AAVH;;;;;EAiBI,mBAAkB;EAClB,OAAM;EACN,UAAS;EACT,QAAO;EACP,YAAW;EACX,aAAY;EACZ,UAAS;CACV;;AAGH;EAEI,wBAA+B;CAChC;;AAGH;EAEI,oBAA+B;CAChC;;AAGH;EAEI,iBAA8B;CAC/B;;AAGH;EAEI,kBAA8B;CAC/B;;ACxCC;EAAgC,mCAA8B;EAA9B,+BAA8B;CAAI;;AAClE;EAAgC,sCAAiC;EAAjC,kCAAiC;CAAI;;AACrE;EAAgC,2CAAsC;EAAtC,uCAAsC;CAAI;;AAC1E;EAAgC,8CAAyC;EAAzC,0CAAyC;CAAI;;AAE7E;EAA8B,+BAA0B;EAA1B,2BAA0B;CAAI;;AAC5D;EAA8B,iCAA4B;EAA5B,6BAA4B;CAAI;;AAC9D;EAA8B,uCAAkC;EAAlC,mCAAkC;CAAI;;AACpE;EAA8B,8BAAyB;EAAzB,0BAAyB;CAAI;;AAC3D;EAA8B,gCAAuB;EAAvB,wBAAuB;CAAI;;AACzD;EAA8B,gCAAuB;EAAvB,wBAAuB;CAAI;;AACzD;EAA8B,gCAAyB;EAAzB,0BAAyB;CAAI;;AAC3D;EAA8B,gCAAyB;EAAzB,0BAAyB;CAAI;;AAE3D;EAAoC,gCAAsC;EAAtC,uCAAsC;CAAI;;AAC9E;EAAoC,8BAAoC;EAApC,qCAAoC;CAAI;;AAC5E;EAAoC,iCAAkC;EAAlC,mCAAkC;CAAI;;AAC1E;EAAoC,kCAAyC;EAAzC,0CAAyC;CAAI;;AACjF;EAAoC,qCAAwC;EAAxC,yCAAwC;CAAI;;AAEhF;EAAiC,iCAAkC;EAAlC,mCAAkC;CAAI;;AACvE;EAAiC,+BAAgC;EAAhC,iCAAgC;CAAI;;AACrE;EAAiC,kCAA8B;EAA9B,+BAA8B;CAAI;;AACnE;EAAiC,oCAAgC;EAAhC,iCAAgC;CAAI;;AACrE;EAAiC,mCAA+B;EAA/B,gCAA+B;CAAI;;AAEpE;EAAkC,qCAAoC;EAApC,qCAAoC;CAAI;;AAC1E;EAAkC,mCAAkC;EAAlC,mCAAkC;CAAI;;AACxE;EAAkC,sCAAgC;EAAhC,iCAAgC;CAAI;;AACtE;EAAkC,uCAAuC;EAAvC,wCAAuC;CAAI;;AAC7E;EAAkC,0CAAsC;EAAtC,uCAAsC;CAAI;;AAC5E;EAAkC,uCAAiC;EAAjC,kCAAiC;CAAI;;AAEvE;EAAgC,qCAA2B;EAA3B,4BAA2B;CAAI;;AAC/D;EAAgC,sCAAiC;EAAjC,kCAAiC;CAAI;;AACrE;EAAgC,oCAA+B;EAA/B,gCAA+B;CAAI;;AACnE;EAAgC,uCAA6B;EAA7B,8BAA6B;CAAI;;AACjE;EAAgC,yCAA+B;EAA/B,gCAA+B;CAAI;;AACnE;EAAgC,wCAA8B;EAA9B,+BAA8B;CAAI;;A9CYlE;E8ClDA;IAAgC,mCAA8B;IAA9B,+BAA8B;GAAI;EAClE;IAAgC,sCAAiC;IAAjC,kCAAiC;GAAI;EACrE;IAAgC,2CAAsC;IAAtC,uCAAsC;GAAI;EAC1E;IAAgC,8CAAyC;IAAzC,0CAAyC;GAAI;EAE7E;IAA8B,+BAA0B;IAA1B,2BAA0B;GAAI;EAC5D;IAA8B,iCAA4B;IAA5B,6BAA4B;GAAI;EAC9D;IAA8B,uCAAkC;IAAlC,mCAAkC;GAAI;EACpE;IAA8B,8BAAyB;IAAzB,0BAAyB;GAAI;EAC3D;IAA8B,gCAAuB;IAAvB,wBAAuB;GAAI;EACzD;IAA8B,gCAAuB;IAAvB,wBAAuB;GAAI;EACzD;IAA8B,gCAAyB;IAAzB,0BAAyB;GAAI;EAC3D;IAA8B,gCAAyB;IAAzB,0BAAyB;GAAI;EAE3D;IAAoC,gCAAsC;IAAtC,uCAAsC;GAAI;EAC9E;IAAoC,8BAAoC;IAApC,qCAAoC;GAAI;EAC5E;IAAoC,iCAAkC;IAAlC,mCAAkC;GAAI;EAC1E;IAAoC,kCAAyC;IAAzC,0CAAyC;GAAI;EACjF;IAAoC,qCAAwC;IAAxC,yCAAwC;GAAI;EAEhF;IAAiC,iCAAkC;IAAlC,mCAAkC;GAAI;EACvE;IAAiC,+BAAgC;IAAhC,iCAAgC;GAAI;EACrE;IAAiC,kCAA8B;IAA9B,+BAA8B;GAAI;EACnE;IAAiC,oCAAgC;IAAhC,iCAAgC;GAAI;EACrE;IAAiC,mCAA+B;IAA/B,gCAA+B;GAAI;EAEpE;IAAkC,qCAAoC;IAApC,qCAAoC;GAAI;EAC1E;IAAkC,mCAAkC;IAAlC,mCAAkC;GAAI;EACxE;IAAkC,sCAAgC;IAAhC,iCAAgC;GAAI;EACtE;IAAkC,uCAAuC;IAAvC,wCAAuC;GAAI;EAC7E;IAAkC,0CAAsC;IAAtC,uCAAsC;GAAI;EAC5E;IAAkC,uCAAiC;IAAjC,kCAAiC;GAAI;EAEvE;IAAgC,qCAA2B;IAA3B,4BAA2B;GAAI;EAC/D;IAAgC,sCAAiC;IAAjC,kCAAiC;GAAI;EACrE;IAAgC,oCAA+B;IAA/B,gCAA+B;GAAI;EACnE;IAAgC,uCAA6B;IAA7B,8BAA6B;GAAI;EACjE;IAAgC,yCAA+B;IAA/B,gCAA+B;GAAI;EACnE;IAAgC,wCAA8B;IAA9B,+BAA8B;GAAI;CxDgqMrE;;AUppMG;E8ClDA;IAAgC,mCAA8B;IAA9B,+BAA8B;GAAI;EAClE;IAAgC,sCAAiC;IAAjC,kCAAiC;GAAI;EACrE;IAAgC,2CAAsC;IAAtC,uCAAsC;GAAI;EAC1E;IAAgC,8CAAyC;IAAzC,0CAAyC;GAAI;EAE7E;IAA8B,+BAA0B;IAA1B,2BAA0B;GAAI;EAC5D;IAA8B,iCAA4B;IAA5B,6BAA4B;GAAI;EAC9D;IAA8B,uCAAkC;IAAlC,mCAAkC;GAAI;EACpE;IAA8B,8BAAyB;IAAzB,0BAAyB;GAAI;EAC3D;IAA8B,gCAAuB;IAAvB,wBAAuB;GAAI;EACzD;IAA8B,gCAAuB;IAAvB,wBAAuB;GAAI;EACzD;IAA8B,gCAAyB;IAAzB,0BAAyB;GAAI;EAC3D;IAA8B,gCAAyB;IAAzB,0BAAyB;GAAI;EAE3D;IAAoC,gCAAsC;IAAtC,uCAAsC;GAAI;EAC9E;IAAoC,8BAAoC;IAApC,qCAAoC;GAAI;EAC5E;IAAoC,iCAAkC;IAAlC,mCAAkC;GAAI;EAC1E;IAAoC,kCAAyC;IAAzC,0CAAyC;GAAI;EACjF;IAAoC,qCAAwC;IAAxC,yCAAwC;GAAI;EAEhF;IAAiC,iCAAkC;IAAlC,mCAAkC;GAAI;EACvE;IAAiC,+BAAgC;IAAhC,iCAAgC;GAAI;EACrE;IAAiC,kCAA8B;IAA9B,+BAA8B;GAAI;EACnE;IAAiC,oCAAgC;IAAhC,iCAAgC;GAAI;EACrE;IAAiC,mCAA+B;IAA/B,gCAA+B;GAAI;EAEpE;IAAkC,qCAAoC;IAApC,qCAAoC;GAAI;EAC1E;IAAkC,mCAAkC;IAAlC,mCAAkC;GAAI;EACxE;IAAkC,sCAAgC;IAAhC,iCAAgC;GAAI;EACtE;IAAkC,uCAAuC;IAAvC,wCAAuC;GAAI;EAC7E;IAAkC,0CAAsC;IAAtC,uCAAsC;GAAI;EAC5E;IAAkC,uCAAiC;IAAjC,kCAAiC;GAAI;EAEvE;IAAgC,qCAA2B;IAA3B,4BAA2B;GAAI;EAC/D;IAAgC,sCAAiC;IAAjC,kCAAiC;GAAI;EACrE;IAAgC,oCAA+B;IAA/B,gCAA+B;GAAI;EACnE;IAAgC,uCAA6B;IAA7B,8BAA6B;GAAI;EACjE;IAAgC,yCAA+B;IAA/B,gCAA+B;GAAI;EACnE;IAAgC,wCAA8B;IAA9B,+BAA8B;GAAI;CxDywMrE;;AU7vMG;E8ClDA;IAAgC,mCAA8B;IAA9B,+BAA8B;GAAI;EAClE;IAAgC,sCAAiC;IAAjC,kCAAiC;GAAI;EACrE;IAAgC,2CAAsC;IAAtC,uCAAsC;GAAI;EAC1E;IAAgC,8CAAyC;IAAzC,0CAAyC;GAAI;EAE7E;IAA8B,+BAA0B;IAA1B,2BAA0B;GAAI;EAC5D;IAA8B,iCAA4B;IAA5B,6BAA4B;GAAI;EAC9D;IAA8B,uCAAkC;IAAlC,mCAAkC;GAAI;EACpE;IAA8B,8BAAyB;IAAzB,0BAAyB;GAAI;EAC3D;IAA8B,gCAAuB;IAAvB,wBAAuB;GAAI;EACzD;IAA8B,gCAAuB;IAAvB,wBAAuB;GAAI;EACzD;IAA8B,gCAAyB;IAAzB,0BAAyB;GAAI;EAC3D;IAA8B,gCAAyB;IAAzB,0BAAyB;GAAI;EAE3D;IAAoC,gCAAsC;IAAtC,uCAAsC;GAAI;EAC9E;IAAoC,8BAAoC;IAApC,qCAAoC;GAAI;EAC5E;IAAoC,iCAAkC;IAAlC,mCAAkC;GAAI;EAC1E;IAAoC,kCAAyC;IAAzC,0CAAyC;GAAI;EACjF;IAAoC,qCAAwC;IAAxC,yCAAwC;GAAI;EAEhF;IAAiC,iCAAkC;IAAlC,mCAAkC;GAAI;EACvE;IAAiC,+BAAgC;IAAhC,iCAAgC;GAAI;EACrE;IAAiC,kCAA8B;IAA9B,+BAA8B;GAAI;EACnE;IAAiC,oCAAgC;IAAhC,iCAAgC;GAAI;EACrE;IAAiC,mCAA+B;IAA/B,gCAA+B;GAAI;EAEpE;IAAkC,qCAAoC;IAApC,qCAAoC;GAAI;EAC1E;IAAkC,mCAAkC;IAAlC,mCAAkC;GAAI;EACxE;IAAkC,sCAAgC;IAAhC,iCAAgC;GAAI;EACtE;IAAkC,uCAAuC;IAAvC,wCAAuC;GAAI;EAC7E;IAAkC,0CAAsC;IAAtC,uCAAsC;GAAI;EAC5E;IAAkC,uCAAiC;IAAjC,kCAAiC;GAAI;EAEvE;IAAgC,qCAA2B;IAA3B,4BAA2B;GAAI;EAC/D;IAAgC,sCAAiC;IAAjC,kCAAiC;GAAI;EACrE;IAAgC,oCAA+B;IAA/B,gCAA+B;GAAI;EACnE;IAAgC,uCAA6B;IAA7B,8BAA6B;GAAI;EACjE;IAAgC,yCAA+B;IAA/B,gCAA+B;GAAI;EACnE;IAAgC,wCAA8B;IAA9B,+BAA8B;GAAI;CxDk3MrE;;AUt2MG;E8ClDA;IAAgC,mCAA8B;IAA9B,+BAA8B;GAAI;EAClE;IAAgC,sCAAiC;IAAjC,kCAAiC;GAAI;EACrE;IAAgC,2CAAsC;IAAtC,uCAAsC;GAAI;EAC1E;IAAgC,8CAAyC;IAAzC,0CAAyC;GAAI;EAE7E;IAA8B,+BAA0B;IAA1B,2BAA0B;GAAI;EAC5D;IAA8B,iCAA4B;IAA5B,6BAA4B;GAAI;EAC9D;IAA8B,uCAAkC;IAAlC,mCAAkC;GAAI;EACpE;IAA8B,8BAAyB;IAAzB,0BAAyB;GAAI;EAC3D;IAA8B,gCAAuB;IAAvB,wBAAuB;GAAI;EACzD;IAA8B,gCAAuB;IAAvB,wBAAuB;GAAI;EACzD;IAA8B,gCAAyB;IAAzB,0BAAyB;GAAI;EAC3D;IAA8B,gCAAyB;IAAzB,0BAAyB;GAAI;EAE3D;IAAoC,gCAAsC;IAAtC,uCAAsC;GAAI;EAC9E;IAAoC,8BAAoC;IAApC,qCAAoC;GAAI;EAC5E;IAAoC,iCAAkC;IAAlC,mCAAkC;GAAI;EAC1E;IAAoC,kCAAyC;IAAzC,0CAAyC;GAAI;EACjF;IAAoC,qCAAwC;IAAxC,yCAAwC;GAAI;EAEhF;IAAiC,iCAAkC;IAAlC,mCAAkC;GAAI;EACvE;IAAiC,+BAAgC;IAAhC,iCAAgC;GAAI;EACrE;IAAiC,kCAA8B;IAA9B,+BAA8B;GAAI;EACnE;IAAiC,oCAAgC;IAAhC,iCAAgC;GAAI;EACrE;IAAiC,mCAA+B;IAA/B,gCAA+B;GAAI;EAEpE;IAAkC,qCAAoC;IAApC,qCAAoC;GAAI;EAC1E;IAAkC,mCAAkC;IAAlC,mCAAkC;GAAI;EACxE;IAAkC,sCAAgC;IAAhC,iCAAgC;GAAI;EACtE;IAAkC,uCAAuC;IAAvC,wCAAuC;GAAI;EAC7E;IAAkC,0CAAsC;IAAtC,uCAAsC;GAAI;EAC5E;IAAkC,uCAAiC;IAAjC,kCAAiC;GAAI;EAEvE;IAAgC,qCAA2B;IAA3B,4BAA2B;GAAI;EAC/D;IAAgC,sCAAiC;IAAjC,kCAAiC;GAAI;EACrE;IAAgC,oCAA+B;IAA/B,gCAA+B;GAAI;EACnE;IAAgC,uCAA6B;IAA7B,8BAA6B;GAAI;EACjE;IAAgC,yCAA+B;IAA/B,gCAA+B;GAAI;EACnE;IAAgC,wCAA8B;IAA9B,+BAA8B;GAAI;CxD29MrE;;AyDvgNG;ECDF,uBAAsB;CDC2B;;AAC/C;ECCF,wBAAuB;CDD2B;;AAChD;ECGF,uBAAsB;CDH2B;;A/CsD/C;E+CxDA;ICDF,uBAAsB;GDC2B;EAC/C;ICCF,wBAAuB;GDD2B;EAChD;ICGF,uBAAsB;GDH2B;CzD6hNlD;;AUv+MG;E+CxDA;ICDF,uBAAsB;GDC2B;EAC/C;ICCF,wBAAuB;GDD2B;EAChD;ICGF,uBAAsB;GDH2B;CzDyiNlD;;AUn/MG;E+CxDA;ICDF,uBAAsB;GDC2B;EAC/C;ICCF,wBAAuB;GDD2B;EAChD;ICGF,uBAAsB;GDH2B;CzDqjNlD;;AU//MG;E+CxDA;ICDF,uBAAsB;GDC2B;EAC/C;ICCF,wBAAuB;GDD2B;EAChD;ICGF,uBAAsB;GDH2B;CzDikNlD;;A2D9jNC;EAAyB,4BAA8B;CAAI;;AAA3D;EAAyB,8BAA8B;CAAI;;AAA3D;EAAyB,8BAA8B;CAAI;;AAA3D;EAAyB,2BAA8B;CAAI;;AAA3D;EAAyB,oCAA8B;EAA9B,4BAA8B;CAAI;;AAK7D;EACE,gBAAe;EACf,OAAM;EACN,SAAQ;EACR,QAAO;EACP,c5D4jBsC;C4D3jBvC;;AAED;EACE,gBAAe;EACf,SAAQ;EACR,UAAS;EACT,QAAO;EACP,c5DojBsC;C4DnjBvC;;AAG6B;EAD9B;IAEI,yBAAgB;IAAhB,iBAAgB;IAChB,OAAM;IACN,c5D4iBoC;G4D1iBvC;C3D+kNA;;A4D/mND;ECEE,mBAAkB;EAClB,WAAU;EACV,YAAW;EACX,WAAU;EACV,iBAAgB;EAChB,uBAAsB;EACtB,oBAAmB;EACnB,UAAS;CDPV;;ACiBC;EAEE,iBAAgB;EAChB,YAAW;EACX,aAAY;EACZ,kBAAiB;EACjB,WAAU;EACV,oBAAmB;CACpB;;AC7BH;EAAa,+DAAqC;CAAI;;AACtD;EAAU,yDAAkC;CAAI;;AAChD;EAAa,wDAAqC;CAAI;;AACtD;EAAe,4BAA2B;CAAI;;ACC1C;EAAuB,sBAA4B;CAAI;;AAAvD;EAAuB,sBAA4B;CAAI;;AAAvD;EAAuB,sBAA4B;CAAI;;AAAvD;EAAuB,uBAA4B;CAAI;;AAAvD;EAAuB,uBAA4B;CAAI;;AAAvD;EAAuB,uBAA4B;CAAI;;AAAvD;EAAuB,uBAA4B;CAAI;;AAAvD;EAAuB,uBAA4B;CAAI;;AAAvD;EAAuB,wBAA4B;CAAI;;AAAvD;EAAuB,wBAA4B;CAAI;;AAI3D;EAAU,2BAA0B;CAAI;;AACxC;EAAU,4BAA2B;CAAI;;ACAjC;EAAgC,qBAA4B;CAAI;;AAChE;;EAEE,yBAAoC;CACrC;;AACD;;EAEE,2BAAwC;CACzC;;AACD;;EAEE,4BAA0C;CAC3C;;AACD;;EAEE,0BAAsC;CACvC;;AAhBD;EAAgC,2BAA4B;CAAI;;AAChE;;EAEE,+BAAoC;CACrC;;AACD;;EAEE,iCAAwC;CACzC;;AACD;;EAEE,kCAA0C;CAC3C;;AACD;;EAEE,gCAAsC;CACvC;;AAhBD;EAAgC,0BAA4B;CAAI;;AAChE;;EAEE,8BAAoC;CACrC;;AACD;;EAEE,gCAAwC;CACzC;;AACD;;EAEE,iCAA0C;CAC3C;;AACD;;EAEE,+BAAsC;CACvC;;AAhBD;EAAgC,wBAA4B;CAAI;;AAChE;;EAEE,4BAAoC;CACrC;;AACD;;EAEE,8BAAwC;CACzC;;AACD;;EAEE,+BAA0C;CAC3C;;AACD;;EAEE,6BAAsC;CACvC;;AAhBD;EAAgC,0BAA4B;CAAI;;AAChE;;EAEE,8BAAoC;CACrC;;AACD;;EAEE,gCAAwC;CACzC;;AACD;;EAEE,iCAA0C;CAC3C;;AACD;;EAEE,+BAAsC;CACvC;;AAhBD;EAAgC,wBAA4B;CAAI;;AAChE;;EAEE,4BAAoC;CACrC;;AACD;;EAEE,8BAAwC;CACzC;;AACD;;EAEE,+BAA0C;CAC3C;;AACD;;EAEE,6BAAsC;CACvC;;AAhBD;EAAgC,sBAA4B;CAAI;;AAChE;;EAEE,0BAAoC;CACrC;;AACD;;EAEE,4BAAwC;CACzC;;AACD;;EAEE,6BAA0C;CAC3C;;AACD;;EAEE,2BAAsC;CACvC;;AAhBD;EAAgC,4BAA4B;CAAI;;AAChE;;EAEE,gCAAoC;CACrC;;AACD;;EAEE,kCAAwC;CACzC;;AACD;;EAEE,mCAA0C;CAC3C;;AACD;;EAEE,iCAAsC;CACvC;;AAhBD;EAAgC,2BAA4B;CAAI;;AAChE;;EAEE,+BAAoC;CACrC;;AACD;;EAEE,iCAAwC;CACzC;;AACD;;EAEE,kCAA0C;CAC3C;;AACD;;EAEE,gCAAsC;CACvC;;AAhBD;EAAgC,yBAA4B;CAAI;;AAChE;;EAEE,6BAAoC;CACrC;;AACD;;EAEE,+BAAwC;CACzC;;AACD;;EAEE,gCAA0C;CAC3C;;AACD;;EAEE,8BAAsC;CACvC;;AAhBD;EAAgC,2BAA4B;CAAI;;AAChE;;EAEE,+BAAoC;CACrC;;AACD;;EAEE,iCAAwC;CACzC;;AACD;;EAEE,kCAA0C;CAC3C;;AACD;;EAEE,gCAAsC;CACvC;;AAhBD;EAAgC,yBAA4B;CAAI;;AAChE;;EAEE,6BAAoC;CACrC;;AACD;;EAEE,+BAAwC;CACzC;;AACD;;EAEE,gCAA0C;CAC3C;;AACD;;EAEE,8BAAsC;CACvC;;AAKL;EAAmB,wBAAuB;CAAI;;AAC9C;;EAEE,4BAA2B;CAC5B;;AACD;;EAEE,8BAA6B;CAC9B;;AACD;;EAEE,+BAA8B;CAC/B;;AACD;;EAEE,6BAA4B;CAC7B;;AtDYD;EsDjDI;IAAgC,qBAA4B;GAAI;EAChE;;IAEE,yBAAoC;GACrC;EACD;;IAEE,2BAAwC;GACzC;EACD;;IAEE,4BAA0C;GAC3C;EACD;;IAEE,0BAAsC;GACvC;EAhBD;IAAgC,2BAA4B;GAAI;EAChE;;IAEE,+BAAoC;GACrC;EACD;;IAEE,iCAAwC;GACzC;EACD;;IAEE,kCAA0C;GAC3C;EACD;;IAEE,gCAAsC;GACvC;EAhBD;IAAgC,0BAA4B;GAAI;EAChE;;IAEE,8BAAoC;GACrC;EACD;;IAEE,gCAAwC;GACzC;EACD;;IAEE,iCAA0C;GAC3C;EACD;;IAEE,+BAAsC;GACvC;EAhBD;IAAgC,wBAA4B;GAAI;EAChE;;IAEE,4BAAoC;GACrC;EACD;;IAEE,8BAAwC;GACzC;EACD;;IAEE,+BAA0C;GAC3C;EACD;;IAEE,6BAAsC;GACvC;EAhBD;IAAgC,0BAA4B;GAAI;EAChE;;IAEE,8BAAoC;GACrC;EACD;;IAEE,gCAAwC;GACzC;EACD;;IAEE,iCAA0C;GAC3C;EACD;;IAEE,+BAAsC;GACvC;EAhBD;IAAgC,wBAA4B;GAAI;EAChE;;IAEE,4BAAoC;GACrC;EACD;;IAEE,8BAAwC;GACzC;EACD;;IAEE,+BAA0C;GAC3C;EACD;;IAEE,6BAAsC;GACvC;EAhBD;IAAgC,sBAA4B;GAAI;EAChE;;IAEE,0BAAoC;GACrC;EACD;;IAEE,4BAAwC;GACzC;EACD;;IAEE,6BAA0C;GAC3C;EACD;;IAEE,2BAAsC;GACvC;EAhBD;IAAgC,4BAA4B;GAAI;EAChE;;IAEE,gCAAoC;GACrC;EACD;;IAEE,kCAAwC;GACzC;EACD;;IAEE,mCAA0C;GAC3C;EACD;;IAEE,iCAAsC;GACvC;EAhBD;IAAgC,2BAA4B;GAAI;EAChE;;IAEE,+BAAoC;GACrC;EACD;;IAEE,iCAAwC;GACzC;EACD;;IAEE,kCAA0C;GAC3C;EACD;;IAEE,gCAAsC;GACvC;EAhBD;IAAgC,yBAA4B;GAAI;EAChE;;IAEE,6BAAoC;GACrC;EACD;;IAEE,+BAAwC;GACzC;EACD;;IAEE,gCAA0C;GAC3C;EACD;;IAEE,8BAAsC;GACvC;EAhBD;IAAgC,2BAA4B;GAAI;EAChE;;IAEE,+BAAoC;GACrC;EACD;;IAEE,iCAAwC;GACzC;EACD;;IAEE,kCAA0C;GAC3C;EACD;;IAEE,gCAAsC;GACvC;EAhBD;IAAgC,yBAA4B;GAAI;EAChE;;IAEE,6BAAoC;GACrC;EACD;;IAEE,+BAAwC;GACzC;EACD;;IAEE,gCAA0C;GAC3C;EACD;;IAEE,8BAAsC;GACvC;EAKL;IAAmB,wBAAuB;GAAI;EAC9C;;IAEE,4BAA2B;GAC5B;EACD;;IAEE,8BAA6B;GAC9B;EACD;;IAEE,+BAA8B;GAC/B;EACD;;IAEE,6BAA4B;GAC7B;ChEysOJ;;AU7rOG;EsDjDI;IAAgC,qBAA4B;GAAI;EAChE;;IAEE,yBAAoC;GACrC;EACD;;IAEE,2BAAwC;GACzC;EACD;;IAEE,4BAA0C;GAC3C;EACD;;IAEE,0BAAsC;GACvC;EAhBD;IAAgC,2BAA4B;GAAI;EAChE;;IAEE,+BAAoC;GACrC;EACD;;IAEE,iCAAwC;GACzC;EACD;;IAEE,kCAA0C;GAC3C;EACD;;IAEE,gCAAsC;GACvC;EAhBD;IAAgC,0BAA4B;GAAI;EAChE;;IAEE,8BAAoC;GACrC;EACD;;IAEE,gCAAwC;GACzC;EACD;;IAEE,iCAA0C;GAC3C;EACD;;IAEE,+BAAsC;GACvC;EAhBD;IAAgC,wBAA4B;GAAI;EAChE;;IAEE,4BAAoC;GACrC;EACD;;IAEE,8BAAwC;GACzC;EACD;;IAEE,+BAA0C;GAC3C;EACD;;IAEE,6BAAsC;GACvC;EAhBD;IAAgC,0BAA4B;GAAI;EAChE;;IAEE,8BAAoC;GACrC;EACD;;IAEE,gCAAwC;GACzC;EACD;;IAEE,iCAA0C;GAC3C;EACD;;IAEE,+BAAsC;GACvC;EAhBD;IAAgC,wBAA4B;GAAI;EAChE;;IAEE,4BAAoC;GACrC;EACD;;IAEE,8BAAwC;GACzC;EACD;;IAEE,+BAA0C;GAC3C;EACD;;IAEE,6BAAsC;GACvC;EAhBD;IAAgC,sBAA4B;GAAI;EAChE;;IAEE,0BAAoC;GACrC;EACD;;IAEE,4BAAwC;GACzC;EACD;;IAEE,6BAA0C;GAC3C;EACD;;IAEE,2BAAsC;GACvC;EAhBD;IAAgC,4BAA4B;GAAI;EAChE;;IAEE,gCAAoC;GACrC;EACD;;IAEE,kCAAwC;GACzC;EACD;;IAEE,mCAA0C;GAC3C;EACD;;IAEE,iCAAsC;GACvC;EAhBD;IAAgC,2BAA4B;GAAI;EAChE;;IAEE,+BAAoC;GACrC;EACD;;IAEE,iCAAwC;GACzC;EACD;;IAEE,kCAA0C;GAC3C;EACD;;IAEE,gCAAsC;GACvC;EAhBD;IAAgC,yBAA4B;GAAI;EAChE;;IAEE,6BAAoC;GACrC;EACD;;IAEE,+BAAwC;GACzC;EACD;;IAEE,gCAA0C;GAC3C;EACD;;IAEE,8BAAsC;GACvC;EAhBD;IAAgC,2BAA4B;GAAI;EAChE;;IAEE,+BAAoC;GACrC;EACD;;IAEE,iCAAwC;GACzC;EACD;;IAEE,kCAA0C;GAC3C;EACD;;IAEE,gCAAsC;GACvC;EAhBD;IAAgC,yBAA4B;GAAI;EAChE;;IAEE,6BAAoC;GACrC;EACD;;IAEE,+BAAwC;GACzC;EACD;;IAEE,gCAA0C;GAC3C;EACD;;IAEE,8BAAsC;GACvC;EAKL;IAAmB,wBAAuB;GAAI;EAC9C;;IAEE,4BAA2B;GAC5B;EACD;;IAEE,8BAA6B;GAC9B;EACD;;IAEE,+BAA8B;GAC/B;EACD;;IAEE,6BAA4B;GAC7B;ChEm8OJ;;AUv7OG;EsDjDI;IAAgC,qBAA4B;GAAI;EAChE;;IAEE,yBAAoC;GACrC;EACD;;IAEE,2BAAwC;GACzC;EACD;;IAEE,4BAA0C;GAC3C;EACD;;IAEE,0BAAsC;GACvC;EAhBD;IAAgC,2BAA4B;GAAI;EAChE;;IAEE,+BAAoC;GACrC;EACD;;IAEE,iCAAwC;GACzC;EACD;;IAEE,kCAA0C;GAC3C;EACD;;IAEE,gCAAsC;GACvC;EAhBD;IAAgC,0BAA4B;GAAI;EAChE;;IAEE,8BAAoC;GACrC;EACD;;IAEE,gCAAwC;GACzC;EACD;;IAEE,iCAA0C;GAC3C;EACD;;IAEE,+BAAsC;GACvC;EAhBD;IAAgC,wBAA4B;GAAI;EAChE;;IAEE,4BAAoC;GACrC;EACD;;IAEE,8BAAwC;GACzC;EACD;;IAEE,+BAA0C;GAC3C;EACD;;IAEE,6BAAsC;GACvC;EAhBD;IAAgC,0BAA4B;GAAI;EAChE;;IAEE,8BAAoC;GACrC;EACD;;IAEE,gCAAwC;GACzC;EACD;;IAEE,iCAA0C;GAC3C;EACD;;IAEE,+BAAsC;GACvC;EAhBD;IAAgC,wBAA4B;GAAI;EAChE;;IAEE,4BAAoC;GACrC;EACD;;IAEE,8BAAwC;GACzC;EACD;;IAEE,+BAA0C;GAC3C;EACD;;IAEE,6BAAsC;GACvC;EAhBD;IAAgC,sBAA4B;GAAI;EAChE;;IAEE,0BAAoC;GACrC;EACD;;IAEE,4BAAwC;GACzC;EACD;;IAEE,6BAA0C;GAC3C;EACD;;IAEE,2BAAsC;GACvC;EAhBD;IAAgC,4BAA4B;GAAI;EAChE;;IAEE,gCAAoC;GACrC;EACD;;IAEE,kCAAwC;GACzC;EACD;;IAEE,mCAA0C;GAC3C;EACD;;IAEE,iCAAsC;GACvC;EAhBD;IAAgC,2BAA4B;GAAI;EAChE;;IAEE,+BAAoC;GACrC;EACD;;IAEE,iCAAwC;GACzC;EACD;;IAEE,kCAA0C;GAC3C;EACD;;IAEE,gCAAsC;GACvC;EAhBD;IAAgC,yBAA4B;GAAI;EAChE;;IAEE,6BAAoC;GACrC;EACD;;IAEE,+BAAwC;GACzC;EACD;;IAEE,gCAA0C;GAC3C;EACD;;IAEE,8BAAsC;GACvC;EAhBD;IAAgC,2BAA4B;GAAI;EAChE;;IAEE,+BAAoC;GACrC;EACD;;IAEE,iCAAwC;GACzC;EACD;;IAEE,kCAA0C;GAC3C;EACD;;IAEE,gCAAsC;GACvC;EAhBD;IAAgC,yBAA4B;GAAI;EAChE;;IAEE,6BAAoC;GACrC;EACD;;IAEE,+BAAwC;GACzC;EACD;;IAEE,gCAA0C;GAC3C;EACD;;IAEE,8BAAsC;GACvC;EAKL;IAAmB,wBAAuB;GAAI;EAC9C;;IAEE,4BAA2B;GAC5B;EACD;;IAEE,8BAA6B;GAC9B;EACD;;IAEE,+BAA8B;GAC/B;EACD;;IAEE,6BAA4B;GAC7B;ChE6rPJ;;AUjrPG;EsDjDI;IAAgC,qBAA4B;GAAI;EAChE;;IAEE,yBAAoC;GACrC;EACD;;IAEE,2BAAwC;GACzC;EACD;;IAEE,4BAA0C;GAC3C;EACD;;IAEE,0BAAsC;GACvC;EAhBD;IAAgC,2BAA4B;GAAI;EAChE;;IAEE,+BAAoC;GACrC;EACD;;IAEE,iCAAwC;GACzC;EACD;;IAEE,kCAA0C;GAC3C;EACD;;IAEE,gCAAsC;GACvC;EAhBD;IAAgC,0BAA4B;GAAI;EAChE;;IAEE,8BAAoC;GACrC;EACD;;IAEE,gCAAwC;GACzC;EACD;;IAEE,iCAA0C;GAC3C;EACD;;IAEE,+BAAsC;GACvC;EAhBD;IAAgC,wBAA4B;GAAI;EAChE;;IAEE,4BAAoC;GACrC;EACD;;IAEE,8BAAwC;GACzC;EACD;;IAEE,+BAA0C;GAC3C;EACD;;IAEE,6BAAsC;GACvC;EAhBD;IAAgC,0BAA4B;GAAI;EAChE;;IAEE,8BAAoC;GACrC;EACD;;IAEE,gCAAwC;GACzC;EACD;;IAEE,iCAA0C;GAC3C;EACD;;IAEE,+BAAsC;GACvC;EAhBD;IAAgC,wBAA4B;GAAI;EAChE;;IAEE,4BAAoC;GACrC;EACD;;IAEE,8BAAwC;GACzC;EACD;;IAEE,+BAA0C;GAC3C;EACD;;IAEE,6BAAsC;GACvC;EAhBD;IAAgC,sBAA4B;GAAI;EAChE;;IAEE,0BAAoC;GACrC;EACD;;IAEE,4BAAwC;GACzC;EACD;;IAEE,6BAA0C;GAC3C;EACD;;IAEE,2BAAsC;GACvC;EAhBD;IAAgC,4BAA4B;GAAI;EAChE;;IAEE,gCAAoC;GACrC;EACD;;IAEE,kCAAwC;GACzC;EACD;;IAEE,mCAA0C;GAC3C;EACD;;IAEE,iCAAsC;GACvC;EAhBD;IAAgC,2BAA4B;GAAI;EAChE;;IAEE,+BAAoC;GACrC;EACD;;IAEE,iCAAwC;GACzC;EACD;;IAEE,kCAA0C;GAC3C;EACD;;IAEE,gCAAsC;GACvC;EAhBD;IAAgC,yBAA4B;GAAI;EAChE;;IAEE,6BAAoC;GACrC;EACD;;IAEE,+BAAwC;GACzC;EACD;;IAEE,gCAA0C;GAC3C;EACD;;IAEE,8BAAsC;GACvC;EAhBD;IAAgC,2BAA4B;GAAI;EAChE;;IAEE,+BAAoC;GACrC;EACD;;IAEE,iCAAwC;GACzC;EACD;;IAEE,kCAA0C;GAC3C;EACD;;IAEE,gCAAsC;GACvC;EAhBD;IAAgC,yBAA4B;GAAI;EAChE;;IAEE,6BAAoC;GACrC;EACD;;IAEE,+BAAwC;GACzC;EACD;;IAEE,gCAA0C;GAC3C;EACD;;IAEE,8BAAsC;GACvC;EAKL;IAAmB,wBAAuB;GAAI;EAC9C;;IAEE,4BAA2B;GAC5B;EACD;;IAEE,8BAA6B;GAC9B;EACD;;IAEE,+BAA8B;GAC/B;EACD;;IAEE,6BAA4B;GAC7B;ChEu7PJ;;AiEj+PD;EAAkB,kGlEoOgG;CkEpOzD;;AAIzD;EAAiB,+BAA8B;CAAI;;AACnD;EAAiB,+BAA8B;CAAI;;AACnD;ECRE,iBAAgB;EAChB,wBAAuB;EACvB,oBAAmB;CDMsB;;AAQvC;EAAwB,4BAA2B;CAAI;;AACvD;EAAwB,6BAA4B;CAAI;;AACxD;EAAwB,8BAA6B;CAAI;;AvDsCzD;EuDxCA;IAAwB,4BAA2B;GAAI;EACvD;IAAwB,6BAA4B;GAAI;EACxD;IAAwB,8BAA6B;GAAI;CjE2/P5D;;AUr9PG;EuDxCA;IAAwB,4BAA2B;GAAI;EACvD;IAAwB,6BAA4B;GAAI;EACxD;IAAwB,8BAA6B;GAAI;CjEugQ5D;;AUj+PG;EuDxCA;IAAwB,4BAA2B;GAAI;EACvD;IAAwB,6BAA4B;GAAI;EACxD;IAAwB,8BAA6B;GAAI;CjEmhQ5D;;AU7+PG;EuDxCA;IAAwB,4BAA2B;GAAI;EACvD;IAAwB,6BAA4B;GAAI;EACxD;IAAwB,8BAA6B;GAAI;CjE+hQ5D;;AiEzhQD;EAAmB,qCAAoC;CAAI;;AAC3D;EAAmB,qCAAoC;CAAI;;AAC3D;EAAmB,sCAAqC;CAAI;;AAI5D;EAAsB,4BAA0C;CAAI;;AACpE;EAAsB,4BAA2C;CAAI;;AACrE;EAAsB,4BAAyC;CAAI;;AACnE;EAAsB,8BAA6B;CAAI;;AAIvD;EAAc,uBAAwB;CAAI;;AEpCxC;EACE,0BAAwB;CACzB;;AlESD;EkENI,0BAAqC;ClESxC;;AkEdD;EACE,0BAAwB;CACzB;;AlESD;EkENI,0BAAqC;ClESxC;;AkEdD;EACE,0BAAwB;CACzB;;AlESD;EkENI,0BAAqC;ClESxC;;AkEdD;EACE,0BAAwB;CACzB;;AlESD;EkENI,0BAAqC;ClESxC;;AkEdD;EACE,0BAAwB;CACzB;;AlESD;EkENI,0BAAqC;ClESxC;;AkEdD;EACE,0BAAwB;CACzB;;AlESD;EkENI,0BAAqC;ClESxC;;AkEdD;EACE,0BAAwB;CACzB;;AlESD;EkENI,0BAAqC;ClESxC;;AkEdD;EACE,0BAAwB;CACzB;;AlESD;EkENI,0BAAqC;ClESxC;;AgE4BH;EAAa,0BAA6B;CAAI;;AAC9C;EAAc,0BAA6B;CAAI;;AAE/C;EAAiB,qCAAkC;CAAI;;AACvD;EAAiB,2CAAkC;CAAI;;AAIvD;EGpDE,YAAW;EACX,mBAAkB;EAClB,kBAAiB;EACjB,8BAA6B;EAC7B,UAAS;CHkDV;;AIrDD;ECCE,+BAAkC;CDCnC;;AAED;ECHE,8BAAkC;CDKnC;;AECC;EzESF;;;IyEHM,6BAA4B;IAE5B,4BAA2B;GAC5B;EAED;IAEI,2BAA0B;GAC3B;EAQH;IACE,8BAA6B;GAC9B;EzE+ML;IyEjMM,iCAAgC;GACjC;EACD;;IAEE,0BxErCY;IwEsCZ,yBAAwB;GACzB;EAOD;IACE,4BAA2B;GAC5B;EAED;;IAEE,yBAAwB;GACzB;EAED;;;IAGE,WAAU;IACV,UAAS;GACV;EAED;;IAEE,wBAAuB;GACxB;EAOD;IACE,SxEs0BgC;GCg0OnC;EFvqQH;IyEoCM,4BAA2C;GAC5C;E/DxFH;I+D0FI,4BAA2C;GAC5C;E1C/EL;I0CmFM,cAAa;GACd;ErChGL;IqCkGM,uBxElFS;GwEmFV;E3DpGL;I2DuGM,qCAAoC;GAMrC;EAPD;;IAKI,kCAAmC;GACpC;E3DhEP;;I2DsEQ,qCAAsC;GACvC;E3DaP;I2DTM,eAAc;GAQf;EATD;;;;IAOI,sBxEnHU;GwEoHX;E3DhBP;I2DoBM,eAAc;IACd,sBxEzHY;GwE0Hb;CvE4nQJ","file":"bootstrap.css","sourcesContent":["/*!\n * Bootstrap v4.1.1 (https://getbootstrap.com/)\n * Copyright 2011-2018 The Bootstrap Authors\n * Copyright 2011-2018 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n */\n\n@import \"functions\";\n@import \"variables\";\n@import \"mixins\";\n@import \"root\";\n@import \"reboot\";\n@import \"type\";\n@import \"images\";\n@import \"code\";\n@import \"grid\";\n@import \"tables\";\n@import \"forms\";\n@import \"buttons\";\n@import \"transitions\";\n@import \"dropdown\";\n@import \"button-group\";\n@import \"input-group\";\n@import \"custom-forms\";\n@import \"nav\";\n@import \"navbar\";\n@import \"card\";\n@import \"breadcrumb\";\n@import \"pagination\";\n@import \"badge\";\n@import \"jumbotron\";\n@import \"alert\";\n@import \"progress\";\n@import \"media\";\n@import \"list-group\";\n@import \"close\";\n@import \"modal\";\n@import \"tooltip\";\n@import \"popover\";\n@import \"carousel\";\n@import \"utilities\";\n@import \"print\";\n",":root {\n // Custom variable values only support SassScript inside `#{}`.\n @each $color, $value in $colors {\n --#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors {\n --#{$color}: #{$value};\n }\n\n @each $bp, $value in $grid-breakpoints {\n --breakpoint-#{$bp}: #{$value};\n }\n\n // Use `inspect` for lists so that quoted items keep the quotes.\n // See https://github.com/sass/sass/issues/2383#issuecomment-336349172\n --font-family-sans-serif: #{inspect($font-family-sans-serif)};\n --font-family-monospace: #{inspect($font-family-monospace)};\n}\n","// stylelint-disable at-rule-no-vendor-prefix, declaration-no-important, selector-no-qualifying-type, property-no-vendor-prefix\n\n// Reboot\n//\n// Normalization of HTML elements, manually forked from Normalize.css to remove\n// styles targeting irrelevant browsers while applying new styles.\n//\n// Normalize is licensed MIT. https://github.com/necolas/normalize.css\n\n\n// Document\n//\n// 1. Change from `box-sizing: content-box` so that `width` is not affected by `padding` or `border`.\n// 2. Change the default font family in all browsers.\n// 3. Correct the line height in all browsers.\n// 4. Prevent adjustments of font size after orientation changes in IE on Windows Phone and in iOS.\n// 5. Setting @viewport causes scrollbars to overlap content in IE11 and Edge, so\n// we force a non-overlapping, non-auto-hiding scrollbar to counteract.\n// 6. Change the default tap highlight to be completely transparent in iOS.\n\n*,\n*::before,\n*::after {\n box-sizing: border-box; // 1\n}\n\nhtml {\n font-family: sans-serif; // 2\n line-height: 1.15; // 3\n -webkit-text-size-adjust: 100%; // 4\n -ms-text-size-adjust: 100%; // 4\n -ms-overflow-style: scrollbar; // 5\n -webkit-tap-highlight-color: rgba($black, 0); // 6\n}\n\n// IE10+ doesn't honor `` in some cases.\n@at-root {\n @-ms-viewport {\n width: device-width;\n }\n}\n\n// stylelint-disable selector-list-comma-newline-after\n// Shim for \"new\" HTML5 structural elements to display correctly (IE10, older browsers)\narticle, aside, figcaption, figure, footer, header, hgroup, main, nav, section {\n display: block;\n}\n// stylelint-enable selector-list-comma-newline-after\n\n// Body\n//\n// 1. Remove the margin in all browsers.\n// 2. As a best practice, apply a default `background-color`.\n// 3. Set an explicit initial text-align value so that we can later use the\n// the `inherit` value on things like `` elements.\n\nbody {\n margin: 0; // 1\n font-family: $font-family-base;\n font-size: $font-size-base;\n font-weight: $font-weight-base;\n line-height: $line-height-base;\n color: $body-color;\n text-align: left; // 3\n background-color: $body-bg; // 2\n}\n\n// Suppress the focus outline on elements that cannot be accessed via keyboard.\n// This prevents an unwanted focus outline from appearing around elements that\n// might still respond to pointer events.\n//\n// Credit: https://github.com/suitcss/base\n[tabindex=\"-1\"]:focus {\n outline: 0 !important;\n}\n\n\n// Content grouping\n//\n// 1. Add the correct box sizing in Firefox.\n// 2. Show the overflow in Edge and IE.\n\nhr {\n box-sizing: content-box; // 1\n height: 0; // 1\n overflow: visible; // 2\n}\n\n\n//\n// Typography\n//\n\n// Remove top margins from headings\n//\n// By default, `

`-`

` all receive top and bottom margins. We nuke the top\n// margin for easier control within type scales as it avoids margin collapsing.\n// stylelint-disable selector-list-comma-newline-after\nh1, h2, h3, h4, h5, h6 {\n margin-top: 0;\n margin-bottom: $headings-margin-bottom;\n}\n// stylelint-enable selector-list-comma-newline-after\n\n// Reset margins on paragraphs\n//\n// Similarly, the top margin on `

`s get reset. However, we also reset the\n// bottom margin to use `rem` units instead of `em`.\np {\n margin-top: 0;\n margin-bottom: $paragraph-margin-bottom;\n}\n\n// Abbreviations\n//\n// 1. Remove the bottom border in Firefox 39-.\n// 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.\n// 3. Add explicit cursor to indicate changed behavior.\n// 4. Duplicate behavior to the data-* attribute for our tooltip plugin\n\nabbr[title],\nabbr[data-original-title] { // 4\n text-decoration: underline; // 2\n text-decoration: underline dotted; // 2\n cursor: help; // 3\n border-bottom: 0; // 1\n}\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: $dt-font-weight;\n}\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0; // Undo browser default\n}\n\nblockquote {\n margin: 0 0 1rem;\n}\n\ndfn {\n font-style: italic; // Add the correct font style in Android 4.3-\n}\n\n// stylelint-disable font-weight-notation\nb,\nstrong {\n font-weight: bolder; // Add the correct font weight in Chrome, Edge, and Safari\n}\n// stylelint-enable font-weight-notation\n\nsmall {\n font-size: 80%; // Add the correct font size in all browsers\n}\n\n//\n// Prevent `sub` and `sup` elements from affecting the line height in\n// all browsers.\n//\n\nsub,\nsup {\n position: relative;\n font-size: 75%;\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub { bottom: -.25em; }\nsup { top: -.5em; }\n\n\n//\n// Links\n//\n\na {\n color: $link-color;\n text-decoration: $link-decoration;\n background-color: transparent; // Remove the gray background on active links in IE 10.\n -webkit-text-decoration-skip: objects; // Remove gaps in links underline in iOS 8+ and Safari 8+.\n\n @include hover {\n color: $link-hover-color;\n text-decoration: $link-hover-decoration;\n }\n}\n\n// And undo these styles for placeholder links/named anchors (without href)\n// which have not been made explicitly keyboard-focusable (without tabindex).\n// It would be more straightforward to just use a[href] in previous block, but that\n// causes specificity issues in many other styles that are too complex to fix.\n// See https://github.com/twbs/bootstrap/issues/19402\n\na:not([href]):not([tabindex]) {\n color: inherit;\n text-decoration: none;\n\n @include hover-focus {\n color: inherit;\n text-decoration: none;\n }\n\n &:focus {\n outline: 0;\n }\n}\n\n\n//\n// Code\n//\n\npre,\ncode,\nkbd,\nsamp {\n font-family: $font-family-monospace;\n font-size: 1em; // Correct the odd `em` font sizing in all browsers.\n}\n\npre {\n // Remove browser default top margin\n margin-top: 0;\n // Reset browser default of `1em` to use `rem`s\n margin-bottom: 1rem;\n // Don't allow content to break outside\n overflow: auto;\n // We have @viewport set which causes scrollbars to overlap content in IE11 and Edge, so\n // we force a non-overlapping, non-auto-hiding scrollbar to counteract.\n -ms-overflow-style: scrollbar;\n}\n\n\n//\n// Figures\n//\n\nfigure {\n // Apply a consistent margin strategy (matches our type styles).\n margin: 0 0 1rem;\n}\n\n\n//\n// Images and content\n//\n\nimg {\n vertical-align: middle;\n border-style: none; // Remove the border on images inside links in IE 10-.\n}\n\nsvg:not(:root) {\n overflow: hidden; // Hide the overflow in IE\n}\n\n\n//\n// Tables\n//\n\ntable {\n border-collapse: collapse; // Prevent double borders\n}\n\ncaption {\n padding-top: $table-cell-padding;\n padding-bottom: $table-cell-padding;\n color: $table-caption-color;\n text-align: left;\n caption-side: bottom;\n}\n\nth {\n // Matches default `` alignment by inheriting from the ``, or the\n // closest parent with a set `text-align`.\n text-align: inherit;\n}\n\n\n//\n// Forms\n//\n\nlabel {\n // Allow labels to use `margin` for spacing.\n display: inline-block;\n margin-bottom: $label-margin-bottom;\n}\n\n// Remove the default `border-radius` that macOS Chrome adds.\n//\n// Details at https://github.com/twbs/bootstrap/issues/24093\nbutton {\n border-radius: 0;\n}\n\n// Work around a Firefox/IE bug where the transparent `button` background\n// results in a loss of the default `button` focus styles.\n//\n// Credit: https://github.com/suitcss/base/\nbutton:focus {\n outline: 1px dotted;\n outline: 5px auto -webkit-focus-ring-color;\n}\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0; // Remove the margin in Firefox and Safari\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\n\nbutton,\ninput {\n overflow: visible; // Show the overflow in Edge\n}\n\nbutton,\nselect {\n text-transform: none; // Remove the inheritance of text transform in Firefox\n}\n\n// 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`\n// controls in Android 4.\n// 2. Correct the inability to style clickable types in iOS and Safari.\nbutton,\nhtml [type=\"button\"], // 1\n[type=\"reset\"],\n[type=\"submit\"] {\n -webkit-appearance: button; // 2\n}\n\n// Remove inner border and padding from Firefox, but don't restore the outline like Normalize.\nbutton::-moz-focus-inner,\n[type=\"button\"]::-moz-focus-inner,\n[type=\"reset\"]::-moz-focus-inner,\n[type=\"submit\"]::-moz-focus-inner {\n padding: 0;\n border-style: none;\n}\n\ninput[type=\"radio\"],\ninput[type=\"checkbox\"] {\n box-sizing: border-box; // 1. Add the correct box sizing in IE 10-\n padding: 0; // 2. Remove the padding in IE 10-\n}\n\n\ninput[type=\"date\"],\ninput[type=\"time\"],\ninput[type=\"datetime-local\"],\ninput[type=\"month\"] {\n // Remove the default appearance of temporal inputs to avoid a Mobile Safari\n // bug where setting a custom line-height prevents text from being vertically\n // centered within the input.\n // See https://bugs.webkit.org/show_bug.cgi?id=139848\n // and https://github.com/twbs/bootstrap/issues/11266\n -webkit-appearance: listbox;\n}\n\ntextarea {\n overflow: auto; // Remove the default vertical scrollbar in IE.\n // Textareas should really only resize vertically so they don't break their (horizontal) containers.\n resize: vertical;\n}\n\nfieldset {\n // Browsers set a default `min-width: min-content;` on fieldsets,\n // unlike e.g. `

`s, which have `min-width: 0;` by default.\n // So we reset that to ensure fieldsets behave more like a standard block element.\n // See https://github.com/twbs/bootstrap/issues/12359\n // and https://html.spec.whatwg.org/multipage/#the-fieldset-and-legend-elements\n min-width: 0;\n // Reset the default outline behavior of fieldsets so they don't affect page layout.\n padding: 0;\n margin: 0;\n border: 0;\n}\n\n// 1. Correct the text wrapping in Edge and IE.\n// 2. Correct the color inheritance from `fieldset` elements in IE.\nlegend {\n display: block;\n width: 100%;\n max-width: 100%; // 1\n padding: 0;\n margin-bottom: .5rem;\n font-size: 1.5rem;\n line-height: inherit;\n color: inherit; // 2\n white-space: normal; // 1\n}\n\nprogress {\n vertical-align: baseline; // Add the correct vertical alignment in Chrome, Firefox, and Opera.\n}\n\n// Correct the cursor style of increment and decrement buttons in Chrome.\n[type=\"number\"]::-webkit-inner-spin-button,\n[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\n\n[type=\"search\"] {\n // This overrides the extra rounded corners on search inputs in iOS so that our\n // `.form-control` class can properly style them. Note that this cannot simply\n // be added to `.form-control` as it's not specific enough. For details, see\n // https://github.com/twbs/bootstrap/issues/11586.\n outline-offset: -2px; // 2. Correct the outline style in Safari.\n -webkit-appearance: none;\n}\n\n//\n// Remove the inner padding and cancel buttons in Chrome and Safari on macOS.\n//\n\n[type=\"search\"]::-webkit-search-cancel-button,\n[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n//\n// 1. Correct the inability to style clickable types in iOS and Safari.\n// 2. Change font properties to `inherit` in Safari.\n//\n\n::-webkit-file-upload-button {\n font: inherit; // 2\n -webkit-appearance: button; // 1\n}\n\n//\n// Correct element displays\n//\n\noutput {\n display: inline-block;\n}\n\nsummary {\n display: list-item; // Add the correct display in all browsers\n cursor: pointer;\n}\n\ntemplate {\n display: none; // Add the correct display in IE\n}\n\n// Always hide an element with the `hidden` HTML attribute (from PureCSS).\n// Needed for proper display in IE 10-.\n[hidden] {\n display: none !important;\n}\n","// Variables\n//\n// Variables should follow the `$component-state-property-size` formula for\n// consistent naming. Ex: $nav-link-disabled-color and $modal-content-box-shadow-xs.\n\n\n//\n// Color system\n//\n\n// stylelint-disable\n$white: #fff !default;\n$gray-100: #f8f9fa !default;\n$gray-200: #e9ecef !default;\n$gray-300: #dee2e6 !default;\n$gray-400: #ced4da !default;\n$gray-500: #adb5bd !default;\n$gray-600: #6c757d !default;\n$gray-700: #495057 !default;\n$gray-800: #343a40 !default;\n$gray-900: #212529 !default;\n$black: #000 !default;\n\n$grays: () !default;\n$grays: map-merge((\n \"100\": $gray-100,\n \"200\": $gray-200,\n \"300\": $gray-300,\n \"400\": $gray-400,\n \"500\": $gray-500,\n \"600\": $gray-600,\n \"700\": $gray-700,\n \"800\": $gray-800,\n \"900\": $gray-900\n), $grays);\n\n$blue: #007bff !default;\n$indigo: #6610f2 !default;\n$purple: #6f42c1 !default;\n$pink: #e83e8c !default;\n$red: #dc3545 !default;\n$orange: #fd7e14 !default;\n$yellow: #ffc107 !default;\n$green: #28a745 !default;\n$teal: #20c997 !default;\n$cyan: #17a2b8 !default;\n\n$colors: () !default;\n$colors: map-merge((\n \"blue\": $blue,\n \"indigo\": $indigo,\n \"purple\": $purple,\n \"pink\": $pink,\n \"red\": $red,\n \"orange\": $orange,\n \"yellow\": $yellow,\n \"green\": $green,\n \"teal\": $teal,\n \"cyan\": $cyan,\n \"white\": $white,\n \"gray\": $gray-600,\n \"gray-dark\": $gray-800\n), $colors);\n\n$primary: $blue !default;\n$secondary: $gray-600 !default;\n$success: $green !default;\n$info: $cyan !default;\n$warning: $yellow !default;\n$danger: $red !default;\n$light: $gray-100 !default;\n$dark: $gray-800 !default;\n\n$theme-colors: () !default;\n$theme-colors: map-merge((\n \"primary\": $primary,\n \"secondary\": $secondary,\n \"success\": $success,\n \"info\": $info,\n \"warning\": $warning,\n \"danger\": $danger,\n \"light\": $light,\n \"dark\": $dark\n), $theme-colors);\n// stylelint-enable\n\n// Set a specific jump point for requesting color jumps\n$theme-color-interval: 8% !default;\n\n// The yiq lightness value that determines when the lightness of color changes from \"dark\" to \"light\". Acceptable values are between 0 and 255.\n$yiq-contrasted-threshold: 150 !default;\n\n// Customize the light and dark text colors for use in our YIQ color contrast function.\n$yiq-text-dark: $gray-900 !default;\n$yiq-text-light: $white !default;\n\n// Options\n//\n// Quickly modify global styling by enabling or disabling optional features.\n\n$enable-caret: true !default;\n$enable-rounded: true !default;\n$enable-shadows: false !default;\n$enable-gradients: false !default;\n$enable-transitions: true !default;\n$enable-hover-media-query: false !default; // Deprecated, no longer affects any compiled CSS\n$enable-grid-classes: true !default;\n$enable-print-styles: true !default;\n\n\n// Spacing\n//\n// Control the default styling of most Bootstrap elements by modifying these\n// variables. Mostly focused on spacing.\n// You can add more entries to the $spacers map, should you need more variation.\n\n// stylelint-disable\n$spacer: 1rem !default;\n$spacers: () !default;\n$spacers: map-merge((\n 0: 0,\n 1: ($spacer * .25),\n 2: ($spacer * .5),\n 3: $spacer,\n 4: ($spacer * 1.5),\n 5: ($spacer * 3)\n), $spacers);\n\n// This variable affects the `.h-*` and `.w-*` classes.\n$sizes: () !default;\n$sizes: map-merge((\n 25: 25%,\n 50: 50%,\n 75: 75%,\n 100: 100%,\n auto: auto\n), $sizes);\n// stylelint-enable\n\n// Body\n//\n// Settings for the `` element.\n\n$body-bg: $white !default;\n$body-color: $gray-900 !default;\n\n// Links\n//\n// Style anchor elements.\n\n$link-color: theme-color(\"primary\") !default;\n$link-decoration: none !default;\n$link-hover-color: darken($link-color, 15%) !default;\n$link-hover-decoration: underline !default;\n\n// Paragraphs\n//\n// Style p element.\n\n$paragraph-margin-bottom: 1rem !default;\n\n\n// Grid breakpoints\n//\n// Define the minimum dimensions at which your layout will change,\n// adapting to different screen sizes, for use in media queries.\n\n$grid-breakpoints: (\n xs: 0,\n sm: 576px,\n md: 768px,\n lg: 992px,\n xl: 1200px\n) !default;\n\n@include _assert-ascending($grid-breakpoints, \"$grid-breakpoints\");\n@include _assert-starts-at-zero($grid-breakpoints);\n\n\n// Grid containers\n//\n// Define the maximum width of `.container` for different screen sizes.\n\n$container-max-widths: (\n sm: 540px,\n md: 720px,\n lg: 960px,\n xl: 1140px\n) !default;\n\n@include _assert-ascending($container-max-widths, \"$container-max-widths\");\n\n\n// Grid columns\n//\n// Set the number of columns and specify the width of the gutters.\n\n$grid-columns: 12 !default;\n$grid-gutter-width: 30px !default;\n\n// Components\n//\n// Define common padding and border radius sizes and more.\n\n$line-height-lg: 1.5 !default;\n$line-height-sm: 1.5 !default;\n\n$border-width: 1px !default;\n$border-color: $gray-300 !default;\n\n$border-radius: .25rem !default;\n$border-radius-lg: .3rem !default;\n$border-radius-sm: .2rem !default;\n\n$box-shadow-sm: 0 .125rem .25rem rgba($black, .075) !default;\n$box-shadow: 0 .5rem 1rem rgba($black, .15) !default;\n$box-shadow-lg: 0 1rem 3rem rgba($black, .175) !default;\n\n$component-active-color: $white !default;\n$component-active-bg: theme-color(\"primary\") !default;\n\n$caret-width: .3em !default;\n\n$transition-base: all .2s ease-in-out !default;\n$transition-fade: opacity .15s linear !default;\n$transition-collapse: height .35s ease !default;\n\n\n// Fonts\n//\n// Font, line-height, and color for body text, headings, and more.\n\n// stylelint-disable value-keyword-case\n$font-family-sans-serif: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\" !default;\n$font-family-monospace: SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace !default;\n$font-family-base: $font-family-sans-serif !default;\n// stylelint-enable value-keyword-case\n\n$font-size-base: 1rem !default; // Assumes the browser default, typically `16px`\n$font-size-lg: ($font-size-base * 1.25) !default;\n$font-size-sm: ($font-size-base * .875) !default;\n\n$font-weight-light: 300 !default;\n$font-weight-normal: 400 !default;\n$font-weight-bold: 700 !default;\n\n$font-weight-base: $font-weight-normal !default;\n$line-height-base: 1.5 !default;\n\n$h1-font-size: $font-size-base * 2.5 !default;\n$h2-font-size: $font-size-base * 2 !default;\n$h3-font-size: $font-size-base * 1.75 !default;\n$h4-font-size: $font-size-base * 1.5 !default;\n$h5-font-size: $font-size-base * 1.25 !default;\n$h6-font-size: $font-size-base !default;\n\n$headings-margin-bottom: ($spacer / 2) !default;\n$headings-font-family: inherit !default;\n$headings-font-weight: 500 !default;\n$headings-line-height: 1.2 !default;\n$headings-color: inherit !default;\n\n$display1-size: 6rem !default;\n$display2-size: 5.5rem !default;\n$display3-size: 4.5rem !default;\n$display4-size: 3.5rem !default;\n\n$display1-weight: 300 !default;\n$display2-weight: 300 !default;\n$display3-weight: 300 !default;\n$display4-weight: 300 !default;\n$display-line-height: $headings-line-height !default;\n\n$lead-font-size: ($font-size-base * 1.25) !default;\n$lead-font-weight: 300 !default;\n\n$small-font-size: 80% !default;\n\n$text-muted: $gray-600 !default;\n\n$blockquote-small-color: $gray-600 !default;\n$blockquote-font-size: ($font-size-base * 1.25) !default;\n\n$hr-border-color: rgba($black, .1) !default;\n$hr-border-width: $border-width !default;\n\n$mark-padding: .2em !default;\n\n$dt-font-weight: $font-weight-bold !default;\n\n$kbd-box-shadow: inset 0 -.1rem 0 rgba($black, .25) !default;\n$nested-kbd-font-weight: $font-weight-bold !default;\n\n$list-inline-padding: .5rem !default;\n\n$mark-bg: #fcf8e3 !default;\n\n$hr-margin-y: $spacer !default;\n\n\n// Tables\n//\n// Customizes the `.table` component with basic values, each used across all table variations.\n\n$table-cell-padding: .75rem !default;\n$table-cell-padding-sm: .3rem !default;\n\n$table-bg: transparent !default;\n$table-accent-bg: rgba($black, .05) !default;\n$table-hover-bg: rgba($black, .075) !default;\n$table-active-bg: $table-hover-bg !default;\n\n$table-border-width: $border-width !default;\n$table-border-color: $gray-300 !default;\n\n$table-head-bg: $gray-200 !default;\n$table-head-color: $gray-700 !default;\n\n$table-dark-bg: $gray-900 !default;\n$table-dark-accent-bg: rgba($white, .05) !default;\n$table-dark-hover-bg: rgba($white, .075) !default;\n$table-dark-border-color: lighten($gray-900, 7.5%) !default;\n$table-dark-color: $body-bg !default;\n\n$table-striped-order: odd !default;\n\n$table-caption-color: $text-muted !default;\n\n// Buttons + Forms\n//\n// Shared variables that are reassigned to `$input-` and `$btn-` specific variables.\n\n$input-btn-padding-y: .375rem !default;\n$input-btn-padding-x: .75rem !default;\n$input-btn-line-height: $line-height-base !default;\n\n$input-btn-focus-width: .2rem !default;\n$input-btn-focus-color: rgba($component-active-bg, .25) !default;\n$input-btn-focus-box-shadow: 0 0 0 $input-btn-focus-width $input-btn-focus-color !default;\n\n$input-btn-padding-y-sm: .25rem !default;\n$input-btn-padding-x-sm: .5rem !default;\n$input-btn-line-height-sm: $line-height-sm !default;\n\n$input-btn-padding-y-lg: .5rem !default;\n$input-btn-padding-x-lg: 1rem !default;\n$input-btn-line-height-lg: $line-height-lg !default;\n\n$input-btn-border-width: $border-width !default;\n\n\n// Buttons\n//\n// For each of Bootstrap's buttons, define text, background, and border color.\n\n$btn-padding-y: $input-btn-padding-y !default;\n$btn-padding-x: $input-btn-padding-x !default;\n$btn-line-height: $input-btn-line-height !default;\n\n$btn-padding-y-sm: $input-btn-padding-y-sm !default;\n$btn-padding-x-sm: $input-btn-padding-x-sm !default;\n$btn-line-height-sm: $input-btn-line-height-sm !default;\n\n$btn-padding-y-lg: $input-btn-padding-y-lg !default;\n$btn-padding-x-lg: $input-btn-padding-x-lg !default;\n$btn-line-height-lg: $input-btn-line-height-lg !default;\n\n$btn-border-width: $input-btn-border-width !default;\n\n$btn-font-weight: $font-weight-normal !default;\n$btn-box-shadow: inset 0 1px 0 rgba($white, .15), 0 1px 1px rgba($black, .075) !default;\n$btn-focus-width: $input-btn-focus-width !default;\n$btn-focus-box-shadow: $input-btn-focus-box-shadow !default;\n$btn-disabled-opacity: .65 !default;\n$btn-active-box-shadow: inset 0 3px 5px rgba($black, .125) !default;\n\n$btn-link-disabled-color: $gray-600 !default;\n\n$btn-block-spacing-y: .5rem !default;\n\n// Allows for customizing button radius independently from global border radius\n$btn-border-radius: $border-radius !default;\n$btn-border-radius-lg: $border-radius-lg !default;\n$btn-border-radius-sm: $border-radius-sm !default;\n\n$btn-transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;\n\n\n// Forms\n\n$label-margin-bottom: .5rem !default;\n\n$input-padding-y: $input-btn-padding-y !default;\n$input-padding-x: $input-btn-padding-x !default;\n$input-line-height: $input-btn-line-height !default;\n\n$input-padding-y-sm: $input-btn-padding-y-sm !default;\n$input-padding-x-sm: $input-btn-padding-x-sm !default;\n$input-line-height-sm: $input-btn-line-height-sm !default;\n\n$input-padding-y-lg: $input-btn-padding-y-lg !default;\n$input-padding-x-lg: $input-btn-padding-x-lg !default;\n$input-line-height-lg: $input-btn-line-height-lg !default;\n\n$input-bg: $white !default;\n$input-disabled-bg: $gray-200 !default;\n\n$input-color: $gray-700 !default;\n$input-border-color: $gray-400 !default;\n$input-border-width: $input-btn-border-width !default;\n$input-box-shadow: inset 0 1px 1px rgba($black, .075) !default;\n\n$input-border-radius: $border-radius !default;\n$input-border-radius-lg: $border-radius-lg !default;\n$input-border-radius-sm: $border-radius-sm !default;\n\n$input-focus-bg: $input-bg !default;\n$input-focus-border-color: lighten($component-active-bg, 25%) !default;\n$input-focus-color: $input-color !default;\n$input-focus-width: $input-btn-focus-width !default;\n$input-focus-box-shadow: $input-btn-focus-box-shadow !default;\n\n$input-placeholder-color: $gray-600 !default;\n$input-plaintext-color: $body-color !default;\n\n$input-height-border: $input-border-width * 2 !default;\n\n$input-height-inner: ($font-size-base * $input-btn-line-height) + ($input-btn-padding-y * 2) !default;\n$input-height: calc(#{$input-height-inner} + #{$input-height-border}) !default;\n\n$input-height-inner-sm: ($font-size-sm * $input-btn-line-height-sm) + ($input-btn-padding-y-sm * 2) !default;\n$input-height-sm: calc(#{$input-height-inner-sm} + #{$input-height-border}) !default;\n\n$input-height-inner-lg: ($font-size-lg * $input-btn-line-height-lg) + ($input-btn-padding-y-lg * 2) !default;\n$input-height-lg: calc(#{$input-height-inner-lg} + #{$input-height-border}) !default;\n\n$input-transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;\n\n$form-text-margin-top: .25rem !default;\n\n$form-check-input-gutter: 1.25rem !default;\n$form-check-input-margin-y: .3rem !default;\n$form-check-input-margin-x: .25rem !default;\n\n$form-check-inline-margin-x: .75rem !default;\n$form-check-inline-input-margin-x: .3125rem !default;\n\n$form-group-margin-bottom: 1rem !default;\n\n$input-group-addon-color: $input-color !default;\n$input-group-addon-bg: $gray-200 !default;\n$input-group-addon-border-color: $input-border-color !default;\n\n$custom-control-gutter: 1.5rem !default;\n$custom-control-spacer-x: 1rem !default;\n\n$custom-control-indicator-size: 1rem !default;\n$custom-control-indicator-bg: $gray-300 !default;\n$custom-control-indicator-bg-size: 50% 50% !default;\n$custom-control-indicator-box-shadow: inset 0 .25rem .25rem rgba($black, .1) !default;\n\n$custom-control-indicator-disabled-bg: $gray-200 !default;\n$custom-control-label-disabled-color: $gray-600 !default;\n\n$custom-control-indicator-checked-color: $component-active-color !default;\n$custom-control-indicator-checked-bg: $component-active-bg !default;\n$custom-control-indicator-checked-disabled-bg: rgba(theme-color(\"primary\"), .5) !default;\n$custom-control-indicator-checked-box-shadow: none !default;\n\n$custom-control-indicator-focus-box-shadow: 0 0 0 1px $body-bg, $input-btn-focus-box-shadow !default;\n\n$custom-control-indicator-active-color: $component-active-color !default;\n$custom-control-indicator-active-bg: lighten($component-active-bg, 35%) !default;\n$custom-control-indicator-active-box-shadow: none !default;\n\n$custom-checkbox-indicator-border-radius: $border-radius !default;\n$custom-checkbox-indicator-icon-checked: str-replace(url(\"data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath fill='#{$custom-control-indicator-checked-color}' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26 2.974 7.25 8 2.193z'/%3E%3C/svg%3E\"), \"#\", \"%23\") !default;\n\n$custom-checkbox-indicator-indeterminate-bg: $component-active-bg !default;\n$custom-checkbox-indicator-indeterminate-color: $custom-control-indicator-checked-color !default;\n$custom-checkbox-indicator-icon-indeterminate: str-replace(url(\"data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 4'%3E%3Cpath stroke='#{$custom-checkbox-indicator-indeterminate-color}' d='M0 2h4'/%3E%3C/svg%3E\"), \"#\", \"%23\") !default;\n$custom-checkbox-indicator-indeterminate-box-shadow: none !default;\n\n$custom-radio-indicator-border-radius: 50% !default;\n$custom-radio-indicator-icon-checked: str-replace(url(\"data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3E%3Ccircle r='3' fill='#{$custom-control-indicator-checked-color}'/%3E%3C/svg%3E\"), \"#\", \"%23\") !default;\n\n$custom-select-padding-y: .375rem !default;\n$custom-select-padding-x: .75rem !default;\n$custom-select-height: $input-height !default;\n$custom-select-indicator-padding: 1rem !default; // Extra padding to account for the presence of the background-image based indicator\n$custom-select-line-height: $input-btn-line-height !default;\n$custom-select-color: $input-color !default;\n$custom-select-disabled-color: $gray-600 !default;\n$custom-select-bg: $input-bg !default;\n$custom-select-disabled-bg: $gray-200 !default;\n$custom-select-bg-size: 8px 10px !default; // In pixels because image dimensions\n$custom-select-indicator-color: $gray-800 !default;\n$custom-select-indicator: str-replace(url(\"data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3E%3Cpath fill='#{$custom-select-indicator-color}' d='M2 0L0 2h4zm0 5L0 3h4z'/%3E%3C/svg%3E\"), \"#\", \"%23\") !default;\n$custom-select-border-width: $input-btn-border-width !default;\n$custom-select-border-color: $input-border-color !default;\n$custom-select-border-radius: $border-radius !default;\n\n$custom-select-focus-border-color: $input-focus-border-color !default;\n$custom-select-focus-box-shadow: inset 0 1px 2px rgba($black, .075), 0 0 5px rgba($custom-select-focus-border-color, .5) !default;\n\n$custom-select-font-size-sm: 75% !default;\n$custom-select-height-sm: $input-height-sm !default;\n\n$custom-select-font-size-lg: 125% !default;\n$custom-select-height-lg: $input-height-lg !default;\n\n$custom-range-track-width: 100% !default;\n$custom-range-track-height: .5rem !default;\n$custom-range-track-cursor: pointer !default;\n$custom-range-track-bg: $gray-300 !default;\n$custom-range-track-border-radius: 1rem !default;\n$custom-range-track-box-shadow: inset 0 .25rem .25rem rgba($black, .1) !default;\n\n$custom-range-thumb-width: 1rem !default;\n$custom-range-thumb-height: $custom-range-thumb-width !default;\n$custom-range-thumb-bg: $component-active-bg !default;\n$custom-range-thumb-border: 0 !default;\n$custom-range-thumb-border-radius: 1rem !default;\n$custom-range-thumb-box-shadow: 0 .1rem .25rem rgba($black, .1) !default;\n$custom-range-thumb-focus-box-shadow: 0 0 0 1px $body-bg, $input-btn-focus-box-shadow !default;\n$custom-range-thumb-active-bg: lighten($component-active-bg, 35%) !default;\n\n$custom-file-height: $input-height !default;\n$custom-file-height-inner: $input-height-inner !default;\n$custom-file-focus-border-color: $input-focus-border-color !default;\n$custom-file-focus-box-shadow: $input-btn-focus-box-shadow !default;\n\n$custom-file-padding-y: $input-btn-padding-y !default;\n$custom-file-padding-x: $input-btn-padding-x !default;\n$custom-file-line-height: $input-btn-line-height !default;\n$custom-file-color: $input-color !default;\n$custom-file-bg: $input-bg !default;\n$custom-file-border-width: $input-btn-border-width !default;\n$custom-file-border-color: $input-border-color !default;\n$custom-file-border-radius: $input-border-radius !default;\n$custom-file-box-shadow: $input-box-shadow !default;\n$custom-file-button-color: $custom-file-color !default;\n$custom-file-button-bg: $input-group-addon-bg !default;\n$custom-file-text: (\n en: \"Browse\"\n) !default;\n\n\n// Form validation\n$form-feedback-margin-top: $form-text-margin-top !default;\n$form-feedback-font-size: $small-font-size !default;\n$form-feedback-valid-color: theme-color(\"success\") !default;\n$form-feedback-invalid-color: theme-color(\"danger\") !default;\n\n\n// Dropdowns\n//\n// Dropdown menu container and contents.\n\n$dropdown-min-width: 10rem !default;\n$dropdown-padding-y: .5rem !default;\n$dropdown-spacer: .125rem !default;\n$dropdown-bg: $white !default;\n$dropdown-border-color: rgba($black, .15) !default;\n$dropdown-border-radius: $border-radius !default;\n$dropdown-border-width: $border-width !default;\n$dropdown-divider-bg: $gray-200 !default;\n$dropdown-box-shadow: 0 .5rem 1rem rgba($black, .175) !default;\n\n$dropdown-link-color: $gray-900 !default;\n$dropdown-link-hover-color: darken($gray-900, 5%) !default;\n$dropdown-link-hover-bg: $gray-100 !default;\n\n$dropdown-link-active-color: $component-active-color !default;\n$dropdown-link-active-bg: $component-active-bg !default;\n\n$dropdown-link-disabled-color: $gray-600 !default;\n\n$dropdown-item-padding-y: .25rem !default;\n$dropdown-item-padding-x: 1.5rem !default;\n\n$dropdown-header-color: $gray-600 !default;\n\n\n// Z-index master list\n//\n// Warning: Avoid customizing these values. They're used for a bird's eye view\n// of components dependent on the z-axis and are designed to all work together.\n\n$zindex-dropdown: 1000 !default;\n$zindex-sticky: 1020 !default;\n$zindex-fixed: 1030 !default;\n$zindex-modal-backdrop: 1040 !default;\n$zindex-modal: 1050 !default;\n$zindex-popover: 1060 !default;\n$zindex-tooltip: 1070 !default;\n\n// Navs\n\n$nav-link-padding-y: .5rem !default;\n$nav-link-padding-x: 1rem !default;\n$nav-link-disabled-color: $gray-600 !default;\n\n$nav-tabs-border-color: $gray-300 !default;\n$nav-tabs-border-width: $border-width !default;\n$nav-tabs-border-radius: $border-radius !default;\n$nav-tabs-link-hover-border-color: $gray-200 $gray-200 $nav-tabs-border-color !default;\n$nav-tabs-link-active-color: $gray-700 !default;\n$nav-tabs-link-active-bg: $body-bg !default;\n$nav-tabs-link-active-border-color: $gray-300 $gray-300 $nav-tabs-link-active-bg !default;\n\n$nav-pills-border-radius: $border-radius !default;\n$nav-pills-link-active-color: $component-active-color !default;\n$nav-pills-link-active-bg: $component-active-bg !default;\n\n$nav-divider-color: $gray-200 !default;\n$nav-divider-margin-y: ($spacer / 2) !default;\n\n// Navbar\n\n$navbar-padding-y: ($spacer / 2) !default;\n$navbar-padding-x: $spacer !default;\n\n$navbar-nav-link-padding-x: .5rem !default;\n\n$navbar-brand-font-size: $font-size-lg !default;\n// Compute the navbar-brand padding-y so the navbar-brand will have the same height as navbar-text and nav-link\n$nav-link-height: ($font-size-base * $line-height-base + $nav-link-padding-y * 2) !default;\n$navbar-brand-height: $navbar-brand-font-size * $line-height-base !default;\n$navbar-brand-padding-y: ($nav-link-height - $navbar-brand-height) / 2 !default;\n\n$navbar-toggler-padding-y: .25rem !default;\n$navbar-toggler-padding-x: .75rem !default;\n$navbar-toggler-font-size: $font-size-lg !default;\n$navbar-toggler-border-radius: $btn-border-radius !default;\n\n$navbar-dark-color: rgba($white, .5) !default;\n$navbar-dark-hover-color: rgba($white, .75) !default;\n$navbar-dark-active-color: $white !default;\n$navbar-dark-disabled-color: rgba($white, .25) !default;\n$navbar-dark-toggler-icon-bg: str-replace(url(\"data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='#{$navbar-dark-color}' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E\"), \"#\", \"%23\") !default;\n$navbar-dark-toggler-border-color: rgba($white, .1) !default;\n\n$navbar-light-color: rgba($black, .5) !default;\n$navbar-light-hover-color: rgba($black, .7) !default;\n$navbar-light-active-color: rgba($black, .9) !default;\n$navbar-light-disabled-color: rgba($black, .3) !default;\n$navbar-light-toggler-icon-bg: str-replace(url(\"data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='#{$navbar-light-color}' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E\"), \"#\", \"%23\") !default;\n$navbar-light-toggler-border-color: rgba($black, .1) !default;\n\n// Pagination\n\n$pagination-padding-y: .5rem !default;\n$pagination-padding-x: .75rem !default;\n$pagination-padding-y-sm: .25rem !default;\n$pagination-padding-x-sm: .5rem !default;\n$pagination-padding-y-lg: .75rem !default;\n$pagination-padding-x-lg: 1.5rem !default;\n$pagination-line-height: 1.25 !default;\n\n$pagination-color: $link-color !default;\n$pagination-bg: $white !default;\n$pagination-border-width: $border-width !default;\n$pagination-border-color: $gray-300 !default;\n\n$pagination-focus-box-shadow: $input-btn-focus-box-shadow !default;\n$pagination-focus-outline: 0 !default;\n\n$pagination-hover-color: $link-hover-color !default;\n$pagination-hover-bg: $gray-200 !default;\n$pagination-hover-border-color: $gray-300 !default;\n\n$pagination-active-color: $component-active-color !default;\n$pagination-active-bg: $component-active-bg !default;\n$pagination-active-border-color: $pagination-active-bg !default;\n\n$pagination-disabled-color: $gray-600 !default;\n$pagination-disabled-bg: $white !default;\n$pagination-disabled-border-color: $gray-300 !default;\n\n\n// Jumbotron\n\n$jumbotron-padding: 2rem !default;\n$jumbotron-bg: $gray-200 !default;\n\n\n// Cards\n\n$card-spacer-y: .75rem !default;\n$card-spacer-x: 1.25rem !default;\n$card-border-width: $border-width !default;\n$card-border-radius: $border-radius !default;\n$card-border-color: rgba($black, .125) !default;\n$card-inner-border-radius: calc(#{$card-border-radius} - #{$card-border-width}) !default;\n$card-cap-bg: rgba($black, .03) !default;\n$card-bg: $white !default;\n\n$card-img-overlay-padding: 1.25rem !default;\n\n$card-group-margin: ($grid-gutter-width / 2) !default;\n$card-deck-margin: $card-group-margin !default;\n\n$card-columns-count: 3 !default;\n$card-columns-gap: 1.25rem !default;\n$card-columns-margin: $card-spacer-y !default;\n\n\n// Tooltips\n\n$tooltip-font-size: $font-size-sm !default;\n$tooltip-max-width: 200px !default;\n$tooltip-color: $white !default;\n$tooltip-bg: $black !default;\n$tooltip-border-radius: $border-radius !default;\n$tooltip-opacity: .9 !default;\n$tooltip-padding-y: .25rem !default;\n$tooltip-padding-x: .5rem !default;\n$tooltip-margin: 0 !default;\n\n$tooltip-arrow-width: .8rem !default;\n$tooltip-arrow-height: .4rem !default;\n$tooltip-arrow-color: $tooltip-bg !default;\n\n\n// Popovers\n\n$popover-font-size: $font-size-sm !default;\n$popover-bg: $white !default;\n$popover-max-width: 276px !default;\n$popover-border-width: $border-width !default;\n$popover-border-color: rgba($black, .2) !default;\n$popover-border-radius: $border-radius-lg !default;\n$popover-box-shadow: 0 .25rem .5rem rgba($black, .2) !default;\n\n$popover-header-bg: darken($popover-bg, 3%) !default;\n$popover-header-color: $headings-color !default;\n$popover-header-padding-y: .5rem !default;\n$popover-header-padding-x: .75rem !default;\n\n$popover-body-color: $body-color !default;\n$popover-body-padding-y: $popover-header-padding-y !default;\n$popover-body-padding-x: $popover-header-padding-x !default;\n\n$popover-arrow-width: 1rem !default;\n$popover-arrow-height: .5rem !default;\n$popover-arrow-color: $popover-bg !default;\n\n$popover-arrow-outer-color: fade-in($popover-border-color, .05) !default;\n\n\n// Badges\n\n$badge-font-size: 75% !default;\n$badge-font-weight: $font-weight-bold !default;\n$badge-padding-y: .25em !default;\n$badge-padding-x: .4em !default;\n$badge-border-radius: $border-radius !default;\n\n$badge-pill-padding-x: .6em !default;\n// Use a higher than normal value to ensure completely rounded edges when\n// customizing padding or font-size on labels.\n$badge-pill-border-radius: 10rem !default;\n\n\n// Modals\n\n// Padding applied to the modal body\n$modal-inner-padding: 1rem !default;\n\n$modal-dialog-margin: .5rem !default;\n$modal-dialog-margin-y-sm-up: 1.75rem !default;\n\n$modal-title-line-height: $line-height-base !default;\n\n$modal-content-bg: $white !default;\n$modal-content-border-color: rgba($black, .2) !default;\n$modal-content-border-width: $border-width !default;\n$modal-content-border-radius: $border-radius-lg !default;\n$modal-content-box-shadow-xs: 0 .25rem .5rem rgba($black, .5) !default;\n$modal-content-box-shadow-sm-up: 0 .5rem 1rem rgba($black, .5) !default;\n\n$modal-backdrop-bg: $black !default;\n$modal-backdrop-opacity: .5 !default;\n$modal-header-border-color: $gray-200 !default;\n$modal-footer-border-color: $modal-header-border-color !default;\n$modal-header-border-width: $modal-content-border-width !default;\n$modal-footer-border-width: $modal-header-border-width !default;\n$modal-header-padding: 1rem !default;\n\n$modal-lg: 800px !default;\n$modal-md: 500px !default;\n$modal-sm: 300px !default;\n\n$modal-transition: transform .3s ease-out !default;\n\n\n// Alerts\n//\n// Define alert colors, border radius, and padding.\n\n$alert-padding-y: .75rem !default;\n$alert-padding-x: 1.25rem !default;\n$alert-margin-bottom: 1rem !default;\n$alert-border-radius: $border-radius !default;\n$alert-link-font-weight: $font-weight-bold !default;\n$alert-border-width: $border-width !default;\n\n$alert-bg-level: -10 !default;\n$alert-border-level: -9 !default;\n$alert-color-level: 6 !default;\n\n\n// Progress bars\n\n$progress-height: 1rem !default;\n$progress-font-size: ($font-size-base * .75) !default;\n$progress-bg: $gray-200 !default;\n$progress-border-radius: $border-radius !default;\n$progress-box-shadow: inset 0 .1rem .1rem rgba($black, .1) !default;\n$progress-bar-color: $white !default;\n$progress-bar-bg: theme-color(\"primary\") !default;\n$progress-bar-animation-timing: 1s linear infinite !default;\n$progress-bar-transition: width .6s ease !default;\n\n// List group\n\n$list-group-bg: $white !default;\n$list-group-border-color: rgba($black, .125) !default;\n$list-group-border-width: $border-width !default;\n$list-group-border-radius: $border-radius !default;\n\n$list-group-item-padding-y: .75rem !default;\n$list-group-item-padding-x: 1.25rem !default;\n\n$list-group-hover-bg: $gray-100 !default;\n$list-group-active-color: $component-active-color !default;\n$list-group-active-bg: $component-active-bg !default;\n$list-group-active-border-color: $list-group-active-bg !default;\n\n$list-group-disabled-color: $gray-600 !default;\n$list-group-disabled-bg: $list-group-bg !default;\n\n$list-group-action-color: $gray-700 !default;\n$list-group-action-hover-color: $list-group-action-color !default;\n\n$list-group-action-active-color: $body-color !default;\n$list-group-action-active-bg: $gray-200 !default;\n\n\n// Image thumbnails\n\n$thumbnail-padding: .25rem !default;\n$thumbnail-bg: $body-bg !default;\n$thumbnail-border-width: $border-width !default;\n$thumbnail-border-color: $gray-300 !default;\n$thumbnail-border-radius: $border-radius !default;\n$thumbnail-box-shadow: 0 1px 2px rgba($black, .075) !default;\n\n\n// Figures\n\n$figure-caption-font-size: 90% !default;\n$figure-caption-color: $gray-600 !default;\n\n\n// Breadcrumbs\n\n$breadcrumb-padding-y: .75rem !default;\n$breadcrumb-padding-x: 1rem !default;\n$breadcrumb-item-padding: .5rem !default;\n\n$breadcrumb-margin-bottom: 1rem !default;\n\n$breadcrumb-bg: $gray-200 !default;\n$breadcrumb-divider-color: $gray-600 !default;\n$breadcrumb-active-color: $gray-600 !default;\n$breadcrumb-divider: quote(\"/\") !default;\n\n$breadcrumb-border-radius: $border-radius !default;\n\n\n// Carousel\n\n$carousel-control-color: $white !default;\n$carousel-control-width: 15% !default;\n$carousel-control-opacity: .5 !default;\n\n$carousel-indicator-width: 30px !default;\n$carousel-indicator-height: 3px !default;\n$carousel-indicator-spacer: 3px !default;\n$carousel-indicator-active-bg: $white !default;\n\n$carousel-caption-width: 70% !default;\n$carousel-caption-color: $white !default;\n\n$carousel-control-icon-width: 20px !default;\n\n$carousel-control-prev-icon-bg: str-replace(url(\"data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='#{$carousel-control-color}' viewBox='0 0 8 8'%3E%3Cpath d='M5.25 0l-4 4 4 4 1.5-1.5-2.5-2.5 2.5-2.5-1.5-1.5z'/%3E%3C/svg%3E\"), \"#\", \"%23\") !default;\n$carousel-control-next-icon-bg: str-replace(url(\"data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='#{$carousel-control-color}' viewBox='0 0 8 8'%3E%3Cpath d='M2.75 0l-1.5 1.5 2.5 2.5-2.5 2.5 1.5 1.5 4-4-4-4z'/%3E%3C/svg%3E\"), \"#\", \"%23\") !default;\n\n$carousel-transition: transform .6s ease !default; // Define transform transition first if using multiple transitions (e.g., `transform 2s ease, opacity .5s ease-out`)\n\n\n// Close\n\n$close-font-size: $font-size-base * 1.5 !default;\n$close-font-weight: $font-weight-bold !default;\n$close-color: $black !default;\n$close-text-shadow: 0 1px 0 $white !default;\n\n// Code\n\n$code-font-size: 87.5% !default;\n$code-color: $pink !default;\n\n$kbd-padding-y: .2rem !default;\n$kbd-padding-x: .4rem !default;\n$kbd-font-size: $code-font-size !default;\n$kbd-color: $white !default;\n$kbd-bg: $gray-900 !default;\n\n$pre-color: $gray-900 !default;\n$pre-scrollable-max-height: 340px !default;\n\n\n// Printing\n$print-page-size: a3 !default;\n$print-body-min-width: map-get($grid-breakpoints, \"lg\") !default;\n","/*!\n * Bootstrap v4.1.1 (https://getbootstrap.com/)\n * Copyright 2011-2018 The Bootstrap Authors\n * Copyright 2011-2018 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n */\n:root {\n --blue: #007bff;\n --indigo: #6610f2;\n --purple: #6f42c1;\n --pink: #e83e8c;\n --red: #dc3545;\n --orange: #fd7e14;\n --yellow: #ffc107;\n --green: #28a745;\n --teal: #20c997;\n --cyan: #17a2b8;\n --white: #fff;\n --gray: #6c757d;\n --gray-dark: #343a40;\n --primary: #007bff;\n --secondary: #6c757d;\n --success: #28a745;\n --info: #17a2b8;\n --warning: #ffc107;\n --danger: #dc3545;\n --light: #f8f9fa;\n --dark: #343a40;\n --breakpoint-xs: 0;\n --breakpoint-sm: 576px;\n --breakpoint-md: 768px;\n --breakpoint-lg: 992px;\n --breakpoint-xl: 1200px;\n --font-family-sans-serif: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n --font-family-monospace: SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;\n}\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\nhtml {\n font-family: sans-serif;\n line-height: 1.15;\n -webkit-text-size-adjust: 100%;\n -ms-text-size-adjust: 100%;\n -ms-overflow-style: scrollbar;\n -webkit-tap-highlight-color: transparent;\n}\n\n@-ms-viewport {\n width: device-width;\n}\n\narticle, aside, figcaption, figure, footer, header, hgroup, main, nav, section {\n display: block;\n}\n\nbody {\n margin: 0;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n font-size: 1rem;\n font-weight: 400;\n line-height: 1.5;\n color: #212529;\n text-align: left;\n background-color: #fff;\n}\n\n[tabindex=\"-1\"]:focus {\n outline: 0 !important;\n}\n\nhr {\n box-sizing: content-box;\n height: 0;\n overflow: visible;\n}\n\nh1, h2, h3, h4, h5, h6 {\n margin-top: 0;\n margin-bottom: 0.5rem;\n}\n\np {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nabbr[title],\nabbr[data-original-title] {\n text-decoration: underline;\n text-decoration: underline dotted;\n cursor: help;\n border-bottom: 0;\n}\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: 700;\n}\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0;\n}\n\nblockquote {\n margin: 0 0 1rem;\n}\n\ndfn {\n font-style: italic;\n}\n\nb,\nstrong {\n font-weight: bolder;\n}\n\nsmall {\n font-size: 80%;\n}\n\nsub,\nsup {\n position: relative;\n font-size: 75%;\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub {\n bottom: -.25em;\n}\n\nsup {\n top: -.5em;\n}\n\na {\n color: #007bff;\n text-decoration: none;\n background-color: transparent;\n -webkit-text-decoration-skip: objects;\n}\n\na:hover {\n color: #0056b3;\n text-decoration: underline;\n}\n\na:not([href]):not([tabindex]) {\n color: inherit;\n text-decoration: none;\n}\n\na:not([href]):not([tabindex]):hover, a:not([href]):not([tabindex]):focus {\n color: inherit;\n text-decoration: none;\n}\n\na:not([href]):not([tabindex]):focus {\n outline: 0;\n}\n\npre,\ncode,\nkbd,\nsamp {\n font-family: SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;\n font-size: 1em;\n}\n\npre {\n margin-top: 0;\n margin-bottom: 1rem;\n overflow: auto;\n -ms-overflow-style: scrollbar;\n}\n\nfigure {\n margin: 0 0 1rem;\n}\n\nimg {\n vertical-align: middle;\n border-style: none;\n}\n\nsvg:not(:root) {\n overflow: hidden;\n}\n\ntable {\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: 0.75rem;\n padding-bottom: 0.75rem;\n color: #6c757d;\n text-align: left;\n caption-side: bottom;\n}\n\nth {\n text-align: inherit;\n}\n\nlabel {\n display: inline-block;\n margin-bottom: 0.5rem;\n}\n\nbutton {\n border-radius: 0;\n}\n\nbutton:focus {\n outline: 1px dotted;\n outline: 5px auto -webkit-focus-ring-color;\n}\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\n\nbutton,\ninput {\n overflow: visible;\n}\n\nbutton,\nselect {\n text-transform: none;\n}\n\nbutton,\nhtml [type=\"button\"],\n[type=\"reset\"],\n[type=\"submit\"] {\n -webkit-appearance: button;\n}\n\nbutton::-moz-focus-inner,\n[type=\"button\"]::-moz-focus-inner,\n[type=\"reset\"]::-moz-focus-inner,\n[type=\"submit\"]::-moz-focus-inner {\n padding: 0;\n border-style: none;\n}\n\ninput[type=\"radio\"],\ninput[type=\"checkbox\"] {\n box-sizing: border-box;\n padding: 0;\n}\n\ninput[type=\"date\"],\ninput[type=\"time\"],\ninput[type=\"datetime-local\"],\ninput[type=\"month\"] {\n -webkit-appearance: listbox;\n}\n\ntextarea {\n overflow: auto;\n resize: vertical;\n}\n\nfieldset {\n min-width: 0;\n padding: 0;\n margin: 0;\n border: 0;\n}\n\nlegend {\n display: block;\n width: 100%;\n max-width: 100%;\n padding: 0;\n margin-bottom: .5rem;\n font-size: 1.5rem;\n line-height: inherit;\n color: inherit;\n white-space: normal;\n}\n\nprogress {\n vertical-align: baseline;\n}\n\n[type=\"number\"]::-webkit-inner-spin-button,\n[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\n\n[type=\"search\"] {\n outline-offset: -2px;\n -webkit-appearance: none;\n}\n\n[type=\"search\"]::-webkit-search-cancel-button,\n[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n::-webkit-file-upload-button {\n font: inherit;\n -webkit-appearance: button;\n}\n\noutput {\n display: inline-block;\n}\n\nsummary {\n display: list-item;\n cursor: pointer;\n}\n\ntemplate {\n display: none;\n}\n\n[hidden] {\n display: none !important;\n}\n\nh1, h2, h3, h4, h5, h6,\n.h1, .h2, .h3, .h4, .h5, .h6 {\n margin-bottom: 0.5rem;\n font-family: inherit;\n font-weight: 500;\n line-height: 1.2;\n color: inherit;\n}\n\nh1, .h1 {\n font-size: 2.5rem;\n}\n\nh2, .h2 {\n font-size: 2rem;\n}\n\nh3, .h3 {\n font-size: 1.75rem;\n}\n\nh4, .h4 {\n font-size: 1.5rem;\n}\n\nh5, .h5 {\n font-size: 1.25rem;\n}\n\nh6, .h6 {\n font-size: 1rem;\n}\n\n.lead {\n font-size: 1.25rem;\n font-weight: 300;\n}\n\n.display-1 {\n font-size: 6rem;\n font-weight: 300;\n line-height: 1.2;\n}\n\n.display-2 {\n font-size: 5.5rem;\n font-weight: 300;\n line-height: 1.2;\n}\n\n.display-3 {\n font-size: 4.5rem;\n font-weight: 300;\n line-height: 1.2;\n}\n\n.display-4 {\n font-size: 3.5rem;\n font-weight: 300;\n line-height: 1.2;\n}\n\nhr {\n margin-top: 1rem;\n margin-bottom: 1rem;\n border: 0;\n border-top: 1px solid rgba(0, 0, 0, 0.1);\n}\n\nsmall,\n.small {\n font-size: 80%;\n font-weight: 400;\n}\n\nmark,\n.mark {\n padding: 0.2em;\n background-color: #fcf8e3;\n}\n\n.list-unstyled {\n padding-left: 0;\n list-style: none;\n}\n\n.list-inline {\n padding-left: 0;\n list-style: none;\n}\n\n.list-inline-item {\n display: inline-block;\n}\n\n.list-inline-item:not(:last-child) {\n margin-right: 0.5rem;\n}\n\n.initialism {\n font-size: 90%;\n text-transform: uppercase;\n}\n\n.blockquote {\n margin-bottom: 1rem;\n font-size: 1.25rem;\n}\n\n.blockquote-footer {\n display: block;\n font-size: 80%;\n color: #6c757d;\n}\n\n.blockquote-footer::before {\n content: \"\\2014 \\00A0\";\n}\n\n.img-fluid {\n max-width: 100%;\n height: auto;\n}\n\n.img-thumbnail {\n padding: 0.25rem;\n background-color: #fff;\n border: 1px solid #dee2e6;\n border-radius: 0.25rem;\n max-width: 100%;\n height: auto;\n}\n\n.figure {\n display: inline-block;\n}\n\n.figure-img {\n margin-bottom: 0.5rem;\n line-height: 1;\n}\n\n.figure-caption {\n font-size: 90%;\n color: #6c757d;\n}\n\ncode {\n font-size: 87.5%;\n color: #e83e8c;\n word-break: break-word;\n}\n\na > code {\n color: inherit;\n}\n\nkbd {\n padding: 0.2rem 0.4rem;\n font-size: 87.5%;\n color: #fff;\n background-color: #212529;\n border-radius: 0.2rem;\n}\n\nkbd kbd {\n padding: 0;\n font-size: 100%;\n font-weight: 700;\n}\n\npre {\n display: block;\n font-size: 87.5%;\n color: #212529;\n}\n\npre code {\n font-size: inherit;\n color: inherit;\n word-break: normal;\n}\n\n.pre-scrollable {\n max-height: 340px;\n overflow-y: scroll;\n}\n\n.container {\n width: 100%;\n padding-right: 15px;\n padding-left: 15px;\n margin-right: auto;\n margin-left: auto;\n}\n\n@media (min-width: 576px) {\n .container {\n max-width: 540px;\n }\n}\n\n@media (min-width: 768px) {\n .container {\n max-width: 720px;\n }\n}\n\n@media (min-width: 992px) {\n .container {\n max-width: 960px;\n }\n}\n\n@media (min-width: 1200px) {\n .container {\n max-width: 1140px;\n }\n}\n\n.container-fluid {\n width: 100%;\n padding-right: 15px;\n padding-left: 15px;\n margin-right: auto;\n margin-left: auto;\n}\n\n.row {\n display: flex;\n flex-wrap: wrap;\n margin-right: -15px;\n margin-left: -15px;\n}\n\n.no-gutters {\n margin-right: 0;\n margin-left: 0;\n}\n\n.no-gutters > .col,\n.no-gutters > [class*=\"col-\"] {\n padding-right: 0;\n padding-left: 0;\n}\n\n.col-1, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-10, .col-11, .col-12, .col,\n.col-auto, .col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12, .col-sm,\n.col-sm-auto, .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12, .col-md,\n.col-md-auto, .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12, .col-lg,\n.col-lg-auto, .col-xl-1, .col-xl-2, .col-xl-3, .col-xl-4, .col-xl-5, .col-xl-6, .col-xl-7, .col-xl-8, .col-xl-9, .col-xl-10, .col-xl-11, .col-xl-12, .col-xl,\n.col-xl-auto {\n position: relative;\n width: 100%;\n min-height: 1px;\n padding-right: 15px;\n padding-left: 15px;\n}\n\n.col {\n flex-basis: 0;\n flex-grow: 1;\n max-width: 100%;\n}\n\n.col-auto {\n flex: 0 0 auto;\n width: auto;\n max-width: none;\n}\n\n.col-1 {\n flex: 0 0 8.333333%;\n max-width: 8.333333%;\n}\n\n.col-2 {\n flex: 0 0 16.666667%;\n max-width: 16.666667%;\n}\n\n.col-3 {\n flex: 0 0 25%;\n max-width: 25%;\n}\n\n.col-4 {\n flex: 0 0 33.333333%;\n max-width: 33.333333%;\n}\n\n.col-5 {\n flex: 0 0 41.666667%;\n max-width: 41.666667%;\n}\n\n.col-6 {\n flex: 0 0 50%;\n max-width: 50%;\n}\n\n.col-7 {\n flex: 0 0 58.333333%;\n max-width: 58.333333%;\n}\n\n.col-8 {\n flex: 0 0 66.666667%;\n max-width: 66.666667%;\n}\n\n.col-9 {\n flex: 0 0 75%;\n max-width: 75%;\n}\n\n.col-10 {\n flex: 0 0 83.333333%;\n max-width: 83.333333%;\n}\n\n.col-11 {\n flex: 0 0 91.666667%;\n max-width: 91.666667%;\n}\n\n.col-12 {\n flex: 0 0 100%;\n max-width: 100%;\n}\n\n.order-first {\n order: -1;\n}\n\n.order-last {\n order: 13;\n}\n\n.order-0 {\n order: 0;\n}\n\n.order-1 {\n order: 1;\n}\n\n.order-2 {\n order: 2;\n}\n\n.order-3 {\n order: 3;\n}\n\n.order-4 {\n order: 4;\n}\n\n.order-5 {\n order: 5;\n}\n\n.order-6 {\n order: 6;\n}\n\n.order-7 {\n order: 7;\n}\n\n.order-8 {\n order: 8;\n}\n\n.order-9 {\n order: 9;\n}\n\n.order-10 {\n order: 10;\n}\n\n.order-11 {\n order: 11;\n}\n\n.order-12 {\n order: 12;\n}\n\n.offset-1 {\n margin-left: 8.333333%;\n}\n\n.offset-2 {\n margin-left: 16.666667%;\n}\n\n.offset-3 {\n margin-left: 25%;\n}\n\n.offset-4 {\n margin-left: 33.333333%;\n}\n\n.offset-5 {\n margin-left: 41.666667%;\n}\n\n.offset-6 {\n margin-left: 50%;\n}\n\n.offset-7 {\n margin-left: 58.333333%;\n}\n\n.offset-8 {\n margin-left: 66.666667%;\n}\n\n.offset-9 {\n margin-left: 75%;\n}\n\n.offset-10 {\n margin-left: 83.333333%;\n}\n\n.offset-11 {\n margin-left: 91.666667%;\n}\n\n@media (min-width: 576px) {\n .col-sm {\n flex-basis: 0;\n flex-grow: 1;\n max-width: 100%;\n }\n .col-sm-auto {\n flex: 0 0 auto;\n width: auto;\n max-width: none;\n }\n .col-sm-1 {\n flex: 0 0 8.333333%;\n max-width: 8.333333%;\n }\n .col-sm-2 {\n flex: 0 0 16.666667%;\n max-width: 16.666667%;\n }\n .col-sm-3 {\n flex: 0 0 25%;\n max-width: 25%;\n }\n .col-sm-4 {\n flex: 0 0 33.333333%;\n max-width: 33.333333%;\n }\n .col-sm-5 {\n flex: 0 0 41.666667%;\n max-width: 41.666667%;\n }\n .col-sm-6 {\n flex: 0 0 50%;\n max-width: 50%;\n }\n .col-sm-7 {\n flex: 0 0 58.333333%;\n max-width: 58.333333%;\n }\n .col-sm-8 {\n flex: 0 0 66.666667%;\n max-width: 66.666667%;\n }\n .col-sm-9 {\n flex: 0 0 75%;\n max-width: 75%;\n }\n .col-sm-10 {\n flex: 0 0 83.333333%;\n max-width: 83.333333%;\n }\n .col-sm-11 {\n flex: 0 0 91.666667%;\n max-width: 91.666667%;\n }\n .col-sm-12 {\n flex: 0 0 100%;\n max-width: 100%;\n }\n .order-sm-first {\n order: -1;\n }\n .order-sm-last {\n order: 13;\n }\n .order-sm-0 {\n order: 0;\n }\n .order-sm-1 {\n order: 1;\n }\n .order-sm-2 {\n order: 2;\n }\n .order-sm-3 {\n order: 3;\n }\n .order-sm-4 {\n order: 4;\n }\n .order-sm-5 {\n order: 5;\n }\n .order-sm-6 {\n order: 6;\n }\n .order-sm-7 {\n order: 7;\n }\n .order-sm-8 {\n order: 8;\n }\n .order-sm-9 {\n order: 9;\n }\n .order-sm-10 {\n order: 10;\n }\n .order-sm-11 {\n order: 11;\n }\n .order-sm-12 {\n order: 12;\n }\n .offset-sm-0 {\n margin-left: 0;\n }\n .offset-sm-1 {\n margin-left: 8.333333%;\n }\n .offset-sm-2 {\n margin-left: 16.666667%;\n }\n .offset-sm-3 {\n margin-left: 25%;\n }\n .offset-sm-4 {\n margin-left: 33.333333%;\n }\n .offset-sm-5 {\n margin-left: 41.666667%;\n }\n .offset-sm-6 {\n margin-left: 50%;\n }\n .offset-sm-7 {\n margin-left: 58.333333%;\n }\n .offset-sm-8 {\n margin-left: 66.666667%;\n }\n .offset-sm-9 {\n margin-left: 75%;\n }\n .offset-sm-10 {\n margin-left: 83.333333%;\n }\n .offset-sm-11 {\n margin-left: 91.666667%;\n }\n}\n\n@media (min-width: 768px) {\n .col-md {\n flex-basis: 0;\n flex-grow: 1;\n max-width: 100%;\n }\n .col-md-auto {\n flex: 0 0 auto;\n width: auto;\n max-width: none;\n }\n .col-md-1 {\n flex: 0 0 8.333333%;\n max-width: 8.333333%;\n }\n .col-md-2 {\n flex: 0 0 16.666667%;\n max-width: 16.666667%;\n }\n .col-md-3 {\n flex: 0 0 25%;\n max-width: 25%;\n }\n .col-md-4 {\n flex: 0 0 33.333333%;\n max-width: 33.333333%;\n }\n .col-md-5 {\n flex: 0 0 41.666667%;\n max-width: 41.666667%;\n }\n .col-md-6 {\n flex: 0 0 50%;\n max-width: 50%;\n }\n .col-md-7 {\n flex: 0 0 58.333333%;\n max-width: 58.333333%;\n }\n .col-md-8 {\n flex: 0 0 66.666667%;\n max-width: 66.666667%;\n }\n .col-md-9 {\n flex: 0 0 75%;\n max-width: 75%;\n }\n .col-md-10 {\n flex: 0 0 83.333333%;\n max-width: 83.333333%;\n }\n .col-md-11 {\n flex: 0 0 91.666667%;\n max-width: 91.666667%;\n }\n .col-md-12 {\n flex: 0 0 100%;\n max-width: 100%;\n }\n .order-md-first {\n order: -1;\n }\n .order-md-last {\n order: 13;\n }\n .order-md-0 {\n order: 0;\n }\n .order-md-1 {\n order: 1;\n }\n .order-md-2 {\n order: 2;\n }\n .order-md-3 {\n order: 3;\n }\n .order-md-4 {\n order: 4;\n }\n .order-md-5 {\n order: 5;\n }\n .order-md-6 {\n order: 6;\n }\n .order-md-7 {\n order: 7;\n }\n .order-md-8 {\n order: 8;\n }\n .order-md-9 {\n order: 9;\n }\n .order-md-10 {\n order: 10;\n }\n .order-md-11 {\n order: 11;\n }\n .order-md-12 {\n order: 12;\n }\n .offset-md-0 {\n margin-left: 0;\n }\n .offset-md-1 {\n margin-left: 8.333333%;\n }\n .offset-md-2 {\n margin-left: 16.666667%;\n }\n .offset-md-3 {\n margin-left: 25%;\n }\n .offset-md-4 {\n margin-left: 33.333333%;\n }\n .offset-md-5 {\n margin-left: 41.666667%;\n }\n .offset-md-6 {\n margin-left: 50%;\n }\n .offset-md-7 {\n margin-left: 58.333333%;\n }\n .offset-md-8 {\n margin-left: 66.666667%;\n }\n .offset-md-9 {\n margin-left: 75%;\n }\n .offset-md-10 {\n margin-left: 83.333333%;\n }\n .offset-md-11 {\n margin-left: 91.666667%;\n }\n}\n\n@media (min-width: 992px) {\n .col-lg {\n flex-basis: 0;\n flex-grow: 1;\n max-width: 100%;\n }\n .col-lg-auto {\n flex: 0 0 auto;\n width: auto;\n max-width: none;\n }\n .col-lg-1 {\n flex: 0 0 8.333333%;\n max-width: 8.333333%;\n }\n .col-lg-2 {\n flex: 0 0 16.666667%;\n max-width: 16.666667%;\n }\n .col-lg-3 {\n flex: 0 0 25%;\n max-width: 25%;\n }\n .col-lg-4 {\n flex: 0 0 33.333333%;\n max-width: 33.333333%;\n }\n .col-lg-5 {\n flex: 0 0 41.666667%;\n max-width: 41.666667%;\n }\n .col-lg-6 {\n flex: 0 0 50%;\n max-width: 50%;\n }\n .col-lg-7 {\n flex: 0 0 58.333333%;\n max-width: 58.333333%;\n }\n .col-lg-8 {\n flex: 0 0 66.666667%;\n max-width: 66.666667%;\n }\n .col-lg-9 {\n flex: 0 0 75%;\n max-width: 75%;\n }\n .col-lg-10 {\n flex: 0 0 83.333333%;\n max-width: 83.333333%;\n }\n .col-lg-11 {\n flex: 0 0 91.666667%;\n max-width: 91.666667%;\n }\n .col-lg-12 {\n flex: 0 0 100%;\n max-width: 100%;\n }\n .order-lg-first {\n order: -1;\n }\n .order-lg-last {\n order: 13;\n }\n .order-lg-0 {\n order: 0;\n }\n .order-lg-1 {\n order: 1;\n }\n .order-lg-2 {\n order: 2;\n }\n .order-lg-3 {\n order: 3;\n }\n .order-lg-4 {\n order: 4;\n }\n .order-lg-5 {\n order: 5;\n }\n .order-lg-6 {\n order: 6;\n }\n .order-lg-7 {\n order: 7;\n }\n .order-lg-8 {\n order: 8;\n }\n .order-lg-9 {\n order: 9;\n }\n .order-lg-10 {\n order: 10;\n }\n .order-lg-11 {\n order: 11;\n }\n .order-lg-12 {\n order: 12;\n }\n .offset-lg-0 {\n margin-left: 0;\n }\n .offset-lg-1 {\n margin-left: 8.333333%;\n }\n .offset-lg-2 {\n margin-left: 16.666667%;\n }\n .offset-lg-3 {\n margin-left: 25%;\n }\n .offset-lg-4 {\n margin-left: 33.333333%;\n }\n .offset-lg-5 {\n margin-left: 41.666667%;\n }\n .offset-lg-6 {\n margin-left: 50%;\n }\n .offset-lg-7 {\n margin-left: 58.333333%;\n }\n .offset-lg-8 {\n margin-left: 66.666667%;\n }\n .offset-lg-9 {\n margin-left: 75%;\n }\n .offset-lg-10 {\n margin-left: 83.333333%;\n }\n .offset-lg-11 {\n margin-left: 91.666667%;\n }\n}\n\n@media (min-width: 1200px) {\n .col-xl {\n flex-basis: 0;\n flex-grow: 1;\n max-width: 100%;\n }\n .col-xl-auto {\n flex: 0 0 auto;\n width: auto;\n max-width: none;\n }\n .col-xl-1 {\n flex: 0 0 8.333333%;\n max-width: 8.333333%;\n }\n .col-xl-2 {\n flex: 0 0 16.666667%;\n max-width: 16.666667%;\n }\n .col-xl-3 {\n flex: 0 0 25%;\n max-width: 25%;\n }\n .col-xl-4 {\n flex: 0 0 33.333333%;\n max-width: 33.333333%;\n }\n .col-xl-5 {\n flex: 0 0 41.666667%;\n max-width: 41.666667%;\n }\n .col-xl-6 {\n flex: 0 0 50%;\n max-width: 50%;\n }\n .col-xl-7 {\n flex: 0 0 58.333333%;\n max-width: 58.333333%;\n }\n .col-xl-8 {\n flex: 0 0 66.666667%;\n max-width: 66.666667%;\n }\n .col-xl-9 {\n flex: 0 0 75%;\n max-width: 75%;\n }\n .col-xl-10 {\n flex: 0 0 83.333333%;\n max-width: 83.333333%;\n }\n .col-xl-11 {\n flex: 0 0 91.666667%;\n max-width: 91.666667%;\n }\n .col-xl-12 {\n flex: 0 0 100%;\n max-width: 100%;\n }\n .order-xl-first {\n order: -1;\n }\n .order-xl-last {\n order: 13;\n }\n .order-xl-0 {\n order: 0;\n }\n .order-xl-1 {\n order: 1;\n }\n .order-xl-2 {\n order: 2;\n }\n .order-xl-3 {\n order: 3;\n }\n .order-xl-4 {\n order: 4;\n }\n .order-xl-5 {\n order: 5;\n }\n .order-xl-6 {\n order: 6;\n }\n .order-xl-7 {\n order: 7;\n }\n .order-xl-8 {\n order: 8;\n }\n .order-xl-9 {\n order: 9;\n }\n .order-xl-10 {\n order: 10;\n }\n .order-xl-11 {\n order: 11;\n }\n .order-xl-12 {\n order: 12;\n }\n .offset-xl-0 {\n margin-left: 0;\n }\n .offset-xl-1 {\n margin-left: 8.333333%;\n }\n .offset-xl-2 {\n margin-left: 16.666667%;\n }\n .offset-xl-3 {\n margin-left: 25%;\n }\n .offset-xl-4 {\n margin-left: 33.333333%;\n }\n .offset-xl-5 {\n margin-left: 41.666667%;\n }\n .offset-xl-6 {\n margin-left: 50%;\n }\n .offset-xl-7 {\n margin-left: 58.333333%;\n }\n .offset-xl-8 {\n margin-left: 66.666667%;\n }\n .offset-xl-9 {\n margin-left: 75%;\n }\n .offset-xl-10 {\n margin-left: 83.333333%;\n }\n .offset-xl-11 {\n margin-left: 91.666667%;\n }\n}\n\n.table {\n width: 100%;\n max-width: 100%;\n margin-bottom: 1rem;\n background-color: transparent;\n}\n\n.table th,\n.table td {\n padding: 0.75rem;\n vertical-align: top;\n border-top: 1px solid #dee2e6;\n}\n\n.table thead th {\n vertical-align: bottom;\n border-bottom: 2px solid #dee2e6;\n}\n\n.table tbody + tbody {\n border-top: 2px solid #dee2e6;\n}\n\n.table .table {\n background-color: #fff;\n}\n\n.table-sm th,\n.table-sm td {\n padding: 0.3rem;\n}\n\n.table-bordered {\n border: 1px solid #dee2e6;\n}\n\n.table-bordered th,\n.table-bordered td {\n border: 1px solid #dee2e6;\n}\n\n.table-bordered thead th,\n.table-bordered thead td {\n border-bottom-width: 2px;\n}\n\n.table-borderless th,\n.table-borderless td,\n.table-borderless thead th,\n.table-borderless tbody + tbody {\n border: 0;\n}\n\n.table-striped tbody tr:nth-of-type(odd) {\n background-color: rgba(0, 0, 0, 0.05);\n}\n\n.table-hover tbody tr:hover {\n background-color: rgba(0, 0, 0, 0.075);\n}\n\n.table-primary,\n.table-primary > th,\n.table-primary > td {\n background-color: #b8daff;\n}\n\n.table-hover .table-primary:hover {\n background-color: #9fcdff;\n}\n\n.table-hover .table-primary:hover > td,\n.table-hover .table-primary:hover > th {\n background-color: #9fcdff;\n}\n\n.table-secondary,\n.table-secondary > th,\n.table-secondary > td {\n background-color: #d6d8db;\n}\n\n.table-hover .table-secondary:hover {\n background-color: #c8cbcf;\n}\n\n.table-hover .table-secondary:hover > td,\n.table-hover .table-secondary:hover > th {\n background-color: #c8cbcf;\n}\n\n.table-success,\n.table-success > th,\n.table-success > td {\n background-color: #c3e6cb;\n}\n\n.table-hover .table-success:hover {\n background-color: #b1dfbb;\n}\n\n.table-hover .table-success:hover > td,\n.table-hover .table-success:hover > th {\n background-color: #b1dfbb;\n}\n\n.table-info,\n.table-info > th,\n.table-info > td {\n background-color: #bee5eb;\n}\n\n.table-hover .table-info:hover {\n background-color: #abdde5;\n}\n\n.table-hover .table-info:hover > td,\n.table-hover .table-info:hover > th {\n background-color: #abdde5;\n}\n\n.table-warning,\n.table-warning > th,\n.table-warning > td {\n background-color: #ffeeba;\n}\n\n.table-hover .table-warning:hover {\n background-color: #ffe8a1;\n}\n\n.table-hover .table-warning:hover > td,\n.table-hover .table-warning:hover > th {\n background-color: #ffe8a1;\n}\n\n.table-danger,\n.table-danger > th,\n.table-danger > td {\n background-color: #f5c6cb;\n}\n\n.table-hover .table-danger:hover {\n background-color: #f1b0b7;\n}\n\n.table-hover .table-danger:hover > td,\n.table-hover .table-danger:hover > th {\n background-color: #f1b0b7;\n}\n\n.table-light,\n.table-light > th,\n.table-light > td {\n background-color: #fdfdfe;\n}\n\n.table-hover .table-light:hover {\n background-color: #ececf6;\n}\n\n.table-hover .table-light:hover > td,\n.table-hover .table-light:hover > th {\n background-color: #ececf6;\n}\n\n.table-dark,\n.table-dark > th,\n.table-dark > td {\n background-color: #c6c8ca;\n}\n\n.table-hover .table-dark:hover {\n background-color: #b9bbbe;\n}\n\n.table-hover .table-dark:hover > td,\n.table-hover .table-dark:hover > th {\n background-color: #b9bbbe;\n}\n\n.table-active,\n.table-active > th,\n.table-active > td {\n background-color: rgba(0, 0, 0, 0.075);\n}\n\n.table-hover .table-active:hover {\n background-color: rgba(0, 0, 0, 0.075);\n}\n\n.table-hover .table-active:hover > td,\n.table-hover .table-active:hover > th {\n background-color: rgba(0, 0, 0, 0.075);\n}\n\n.table .thead-dark th {\n color: #fff;\n background-color: #212529;\n border-color: #32383e;\n}\n\n.table .thead-light th {\n color: #495057;\n background-color: #e9ecef;\n border-color: #dee2e6;\n}\n\n.table-dark {\n color: #fff;\n background-color: #212529;\n}\n\n.table-dark th,\n.table-dark td,\n.table-dark thead th {\n border-color: #32383e;\n}\n\n.table-dark.table-bordered {\n border: 0;\n}\n\n.table-dark.table-striped tbody tr:nth-of-type(odd) {\n background-color: rgba(255, 255, 255, 0.05);\n}\n\n.table-dark.table-hover tbody tr:hover {\n background-color: rgba(255, 255, 255, 0.075);\n}\n\n@media (max-width: 575.98px) {\n .table-responsive-sm {\n display: block;\n width: 100%;\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n }\n .table-responsive-sm > .table-bordered {\n border: 0;\n }\n}\n\n@media (max-width: 767.98px) {\n .table-responsive-md {\n display: block;\n width: 100%;\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n }\n .table-responsive-md > .table-bordered {\n border: 0;\n }\n}\n\n@media (max-width: 991.98px) {\n .table-responsive-lg {\n display: block;\n width: 100%;\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n }\n .table-responsive-lg > .table-bordered {\n border: 0;\n }\n}\n\n@media (max-width: 1199.98px) {\n .table-responsive-xl {\n display: block;\n width: 100%;\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n }\n .table-responsive-xl > .table-bordered {\n border: 0;\n }\n}\n\n.table-responsive {\n display: block;\n width: 100%;\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n}\n\n.table-responsive > .table-bordered {\n border: 0;\n}\n\n.form-control {\n display: block;\n width: 100%;\n padding: 0.375rem 0.75rem;\n font-size: 1rem;\n line-height: 1.5;\n color: #495057;\n background-color: #fff;\n background-clip: padding-box;\n border: 1px solid #ced4da;\n border-radius: 0.25rem;\n transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n\n@media screen and (prefers-reduced-motion: reduce) {\n .form-control {\n transition: none;\n }\n}\n\n.form-control::-ms-expand {\n background-color: transparent;\n border: 0;\n}\n\n.form-control:focus {\n color: #495057;\n background-color: #fff;\n border-color: #80bdff;\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.form-control::placeholder {\n color: #6c757d;\n opacity: 1;\n}\n\n.form-control:disabled, .form-control[readonly] {\n background-color: #e9ecef;\n opacity: 1;\n}\n\nselect.form-control:not([size]):not([multiple]) {\n height: calc(2.25rem + 2px);\n}\n\nselect.form-control:focus::-ms-value {\n color: #495057;\n background-color: #fff;\n}\n\n.form-control-file,\n.form-control-range {\n display: block;\n width: 100%;\n}\n\n.col-form-label {\n padding-top: calc(0.375rem + 1px);\n padding-bottom: calc(0.375rem + 1px);\n margin-bottom: 0;\n font-size: inherit;\n line-height: 1.5;\n}\n\n.col-form-label-lg {\n padding-top: calc(0.5rem + 1px);\n padding-bottom: calc(0.5rem + 1px);\n font-size: 1.25rem;\n line-height: 1.5;\n}\n\n.col-form-label-sm {\n padding-top: calc(0.25rem + 1px);\n padding-bottom: calc(0.25rem + 1px);\n font-size: 0.875rem;\n line-height: 1.5;\n}\n\n.form-control-plaintext {\n display: block;\n width: 100%;\n padding-top: 0.375rem;\n padding-bottom: 0.375rem;\n margin-bottom: 0;\n line-height: 1.5;\n color: #212529;\n background-color: transparent;\n border: solid transparent;\n border-width: 1px 0;\n}\n\n.form-control-plaintext.form-control-sm, .input-group-sm > .form-control-plaintext.form-control,\n.input-group-sm > .input-group-prepend > .form-control-plaintext.input-group-text,\n.input-group-sm > .input-group-append > .form-control-plaintext.input-group-text,\n.input-group-sm > .input-group-prepend > .form-control-plaintext.btn,\n.input-group-sm > .input-group-append > .form-control-plaintext.btn, .form-control-plaintext.form-control-lg, .input-group-lg > .form-control-plaintext.form-control,\n.input-group-lg > .input-group-prepend > .form-control-plaintext.input-group-text,\n.input-group-lg > .input-group-append > .form-control-plaintext.input-group-text,\n.input-group-lg > .input-group-prepend > .form-control-plaintext.btn,\n.input-group-lg > .input-group-append > .form-control-plaintext.btn {\n padding-right: 0;\n padding-left: 0;\n}\n\n.form-control-sm, .input-group-sm > .form-control,\n.input-group-sm > .input-group-prepend > .input-group-text,\n.input-group-sm > .input-group-append > .input-group-text,\n.input-group-sm > .input-group-prepend > .btn,\n.input-group-sm > .input-group-append > .btn {\n padding: 0.25rem 0.5rem;\n font-size: 0.875rem;\n line-height: 1.5;\n border-radius: 0.2rem;\n}\n\nselect.form-control-sm:not([size]):not([multiple]), .input-group-sm > select.form-control:not([size]):not([multiple]),\n.input-group-sm > .input-group-prepend > select.input-group-text:not([size]):not([multiple]),\n.input-group-sm > .input-group-append > select.input-group-text:not([size]):not([multiple]),\n.input-group-sm > .input-group-prepend > select.btn:not([size]):not([multiple]),\n.input-group-sm > .input-group-append > select.btn:not([size]):not([multiple]) {\n height: calc(1.8125rem + 2px);\n}\n\n.form-control-lg, .input-group-lg > .form-control,\n.input-group-lg > .input-group-prepend > .input-group-text,\n.input-group-lg > .input-group-append > .input-group-text,\n.input-group-lg > .input-group-prepend > .btn,\n.input-group-lg > .input-group-append > .btn {\n padding: 0.5rem 1rem;\n font-size: 1.25rem;\n line-height: 1.5;\n border-radius: 0.3rem;\n}\n\nselect.form-control-lg:not([size]):not([multiple]), .input-group-lg > select.form-control:not([size]):not([multiple]),\n.input-group-lg > .input-group-prepend > select.input-group-text:not([size]):not([multiple]),\n.input-group-lg > .input-group-append > select.input-group-text:not([size]):not([multiple]),\n.input-group-lg > .input-group-prepend > select.btn:not([size]):not([multiple]),\n.input-group-lg > .input-group-append > select.btn:not([size]):not([multiple]) {\n height: calc(2.875rem + 2px);\n}\n\n.form-group {\n margin-bottom: 1rem;\n}\n\n.form-text {\n display: block;\n margin-top: 0.25rem;\n}\n\n.form-row {\n display: flex;\n flex-wrap: wrap;\n margin-right: -5px;\n margin-left: -5px;\n}\n\n.form-row > .col,\n.form-row > [class*=\"col-\"] {\n padding-right: 5px;\n padding-left: 5px;\n}\n\n.form-check {\n position: relative;\n display: block;\n padding-left: 1.25rem;\n}\n\n.form-check-input {\n position: absolute;\n margin-top: 0.3rem;\n margin-left: -1.25rem;\n}\n\n.form-check-input:disabled ~ .form-check-label {\n color: #6c757d;\n}\n\n.form-check-label {\n margin-bottom: 0;\n}\n\n.form-check-inline {\n display: inline-flex;\n align-items: center;\n padding-left: 0;\n margin-right: 0.75rem;\n}\n\n.form-check-inline .form-check-input {\n position: static;\n margin-top: 0;\n margin-right: 0.3125rem;\n margin-left: 0;\n}\n\n.valid-feedback {\n display: none;\n width: 100%;\n margin-top: 0.25rem;\n font-size: 80%;\n color: #28a745;\n}\n\n.valid-tooltip {\n position: absolute;\n top: 100%;\n z-index: 5;\n display: none;\n max-width: 100%;\n padding: .5rem;\n margin-top: .1rem;\n font-size: .875rem;\n line-height: 1;\n color: #fff;\n background-color: rgba(40, 167, 69, 0.8);\n border-radius: .2rem;\n}\n\n.was-validated .form-control:valid, .form-control.is-valid, .was-validated\n.custom-select:valid,\n.custom-select.is-valid {\n border-color: #28a745;\n}\n\n.was-validated .form-control:valid:focus, .form-control.is-valid:focus, .was-validated\n.custom-select:valid:focus,\n.custom-select.is-valid:focus {\n border-color: #28a745;\n box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25);\n}\n\n.was-validated .form-control:valid ~ .valid-feedback,\n.was-validated .form-control:valid ~ .valid-tooltip, .form-control.is-valid ~ .valid-feedback,\n.form-control.is-valid ~ .valid-tooltip, .was-validated\n.custom-select:valid ~ .valid-feedback,\n.was-validated\n.custom-select:valid ~ .valid-tooltip,\n.custom-select.is-valid ~ .valid-feedback,\n.custom-select.is-valid ~ .valid-tooltip {\n display: block;\n}\n\n.was-validated .form-control-file:valid ~ .valid-feedback,\n.was-validated .form-control-file:valid ~ .valid-tooltip, .form-control-file.is-valid ~ .valid-feedback,\n.form-control-file.is-valid ~ .valid-tooltip {\n display: block;\n}\n\n.was-validated .form-check-input:valid ~ .form-check-label, .form-check-input.is-valid ~ .form-check-label {\n color: #28a745;\n}\n\n.was-validated .form-check-input:valid ~ .valid-feedback,\n.was-validated .form-check-input:valid ~ .valid-tooltip, .form-check-input.is-valid ~ .valid-feedback,\n.form-check-input.is-valid ~ .valid-tooltip {\n display: block;\n}\n\n.was-validated .custom-control-input:valid ~ .custom-control-label, .custom-control-input.is-valid ~ .custom-control-label {\n color: #28a745;\n}\n\n.was-validated .custom-control-input:valid ~ .custom-control-label::before, .custom-control-input.is-valid ~ .custom-control-label::before {\n background-color: #71dd8a;\n}\n\n.was-validated .custom-control-input:valid ~ .valid-feedback,\n.was-validated .custom-control-input:valid ~ .valid-tooltip, .custom-control-input.is-valid ~ .valid-feedback,\n.custom-control-input.is-valid ~ .valid-tooltip {\n display: block;\n}\n\n.was-validated .custom-control-input:valid:checked ~ .custom-control-label::before, .custom-control-input.is-valid:checked ~ .custom-control-label::before {\n background-color: #34ce57;\n}\n\n.was-validated .custom-control-input:valid:focus ~ .custom-control-label::before, .custom-control-input.is-valid:focus ~ .custom-control-label::before {\n box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(40, 167, 69, 0.25);\n}\n\n.was-validated .custom-file-input:valid ~ .custom-file-label, .custom-file-input.is-valid ~ .custom-file-label {\n border-color: #28a745;\n}\n\n.was-validated .custom-file-input:valid ~ .custom-file-label::before, .custom-file-input.is-valid ~ .custom-file-label::before {\n border-color: inherit;\n}\n\n.was-validated .custom-file-input:valid ~ .valid-feedback,\n.was-validated .custom-file-input:valid ~ .valid-tooltip, .custom-file-input.is-valid ~ .valid-feedback,\n.custom-file-input.is-valid ~ .valid-tooltip {\n display: block;\n}\n\n.was-validated .custom-file-input:valid:focus ~ .custom-file-label, .custom-file-input.is-valid:focus ~ .custom-file-label {\n box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25);\n}\n\n.invalid-feedback {\n display: none;\n width: 100%;\n margin-top: 0.25rem;\n font-size: 80%;\n color: #dc3545;\n}\n\n.invalid-tooltip {\n position: absolute;\n top: 100%;\n z-index: 5;\n display: none;\n max-width: 100%;\n padding: .5rem;\n margin-top: .1rem;\n font-size: .875rem;\n line-height: 1;\n color: #fff;\n background-color: rgba(220, 53, 69, 0.8);\n border-radius: .2rem;\n}\n\n.was-validated .form-control:invalid, .form-control.is-invalid, .was-validated\n.custom-select:invalid,\n.custom-select.is-invalid {\n border-color: #dc3545;\n}\n\n.was-validated .form-control:invalid:focus, .form-control.is-invalid:focus, .was-validated\n.custom-select:invalid:focus,\n.custom-select.is-invalid:focus {\n border-color: #dc3545;\n box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);\n}\n\n.was-validated .form-control:invalid ~ .invalid-feedback,\n.was-validated .form-control:invalid ~ .invalid-tooltip, .form-control.is-invalid ~ .invalid-feedback,\n.form-control.is-invalid ~ .invalid-tooltip, .was-validated\n.custom-select:invalid ~ .invalid-feedback,\n.was-validated\n.custom-select:invalid ~ .invalid-tooltip,\n.custom-select.is-invalid ~ .invalid-feedback,\n.custom-select.is-invalid ~ .invalid-tooltip {\n display: block;\n}\n\n.was-validated .form-control-file:invalid ~ .invalid-feedback,\n.was-validated .form-control-file:invalid ~ .invalid-tooltip, .form-control-file.is-invalid ~ .invalid-feedback,\n.form-control-file.is-invalid ~ .invalid-tooltip {\n display: block;\n}\n\n.was-validated .form-check-input:invalid ~ .form-check-label, .form-check-input.is-invalid ~ .form-check-label {\n color: #dc3545;\n}\n\n.was-validated .form-check-input:invalid ~ .invalid-feedback,\n.was-validated .form-check-input:invalid ~ .invalid-tooltip, .form-check-input.is-invalid ~ .invalid-feedback,\n.form-check-input.is-invalid ~ .invalid-tooltip {\n display: block;\n}\n\n.was-validated .custom-control-input:invalid ~ .custom-control-label, .custom-control-input.is-invalid ~ .custom-control-label {\n color: #dc3545;\n}\n\n.was-validated .custom-control-input:invalid ~ .custom-control-label::before, .custom-control-input.is-invalid ~ .custom-control-label::before {\n background-color: #efa2a9;\n}\n\n.was-validated .custom-control-input:invalid ~ .invalid-feedback,\n.was-validated .custom-control-input:invalid ~ .invalid-tooltip, .custom-control-input.is-invalid ~ .invalid-feedback,\n.custom-control-input.is-invalid ~ .invalid-tooltip {\n display: block;\n}\n\n.was-validated .custom-control-input:invalid:checked ~ .custom-control-label::before, .custom-control-input.is-invalid:checked ~ .custom-control-label::before {\n background-color: #e4606d;\n}\n\n.was-validated .custom-control-input:invalid:focus ~ .custom-control-label::before, .custom-control-input.is-invalid:focus ~ .custom-control-label::before {\n box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(220, 53, 69, 0.25);\n}\n\n.was-validated .custom-file-input:invalid ~ .custom-file-label, .custom-file-input.is-invalid ~ .custom-file-label {\n border-color: #dc3545;\n}\n\n.was-validated .custom-file-input:invalid ~ .custom-file-label::before, .custom-file-input.is-invalid ~ .custom-file-label::before {\n border-color: inherit;\n}\n\n.was-validated .custom-file-input:invalid ~ .invalid-feedback,\n.was-validated .custom-file-input:invalid ~ .invalid-tooltip, .custom-file-input.is-invalid ~ .invalid-feedback,\n.custom-file-input.is-invalid ~ .invalid-tooltip {\n display: block;\n}\n\n.was-validated .custom-file-input:invalid:focus ~ .custom-file-label, .custom-file-input.is-invalid:focus ~ .custom-file-label {\n box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);\n}\n\n.form-inline {\n display: flex;\n flex-flow: row wrap;\n align-items: center;\n}\n\n.form-inline .form-check {\n width: 100%;\n}\n\n@media (min-width: 576px) {\n .form-inline label {\n display: flex;\n align-items: center;\n justify-content: center;\n margin-bottom: 0;\n }\n .form-inline .form-group {\n display: flex;\n flex: 0 0 auto;\n flex-flow: row wrap;\n align-items: center;\n margin-bottom: 0;\n }\n .form-inline .form-control {\n display: inline-block;\n width: auto;\n vertical-align: middle;\n }\n .form-inline .form-control-plaintext {\n display: inline-block;\n }\n .form-inline .input-group,\n .form-inline .custom-select {\n width: auto;\n }\n .form-inline .form-check {\n display: flex;\n align-items: center;\n justify-content: center;\n width: auto;\n padding-left: 0;\n }\n .form-inline .form-check-input {\n position: relative;\n margin-top: 0;\n margin-right: 0.25rem;\n margin-left: 0;\n }\n .form-inline .custom-control {\n align-items: center;\n justify-content: center;\n }\n .form-inline .custom-control-label {\n margin-bottom: 0;\n }\n}\n\n.btn {\n display: inline-block;\n font-weight: 400;\n text-align: center;\n white-space: nowrap;\n vertical-align: middle;\n user-select: none;\n border: 1px solid transparent;\n padding: 0.375rem 0.75rem;\n font-size: 1rem;\n line-height: 1.5;\n border-radius: 0.25rem;\n transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n\n@media screen and (prefers-reduced-motion: reduce) {\n .btn {\n transition: none;\n }\n}\n\n.btn:hover, .btn:focus {\n text-decoration: none;\n}\n\n.btn:focus, .btn.focus {\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.btn.disabled, .btn:disabled {\n opacity: 0.65;\n}\n\n.btn:not(:disabled):not(.disabled) {\n cursor: pointer;\n}\n\n.btn:not(:disabled):not(.disabled):active, .btn:not(:disabled):not(.disabled).active {\n background-image: none;\n}\n\na.btn.disabled,\nfieldset:disabled a.btn {\n pointer-events: none;\n}\n\n.btn-primary {\n color: #fff;\n background-color: #007bff;\n border-color: #007bff;\n}\n\n.btn-primary:hover {\n color: #fff;\n background-color: #0069d9;\n border-color: #0062cc;\n}\n\n.btn-primary:focus, .btn-primary.focus {\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.5);\n}\n\n.btn-primary.disabled, .btn-primary:disabled {\n color: #fff;\n background-color: #007bff;\n border-color: #007bff;\n}\n\n.btn-primary:not(:disabled):not(.disabled):active, .btn-primary:not(:disabled):not(.disabled).active,\n.show > .btn-primary.dropdown-toggle {\n color: #fff;\n background-color: #0062cc;\n border-color: #005cbf;\n}\n\n.btn-primary:not(:disabled):not(.disabled):active:focus, .btn-primary:not(:disabled):not(.disabled).active:focus,\n.show > .btn-primary.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.5);\n}\n\n.btn-secondary {\n color: #fff;\n background-color: #6c757d;\n border-color: #6c757d;\n}\n\n.btn-secondary:hover {\n color: #fff;\n background-color: #5a6268;\n border-color: #545b62;\n}\n\n.btn-secondary:focus, .btn-secondary.focus {\n box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5);\n}\n\n.btn-secondary.disabled, .btn-secondary:disabled {\n color: #fff;\n background-color: #6c757d;\n border-color: #6c757d;\n}\n\n.btn-secondary:not(:disabled):not(.disabled):active, .btn-secondary:not(:disabled):not(.disabled).active,\n.show > .btn-secondary.dropdown-toggle {\n color: #fff;\n background-color: #545b62;\n border-color: #4e555b;\n}\n\n.btn-secondary:not(:disabled):not(.disabled):active:focus, .btn-secondary:not(:disabled):not(.disabled).active:focus,\n.show > .btn-secondary.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5);\n}\n\n.btn-success {\n color: #fff;\n background-color: #28a745;\n border-color: #28a745;\n}\n\n.btn-success:hover {\n color: #fff;\n background-color: #218838;\n border-color: #1e7e34;\n}\n\n.btn-success:focus, .btn-success.focus {\n box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.5);\n}\n\n.btn-success.disabled, .btn-success:disabled {\n color: #fff;\n background-color: #28a745;\n border-color: #28a745;\n}\n\n.btn-success:not(:disabled):not(.disabled):active, .btn-success:not(:disabled):not(.disabled).active,\n.show > .btn-success.dropdown-toggle {\n color: #fff;\n background-color: #1e7e34;\n border-color: #1c7430;\n}\n\n.btn-success:not(:disabled):not(.disabled):active:focus, .btn-success:not(:disabled):not(.disabled).active:focus,\n.show > .btn-success.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.5);\n}\n\n.btn-info {\n color: #fff;\n background-color: #17a2b8;\n border-color: #17a2b8;\n}\n\n.btn-info:hover {\n color: #fff;\n background-color: #138496;\n border-color: #117a8b;\n}\n\n.btn-info:focus, .btn-info.focus {\n box-shadow: 0 0 0 0.2rem rgba(23, 162, 184, 0.5);\n}\n\n.btn-info.disabled, .btn-info:disabled {\n color: #fff;\n background-color: #17a2b8;\n border-color: #17a2b8;\n}\n\n.btn-info:not(:disabled):not(.disabled):active, .btn-info:not(:disabled):not(.disabled).active,\n.show > .btn-info.dropdown-toggle {\n color: #fff;\n background-color: #117a8b;\n border-color: #10707f;\n}\n\n.btn-info:not(:disabled):not(.disabled):active:focus, .btn-info:not(:disabled):not(.disabled).active:focus,\n.show > .btn-info.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(23, 162, 184, 0.5);\n}\n\n.btn-warning {\n color: #212529;\n background-color: #ffc107;\n border-color: #ffc107;\n}\n\n.btn-warning:hover {\n color: #212529;\n background-color: #e0a800;\n border-color: #d39e00;\n}\n\n.btn-warning:focus, .btn-warning.focus {\n box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.5);\n}\n\n.btn-warning.disabled, .btn-warning:disabled {\n color: #212529;\n background-color: #ffc107;\n border-color: #ffc107;\n}\n\n.btn-warning:not(:disabled):not(.disabled):active, .btn-warning:not(:disabled):not(.disabled).active,\n.show > .btn-warning.dropdown-toggle {\n color: #212529;\n background-color: #d39e00;\n border-color: #c69500;\n}\n\n.btn-warning:not(:disabled):not(.disabled):active:focus, .btn-warning:not(:disabled):not(.disabled).active:focus,\n.show > .btn-warning.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.5);\n}\n\n.btn-danger {\n color: #fff;\n background-color: #dc3545;\n border-color: #dc3545;\n}\n\n.btn-danger:hover {\n color: #fff;\n background-color: #c82333;\n border-color: #bd2130;\n}\n\n.btn-danger:focus, .btn-danger.focus {\n box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.5);\n}\n\n.btn-danger.disabled, .btn-danger:disabled {\n color: #fff;\n background-color: #dc3545;\n border-color: #dc3545;\n}\n\n.btn-danger:not(:disabled):not(.disabled):active, .btn-danger:not(:disabled):not(.disabled).active,\n.show > .btn-danger.dropdown-toggle {\n color: #fff;\n background-color: #bd2130;\n border-color: #b21f2d;\n}\n\n.btn-danger:not(:disabled):not(.disabled):active:focus, .btn-danger:not(:disabled):not(.disabled).active:focus,\n.show > .btn-danger.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.5);\n}\n\n.btn-light {\n color: #212529;\n background-color: #f8f9fa;\n border-color: #f8f9fa;\n}\n\n.btn-light:hover {\n color: #212529;\n background-color: #e2e6ea;\n border-color: #dae0e5;\n}\n\n.btn-light:focus, .btn-light.focus {\n box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5);\n}\n\n.btn-light.disabled, .btn-light:disabled {\n color: #212529;\n background-color: #f8f9fa;\n border-color: #f8f9fa;\n}\n\n.btn-light:not(:disabled):not(.disabled):active, .btn-light:not(:disabled):not(.disabled).active,\n.show > .btn-light.dropdown-toggle {\n color: #212529;\n background-color: #dae0e5;\n border-color: #d3d9df;\n}\n\n.btn-light:not(:disabled):not(.disabled):active:focus, .btn-light:not(:disabled):not(.disabled).active:focus,\n.show > .btn-light.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5);\n}\n\n.btn-dark {\n color: #fff;\n background-color: #343a40;\n border-color: #343a40;\n}\n\n.btn-dark:hover {\n color: #fff;\n background-color: #23272b;\n border-color: #1d2124;\n}\n\n.btn-dark:focus, .btn-dark.focus {\n box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5);\n}\n\n.btn-dark.disabled, .btn-dark:disabled {\n color: #fff;\n background-color: #343a40;\n border-color: #343a40;\n}\n\n.btn-dark:not(:disabled):not(.disabled):active, .btn-dark:not(:disabled):not(.disabled).active,\n.show > .btn-dark.dropdown-toggle {\n color: #fff;\n background-color: #1d2124;\n border-color: #171a1d;\n}\n\n.btn-dark:not(:disabled):not(.disabled):active:focus, .btn-dark:not(:disabled):not(.disabled).active:focus,\n.show > .btn-dark.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5);\n}\n\n.btn-outline-primary {\n color: #007bff;\n background-color: transparent;\n background-image: none;\n border-color: #007bff;\n}\n\n.btn-outline-primary:hover {\n color: #fff;\n background-color: #007bff;\n border-color: #007bff;\n}\n\n.btn-outline-primary:focus, .btn-outline-primary.focus {\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.5);\n}\n\n.btn-outline-primary.disabled, .btn-outline-primary:disabled {\n color: #007bff;\n background-color: transparent;\n}\n\n.btn-outline-primary:not(:disabled):not(.disabled):active, .btn-outline-primary:not(:disabled):not(.disabled).active,\n.show > .btn-outline-primary.dropdown-toggle {\n color: #fff;\n background-color: #007bff;\n border-color: #007bff;\n}\n\n.btn-outline-primary:not(:disabled):not(.disabled):active:focus, .btn-outline-primary:not(:disabled):not(.disabled).active:focus,\n.show > .btn-outline-primary.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.5);\n}\n\n.btn-outline-secondary {\n color: #6c757d;\n background-color: transparent;\n background-image: none;\n border-color: #6c757d;\n}\n\n.btn-outline-secondary:hover {\n color: #fff;\n background-color: #6c757d;\n border-color: #6c757d;\n}\n\n.btn-outline-secondary:focus, .btn-outline-secondary.focus {\n box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5);\n}\n\n.btn-outline-secondary.disabled, .btn-outline-secondary:disabled {\n color: #6c757d;\n background-color: transparent;\n}\n\n.btn-outline-secondary:not(:disabled):not(.disabled):active, .btn-outline-secondary:not(:disabled):not(.disabled).active,\n.show > .btn-outline-secondary.dropdown-toggle {\n color: #fff;\n background-color: #6c757d;\n border-color: #6c757d;\n}\n\n.btn-outline-secondary:not(:disabled):not(.disabled):active:focus, .btn-outline-secondary:not(:disabled):not(.disabled).active:focus,\n.show > .btn-outline-secondary.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5);\n}\n\n.btn-outline-success {\n color: #28a745;\n background-color: transparent;\n background-image: none;\n border-color: #28a745;\n}\n\n.btn-outline-success:hover {\n color: #fff;\n background-color: #28a745;\n border-color: #28a745;\n}\n\n.btn-outline-success:focus, .btn-outline-success.focus {\n box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.5);\n}\n\n.btn-outline-success.disabled, .btn-outline-success:disabled {\n color: #28a745;\n background-color: transparent;\n}\n\n.btn-outline-success:not(:disabled):not(.disabled):active, .btn-outline-success:not(:disabled):not(.disabled).active,\n.show > .btn-outline-success.dropdown-toggle {\n color: #fff;\n background-color: #28a745;\n border-color: #28a745;\n}\n\n.btn-outline-success:not(:disabled):not(.disabled):active:focus, .btn-outline-success:not(:disabled):not(.disabled).active:focus,\n.show > .btn-outline-success.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.5);\n}\n\n.btn-outline-info {\n color: #17a2b8;\n background-color: transparent;\n background-image: none;\n border-color: #17a2b8;\n}\n\n.btn-outline-info:hover {\n color: #fff;\n background-color: #17a2b8;\n border-color: #17a2b8;\n}\n\n.btn-outline-info:focus, .btn-outline-info.focus {\n box-shadow: 0 0 0 0.2rem rgba(23, 162, 184, 0.5);\n}\n\n.btn-outline-info.disabled, .btn-outline-info:disabled {\n color: #17a2b8;\n background-color: transparent;\n}\n\n.btn-outline-info:not(:disabled):not(.disabled):active, .btn-outline-info:not(:disabled):not(.disabled).active,\n.show > .btn-outline-info.dropdown-toggle {\n color: #fff;\n background-color: #17a2b8;\n border-color: #17a2b8;\n}\n\n.btn-outline-info:not(:disabled):not(.disabled):active:focus, .btn-outline-info:not(:disabled):not(.disabled).active:focus,\n.show > .btn-outline-info.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(23, 162, 184, 0.5);\n}\n\n.btn-outline-warning {\n color: #ffc107;\n background-color: transparent;\n background-image: none;\n border-color: #ffc107;\n}\n\n.btn-outline-warning:hover {\n color: #212529;\n background-color: #ffc107;\n border-color: #ffc107;\n}\n\n.btn-outline-warning:focus, .btn-outline-warning.focus {\n box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.5);\n}\n\n.btn-outline-warning.disabled, .btn-outline-warning:disabled {\n color: #ffc107;\n background-color: transparent;\n}\n\n.btn-outline-warning:not(:disabled):not(.disabled):active, .btn-outline-warning:not(:disabled):not(.disabled).active,\n.show > .btn-outline-warning.dropdown-toggle {\n color: #212529;\n background-color: #ffc107;\n border-color: #ffc107;\n}\n\n.btn-outline-warning:not(:disabled):not(.disabled):active:focus, .btn-outline-warning:not(:disabled):not(.disabled).active:focus,\n.show > .btn-outline-warning.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.5);\n}\n\n.btn-outline-danger {\n color: #dc3545;\n background-color: transparent;\n background-image: none;\n border-color: #dc3545;\n}\n\n.btn-outline-danger:hover {\n color: #fff;\n background-color: #dc3545;\n border-color: #dc3545;\n}\n\n.btn-outline-danger:focus, .btn-outline-danger.focus {\n box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.5);\n}\n\n.btn-outline-danger.disabled, .btn-outline-danger:disabled {\n color: #dc3545;\n background-color: transparent;\n}\n\n.btn-outline-danger:not(:disabled):not(.disabled):active, .btn-outline-danger:not(:disabled):not(.disabled).active,\n.show > .btn-outline-danger.dropdown-toggle {\n color: #fff;\n background-color: #dc3545;\n border-color: #dc3545;\n}\n\n.btn-outline-danger:not(:disabled):not(.disabled):active:focus, .btn-outline-danger:not(:disabled):not(.disabled).active:focus,\n.show > .btn-outline-danger.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.5);\n}\n\n.btn-outline-light {\n color: #f8f9fa;\n background-color: transparent;\n background-image: none;\n border-color: #f8f9fa;\n}\n\n.btn-outline-light:hover {\n color: #212529;\n background-color: #f8f9fa;\n border-color: #f8f9fa;\n}\n\n.btn-outline-light:focus, .btn-outline-light.focus {\n box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5);\n}\n\n.btn-outline-light.disabled, .btn-outline-light:disabled {\n color: #f8f9fa;\n background-color: transparent;\n}\n\n.btn-outline-light:not(:disabled):not(.disabled):active, .btn-outline-light:not(:disabled):not(.disabled).active,\n.show > .btn-outline-light.dropdown-toggle {\n color: #212529;\n background-color: #f8f9fa;\n border-color: #f8f9fa;\n}\n\n.btn-outline-light:not(:disabled):not(.disabled):active:focus, .btn-outline-light:not(:disabled):not(.disabled).active:focus,\n.show > .btn-outline-light.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5);\n}\n\n.btn-outline-dark {\n color: #343a40;\n background-color: transparent;\n background-image: none;\n border-color: #343a40;\n}\n\n.btn-outline-dark:hover {\n color: #fff;\n background-color: #343a40;\n border-color: #343a40;\n}\n\n.btn-outline-dark:focus, .btn-outline-dark.focus {\n box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5);\n}\n\n.btn-outline-dark.disabled, .btn-outline-dark:disabled {\n color: #343a40;\n background-color: transparent;\n}\n\n.btn-outline-dark:not(:disabled):not(.disabled):active, .btn-outline-dark:not(:disabled):not(.disabled).active,\n.show > .btn-outline-dark.dropdown-toggle {\n color: #fff;\n background-color: #343a40;\n border-color: #343a40;\n}\n\n.btn-outline-dark:not(:disabled):not(.disabled):active:focus, .btn-outline-dark:not(:disabled):not(.disabled).active:focus,\n.show > .btn-outline-dark.dropdown-toggle:focus {\n box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5);\n}\n\n.btn-link {\n font-weight: 400;\n color: #007bff;\n background-color: transparent;\n}\n\n.btn-link:hover {\n color: #0056b3;\n text-decoration: underline;\n background-color: transparent;\n border-color: transparent;\n}\n\n.btn-link:focus, .btn-link.focus {\n text-decoration: underline;\n border-color: transparent;\n box-shadow: none;\n}\n\n.btn-link:disabled, .btn-link.disabled {\n color: #6c757d;\n pointer-events: none;\n}\n\n.btn-lg, .btn-group-lg > .btn {\n padding: 0.5rem 1rem;\n font-size: 1.25rem;\n line-height: 1.5;\n border-radius: 0.3rem;\n}\n\n.btn-sm, .btn-group-sm > .btn {\n padding: 0.25rem 0.5rem;\n font-size: 0.875rem;\n line-height: 1.5;\n border-radius: 0.2rem;\n}\n\n.btn-block {\n display: block;\n width: 100%;\n}\n\n.btn-block + .btn-block {\n margin-top: 0.5rem;\n}\n\ninput[type=\"submit\"].btn-block,\ninput[type=\"reset\"].btn-block,\ninput[type=\"button\"].btn-block {\n width: 100%;\n}\n\n.fade {\n transition: opacity 0.15s linear;\n}\n\n@media screen and (prefers-reduced-motion: reduce) {\n .fade {\n transition: none;\n }\n}\n\n.fade:not(.show) {\n opacity: 0;\n}\n\n.collapse:not(.show) {\n display: none;\n}\n\n.collapsing {\n position: relative;\n height: 0;\n overflow: hidden;\n transition: height 0.35s ease;\n}\n\n@media screen and (prefers-reduced-motion: reduce) {\n .collapsing {\n transition: none;\n }\n}\n\n.dropup,\n.dropright,\n.dropdown,\n.dropleft {\n position: relative;\n}\n\n.dropdown-toggle::after {\n display: inline-block;\n width: 0;\n height: 0;\n margin-left: 0.255em;\n vertical-align: 0.255em;\n content: \"\";\n border-top: 0.3em solid;\n border-right: 0.3em solid transparent;\n border-bottom: 0;\n border-left: 0.3em solid transparent;\n}\n\n.dropdown-toggle:empty::after {\n margin-left: 0;\n}\n\n.dropdown-menu {\n position: absolute;\n top: 100%;\n left: 0;\n z-index: 1000;\n display: none;\n float: left;\n min-width: 10rem;\n padding: 0.5rem 0;\n margin: 0.125rem 0 0;\n font-size: 1rem;\n color: #212529;\n text-align: left;\n list-style: none;\n background-color: #fff;\n background-clip: padding-box;\n border: 1px solid rgba(0, 0, 0, 0.15);\n border-radius: 0.25rem;\n}\n\n.dropdown-menu-right {\n right: 0;\n left: auto;\n}\n\n.dropup .dropdown-menu {\n top: auto;\n bottom: 100%;\n margin-top: 0;\n margin-bottom: 0.125rem;\n}\n\n.dropup .dropdown-toggle::after {\n display: inline-block;\n width: 0;\n height: 0;\n margin-left: 0.255em;\n vertical-align: 0.255em;\n content: \"\";\n border-top: 0;\n border-right: 0.3em solid transparent;\n border-bottom: 0.3em solid;\n border-left: 0.3em solid transparent;\n}\n\n.dropup .dropdown-toggle:empty::after {\n margin-left: 0;\n}\n\n.dropright .dropdown-menu {\n top: 0;\n right: auto;\n left: 100%;\n margin-top: 0;\n margin-left: 0.125rem;\n}\n\n.dropright .dropdown-toggle::after {\n display: inline-block;\n width: 0;\n height: 0;\n margin-left: 0.255em;\n vertical-align: 0.255em;\n content: \"\";\n border-top: 0.3em solid transparent;\n border-right: 0;\n border-bottom: 0.3em solid transparent;\n border-left: 0.3em solid;\n}\n\n.dropright .dropdown-toggle:empty::after {\n margin-left: 0;\n}\n\n.dropright .dropdown-toggle::after {\n vertical-align: 0;\n}\n\n.dropleft .dropdown-menu {\n top: 0;\n right: 100%;\n left: auto;\n margin-top: 0;\n margin-right: 0.125rem;\n}\n\n.dropleft .dropdown-toggle::after {\n display: inline-block;\n width: 0;\n height: 0;\n margin-left: 0.255em;\n vertical-align: 0.255em;\n content: \"\";\n}\n\n.dropleft .dropdown-toggle::after {\n display: none;\n}\n\n.dropleft .dropdown-toggle::before {\n display: inline-block;\n width: 0;\n height: 0;\n margin-right: 0.255em;\n vertical-align: 0.255em;\n content: \"\";\n border-top: 0.3em solid transparent;\n border-right: 0.3em solid;\n border-bottom: 0.3em solid transparent;\n}\n\n.dropleft .dropdown-toggle:empty::after {\n margin-left: 0;\n}\n\n.dropleft .dropdown-toggle::before {\n vertical-align: 0;\n}\n\n.dropdown-menu[x-placement^=\"top\"], .dropdown-menu[x-placement^=\"right\"], .dropdown-menu[x-placement^=\"bottom\"], .dropdown-menu[x-placement^=\"left\"] {\n right: auto;\n bottom: auto;\n}\n\n.dropdown-divider {\n height: 0;\n margin: 0.5rem 0;\n overflow: hidden;\n border-top: 1px solid #e9ecef;\n}\n\n.dropdown-item {\n display: block;\n width: 100%;\n padding: 0.25rem 1.5rem;\n clear: both;\n font-weight: 400;\n color: #212529;\n text-align: inherit;\n white-space: nowrap;\n background-color: transparent;\n border: 0;\n}\n\n.dropdown-item:hover, .dropdown-item:focus {\n color: #16181b;\n text-decoration: none;\n background-color: #f8f9fa;\n}\n\n.dropdown-item.active, .dropdown-item:active {\n color: #fff;\n text-decoration: none;\n background-color: #007bff;\n}\n\n.dropdown-item.disabled, .dropdown-item:disabled {\n color: #6c757d;\n background-color: transparent;\n}\n\n.dropdown-menu.show {\n display: block;\n}\n\n.dropdown-header {\n display: block;\n padding: 0.5rem 1.5rem;\n margin-bottom: 0;\n font-size: 0.875rem;\n color: #6c757d;\n white-space: nowrap;\n}\n\n.dropdown-item-text {\n display: block;\n padding: 0.25rem 1.5rem;\n color: #212529;\n}\n\n.btn-group,\n.btn-group-vertical {\n position: relative;\n display: inline-flex;\n vertical-align: middle;\n}\n\n.btn-group > .btn,\n.btn-group-vertical > .btn {\n position: relative;\n flex: 0 1 auto;\n}\n\n.btn-group > .btn:hover,\n.btn-group-vertical > .btn:hover {\n z-index: 1;\n}\n\n.btn-group > .btn:focus, .btn-group > .btn:active, .btn-group > .btn.active,\n.btn-group-vertical > .btn:focus,\n.btn-group-vertical > .btn:active,\n.btn-group-vertical > .btn.active {\n z-index: 1;\n}\n\n.btn-group .btn + .btn,\n.btn-group .btn + .btn-group,\n.btn-group .btn-group + .btn,\n.btn-group .btn-group + .btn-group,\n.btn-group-vertical .btn + .btn,\n.btn-group-vertical .btn + .btn-group,\n.btn-group-vertical .btn-group + .btn,\n.btn-group-vertical .btn-group + .btn-group {\n margin-left: -1px;\n}\n\n.btn-toolbar {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-start;\n}\n\n.btn-toolbar .input-group {\n width: auto;\n}\n\n.btn-group > .btn:first-child {\n margin-left: 0;\n}\n\n.btn-group > .btn:not(:last-child):not(.dropdown-toggle),\n.btn-group > .btn-group:not(:last-child) > .btn {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n\n.btn-group > .btn:not(:first-child),\n.btn-group > .btn-group:not(:first-child) > .btn {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n\n.dropdown-toggle-split {\n padding-right: 0.5625rem;\n padding-left: 0.5625rem;\n}\n\n.dropdown-toggle-split::after,\n.dropup .dropdown-toggle-split::after,\n.dropright .dropdown-toggle-split::after {\n margin-left: 0;\n}\n\n.dropleft .dropdown-toggle-split::before {\n margin-right: 0;\n}\n\n.btn-sm + .dropdown-toggle-split, .btn-group-sm > .btn + .dropdown-toggle-split {\n padding-right: 0.375rem;\n padding-left: 0.375rem;\n}\n\n.btn-lg + .dropdown-toggle-split, .btn-group-lg > .btn + .dropdown-toggle-split {\n padding-right: 0.75rem;\n padding-left: 0.75rem;\n}\n\n.btn-group-vertical {\n flex-direction: column;\n align-items: flex-start;\n justify-content: center;\n}\n\n.btn-group-vertical .btn,\n.btn-group-vertical .btn-group {\n width: 100%;\n}\n\n.btn-group-vertical > .btn + .btn,\n.btn-group-vertical > .btn + .btn-group,\n.btn-group-vertical > .btn-group + .btn,\n.btn-group-vertical > .btn-group + .btn-group {\n margin-top: -1px;\n margin-left: 0;\n}\n\n.btn-group-vertical > .btn:not(:last-child):not(.dropdown-toggle),\n.btn-group-vertical > .btn-group:not(:last-child) > .btn {\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n\n.btn-group-vertical > .btn:not(:first-child),\n.btn-group-vertical > .btn-group:not(:first-child) > .btn {\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n\n.btn-group-toggle > .btn,\n.btn-group-toggle > .btn-group > .btn {\n margin-bottom: 0;\n}\n\n.btn-group-toggle > .btn input[type=\"radio\"],\n.btn-group-toggle > .btn input[type=\"checkbox\"],\n.btn-group-toggle > .btn-group > .btn input[type=\"radio\"],\n.btn-group-toggle > .btn-group > .btn input[type=\"checkbox\"] {\n position: absolute;\n clip: rect(0, 0, 0, 0);\n pointer-events: none;\n}\n\n.input-group {\n position: relative;\n display: flex;\n flex-wrap: wrap;\n align-items: stretch;\n width: 100%;\n}\n\n.input-group > .form-control,\n.input-group > .custom-select,\n.input-group > .custom-file {\n position: relative;\n flex: 1 1 auto;\n width: 1%;\n margin-bottom: 0;\n}\n\n.input-group > .form-control:focus,\n.input-group > .custom-select:focus,\n.input-group > .custom-file:focus {\n z-index: 3;\n}\n\n.input-group > .form-control + .form-control,\n.input-group > .form-control + .custom-select,\n.input-group > .form-control + .custom-file,\n.input-group > .custom-select + .form-control,\n.input-group > .custom-select + .custom-select,\n.input-group > .custom-select + .custom-file,\n.input-group > .custom-file + .form-control,\n.input-group > .custom-file + .custom-select,\n.input-group > .custom-file + .custom-file {\n margin-left: -1px;\n}\n\n.input-group > .form-control:not(:last-child),\n.input-group > .custom-select:not(:last-child) {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n\n.input-group > .form-control:not(:first-child),\n.input-group > .custom-select:not(:first-child) {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n\n.input-group > .custom-file {\n display: flex;\n align-items: center;\n}\n\n.input-group > .custom-file:not(:last-child) .custom-file-label,\n.input-group > .custom-file:not(:last-child) .custom-file-label::after {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n\n.input-group > .custom-file:not(:first-child) .custom-file-label {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n\n.input-group-prepend,\n.input-group-append {\n display: flex;\n}\n\n.input-group-prepend .btn,\n.input-group-append .btn {\n position: relative;\n z-index: 2;\n}\n\n.input-group-prepend .btn + .btn,\n.input-group-prepend .btn + .input-group-text,\n.input-group-prepend .input-group-text + .input-group-text,\n.input-group-prepend .input-group-text + .btn,\n.input-group-append .btn + .btn,\n.input-group-append .btn + .input-group-text,\n.input-group-append .input-group-text + .input-group-text,\n.input-group-append .input-group-text + .btn {\n margin-left: -1px;\n}\n\n.input-group-prepend {\n margin-right: -1px;\n}\n\n.input-group-append {\n margin-left: -1px;\n}\n\n.input-group-text {\n display: flex;\n align-items: center;\n padding: 0.375rem 0.75rem;\n margin-bottom: 0;\n font-size: 1rem;\n font-weight: 400;\n line-height: 1.5;\n color: #495057;\n text-align: center;\n white-space: nowrap;\n background-color: #e9ecef;\n border: 1px solid #ced4da;\n border-radius: 0.25rem;\n}\n\n.input-group-text input[type=\"radio\"],\n.input-group-text input[type=\"checkbox\"] {\n margin-top: 0;\n}\n\n.input-group > .input-group-prepend > .btn,\n.input-group > .input-group-prepend > .input-group-text,\n.input-group > .input-group-append:not(:last-child) > .btn,\n.input-group > .input-group-append:not(:last-child) > .input-group-text,\n.input-group > .input-group-append:last-child > .btn:not(:last-child):not(.dropdown-toggle),\n.input-group > .input-group-append:last-child > .input-group-text:not(:last-child) {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n\n.input-group > .input-group-append > .btn,\n.input-group > .input-group-append > .input-group-text,\n.input-group > .input-group-prepend:not(:first-child) > .btn,\n.input-group > .input-group-prepend:not(:first-child) > .input-group-text,\n.input-group > .input-group-prepend:first-child > .btn:not(:first-child),\n.input-group > .input-group-prepend:first-child > .input-group-text:not(:first-child) {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n\n.custom-control {\n position: relative;\n display: block;\n min-height: 1.5rem;\n padding-left: 1.5rem;\n}\n\n.custom-control-inline {\n display: inline-flex;\n margin-right: 1rem;\n}\n\n.custom-control-input {\n position: absolute;\n z-index: -1;\n opacity: 0;\n}\n\n.custom-control-input:checked ~ .custom-control-label::before {\n color: #fff;\n background-color: #007bff;\n}\n\n.custom-control-input:focus ~ .custom-control-label::before {\n box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.custom-control-input:active ~ .custom-control-label::before {\n color: #fff;\n background-color: #b3d7ff;\n}\n\n.custom-control-input:disabled ~ .custom-control-label {\n color: #6c757d;\n}\n\n.custom-control-input:disabled ~ .custom-control-label::before {\n background-color: #e9ecef;\n}\n\n.custom-control-label {\n position: relative;\n margin-bottom: 0;\n}\n\n.custom-control-label::before {\n position: absolute;\n top: 0.25rem;\n left: -1.5rem;\n display: block;\n width: 1rem;\n height: 1rem;\n pointer-events: none;\n content: \"\";\n user-select: none;\n background-color: #dee2e6;\n}\n\n.custom-control-label::after {\n position: absolute;\n top: 0.25rem;\n left: -1.5rem;\n display: block;\n width: 1rem;\n height: 1rem;\n content: \"\";\n background-repeat: no-repeat;\n background-position: center center;\n background-size: 50% 50%;\n}\n\n.custom-checkbox .custom-control-label::before {\n border-radius: 0.25rem;\n}\n\n.custom-checkbox .custom-control-input:checked ~ .custom-control-label::before {\n background-color: #007bff;\n}\n\n.custom-checkbox .custom-control-input:checked ~ .custom-control-label::after {\n background-image: url(\"data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26 2.974 7.25 8 2.193z'/%3E%3C/svg%3E\");\n}\n\n.custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::before {\n background-color: #007bff;\n}\n\n.custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::after {\n background-image: url(\"data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 4'%3E%3Cpath stroke='%23fff' d='M0 2h4'/%3E%3C/svg%3E\");\n}\n\n.custom-checkbox .custom-control-input:disabled:checked ~ .custom-control-label::before {\n background-color: rgba(0, 123, 255, 0.5);\n}\n\n.custom-checkbox .custom-control-input:disabled:indeterminate ~ .custom-control-label::before {\n background-color: rgba(0, 123, 255, 0.5);\n}\n\n.custom-radio .custom-control-label::before {\n border-radius: 50%;\n}\n\n.custom-radio .custom-control-input:checked ~ .custom-control-label::before {\n background-color: #007bff;\n}\n\n.custom-radio .custom-control-input:checked ~ .custom-control-label::after {\n background-image: url(\"data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3E%3Ccircle r='3' fill='%23fff'/%3E%3C/svg%3E\");\n}\n\n.custom-radio .custom-control-input:disabled:checked ~ .custom-control-label::before {\n background-color: rgba(0, 123, 255, 0.5);\n}\n\n.custom-select {\n display: inline-block;\n width: 100%;\n height: calc(2.25rem + 2px);\n padding: 0.375rem 1.75rem 0.375rem 0.75rem;\n line-height: 1.5;\n color: #495057;\n vertical-align: middle;\n background: #fff url(\"data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3E%3Cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3E%3C/svg%3E\") no-repeat right 0.75rem center;\n background-size: 8px 10px;\n border: 1px solid #ced4da;\n border-radius: 0.25rem;\n appearance: none;\n}\n\n.custom-select:focus {\n border-color: #80bdff;\n outline: 0;\n box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.075), 0 0 5px rgba(128, 189, 255, 0.5);\n}\n\n.custom-select:focus::-ms-value {\n color: #495057;\n background-color: #fff;\n}\n\n.custom-select[multiple], .custom-select[size]:not([size=\"1\"]) {\n height: auto;\n padding-right: 0.75rem;\n background-image: none;\n}\n\n.custom-select:disabled {\n color: #6c757d;\n background-color: #e9ecef;\n}\n\n.custom-select::-ms-expand {\n opacity: 0;\n}\n\n.custom-select-sm {\n height: calc(1.8125rem + 2px);\n padding-top: 0.375rem;\n padding-bottom: 0.375rem;\n font-size: 75%;\n}\n\n.custom-select-lg {\n height: calc(2.875rem + 2px);\n padding-top: 0.375rem;\n padding-bottom: 0.375rem;\n font-size: 125%;\n}\n\n.custom-file {\n position: relative;\n display: inline-block;\n width: 100%;\n height: calc(2.25rem + 2px);\n margin-bottom: 0;\n}\n\n.custom-file-input {\n position: relative;\n z-index: 2;\n width: 100%;\n height: calc(2.25rem + 2px);\n margin: 0;\n opacity: 0;\n}\n\n.custom-file-input:focus ~ .custom-file-label {\n border-color: #80bdff;\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.custom-file-input:focus ~ .custom-file-label::after {\n border-color: #80bdff;\n}\n\n.custom-file-input:lang(en) ~ .custom-file-label::after {\n content: \"Browse\";\n}\n\n.custom-file-label {\n position: absolute;\n top: 0;\n right: 0;\n left: 0;\n z-index: 1;\n height: calc(2.25rem + 2px);\n padding: 0.375rem 0.75rem;\n line-height: 1.5;\n color: #495057;\n background-color: #fff;\n border: 1px solid #ced4da;\n border-radius: 0.25rem;\n}\n\n.custom-file-label::after {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n z-index: 3;\n display: block;\n height: 2.25rem;\n padding: 0.375rem 0.75rem;\n line-height: 1.5;\n color: #495057;\n content: \"Browse\";\n background-color: #e9ecef;\n border-left: 1px solid #ced4da;\n border-radius: 0 0.25rem 0.25rem 0;\n}\n\n.custom-range {\n width: 100%;\n padding-left: 0;\n background-color: transparent;\n appearance: none;\n}\n\n.custom-range:focus {\n outline: none;\n}\n\n.custom-range::-moz-focus-outer {\n border: 0;\n}\n\n.custom-range::-webkit-slider-thumb {\n width: 1rem;\n height: 1rem;\n margin-top: -0.25rem;\n background-color: #007bff;\n border: 0;\n border-radius: 1rem;\n appearance: none;\n}\n\n.custom-range::-webkit-slider-thumb:focus {\n outline: none;\n box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.custom-range::-webkit-slider-thumb:active {\n background-color: #b3d7ff;\n}\n\n.custom-range::-webkit-slider-runnable-track {\n width: 100%;\n height: 0.5rem;\n color: transparent;\n cursor: pointer;\n background-color: #dee2e6;\n border-color: transparent;\n border-radius: 1rem;\n}\n\n.custom-range::-moz-range-thumb {\n width: 1rem;\n height: 1rem;\n background-color: #007bff;\n border: 0;\n border-radius: 1rem;\n appearance: none;\n}\n\n.custom-range::-moz-range-thumb:focus {\n outline: none;\n box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.custom-range::-moz-range-thumb:active {\n background-color: #b3d7ff;\n}\n\n.custom-range::-moz-range-track {\n width: 100%;\n height: 0.5rem;\n color: transparent;\n cursor: pointer;\n background-color: #dee2e6;\n border-color: transparent;\n border-radius: 1rem;\n}\n\n.custom-range::-ms-thumb {\n width: 1rem;\n height: 1rem;\n background-color: #007bff;\n border: 0;\n border-radius: 1rem;\n appearance: none;\n}\n\n.custom-range::-ms-thumb:focus {\n outline: none;\n box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.custom-range::-ms-thumb:active {\n background-color: #b3d7ff;\n}\n\n.custom-range::-ms-track {\n width: 100%;\n height: 0.5rem;\n color: transparent;\n cursor: pointer;\n background-color: transparent;\n border-color: transparent;\n border-width: 0.5rem;\n}\n\n.custom-range::-ms-fill-lower {\n background-color: #dee2e6;\n border-radius: 1rem;\n}\n\n.custom-range::-ms-fill-upper {\n margin-right: 15px;\n background-color: #dee2e6;\n border-radius: 1rem;\n}\n\n.nav {\n display: flex;\n flex-wrap: wrap;\n padding-left: 0;\n margin-bottom: 0;\n list-style: none;\n}\n\n.nav-link {\n display: block;\n padding: 0.5rem 1rem;\n}\n\n.nav-link:hover, .nav-link:focus {\n text-decoration: none;\n}\n\n.nav-link.disabled {\n color: #6c757d;\n}\n\n.nav-tabs {\n border-bottom: 1px solid #dee2e6;\n}\n\n.nav-tabs .nav-item {\n margin-bottom: -1px;\n}\n\n.nav-tabs .nav-link {\n border: 1px solid transparent;\n border-top-left-radius: 0.25rem;\n border-top-right-radius: 0.25rem;\n}\n\n.nav-tabs .nav-link:hover, .nav-tabs .nav-link:focus {\n border-color: #e9ecef #e9ecef #dee2e6;\n}\n\n.nav-tabs .nav-link.disabled {\n color: #6c757d;\n background-color: transparent;\n border-color: transparent;\n}\n\n.nav-tabs .nav-link.active,\n.nav-tabs .nav-item.show .nav-link {\n color: #495057;\n background-color: #fff;\n border-color: #dee2e6 #dee2e6 #fff;\n}\n\n.nav-tabs .dropdown-menu {\n margin-top: -1px;\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n\n.nav-pills .nav-link {\n border-radius: 0.25rem;\n}\n\n.nav-pills .nav-link.active,\n.nav-pills .show > .nav-link {\n color: #fff;\n background-color: #007bff;\n}\n\n.nav-fill .nav-item {\n flex: 1 1 auto;\n text-align: center;\n}\n\n.nav-justified .nav-item {\n flex-basis: 0;\n flex-grow: 1;\n text-align: center;\n}\n\n.tab-content > .tab-pane {\n display: none;\n}\n\n.tab-content > .active {\n display: block;\n}\n\n.navbar {\n position: relative;\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n padding: 0.5rem 1rem;\n}\n\n.navbar > .container,\n.navbar > .container-fluid {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n}\n\n.navbar-brand {\n display: inline-block;\n padding-top: 0.3125rem;\n padding-bottom: 0.3125rem;\n margin-right: 1rem;\n font-size: 1.25rem;\n line-height: inherit;\n white-space: nowrap;\n}\n\n.navbar-brand:hover, .navbar-brand:focus {\n text-decoration: none;\n}\n\n.navbar-nav {\n display: flex;\n flex-direction: column;\n padding-left: 0;\n margin-bottom: 0;\n list-style: none;\n}\n\n.navbar-nav .nav-link {\n padding-right: 0;\n padding-left: 0;\n}\n\n.navbar-nav .dropdown-menu {\n position: static;\n float: none;\n}\n\n.navbar-text {\n display: inline-block;\n padding-top: 0.5rem;\n padding-bottom: 0.5rem;\n}\n\n.navbar-collapse {\n flex-basis: 100%;\n flex-grow: 1;\n align-items: center;\n}\n\n.navbar-toggler {\n padding: 0.25rem 0.75rem;\n font-size: 1.25rem;\n line-height: 1;\n background-color: transparent;\n border: 1px solid transparent;\n border-radius: 0.25rem;\n}\n\n.navbar-toggler:hover, .navbar-toggler:focus {\n text-decoration: none;\n}\n\n.navbar-toggler:not(:disabled):not(.disabled) {\n cursor: pointer;\n}\n\n.navbar-toggler-icon {\n display: inline-block;\n width: 1.5em;\n height: 1.5em;\n vertical-align: middle;\n content: \"\";\n background: no-repeat center center;\n background-size: 100% 100%;\n}\n\n@media (max-width: 575.98px) {\n .navbar-expand-sm > .container,\n .navbar-expand-sm > .container-fluid {\n padding-right: 0;\n padding-left: 0;\n }\n}\n\n@media (min-width: 576px) {\n .navbar-expand-sm {\n flex-flow: row nowrap;\n justify-content: flex-start;\n }\n .navbar-expand-sm .navbar-nav {\n flex-direction: row;\n }\n .navbar-expand-sm .navbar-nav .dropdown-menu {\n position: absolute;\n }\n .navbar-expand-sm .navbar-nav .nav-link {\n padding-right: 0.5rem;\n padding-left: 0.5rem;\n }\n .navbar-expand-sm > .container,\n .navbar-expand-sm > .container-fluid {\n flex-wrap: nowrap;\n }\n .navbar-expand-sm .navbar-collapse {\n display: flex !important;\n flex-basis: auto;\n }\n .navbar-expand-sm .navbar-toggler {\n display: none;\n }\n}\n\n@media (max-width: 767.98px) {\n .navbar-expand-md > .container,\n .navbar-expand-md > .container-fluid {\n padding-right: 0;\n padding-left: 0;\n }\n}\n\n@media (min-width: 768px) {\n .navbar-expand-md {\n flex-flow: row nowrap;\n justify-content: flex-start;\n }\n .navbar-expand-md .navbar-nav {\n flex-direction: row;\n }\n .navbar-expand-md .navbar-nav .dropdown-menu {\n position: absolute;\n }\n .navbar-expand-md .navbar-nav .nav-link {\n padding-right: 0.5rem;\n padding-left: 0.5rem;\n }\n .navbar-expand-md > .container,\n .navbar-expand-md > .container-fluid {\n flex-wrap: nowrap;\n }\n .navbar-expand-md .navbar-collapse {\n display: flex !important;\n flex-basis: auto;\n }\n .navbar-expand-md .navbar-toggler {\n display: none;\n }\n}\n\n@media (max-width: 991.98px) {\n .navbar-expand-lg > .container,\n .navbar-expand-lg > .container-fluid {\n padding-right: 0;\n padding-left: 0;\n }\n}\n\n@media (min-width: 992px) {\n .navbar-expand-lg {\n flex-flow: row nowrap;\n justify-content: flex-start;\n }\n .navbar-expand-lg .navbar-nav {\n flex-direction: row;\n }\n .navbar-expand-lg .navbar-nav .dropdown-menu {\n position: absolute;\n }\n .navbar-expand-lg .navbar-nav .nav-link {\n padding-right: 0.5rem;\n padding-left: 0.5rem;\n }\n .navbar-expand-lg > .container,\n .navbar-expand-lg > .container-fluid {\n flex-wrap: nowrap;\n }\n .navbar-expand-lg .navbar-collapse {\n display: flex !important;\n flex-basis: auto;\n }\n .navbar-expand-lg .navbar-toggler {\n display: none;\n }\n}\n\n@media (max-width: 1199.98px) {\n .navbar-expand-xl > .container,\n .navbar-expand-xl > .container-fluid {\n padding-right: 0;\n padding-left: 0;\n }\n}\n\n@media (min-width: 1200px) {\n .navbar-expand-xl {\n flex-flow: row nowrap;\n justify-content: flex-start;\n }\n .navbar-expand-xl .navbar-nav {\n flex-direction: row;\n }\n .navbar-expand-xl .navbar-nav .dropdown-menu {\n position: absolute;\n }\n .navbar-expand-xl .navbar-nav .nav-link {\n padding-right: 0.5rem;\n padding-left: 0.5rem;\n }\n .navbar-expand-xl > .container,\n .navbar-expand-xl > .container-fluid {\n flex-wrap: nowrap;\n }\n .navbar-expand-xl .navbar-collapse {\n display: flex !important;\n flex-basis: auto;\n }\n .navbar-expand-xl .navbar-toggler {\n display: none;\n }\n}\n\n.navbar-expand {\n flex-flow: row nowrap;\n justify-content: flex-start;\n}\n\n.navbar-expand > .container,\n.navbar-expand > .container-fluid {\n padding-right: 0;\n padding-left: 0;\n}\n\n.navbar-expand .navbar-nav {\n flex-direction: row;\n}\n\n.navbar-expand .navbar-nav .dropdown-menu {\n position: absolute;\n}\n\n.navbar-expand .navbar-nav .nav-link {\n padding-right: 0.5rem;\n padding-left: 0.5rem;\n}\n\n.navbar-expand > .container,\n.navbar-expand > .container-fluid {\n flex-wrap: nowrap;\n}\n\n.navbar-expand .navbar-collapse {\n display: flex !important;\n flex-basis: auto;\n}\n\n.navbar-expand .navbar-toggler {\n display: none;\n}\n\n.navbar-light .navbar-brand {\n color: rgba(0, 0, 0, 0.9);\n}\n\n.navbar-light .navbar-brand:hover, .navbar-light .navbar-brand:focus {\n color: rgba(0, 0, 0, 0.9);\n}\n\n.navbar-light .navbar-nav .nav-link {\n color: rgba(0, 0, 0, 0.5);\n}\n\n.navbar-light .navbar-nav .nav-link:hover, .navbar-light .navbar-nav .nav-link:focus {\n color: rgba(0, 0, 0, 0.7);\n}\n\n.navbar-light .navbar-nav .nav-link.disabled {\n color: rgba(0, 0, 0, 0.3);\n}\n\n.navbar-light .navbar-nav .show > .nav-link,\n.navbar-light .navbar-nav .active > .nav-link,\n.navbar-light .navbar-nav .nav-link.show,\n.navbar-light .navbar-nav .nav-link.active {\n color: rgba(0, 0, 0, 0.9);\n}\n\n.navbar-light .navbar-toggler {\n color: rgba(0, 0, 0, 0.5);\n border-color: rgba(0, 0, 0, 0.1);\n}\n\n.navbar-light .navbar-toggler-icon {\n background-image: url(\"data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(0, 0, 0, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E\");\n}\n\n.navbar-light .navbar-text {\n color: rgba(0, 0, 0, 0.5);\n}\n\n.navbar-light .navbar-text a {\n color: rgba(0, 0, 0, 0.9);\n}\n\n.navbar-light .navbar-text a:hover, .navbar-light .navbar-text a:focus {\n color: rgba(0, 0, 0, 0.9);\n}\n\n.navbar-dark .navbar-brand {\n color: #fff;\n}\n\n.navbar-dark .navbar-brand:hover, .navbar-dark .navbar-brand:focus {\n color: #fff;\n}\n\n.navbar-dark .navbar-nav .nav-link {\n color: rgba(255, 255, 255, 0.5);\n}\n\n.navbar-dark .navbar-nav .nav-link:hover, .navbar-dark .navbar-nav .nav-link:focus {\n color: rgba(255, 255, 255, 0.75);\n}\n\n.navbar-dark .navbar-nav .nav-link.disabled {\n color: rgba(255, 255, 255, 0.25);\n}\n\n.navbar-dark .navbar-nav .show > .nav-link,\n.navbar-dark .navbar-nav .active > .nav-link,\n.navbar-dark .navbar-nav .nav-link.show,\n.navbar-dark .navbar-nav .nav-link.active {\n color: #fff;\n}\n\n.navbar-dark .navbar-toggler {\n color: rgba(255, 255, 255, 0.5);\n border-color: rgba(255, 255, 255, 0.1);\n}\n\n.navbar-dark .navbar-toggler-icon {\n background-image: url(\"data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(255, 255, 255, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E\");\n}\n\n.navbar-dark .navbar-text {\n color: rgba(255, 255, 255, 0.5);\n}\n\n.navbar-dark .navbar-text a {\n color: #fff;\n}\n\n.navbar-dark .navbar-text a:hover, .navbar-dark .navbar-text a:focus {\n color: #fff;\n}\n\n.card {\n position: relative;\n display: flex;\n flex-direction: column;\n min-width: 0;\n word-wrap: break-word;\n background-color: #fff;\n background-clip: border-box;\n border: 1px solid rgba(0, 0, 0, 0.125);\n border-radius: 0.25rem;\n}\n\n.card > hr {\n margin-right: 0;\n margin-left: 0;\n}\n\n.card > .list-group:first-child .list-group-item:first-child {\n border-top-left-radius: 0.25rem;\n border-top-right-radius: 0.25rem;\n}\n\n.card > .list-group:last-child .list-group-item:last-child {\n border-bottom-right-radius: 0.25rem;\n border-bottom-left-radius: 0.25rem;\n}\n\n.card-body {\n flex: 1 1 auto;\n padding: 1.25rem;\n}\n\n.card-title {\n margin-bottom: 0.75rem;\n}\n\n.card-subtitle {\n margin-top: -0.375rem;\n margin-bottom: 0;\n}\n\n.card-text:last-child {\n margin-bottom: 0;\n}\n\n.card-link:hover {\n text-decoration: none;\n}\n\n.card-link + .card-link {\n margin-left: 1.25rem;\n}\n\n.card-header {\n padding: 0.75rem 1.25rem;\n margin-bottom: 0;\n background-color: rgba(0, 0, 0, 0.03);\n border-bottom: 1px solid rgba(0, 0, 0, 0.125);\n}\n\n.card-header:first-child {\n border-radius: calc(0.25rem - 1px) calc(0.25rem - 1px) 0 0;\n}\n\n.card-header + .list-group .list-group-item:first-child {\n border-top: 0;\n}\n\n.card-footer {\n padding: 0.75rem 1.25rem;\n background-color: rgba(0, 0, 0, 0.03);\n border-top: 1px solid rgba(0, 0, 0, 0.125);\n}\n\n.card-footer:last-child {\n border-radius: 0 0 calc(0.25rem - 1px) calc(0.25rem - 1px);\n}\n\n.card-header-tabs {\n margin-right: -0.625rem;\n margin-bottom: -0.75rem;\n margin-left: -0.625rem;\n border-bottom: 0;\n}\n\n.card-header-pills {\n margin-right: -0.625rem;\n margin-left: -0.625rem;\n}\n\n.card-img-overlay {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n padding: 1.25rem;\n}\n\n.card-img {\n width: 100%;\n border-radius: calc(0.25rem - 1px);\n}\n\n.card-img-top {\n width: 100%;\n border-top-left-radius: calc(0.25rem - 1px);\n border-top-right-radius: calc(0.25rem - 1px);\n}\n\n.card-img-bottom {\n width: 100%;\n border-bottom-right-radius: calc(0.25rem - 1px);\n border-bottom-left-radius: calc(0.25rem - 1px);\n}\n\n.card-deck {\n display: flex;\n flex-direction: column;\n}\n\n.card-deck .card {\n margin-bottom: 15px;\n}\n\n@media (min-width: 576px) {\n .card-deck {\n flex-flow: row wrap;\n margin-right: -15px;\n margin-left: -15px;\n }\n .card-deck .card {\n display: flex;\n flex: 1 0 0%;\n flex-direction: column;\n margin-right: 15px;\n margin-bottom: 0;\n margin-left: 15px;\n }\n}\n\n.card-group {\n display: flex;\n flex-direction: column;\n}\n\n.card-group > .card {\n margin-bottom: 15px;\n}\n\n@media (min-width: 576px) {\n .card-group {\n flex-flow: row wrap;\n }\n .card-group > .card {\n flex: 1 0 0%;\n margin-bottom: 0;\n }\n .card-group > .card + .card {\n margin-left: 0;\n border-left: 0;\n }\n .card-group > .card:first-child {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n }\n .card-group > .card:first-child .card-img-top,\n .card-group > .card:first-child .card-header {\n border-top-right-radius: 0;\n }\n .card-group > .card:first-child .card-img-bottom,\n .card-group > .card:first-child .card-footer {\n border-bottom-right-radius: 0;\n }\n .card-group > .card:last-child {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n }\n .card-group > .card:last-child .card-img-top,\n .card-group > .card:last-child .card-header {\n border-top-left-radius: 0;\n }\n .card-group > .card:last-child .card-img-bottom,\n .card-group > .card:last-child .card-footer {\n border-bottom-left-radius: 0;\n }\n .card-group > .card:only-child {\n border-radius: 0.25rem;\n }\n .card-group > .card:only-child .card-img-top,\n .card-group > .card:only-child .card-header {\n border-top-left-radius: 0.25rem;\n border-top-right-radius: 0.25rem;\n }\n .card-group > .card:only-child .card-img-bottom,\n .card-group > .card:only-child .card-footer {\n border-bottom-right-radius: 0.25rem;\n border-bottom-left-radius: 0.25rem;\n }\n .card-group > .card:not(:first-child):not(:last-child):not(:only-child) {\n border-radius: 0;\n }\n .card-group > .card:not(:first-child):not(:last-child):not(:only-child) .card-img-top,\n .card-group > .card:not(:first-child):not(:last-child):not(:only-child) .card-img-bottom,\n .card-group > .card:not(:first-child):not(:last-child):not(:only-child) .card-header,\n .card-group > .card:not(:first-child):not(:last-child):not(:only-child) .card-footer {\n border-radius: 0;\n }\n}\n\n.card-columns .card {\n margin-bottom: 0.75rem;\n}\n\n@media (min-width: 576px) {\n .card-columns {\n column-count: 3;\n column-gap: 1.25rem;\n orphans: 1;\n widows: 1;\n }\n .card-columns .card {\n display: inline-block;\n width: 100%;\n }\n}\n\n.accordion .card:not(:first-of-type):not(:last-of-type) {\n border-bottom: 0;\n border-radius: 0;\n}\n\n.accordion .card:not(:first-of-type) .card-header:first-child {\n border-radius: 0;\n}\n\n.accordion .card:first-of-type {\n border-bottom: 0;\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n\n.accordion .card:last-of-type {\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n\n.breadcrumb {\n display: flex;\n flex-wrap: wrap;\n padding: 0.75rem 1rem;\n margin-bottom: 1rem;\n list-style: none;\n background-color: #e9ecef;\n border-radius: 0.25rem;\n}\n\n.breadcrumb-item + .breadcrumb-item {\n padding-left: 0.5rem;\n}\n\n.breadcrumb-item + .breadcrumb-item::before {\n display: inline-block;\n padding-right: 0.5rem;\n color: #6c757d;\n content: \"/\";\n}\n\n.breadcrumb-item + .breadcrumb-item:hover::before {\n text-decoration: underline;\n}\n\n.breadcrumb-item + .breadcrumb-item:hover::before {\n text-decoration: none;\n}\n\n.breadcrumb-item.active {\n color: #6c757d;\n}\n\n.pagination {\n display: flex;\n padding-left: 0;\n list-style: none;\n border-radius: 0.25rem;\n}\n\n.page-link {\n position: relative;\n display: block;\n padding: 0.5rem 0.75rem;\n margin-left: -1px;\n line-height: 1.25;\n color: #007bff;\n background-color: #fff;\n border: 1px solid #dee2e6;\n}\n\n.page-link:hover {\n z-index: 2;\n color: #0056b3;\n text-decoration: none;\n background-color: #e9ecef;\n border-color: #dee2e6;\n}\n\n.page-link:focus {\n z-index: 2;\n outline: 0;\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.page-link:not(:disabled):not(.disabled) {\n cursor: pointer;\n}\n\n.page-item:first-child .page-link {\n margin-left: 0;\n border-top-left-radius: 0.25rem;\n border-bottom-left-radius: 0.25rem;\n}\n\n.page-item:last-child .page-link {\n border-top-right-radius: 0.25rem;\n border-bottom-right-radius: 0.25rem;\n}\n\n.page-item.active .page-link {\n z-index: 1;\n color: #fff;\n background-color: #007bff;\n border-color: #007bff;\n}\n\n.page-item.disabled .page-link {\n color: #6c757d;\n pointer-events: none;\n cursor: auto;\n background-color: #fff;\n border-color: #dee2e6;\n}\n\n.pagination-lg .page-link {\n padding: 0.75rem 1.5rem;\n font-size: 1.25rem;\n line-height: 1.5;\n}\n\n.pagination-lg .page-item:first-child .page-link {\n border-top-left-radius: 0.3rem;\n border-bottom-left-radius: 0.3rem;\n}\n\n.pagination-lg .page-item:last-child .page-link {\n border-top-right-radius: 0.3rem;\n border-bottom-right-radius: 0.3rem;\n}\n\n.pagination-sm .page-link {\n padding: 0.25rem 0.5rem;\n font-size: 0.875rem;\n line-height: 1.5;\n}\n\n.pagination-sm .page-item:first-child .page-link {\n border-top-left-radius: 0.2rem;\n border-bottom-left-radius: 0.2rem;\n}\n\n.pagination-sm .page-item:last-child .page-link {\n border-top-right-radius: 0.2rem;\n border-bottom-right-radius: 0.2rem;\n}\n\n.badge {\n display: inline-block;\n padding: 0.25em 0.4em;\n font-size: 75%;\n font-weight: 700;\n line-height: 1;\n text-align: center;\n white-space: nowrap;\n vertical-align: baseline;\n border-radius: 0.25rem;\n}\n\n.badge:empty {\n display: none;\n}\n\n.btn .badge {\n position: relative;\n top: -1px;\n}\n\n.badge-pill {\n padding-right: 0.6em;\n padding-left: 0.6em;\n border-radius: 10rem;\n}\n\n.badge-primary {\n color: #fff;\n background-color: #007bff;\n}\n\n.badge-primary[href]:hover, .badge-primary[href]:focus {\n color: #fff;\n text-decoration: none;\n background-color: #0062cc;\n}\n\n.badge-secondary {\n color: #fff;\n background-color: #6c757d;\n}\n\n.badge-secondary[href]:hover, .badge-secondary[href]:focus {\n color: #fff;\n text-decoration: none;\n background-color: #545b62;\n}\n\n.badge-success {\n color: #fff;\n background-color: #28a745;\n}\n\n.badge-success[href]:hover, .badge-success[href]:focus {\n color: #fff;\n text-decoration: none;\n background-color: #1e7e34;\n}\n\n.badge-info {\n color: #fff;\n background-color: #17a2b8;\n}\n\n.badge-info[href]:hover, .badge-info[href]:focus {\n color: #fff;\n text-decoration: none;\n background-color: #117a8b;\n}\n\n.badge-warning {\n color: #212529;\n background-color: #ffc107;\n}\n\n.badge-warning[href]:hover, .badge-warning[href]:focus {\n color: #212529;\n text-decoration: none;\n background-color: #d39e00;\n}\n\n.badge-danger {\n color: #fff;\n background-color: #dc3545;\n}\n\n.badge-danger[href]:hover, .badge-danger[href]:focus {\n color: #fff;\n text-decoration: none;\n background-color: #bd2130;\n}\n\n.badge-light {\n color: #212529;\n background-color: #f8f9fa;\n}\n\n.badge-light[href]:hover, .badge-light[href]:focus {\n color: #212529;\n text-decoration: none;\n background-color: #dae0e5;\n}\n\n.badge-dark {\n color: #fff;\n background-color: #343a40;\n}\n\n.badge-dark[href]:hover, .badge-dark[href]:focus {\n color: #fff;\n text-decoration: none;\n background-color: #1d2124;\n}\n\n.jumbotron {\n padding: 2rem 1rem;\n margin-bottom: 2rem;\n background-color: #e9ecef;\n border-radius: 0.3rem;\n}\n\n@media (min-width: 576px) {\n .jumbotron {\n padding: 4rem 2rem;\n }\n}\n\n.jumbotron-fluid {\n padding-right: 0;\n padding-left: 0;\n border-radius: 0;\n}\n\n.alert {\n position: relative;\n padding: 0.75rem 1.25rem;\n margin-bottom: 1rem;\n border: 1px solid transparent;\n border-radius: 0.25rem;\n}\n\n.alert-heading {\n color: inherit;\n}\n\n.alert-link {\n font-weight: 700;\n}\n\n.alert-dismissible {\n padding-right: 4rem;\n}\n\n.alert-dismissible .close {\n position: absolute;\n top: 0;\n right: 0;\n padding: 0.75rem 1.25rem;\n color: inherit;\n}\n\n.alert-primary {\n color: #004085;\n background-color: #cce5ff;\n border-color: #b8daff;\n}\n\n.alert-primary hr {\n border-top-color: #9fcdff;\n}\n\n.alert-primary .alert-link {\n color: #002752;\n}\n\n.alert-secondary {\n color: #383d41;\n background-color: #e2e3e5;\n border-color: #d6d8db;\n}\n\n.alert-secondary hr {\n border-top-color: #c8cbcf;\n}\n\n.alert-secondary .alert-link {\n color: #202326;\n}\n\n.alert-success {\n color: #155724;\n background-color: #d4edda;\n border-color: #c3e6cb;\n}\n\n.alert-success hr {\n border-top-color: #b1dfbb;\n}\n\n.alert-success .alert-link {\n color: #0b2e13;\n}\n\n.alert-info {\n color: #0c5460;\n background-color: #d1ecf1;\n border-color: #bee5eb;\n}\n\n.alert-info hr {\n border-top-color: #abdde5;\n}\n\n.alert-info .alert-link {\n color: #062c33;\n}\n\n.alert-warning {\n color: #856404;\n background-color: #fff3cd;\n border-color: #ffeeba;\n}\n\n.alert-warning hr {\n border-top-color: #ffe8a1;\n}\n\n.alert-warning .alert-link {\n color: #533f03;\n}\n\n.alert-danger {\n color: #721c24;\n background-color: #f8d7da;\n border-color: #f5c6cb;\n}\n\n.alert-danger hr {\n border-top-color: #f1b0b7;\n}\n\n.alert-danger .alert-link {\n color: #491217;\n}\n\n.alert-light {\n color: #818182;\n background-color: #fefefe;\n border-color: #fdfdfe;\n}\n\n.alert-light hr {\n border-top-color: #ececf6;\n}\n\n.alert-light .alert-link {\n color: #686868;\n}\n\n.alert-dark {\n color: #1b1e21;\n background-color: #d6d8d9;\n border-color: #c6c8ca;\n}\n\n.alert-dark hr {\n border-top-color: #b9bbbe;\n}\n\n.alert-dark .alert-link {\n color: #040505;\n}\n\n@keyframes progress-bar-stripes {\n from {\n background-position: 1rem 0;\n }\n to {\n background-position: 0 0;\n }\n}\n\n.progress {\n display: flex;\n height: 1rem;\n overflow: hidden;\n font-size: 0.75rem;\n background-color: #e9ecef;\n border-radius: 0.25rem;\n}\n\n.progress-bar {\n display: flex;\n flex-direction: column;\n justify-content: center;\n color: #fff;\n text-align: center;\n white-space: nowrap;\n background-color: #007bff;\n transition: width 0.6s ease;\n}\n\n@media screen and (prefers-reduced-motion: reduce) {\n .progress-bar {\n transition: none;\n }\n}\n\n.progress-bar-striped {\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-size: 1rem 1rem;\n}\n\n.progress-bar-animated {\n animation: progress-bar-stripes 1s linear infinite;\n}\n\n.media {\n display: flex;\n align-items: flex-start;\n}\n\n.media-body {\n flex: 1;\n}\n\n.list-group {\n display: flex;\n flex-direction: column;\n padding-left: 0;\n margin-bottom: 0;\n}\n\n.list-group-item-action {\n width: 100%;\n color: #495057;\n text-align: inherit;\n}\n\n.list-group-item-action:hover, .list-group-item-action:focus {\n color: #495057;\n text-decoration: none;\n background-color: #f8f9fa;\n}\n\n.list-group-item-action:active {\n color: #212529;\n background-color: #e9ecef;\n}\n\n.list-group-item {\n position: relative;\n display: block;\n padding: 0.75rem 1.25rem;\n margin-bottom: -1px;\n background-color: #fff;\n border: 1px solid rgba(0, 0, 0, 0.125);\n}\n\n.list-group-item:first-child {\n border-top-left-radius: 0.25rem;\n border-top-right-radius: 0.25rem;\n}\n\n.list-group-item:last-child {\n margin-bottom: 0;\n border-bottom-right-radius: 0.25rem;\n border-bottom-left-radius: 0.25rem;\n}\n\n.list-group-item:hover, .list-group-item:focus {\n z-index: 1;\n text-decoration: none;\n}\n\n.list-group-item.disabled, .list-group-item:disabled {\n color: #6c757d;\n background-color: #fff;\n}\n\n.list-group-item.active {\n z-index: 2;\n color: #fff;\n background-color: #007bff;\n border-color: #007bff;\n}\n\n.list-group-flush .list-group-item {\n border-right: 0;\n border-left: 0;\n border-radius: 0;\n}\n\n.list-group-flush:first-child .list-group-item:first-child {\n border-top: 0;\n}\n\n.list-group-flush:last-child .list-group-item:last-child {\n border-bottom: 0;\n}\n\n.list-group-item-primary {\n color: #004085;\n background-color: #b8daff;\n}\n\n.list-group-item-primary.list-group-item-action:hover, .list-group-item-primary.list-group-item-action:focus {\n color: #004085;\n background-color: #9fcdff;\n}\n\n.list-group-item-primary.list-group-item-action.active {\n color: #fff;\n background-color: #004085;\n border-color: #004085;\n}\n\n.list-group-item-secondary {\n color: #383d41;\n background-color: #d6d8db;\n}\n\n.list-group-item-secondary.list-group-item-action:hover, .list-group-item-secondary.list-group-item-action:focus {\n color: #383d41;\n background-color: #c8cbcf;\n}\n\n.list-group-item-secondary.list-group-item-action.active {\n color: #fff;\n background-color: #383d41;\n border-color: #383d41;\n}\n\n.list-group-item-success {\n color: #155724;\n background-color: #c3e6cb;\n}\n\n.list-group-item-success.list-group-item-action:hover, .list-group-item-success.list-group-item-action:focus {\n color: #155724;\n background-color: #b1dfbb;\n}\n\n.list-group-item-success.list-group-item-action.active {\n color: #fff;\n background-color: #155724;\n border-color: #155724;\n}\n\n.list-group-item-info {\n color: #0c5460;\n background-color: #bee5eb;\n}\n\n.list-group-item-info.list-group-item-action:hover, .list-group-item-info.list-group-item-action:focus {\n color: #0c5460;\n background-color: #abdde5;\n}\n\n.list-group-item-info.list-group-item-action.active {\n color: #fff;\n background-color: #0c5460;\n border-color: #0c5460;\n}\n\n.list-group-item-warning {\n color: #856404;\n background-color: #ffeeba;\n}\n\n.list-group-item-warning.list-group-item-action:hover, .list-group-item-warning.list-group-item-action:focus {\n color: #856404;\n background-color: #ffe8a1;\n}\n\n.list-group-item-warning.list-group-item-action.active {\n color: #fff;\n background-color: #856404;\n border-color: #856404;\n}\n\n.list-group-item-danger {\n color: #721c24;\n background-color: #f5c6cb;\n}\n\n.list-group-item-danger.list-group-item-action:hover, .list-group-item-danger.list-group-item-action:focus {\n color: #721c24;\n background-color: #f1b0b7;\n}\n\n.list-group-item-danger.list-group-item-action.active {\n color: #fff;\n background-color: #721c24;\n border-color: #721c24;\n}\n\n.list-group-item-light {\n color: #818182;\n background-color: #fdfdfe;\n}\n\n.list-group-item-light.list-group-item-action:hover, .list-group-item-light.list-group-item-action:focus {\n color: #818182;\n background-color: #ececf6;\n}\n\n.list-group-item-light.list-group-item-action.active {\n color: #fff;\n background-color: #818182;\n border-color: #818182;\n}\n\n.list-group-item-dark {\n color: #1b1e21;\n background-color: #c6c8ca;\n}\n\n.list-group-item-dark.list-group-item-action:hover, .list-group-item-dark.list-group-item-action:focus {\n color: #1b1e21;\n background-color: #b9bbbe;\n}\n\n.list-group-item-dark.list-group-item-action.active {\n color: #fff;\n background-color: #1b1e21;\n border-color: #1b1e21;\n}\n\n.close {\n float: right;\n font-size: 1.5rem;\n font-weight: 700;\n line-height: 1;\n color: #000;\n text-shadow: 0 1px 0 #fff;\n opacity: .5;\n}\n\n.close:hover, .close:focus {\n color: #000;\n text-decoration: none;\n opacity: .75;\n}\n\n.close:not(:disabled):not(.disabled) {\n cursor: pointer;\n}\n\nbutton.close {\n padding: 0;\n background-color: transparent;\n border: 0;\n -webkit-appearance: none;\n}\n\n.modal-open {\n overflow: hidden;\n}\n\n.modal {\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1050;\n display: none;\n overflow: hidden;\n outline: 0;\n}\n\n.modal-open .modal {\n overflow-x: hidden;\n overflow-y: auto;\n}\n\n.modal-dialog {\n position: relative;\n width: auto;\n margin: 0.5rem;\n pointer-events: none;\n}\n\n.modal.fade .modal-dialog {\n transition: transform 0.3s ease-out;\n transform: translate(0, -25%);\n}\n\n@media screen and (prefers-reduced-motion: reduce) {\n .modal.fade .modal-dialog {\n transition: none;\n }\n}\n\n.modal.show .modal-dialog {\n transform: translate(0, 0);\n}\n\n.modal-dialog-centered {\n display: flex;\n align-items: center;\n min-height: calc(100% - (0.5rem * 2));\n}\n\n.modal-content {\n position: relative;\n display: flex;\n flex-direction: column;\n width: 100%;\n pointer-events: auto;\n background-color: #fff;\n background-clip: padding-box;\n border: 1px solid rgba(0, 0, 0, 0.2);\n border-radius: 0.3rem;\n outline: 0;\n}\n\n.modal-backdrop {\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1040;\n background-color: #000;\n}\n\n.modal-backdrop.fade {\n opacity: 0;\n}\n\n.modal-backdrop.show {\n opacity: 0.5;\n}\n\n.modal-header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n padding: 1rem;\n border-bottom: 1px solid #e9ecef;\n border-top-left-radius: 0.3rem;\n border-top-right-radius: 0.3rem;\n}\n\n.modal-header .close {\n padding: 1rem;\n margin: -1rem -1rem -1rem auto;\n}\n\n.modal-title {\n margin-bottom: 0;\n line-height: 1.5;\n}\n\n.modal-body {\n position: relative;\n flex: 1 1 auto;\n padding: 1rem;\n}\n\n.modal-footer {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n padding: 1rem;\n border-top: 1px solid #e9ecef;\n}\n\n.modal-footer > :not(:first-child) {\n margin-left: .25rem;\n}\n\n.modal-footer > :not(:last-child) {\n margin-right: .25rem;\n}\n\n.modal-scrollbar-measure {\n position: absolute;\n top: -9999px;\n width: 50px;\n height: 50px;\n overflow: scroll;\n}\n\n@media (min-width: 576px) {\n .modal-dialog {\n max-width: 500px;\n margin: 1.75rem auto;\n }\n .modal-dialog-centered {\n min-height: calc(100% - (1.75rem * 2));\n }\n .modal-sm {\n max-width: 300px;\n }\n}\n\n@media (min-width: 992px) {\n .modal-lg {\n max-width: 800px;\n }\n}\n\n.tooltip {\n position: absolute;\n z-index: 1070;\n display: block;\n margin: 0;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n font-style: normal;\n font-weight: 400;\n line-height: 1.5;\n text-align: left;\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n letter-spacing: normal;\n word-break: normal;\n word-spacing: normal;\n white-space: normal;\n line-break: auto;\n font-size: 0.875rem;\n word-wrap: break-word;\n opacity: 0;\n}\n\n.tooltip.show {\n opacity: 0.9;\n}\n\n.tooltip .arrow {\n position: absolute;\n display: block;\n width: 0.8rem;\n height: 0.4rem;\n}\n\n.tooltip .arrow::before {\n position: absolute;\n content: \"\";\n border-color: transparent;\n border-style: solid;\n}\n\n.bs-tooltip-top, .bs-tooltip-auto[x-placement^=\"top\"] {\n padding: 0.4rem 0;\n}\n\n.bs-tooltip-top .arrow, .bs-tooltip-auto[x-placement^=\"top\"] .arrow {\n bottom: 0;\n}\n\n.bs-tooltip-top .arrow::before, .bs-tooltip-auto[x-placement^=\"top\"] .arrow::before {\n top: 0;\n border-width: 0.4rem 0.4rem 0;\n border-top-color: #000;\n}\n\n.bs-tooltip-right, .bs-tooltip-auto[x-placement^=\"right\"] {\n padding: 0 0.4rem;\n}\n\n.bs-tooltip-right .arrow, .bs-tooltip-auto[x-placement^=\"right\"] .arrow {\n left: 0;\n width: 0.4rem;\n height: 0.8rem;\n}\n\n.bs-tooltip-right .arrow::before, .bs-tooltip-auto[x-placement^=\"right\"] .arrow::before {\n right: 0;\n border-width: 0.4rem 0.4rem 0.4rem 0;\n border-right-color: #000;\n}\n\n.bs-tooltip-bottom, .bs-tooltip-auto[x-placement^=\"bottom\"] {\n padding: 0.4rem 0;\n}\n\n.bs-tooltip-bottom .arrow, .bs-tooltip-auto[x-placement^=\"bottom\"] .arrow {\n top: 0;\n}\n\n.bs-tooltip-bottom .arrow::before, .bs-tooltip-auto[x-placement^=\"bottom\"] .arrow::before {\n bottom: 0;\n border-width: 0 0.4rem 0.4rem;\n border-bottom-color: #000;\n}\n\n.bs-tooltip-left, .bs-tooltip-auto[x-placement^=\"left\"] {\n padding: 0 0.4rem;\n}\n\n.bs-tooltip-left .arrow, .bs-tooltip-auto[x-placement^=\"left\"] .arrow {\n right: 0;\n width: 0.4rem;\n height: 0.8rem;\n}\n\n.bs-tooltip-left .arrow::before, .bs-tooltip-auto[x-placement^=\"left\"] .arrow::before {\n left: 0;\n border-width: 0.4rem 0 0.4rem 0.4rem;\n border-left-color: #000;\n}\n\n.tooltip-inner {\n max-width: 200px;\n padding: 0.25rem 0.5rem;\n color: #fff;\n text-align: center;\n background-color: #000;\n border-radius: 0.25rem;\n}\n\n.popover {\n position: absolute;\n top: 0;\n left: 0;\n z-index: 1060;\n display: block;\n max-width: 276px;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n font-style: normal;\n font-weight: 400;\n line-height: 1.5;\n text-align: left;\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n letter-spacing: normal;\n word-break: normal;\n word-spacing: normal;\n white-space: normal;\n line-break: auto;\n font-size: 0.875rem;\n word-wrap: break-word;\n background-color: #fff;\n background-clip: padding-box;\n border: 1px solid rgba(0, 0, 0, 0.2);\n border-radius: 0.3rem;\n}\n\n.popover .arrow {\n position: absolute;\n display: block;\n width: 1rem;\n height: 0.5rem;\n margin: 0 0.3rem;\n}\n\n.popover .arrow::before, .popover .arrow::after {\n position: absolute;\n display: block;\n content: \"\";\n border-color: transparent;\n border-style: solid;\n}\n\n.bs-popover-top, .bs-popover-auto[x-placement^=\"top\"] {\n margin-bottom: 0.5rem;\n}\n\n.bs-popover-top .arrow, .bs-popover-auto[x-placement^=\"top\"] .arrow {\n bottom: calc((0.5rem + 1px) * -1);\n}\n\n.bs-popover-top .arrow::before, .bs-popover-auto[x-placement^=\"top\"] .arrow::before,\n.bs-popover-top .arrow::after,\n.bs-popover-auto[x-placement^=\"top\"] .arrow::after {\n border-width: 0.5rem 0.5rem 0;\n}\n\n.bs-popover-top .arrow::before, .bs-popover-auto[x-placement^=\"top\"] .arrow::before {\n bottom: 0;\n border-top-color: rgba(0, 0, 0, 0.25);\n}\n\n\n.bs-popover-top .arrow::after,\n.bs-popover-auto[x-placement^=\"top\"] .arrow::after {\n bottom: 1px;\n border-top-color: #fff;\n}\n\n.bs-popover-right, .bs-popover-auto[x-placement^=\"right\"] {\n margin-left: 0.5rem;\n}\n\n.bs-popover-right .arrow, .bs-popover-auto[x-placement^=\"right\"] .arrow {\n left: calc((0.5rem + 1px) * -1);\n width: 0.5rem;\n height: 1rem;\n margin: 0.3rem 0;\n}\n\n.bs-popover-right .arrow::before, .bs-popover-auto[x-placement^=\"right\"] .arrow::before,\n.bs-popover-right .arrow::after,\n.bs-popover-auto[x-placement^=\"right\"] .arrow::after {\n border-width: 0.5rem 0.5rem 0.5rem 0;\n}\n\n.bs-popover-right .arrow::before, .bs-popover-auto[x-placement^=\"right\"] .arrow::before {\n left: 0;\n border-right-color: rgba(0, 0, 0, 0.25);\n}\n\n\n.bs-popover-right .arrow::after,\n.bs-popover-auto[x-placement^=\"right\"] .arrow::after {\n left: 1px;\n border-right-color: #fff;\n}\n\n.bs-popover-bottom, .bs-popover-auto[x-placement^=\"bottom\"] {\n margin-top: 0.5rem;\n}\n\n.bs-popover-bottom .arrow, .bs-popover-auto[x-placement^=\"bottom\"] .arrow {\n top: calc((0.5rem + 1px) * -1);\n}\n\n.bs-popover-bottom .arrow::before, .bs-popover-auto[x-placement^=\"bottom\"] .arrow::before,\n.bs-popover-bottom .arrow::after,\n.bs-popover-auto[x-placement^=\"bottom\"] .arrow::after {\n border-width: 0 0.5rem 0.5rem 0.5rem;\n}\n\n.bs-popover-bottom .arrow::before, .bs-popover-auto[x-placement^=\"bottom\"] .arrow::before {\n top: 0;\n border-bottom-color: rgba(0, 0, 0, 0.25);\n}\n\n\n.bs-popover-bottom .arrow::after,\n.bs-popover-auto[x-placement^=\"bottom\"] .arrow::after {\n top: 1px;\n border-bottom-color: #fff;\n}\n\n.bs-popover-bottom .popover-header::before, .bs-popover-auto[x-placement^=\"bottom\"] .popover-header::before {\n position: absolute;\n top: 0;\n left: 50%;\n display: block;\n width: 1rem;\n margin-left: -0.5rem;\n content: \"\";\n border-bottom: 1px solid #f7f7f7;\n}\n\n.bs-popover-left, .bs-popover-auto[x-placement^=\"left\"] {\n margin-right: 0.5rem;\n}\n\n.bs-popover-left .arrow, .bs-popover-auto[x-placement^=\"left\"] .arrow {\n right: calc((0.5rem + 1px) * -1);\n width: 0.5rem;\n height: 1rem;\n margin: 0.3rem 0;\n}\n\n.bs-popover-left .arrow::before, .bs-popover-auto[x-placement^=\"left\"] .arrow::before,\n.bs-popover-left .arrow::after,\n.bs-popover-auto[x-placement^=\"left\"] .arrow::after {\n border-width: 0.5rem 0 0.5rem 0.5rem;\n}\n\n.bs-popover-left .arrow::before, .bs-popover-auto[x-placement^=\"left\"] .arrow::before {\n right: 0;\n border-left-color: rgba(0, 0, 0, 0.25);\n}\n\n\n.bs-popover-left .arrow::after,\n.bs-popover-auto[x-placement^=\"left\"] .arrow::after {\n right: 1px;\n border-left-color: #fff;\n}\n\n.popover-header {\n padding: 0.5rem 0.75rem;\n margin-bottom: 0;\n font-size: 1rem;\n color: inherit;\n background-color: #f7f7f7;\n border-bottom: 1px solid #ebebeb;\n border-top-left-radius: calc(0.3rem - 1px);\n border-top-right-radius: calc(0.3rem - 1px);\n}\n\n.popover-header:empty {\n display: none;\n}\n\n.popover-body {\n padding: 0.5rem 0.75rem;\n color: #212529;\n}\n\n.carousel {\n position: relative;\n}\n\n.carousel-inner {\n position: relative;\n width: 100%;\n overflow: hidden;\n}\n\n.carousel-item {\n position: relative;\n display: none;\n align-items: center;\n width: 100%;\n transition: transform 0.6s ease;\n backface-visibility: hidden;\n perspective: 1000px;\n}\n\n@media screen and (prefers-reduced-motion: reduce) {\n .carousel-item {\n transition: none;\n }\n}\n\n.carousel-item.active,\n.carousel-item-next,\n.carousel-item-prev {\n display: block;\n}\n\n.carousel-item-next,\n.carousel-item-prev {\n position: absolute;\n top: 0;\n}\n\n.carousel-item-next.carousel-item-left,\n.carousel-item-prev.carousel-item-right {\n transform: translateX(0);\n}\n\n@supports (transform-style: preserve-3d) {\n .carousel-item-next.carousel-item-left,\n .carousel-item-prev.carousel-item-right {\n transform: translate3d(0, 0, 0);\n }\n}\n\n.carousel-item-next,\n.active.carousel-item-right {\n transform: translateX(100%);\n}\n\n@supports (transform-style: preserve-3d) {\n .carousel-item-next,\n .active.carousel-item-right {\n transform: translate3d(100%, 0, 0);\n }\n}\n\n.carousel-item-prev,\n.active.carousel-item-left {\n transform: translateX(-100%);\n}\n\n@supports (transform-style: preserve-3d) {\n .carousel-item-prev,\n .active.carousel-item-left {\n transform: translate3d(-100%, 0, 0);\n }\n}\n\n.carousel-fade .carousel-item {\n opacity: 0;\n transition-duration: .6s;\n transition-property: opacity;\n}\n\n.carousel-fade .carousel-item.active,\n.carousel-fade .carousel-item-next.carousel-item-left,\n.carousel-fade .carousel-item-prev.carousel-item-right {\n opacity: 1;\n}\n\n.carousel-fade .active.carousel-item-left,\n.carousel-fade .active.carousel-item-right {\n opacity: 0;\n}\n\n.carousel-fade .carousel-item-next,\n.carousel-fade .carousel-item-prev,\n.carousel-fade .carousel-item.active,\n.carousel-fade .active.carousel-item-left,\n.carousel-fade .active.carousel-item-prev {\n transform: translateX(0);\n}\n\n@supports (transform-style: preserve-3d) {\n .carousel-fade .carousel-item-next,\n .carousel-fade .carousel-item-prev,\n .carousel-fade .carousel-item.active,\n .carousel-fade .active.carousel-item-left,\n .carousel-fade .active.carousel-item-prev {\n transform: translate3d(0, 0, 0);\n }\n}\n\n.carousel-control-prev,\n.carousel-control-next {\n position: absolute;\n top: 0;\n bottom: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n width: 15%;\n color: #fff;\n text-align: center;\n opacity: 0.5;\n}\n\n.carousel-control-prev:hover, .carousel-control-prev:focus,\n.carousel-control-next:hover,\n.carousel-control-next:focus {\n color: #fff;\n text-decoration: none;\n outline: 0;\n opacity: .9;\n}\n\n.carousel-control-prev {\n left: 0;\n}\n\n.carousel-control-next {\n right: 0;\n}\n\n.carousel-control-prev-icon,\n.carousel-control-next-icon {\n display: inline-block;\n width: 20px;\n height: 20px;\n background: transparent no-repeat center center;\n background-size: 100% 100%;\n}\n\n.carousel-control-prev-icon {\n background-image: url(\"data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 8 8'%3E%3Cpath d='M5.25 0l-4 4 4 4 1.5-1.5-2.5-2.5 2.5-2.5-1.5-1.5z'/%3E%3C/svg%3E\");\n}\n\n.carousel-control-next-icon {\n background-image: url(\"data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 8 8'%3E%3Cpath d='M2.75 0l-1.5 1.5 2.5 2.5-2.5 2.5 1.5 1.5 4-4-4-4z'/%3E%3C/svg%3E\");\n}\n\n.carousel-indicators {\n position: absolute;\n right: 0;\n bottom: 10px;\n left: 0;\n z-index: 15;\n display: flex;\n justify-content: center;\n padding-left: 0;\n margin-right: 15%;\n margin-left: 15%;\n list-style: none;\n}\n\n.carousel-indicators li {\n position: relative;\n flex: 0 1 auto;\n width: 30px;\n height: 3px;\n margin-right: 3px;\n margin-left: 3px;\n text-indent: -999px;\n cursor: pointer;\n background-color: rgba(255, 255, 255, 0.5);\n}\n\n.carousel-indicators li::before {\n position: absolute;\n top: -10px;\n left: 0;\n display: inline-block;\n width: 100%;\n height: 10px;\n content: \"\";\n}\n\n.carousel-indicators li::after {\n position: absolute;\n bottom: -10px;\n left: 0;\n display: inline-block;\n width: 100%;\n height: 10px;\n content: \"\";\n}\n\n.carousel-indicators .active {\n background-color: #fff;\n}\n\n.carousel-caption {\n position: absolute;\n right: 15%;\n bottom: 20px;\n left: 15%;\n z-index: 10;\n padding-top: 20px;\n padding-bottom: 20px;\n color: #fff;\n text-align: center;\n}\n\n.align-baseline {\n vertical-align: baseline !important;\n}\n\n.align-top {\n vertical-align: top !important;\n}\n\n.align-middle {\n vertical-align: middle !important;\n}\n\n.align-bottom {\n vertical-align: bottom !important;\n}\n\n.align-text-bottom {\n vertical-align: text-bottom !important;\n}\n\n.align-text-top {\n vertical-align: text-top !important;\n}\n\n.bg-primary {\n background-color: #007bff !important;\n}\n\na.bg-primary:hover, a.bg-primary:focus,\nbutton.bg-primary:hover,\nbutton.bg-primary:focus {\n background-color: #0062cc !important;\n}\n\n.bg-secondary {\n background-color: #6c757d !important;\n}\n\na.bg-secondary:hover, a.bg-secondary:focus,\nbutton.bg-secondary:hover,\nbutton.bg-secondary:focus {\n background-color: #545b62 !important;\n}\n\n.bg-success {\n background-color: #28a745 !important;\n}\n\na.bg-success:hover, a.bg-success:focus,\nbutton.bg-success:hover,\nbutton.bg-success:focus {\n background-color: #1e7e34 !important;\n}\n\n.bg-info {\n background-color: #17a2b8 !important;\n}\n\na.bg-info:hover, a.bg-info:focus,\nbutton.bg-info:hover,\nbutton.bg-info:focus {\n background-color: #117a8b !important;\n}\n\n.bg-warning {\n background-color: #ffc107 !important;\n}\n\na.bg-warning:hover, a.bg-warning:focus,\nbutton.bg-warning:hover,\nbutton.bg-warning:focus {\n background-color: #d39e00 !important;\n}\n\n.bg-danger {\n background-color: #dc3545 !important;\n}\n\na.bg-danger:hover, a.bg-danger:focus,\nbutton.bg-danger:hover,\nbutton.bg-danger:focus {\n background-color: #bd2130 !important;\n}\n\n.bg-light {\n background-color: #f8f9fa !important;\n}\n\na.bg-light:hover, a.bg-light:focus,\nbutton.bg-light:hover,\nbutton.bg-light:focus {\n background-color: #dae0e5 !important;\n}\n\n.bg-dark {\n background-color: #343a40 !important;\n}\n\na.bg-dark:hover, a.bg-dark:focus,\nbutton.bg-dark:hover,\nbutton.bg-dark:focus {\n background-color: #1d2124 !important;\n}\n\n.bg-white {\n background-color: #fff !important;\n}\n\n.bg-transparent {\n background-color: transparent !important;\n}\n\n.border {\n border: 1px solid #dee2e6 !important;\n}\n\n.border-top {\n border-top: 1px solid #dee2e6 !important;\n}\n\n.border-right {\n border-right: 1px solid #dee2e6 !important;\n}\n\n.border-bottom {\n border-bottom: 1px solid #dee2e6 !important;\n}\n\n.border-left {\n border-left: 1px solid #dee2e6 !important;\n}\n\n.border-0 {\n border: 0 !important;\n}\n\n.border-top-0 {\n border-top: 0 !important;\n}\n\n.border-right-0 {\n border-right: 0 !important;\n}\n\n.border-bottom-0 {\n border-bottom: 0 !important;\n}\n\n.border-left-0 {\n border-left: 0 !important;\n}\n\n.border-primary {\n border-color: #007bff !important;\n}\n\n.border-secondary {\n border-color: #6c757d !important;\n}\n\n.border-success {\n border-color: #28a745 !important;\n}\n\n.border-info {\n border-color: #17a2b8 !important;\n}\n\n.border-warning {\n border-color: #ffc107 !important;\n}\n\n.border-danger {\n border-color: #dc3545 !important;\n}\n\n.border-light {\n border-color: #f8f9fa !important;\n}\n\n.border-dark {\n border-color: #343a40 !important;\n}\n\n.border-white {\n border-color: #fff !important;\n}\n\n.rounded {\n border-radius: 0.25rem !important;\n}\n\n.rounded-top {\n border-top-left-radius: 0.25rem !important;\n border-top-right-radius: 0.25rem !important;\n}\n\n.rounded-right {\n border-top-right-radius: 0.25rem !important;\n border-bottom-right-radius: 0.25rem !important;\n}\n\n.rounded-bottom {\n border-bottom-right-radius: 0.25rem !important;\n border-bottom-left-radius: 0.25rem !important;\n}\n\n.rounded-left {\n border-top-left-radius: 0.25rem !important;\n border-bottom-left-radius: 0.25rem !important;\n}\n\n.rounded-circle {\n border-radius: 50% !important;\n}\n\n.rounded-0 {\n border-radius: 0 !important;\n}\n\n.clearfix::after {\n display: block;\n clear: both;\n content: \"\";\n}\n\n.d-none {\n display: none !important;\n}\n\n.d-inline {\n display: inline !important;\n}\n\n.d-inline-block {\n display: inline-block !important;\n}\n\n.d-block {\n display: block !important;\n}\n\n.d-table {\n display: table !important;\n}\n\n.d-table-row {\n display: table-row !important;\n}\n\n.d-table-cell {\n display: table-cell !important;\n}\n\n.d-flex {\n display: flex !important;\n}\n\n.d-inline-flex {\n display: inline-flex !important;\n}\n\n@media (min-width: 576px) {\n .d-sm-none {\n display: none !important;\n }\n .d-sm-inline {\n display: inline !important;\n }\n .d-sm-inline-block {\n display: inline-block !important;\n }\n .d-sm-block {\n display: block !important;\n }\n .d-sm-table {\n display: table !important;\n }\n .d-sm-table-row {\n display: table-row !important;\n }\n .d-sm-table-cell {\n display: table-cell !important;\n }\n .d-sm-flex {\n display: flex !important;\n }\n .d-sm-inline-flex {\n display: inline-flex !important;\n }\n}\n\n@media (min-width: 768px) {\n .d-md-none {\n display: none !important;\n }\n .d-md-inline {\n display: inline !important;\n }\n .d-md-inline-block {\n display: inline-block !important;\n }\n .d-md-block {\n display: block !important;\n }\n .d-md-table {\n display: table !important;\n }\n .d-md-table-row {\n display: table-row !important;\n }\n .d-md-table-cell {\n display: table-cell !important;\n }\n .d-md-flex {\n display: flex !important;\n }\n .d-md-inline-flex {\n display: inline-flex !important;\n }\n}\n\n@media (min-width: 992px) {\n .d-lg-none {\n display: none !important;\n }\n .d-lg-inline {\n display: inline !important;\n }\n .d-lg-inline-block {\n display: inline-block !important;\n }\n .d-lg-block {\n display: block !important;\n }\n .d-lg-table {\n display: table !important;\n }\n .d-lg-table-row {\n display: table-row !important;\n }\n .d-lg-table-cell {\n display: table-cell !important;\n }\n .d-lg-flex {\n display: flex !important;\n }\n .d-lg-inline-flex {\n display: inline-flex !important;\n }\n}\n\n@media (min-width: 1200px) {\n .d-xl-none {\n display: none !important;\n }\n .d-xl-inline {\n display: inline !important;\n }\n .d-xl-inline-block {\n display: inline-block !important;\n }\n .d-xl-block {\n display: block !important;\n }\n .d-xl-table {\n display: table !important;\n }\n .d-xl-table-row {\n display: table-row !important;\n }\n .d-xl-table-cell {\n display: table-cell !important;\n }\n .d-xl-flex {\n display: flex !important;\n }\n .d-xl-inline-flex {\n display: inline-flex !important;\n }\n}\n\n@media print {\n .d-print-none {\n display: none !important;\n }\n .d-print-inline {\n display: inline !important;\n }\n .d-print-inline-block {\n display: inline-block !important;\n }\n .d-print-block {\n display: block !important;\n }\n .d-print-table {\n display: table !important;\n }\n .d-print-table-row {\n display: table-row !important;\n }\n .d-print-table-cell {\n display: table-cell !important;\n }\n .d-print-flex {\n display: flex !important;\n }\n .d-print-inline-flex {\n display: inline-flex !important;\n }\n}\n\n.embed-responsive {\n position: relative;\n display: block;\n width: 100%;\n padding: 0;\n overflow: hidden;\n}\n\n.embed-responsive::before {\n display: block;\n content: \"\";\n}\n\n.embed-responsive .embed-responsive-item,\n.embed-responsive iframe,\n.embed-responsive embed,\n.embed-responsive object,\n.embed-responsive video {\n position: absolute;\n top: 0;\n bottom: 0;\n left: 0;\n width: 100%;\n height: 100%;\n border: 0;\n}\n\n.embed-responsive-21by9::before {\n padding-top: 42.857143%;\n}\n\n.embed-responsive-16by9::before {\n padding-top: 56.25%;\n}\n\n.embed-responsive-4by3::before {\n padding-top: 75%;\n}\n\n.embed-responsive-1by1::before {\n padding-top: 100%;\n}\n\n.flex-row {\n flex-direction: row !important;\n}\n\n.flex-column {\n flex-direction: column !important;\n}\n\n.flex-row-reverse {\n flex-direction: row-reverse !important;\n}\n\n.flex-column-reverse {\n flex-direction: column-reverse !important;\n}\n\n.flex-wrap {\n flex-wrap: wrap !important;\n}\n\n.flex-nowrap {\n flex-wrap: nowrap !important;\n}\n\n.flex-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n}\n\n.flex-fill {\n flex: 1 1 auto !important;\n}\n\n.flex-grow-0 {\n flex-grow: 0 !important;\n}\n\n.flex-grow-1 {\n flex-grow: 1 !important;\n}\n\n.flex-shrink-0 {\n flex-shrink: 0 !important;\n}\n\n.flex-shrink-1 {\n flex-shrink: 1 !important;\n}\n\n.justify-content-start {\n justify-content: flex-start !important;\n}\n\n.justify-content-end {\n justify-content: flex-end !important;\n}\n\n.justify-content-center {\n justify-content: center !important;\n}\n\n.justify-content-between {\n justify-content: space-between !important;\n}\n\n.justify-content-around {\n justify-content: space-around !important;\n}\n\n.align-items-start {\n align-items: flex-start !important;\n}\n\n.align-items-end {\n align-items: flex-end !important;\n}\n\n.align-items-center {\n align-items: center !important;\n}\n\n.align-items-baseline {\n align-items: baseline !important;\n}\n\n.align-items-stretch {\n align-items: stretch !important;\n}\n\n.align-content-start {\n align-content: flex-start !important;\n}\n\n.align-content-end {\n align-content: flex-end !important;\n}\n\n.align-content-center {\n align-content: center !important;\n}\n\n.align-content-between {\n align-content: space-between !important;\n}\n\n.align-content-around {\n align-content: space-around !important;\n}\n\n.align-content-stretch {\n align-content: stretch !important;\n}\n\n.align-self-auto {\n align-self: auto !important;\n}\n\n.align-self-start {\n align-self: flex-start !important;\n}\n\n.align-self-end {\n align-self: flex-end !important;\n}\n\n.align-self-center {\n align-self: center !important;\n}\n\n.align-self-baseline {\n align-self: baseline !important;\n}\n\n.align-self-stretch {\n align-self: stretch !important;\n}\n\n@media (min-width: 576px) {\n .flex-sm-row {\n flex-direction: row !important;\n }\n .flex-sm-column {\n flex-direction: column !important;\n }\n .flex-sm-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-sm-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-sm-wrap {\n flex-wrap: wrap !important;\n }\n .flex-sm-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-sm-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .flex-sm-fill {\n flex: 1 1 auto !important;\n }\n .flex-sm-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-sm-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-sm-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-sm-shrink-1 {\n flex-shrink: 1 !important;\n }\n .justify-content-sm-start {\n justify-content: flex-start !important;\n }\n .justify-content-sm-end {\n justify-content: flex-end !important;\n }\n .justify-content-sm-center {\n justify-content: center !important;\n }\n .justify-content-sm-between {\n justify-content: space-between !important;\n }\n .justify-content-sm-around {\n justify-content: space-around !important;\n }\n .align-items-sm-start {\n align-items: flex-start !important;\n }\n .align-items-sm-end {\n align-items: flex-end !important;\n }\n .align-items-sm-center {\n align-items: center !important;\n }\n .align-items-sm-baseline {\n align-items: baseline !important;\n }\n .align-items-sm-stretch {\n align-items: stretch !important;\n }\n .align-content-sm-start {\n align-content: flex-start !important;\n }\n .align-content-sm-end {\n align-content: flex-end !important;\n }\n .align-content-sm-center {\n align-content: center !important;\n }\n .align-content-sm-between {\n align-content: space-between !important;\n }\n .align-content-sm-around {\n align-content: space-around !important;\n }\n .align-content-sm-stretch {\n align-content: stretch !important;\n }\n .align-self-sm-auto {\n align-self: auto !important;\n }\n .align-self-sm-start {\n align-self: flex-start !important;\n }\n .align-self-sm-end {\n align-self: flex-end !important;\n }\n .align-self-sm-center {\n align-self: center !important;\n }\n .align-self-sm-baseline {\n align-self: baseline !important;\n }\n .align-self-sm-stretch {\n align-self: stretch !important;\n }\n}\n\n@media (min-width: 768px) {\n .flex-md-row {\n flex-direction: row !important;\n }\n .flex-md-column {\n flex-direction: column !important;\n }\n .flex-md-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-md-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-md-wrap {\n flex-wrap: wrap !important;\n }\n .flex-md-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-md-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .flex-md-fill {\n flex: 1 1 auto !important;\n }\n .flex-md-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-md-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-md-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-md-shrink-1 {\n flex-shrink: 1 !important;\n }\n .justify-content-md-start {\n justify-content: flex-start !important;\n }\n .justify-content-md-end {\n justify-content: flex-end !important;\n }\n .justify-content-md-center {\n justify-content: center !important;\n }\n .justify-content-md-between {\n justify-content: space-between !important;\n }\n .justify-content-md-around {\n justify-content: space-around !important;\n }\n .align-items-md-start {\n align-items: flex-start !important;\n }\n .align-items-md-end {\n align-items: flex-end !important;\n }\n .align-items-md-center {\n align-items: center !important;\n }\n .align-items-md-baseline {\n align-items: baseline !important;\n }\n .align-items-md-stretch {\n align-items: stretch !important;\n }\n .align-content-md-start {\n align-content: flex-start !important;\n }\n .align-content-md-end {\n align-content: flex-end !important;\n }\n .align-content-md-center {\n align-content: center !important;\n }\n .align-content-md-between {\n align-content: space-between !important;\n }\n .align-content-md-around {\n align-content: space-around !important;\n }\n .align-content-md-stretch {\n align-content: stretch !important;\n }\n .align-self-md-auto {\n align-self: auto !important;\n }\n .align-self-md-start {\n align-self: flex-start !important;\n }\n .align-self-md-end {\n align-self: flex-end !important;\n }\n .align-self-md-center {\n align-self: center !important;\n }\n .align-self-md-baseline {\n align-self: baseline !important;\n }\n .align-self-md-stretch {\n align-self: stretch !important;\n }\n}\n\n@media (min-width: 992px) {\n .flex-lg-row {\n flex-direction: row !important;\n }\n .flex-lg-column {\n flex-direction: column !important;\n }\n .flex-lg-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-lg-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-lg-wrap {\n flex-wrap: wrap !important;\n }\n .flex-lg-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-lg-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .flex-lg-fill {\n flex: 1 1 auto !important;\n }\n .flex-lg-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-lg-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-lg-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-lg-shrink-1 {\n flex-shrink: 1 !important;\n }\n .justify-content-lg-start {\n justify-content: flex-start !important;\n }\n .justify-content-lg-end {\n justify-content: flex-end !important;\n }\n .justify-content-lg-center {\n justify-content: center !important;\n }\n .justify-content-lg-between {\n justify-content: space-between !important;\n }\n .justify-content-lg-around {\n justify-content: space-around !important;\n }\n .align-items-lg-start {\n align-items: flex-start !important;\n }\n .align-items-lg-end {\n align-items: flex-end !important;\n }\n .align-items-lg-center {\n align-items: center !important;\n }\n .align-items-lg-baseline {\n align-items: baseline !important;\n }\n .align-items-lg-stretch {\n align-items: stretch !important;\n }\n .align-content-lg-start {\n align-content: flex-start !important;\n }\n .align-content-lg-end {\n align-content: flex-end !important;\n }\n .align-content-lg-center {\n align-content: center !important;\n }\n .align-content-lg-between {\n align-content: space-between !important;\n }\n .align-content-lg-around {\n align-content: space-around !important;\n }\n .align-content-lg-stretch {\n align-content: stretch !important;\n }\n .align-self-lg-auto {\n align-self: auto !important;\n }\n .align-self-lg-start {\n align-self: flex-start !important;\n }\n .align-self-lg-end {\n align-self: flex-end !important;\n }\n .align-self-lg-center {\n align-self: center !important;\n }\n .align-self-lg-baseline {\n align-self: baseline !important;\n }\n .align-self-lg-stretch {\n align-self: stretch !important;\n }\n}\n\n@media (min-width: 1200px) {\n .flex-xl-row {\n flex-direction: row !important;\n }\n .flex-xl-column {\n flex-direction: column !important;\n }\n .flex-xl-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-xl-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-xl-wrap {\n flex-wrap: wrap !important;\n }\n .flex-xl-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-xl-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .flex-xl-fill {\n flex: 1 1 auto !important;\n }\n .flex-xl-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-xl-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-xl-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-xl-shrink-1 {\n flex-shrink: 1 !important;\n }\n .justify-content-xl-start {\n justify-content: flex-start !important;\n }\n .justify-content-xl-end {\n justify-content: flex-end !important;\n }\n .justify-content-xl-center {\n justify-content: center !important;\n }\n .justify-content-xl-between {\n justify-content: space-between !important;\n }\n .justify-content-xl-around {\n justify-content: space-around !important;\n }\n .align-items-xl-start {\n align-items: flex-start !important;\n }\n .align-items-xl-end {\n align-items: flex-end !important;\n }\n .align-items-xl-center {\n align-items: center !important;\n }\n .align-items-xl-baseline {\n align-items: baseline !important;\n }\n .align-items-xl-stretch {\n align-items: stretch !important;\n }\n .align-content-xl-start {\n align-content: flex-start !important;\n }\n .align-content-xl-end {\n align-content: flex-end !important;\n }\n .align-content-xl-center {\n align-content: center !important;\n }\n .align-content-xl-between {\n align-content: space-between !important;\n }\n .align-content-xl-around {\n align-content: space-around !important;\n }\n .align-content-xl-stretch {\n align-content: stretch !important;\n }\n .align-self-xl-auto {\n align-self: auto !important;\n }\n .align-self-xl-start {\n align-self: flex-start !important;\n }\n .align-self-xl-end {\n align-self: flex-end !important;\n }\n .align-self-xl-center {\n align-self: center !important;\n }\n .align-self-xl-baseline {\n align-self: baseline !important;\n }\n .align-self-xl-stretch {\n align-self: stretch !important;\n }\n}\n\n.float-left {\n float: left !important;\n}\n\n.float-right {\n float: right !important;\n}\n\n.float-none {\n float: none !important;\n}\n\n@media (min-width: 576px) {\n .float-sm-left {\n float: left !important;\n }\n .float-sm-right {\n float: right !important;\n }\n .float-sm-none {\n float: none !important;\n }\n}\n\n@media (min-width: 768px) {\n .float-md-left {\n float: left !important;\n }\n .float-md-right {\n float: right !important;\n }\n .float-md-none {\n float: none !important;\n }\n}\n\n@media (min-width: 992px) {\n .float-lg-left {\n float: left !important;\n }\n .float-lg-right {\n float: right !important;\n }\n .float-lg-none {\n float: none !important;\n }\n}\n\n@media (min-width: 1200px) {\n .float-xl-left {\n float: left !important;\n }\n .float-xl-right {\n float: right !important;\n }\n .float-xl-none {\n float: none !important;\n }\n}\n\n.position-static {\n position: static !important;\n}\n\n.position-relative {\n position: relative !important;\n}\n\n.position-absolute {\n position: absolute !important;\n}\n\n.position-fixed {\n position: fixed !important;\n}\n\n.position-sticky {\n position: sticky !important;\n}\n\n.fixed-top {\n position: fixed;\n top: 0;\n right: 0;\n left: 0;\n z-index: 1030;\n}\n\n.fixed-bottom {\n position: fixed;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1030;\n}\n\n@supports (position: sticky) {\n .sticky-top {\n position: sticky;\n top: 0;\n z-index: 1020;\n }\n}\n\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border: 0;\n}\n\n.sr-only-focusable:active, .sr-only-focusable:focus {\n position: static;\n width: auto;\n height: auto;\n overflow: visible;\n clip: auto;\n white-space: normal;\n}\n\n.shadow-sm {\n box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075) !important;\n}\n\n.shadow {\n box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;\n}\n\n.shadow-lg {\n box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.175) !important;\n}\n\n.shadow-none {\n box-shadow: none !important;\n}\n\n.w-25 {\n width: 25% !important;\n}\n\n.w-50 {\n width: 50% !important;\n}\n\n.w-75 {\n width: 75% !important;\n}\n\n.w-100 {\n width: 100% !important;\n}\n\n.w-auto {\n width: auto !important;\n}\n\n.h-25 {\n height: 25% !important;\n}\n\n.h-50 {\n height: 50% !important;\n}\n\n.h-75 {\n height: 75% !important;\n}\n\n.h-100 {\n height: 100% !important;\n}\n\n.h-auto {\n height: auto !important;\n}\n\n.mw-100 {\n max-width: 100% !important;\n}\n\n.mh-100 {\n max-height: 100% !important;\n}\n\n.m-0 {\n margin: 0 !important;\n}\n\n.mt-0,\n.my-0 {\n margin-top: 0 !important;\n}\n\n.mr-0,\n.mx-0 {\n margin-right: 0 !important;\n}\n\n.mb-0,\n.my-0 {\n margin-bottom: 0 !important;\n}\n\n.ml-0,\n.mx-0 {\n margin-left: 0 !important;\n}\n\n.m-1 {\n margin: 0.25rem !important;\n}\n\n.mt-1,\n.my-1 {\n margin-top: 0.25rem !important;\n}\n\n.mr-1,\n.mx-1 {\n margin-right: 0.25rem !important;\n}\n\n.mb-1,\n.my-1 {\n margin-bottom: 0.25rem !important;\n}\n\n.ml-1,\n.mx-1 {\n margin-left: 0.25rem !important;\n}\n\n.m-2 {\n margin: 0.5rem !important;\n}\n\n.mt-2,\n.my-2 {\n margin-top: 0.5rem !important;\n}\n\n.mr-2,\n.mx-2 {\n margin-right: 0.5rem !important;\n}\n\n.mb-2,\n.my-2 {\n margin-bottom: 0.5rem !important;\n}\n\n.ml-2,\n.mx-2 {\n margin-left: 0.5rem !important;\n}\n\n.m-3 {\n margin: 1rem !important;\n}\n\n.mt-3,\n.my-3 {\n margin-top: 1rem !important;\n}\n\n.mr-3,\n.mx-3 {\n margin-right: 1rem !important;\n}\n\n.mb-3,\n.my-3 {\n margin-bottom: 1rem !important;\n}\n\n.ml-3,\n.mx-3 {\n margin-left: 1rem !important;\n}\n\n.m-4 {\n margin: 1.5rem !important;\n}\n\n.mt-4,\n.my-4 {\n margin-top: 1.5rem !important;\n}\n\n.mr-4,\n.mx-4 {\n margin-right: 1.5rem !important;\n}\n\n.mb-4,\n.my-4 {\n margin-bottom: 1.5rem !important;\n}\n\n.ml-4,\n.mx-4 {\n margin-left: 1.5rem !important;\n}\n\n.m-5 {\n margin: 3rem !important;\n}\n\n.mt-5,\n.my-5 {\n margin-top: 3rem !important;\n}\n\n.mr-5,\n.mx-5 {\n margin-right: 3rem !important;\n}\n\n.mb-5,\n.my-5 {\n margin-bottom: 3rem !important;\n}\n\n.ml-5,\n.mx-5 {\n margin-left: 3rem !important;\n}\n\n.p-0 {\n padding: 0 !important;\n}\n\n.pt-0,\n.py-0 {\n padding-top: 0 !important;\n}\n\n.pr-0,\n.px-0 {\n padding-right: 0 !important;\n}\n\n.pb-0,\n.py-0 {\n padding-bottom: 0 !important;\n}\n\n.pl-0,\n.px-0 {\n padding-left: 0 !important;\n}\n\n.p-1 {\n padding: 0.25rem !important;\n}\n\n.pt-1,\n.py-1 {\n padding-top: 0.25rem !important;\n}\n\n.pr-1,\n.px-1 {\n padding-right: 0.25rem !important;\n}\n\n.pb-1,\n.py-1 {\n padding-bottom: 0.25rem !important;\n}\n\n.pl-1,\n.px-1 {\n padding-left: 0.25rem !important;\n}\n\n.p-2 {\n padding: 0.5rem !important;\n}\n\n.pt-2,\n.py-2 {\n padding-top: 0.5rem !important;\n}\n\n.pr-2,\n.px-2 {\n padding-right: 0.5rem !important;\n}\n\n.pb-2,\n.py-2 {\n padding-bottom: 0.5rem !important;\n}\n\n.pl-2,\n.px-2 {\n padding-left: 0.5rem !important;\n}\n\n.p-3 {\n padding: 1rem !important;\n}\n\n.pt-3,\n.py-3 {\n padding-top: 1rem !important;\n}\n\n.pr-3,\n.px-3 {\n padding-right: 1rem !important;\n}\n\n.pb-3,\n.py-3 {\n padding-bottom: 1rem !important;\n}\n\n.pl-3,\n.px-3 {\n padding-left: 1rem !important;\n}\n\n.p-4 {\n padding: 1.5rem !important;\n}\n\n.pt-4,\n.py-4 {\n padding-top: 1.5rem !important;\n}\n\n.pr-4,\n.px-4 {\n padding-right: 1.5rem !important;\n}\n\n.pb-4,\n.py-4 {\n padding-bottom: 1.5rem !important;\n}\n\n.pl-4,\n.px-4 {\n padding-left: 1.5rem !important;\n}\n\n.p-5 {\n padding: 3rem !important;\n}\n\n.pt-5,\n.py-5 {\n padding-top: 3rem !important;\n}\n\n.pr-5,\n.px-5 {\n padding-right: 3rem !important;\n}\n\n.pb-5,\n.py-5 {\n padding-bottom: 3rem !important;\n}\n\n.pl-5,\n.px-5 {\n padding-left: 3rem !important;\n}\n\n.m-auto {\n margin: auto !important;\n}\n\n.mt-auto,\n.my-auto {\n margin-top: auto !important;\n}\n\n.mr-auto,\n.mx-auto {\n margin-right: auto !important;\n}\n\n.mb-auto,\n.my-auto {\n margin-bottom: auto !important;\n}\n\n.ml-auto,\n.mx-auto {\n margin-left: auto !important;\n}\n\n@media (min-width: 576px) {\n .m-sm-0 {\n margin: 0 !important;\n }\n .mt-sm-0,\n .my-sm-0 {\n margin-top: 0 !important;\n }\n .mr-sm-0,\n .mx-sm-0 {\n margin-right: 0 !important;\n }\n .mb-sm-0,\n .my-sm-0 {\n margin-bottom: 0 !important;\n }\n .ml-sm-0,\n .mx-sm-0 {\n margin-left: 0 !important;\n }\n .m-sm-1 {\n margin: 0.25rem !important;\n }\n .mt-sm-1,\n .my-sm-1 {\n margin-top: 0.25rem !important;\n }\n .mr-sm-1,\n .mx-sm-1 {\n margin-right: 0.25rem !important;\n }\n .mb-sm-1,\n .my-sm-1 {\n margin-bottom: 0.25rem !important;\n }\n .ml-sm-1,\n .mx-sm-1 {\n margin-left: 0.25rem !important;\n }\n .m-sm-2 {\n margin: 0.5rem !important;\n }\n .mt-sm-2,\n .my-sm-2 {\n margin-top: 0.5rem !important;\n }\n .mr-sm-2,\n .mx-sm-2 {\n margin-right: 0.5rem !important;\n }\n .mb-sm-2,\n .my-sm-2 {\n margin-bottom: 0.5rem !important;\n }\n .ml-sm-2,\n .mx-sm-2 {\n margin-left: 0.5rem !important;\n }\n .m-sm-3 {\n margin: 1rem !important;\n }\n .mt-sm-3,\n .my-sm-3 {\n margin-top: 1rem !important;\n }\n .mr-sm-3,\n .mx-sm-3 {\n margin-right: 1rem !important;\n }\n .mb-sm-3,\n .my-sm-3 {\n margin-bottom: 1rem !important;\n }\n .ml-sm-3,\n .mx-sm-3 {\n margin-left: 1rem !important;\n }\n .m-sm-4 {\n margin: 1.5rem !important;\n }\n .mt-sm-4,\n .my-sm-4 {\n margin-top: 1.5rem !important;\n }\n .mr-sm-4,\n .mx-sm-4 {\n margin-right: 1.5rem !important;\n }\n .mb-sm-4,\n .my-sm-4 {\n margin-bottom: 1.5rem !important;\n }\n .ml-sm-4,\n .mx-sm-4 {\n margin-left: 1.5rem !important;\n }\n .m-sm-5 {\n margin: 3rem !important;\n }\n .mt-sm-5,\n .my-sm-5 {\n margin-top: 3rem !important;\n }\n .mr-sm-5,\n .mx-sm-5 {\n margin-right: 3rem !important;\n }\n .mb-sm-5,\n .my-sm-5 {\n margin-bottom: 3rem !important;\n }\n .ml-sm-5,\n .mx-sm-5 {\n margin-left: 3rem !important;\n }\n .p-sm-0 {\n padding: 0 !important;\n }\n .pt-sm-0,\n .py-sm-0 {\n padding-top: 0 !important;\n }\n .pr-sm-0,\n .px-sm-0 {\n padding-right: 0 !important;\n }\n .pb-sm-0,\n .py-sm-0 {\n padding-bottom: 0 !important;\n }\n .pl-sm-0,\n .px-sm-0 {\n padding-left: 0 !important;\n }\n .p-sm-1 {\n padding: 0.25rem !important;\n }\n .pt-sm-1,\n .py-sm-1 {\n padding-top: 0.25rem !important;\n }\n .pr-sm-1,\n .px-sm-1 {\n padding-right: 0.25rem !important;\n }\n .pb-sm-1,\n .py-sm-1 {\n padding-bottom: 0.25rem !important;\n }\n .pl-sm-1,\n .px-sm-1 {\n padding-left: 0.25rem !important;\n }\n .p-sm-2 {\n padding: 0.5rem !important;\n }\n .pt-sm-2,\n .py-sm-2 {\n padding-top: 0.5rem !important;\n }\n .pr-sm-2,\n .px-sm-2 {\n padding-right: 0.5rem !important;\n }\n .pb-sm-2,\n .py-sm-2 {\n padding-bottom: 0.5rem !important;\n }\n .pl-sm-2,\n .px-sm-2 {\n padding-left: 0.5rem !important;\n }\n .p-sm-3 {\n padding: 1rem !important;\n }\n .pt-sm-3,\n .py-sm-3 {\n padding-top: 1rem !important;\n }\n .pr-sm-3,\n .px-sm-3 {\n padding-right: 1rem !important;\n }\n .pb-sm-3,\n .py-sm-3 {\n padding-bottom: 1rem !important;\n }\n .pl-sm-3,\n .px-sm-3 {\n padding-left: 1rem !important;\n }\n .p-sm-4 {\n padding: 1.5rem !important;\n }\n .pt-sm-4,\n .py-sm-4 {\n padding-top: 1.5rem !important;\n }\n .pr-sm-4,\n .px-sm-4 {\n padding-right: 1.5rem !important;\n }\n .pb-sm-4,\n .py-sm-4 {\n padding-bottom: 1.5rem !important;\n }\n .pl-sm-4,\n .px-sm-4 {\n padding-left: 1.5rem !important;\n }\n .p-sm-5 {\n padding: 3rem !important;\n }\n .pt-sm-5,\n .py-sm-5 {\n padding-top: 3rem !important;\n }\n .pr-sm-5,\n .px-sm-5 {\n padding-right: 3rem !important;\n }\n .pb-sm-5,\n .py-sm-5 {\n padding-bottom: 3rem !important;\n }\n .pl-sm-5,\n .px-sm-5 {\n padding-left: 3rem !important;\n }\n .m-sm-auto {\n margin: auto !important;\n }\n .mt-sm-auto,\n .my-sm-auto {\n margin-top: auto !important;\n }\n .mr-sm-auto,\n .mx-sm-auto {\n margin-right: auto !important;\n }\n .mb-sm-auto,\n .my-sm-auto {\n margin-bottom: auto !important;\n }\n .ml-sm-auto,\n .mx-sm-auto {\n margin-left: auto !important;\n }\n}\n\n@media (min-width: 768px) {\n .m-md-0 {\n margin: 0 !important;\n }\n .mt-md-0,\n .my-md-0 {\n margin-top: 0 !important;\n }\n .mr-md-0,\n .mx-md-0 {\n margin-right: 0 !important;\n }\n .mb-md-0,\n .my-md-0 {\n margin-bottom: 0 !important;\n }\n .ml-md-0,\n .mx-md-0 {\n margin-left: 0 !important;\n }\n .m-md-1 {\n margin: 0.25rem !important;\n }\n .mt-md-1,\n .my-md-1 {\n margin-top: 0.25rem !important;\n }\n .mr-md-1,\n .mx-md-1 {\n margin-right: 0.25rem !important;\n }\n .mb-md-1,\n .my-md-1 {\n margin-bottom: 0.25rem !important;\n }\n .ml-md-1,\n .mx-md-1 {\n margin-left: 0.25rem !important;\n }\n .m-md-2 {\n margin: 0.5rem !important;\n }\n .mt-md-2,\n .my-md-2 {\n margin-top: 0.5rem !important;\n }\n .mr-md-2,\n .mx-md-2 {\n margin-right: 0.5rem !important;\n }\n .mb-md-2,\n .my-md-2 {\n margin-bottom: 0.5rem !important;\n }\n .ml-md-2,\n .mx-md-2 {\n margin-left: 0.5rem !important;\n }\n .m-md-3 {\n margin: 1rem !important;\n }\n .mt-md-3,\n .my-md-3 {\n margin-top: 1rem !important;\n }\n .mr-md-3,\n .mx-md-3 {\n margin-right: 1rem !important;\n }\n .mb-md-3,\n .my-md-3 {\n margin-bottom: 1rem !important;\n }\n .ml-md-3,\n .mx-md-3 {\n margin-left: 1rem !important;\n }\n .m-md-4 {\n margin: 1.5rem !important;\n }\n .mt-md-4,\n .my-md-4 {\n margin-top: 1.5rem !important;\n }\n .mr-md-4,\n .mx-md-4 {\n margin-right: 1.5rem !important;\n }\n .mb-md-4,\n .my-md-4 {\n margin-bottom: 1.5rem !important;\n }\n .ml-md-4,\n .mx-md-4 {\n margin-left: 1.5rem !important;\n }\n .m-md-5 {\n margin: 3rem !important;\n }\n .mt-md-5,\n .my-md-5 {\n margin-top: 3rem !important;\n }\n .mr-md-5,\n .mx-md-5 {\n margin-right: 3rem !important;\n }\n .mb-md-5,\n .my-md-5 {\n margin-bottom: 3rem !important;\n }\n .ml-md-5,\n .mx-md-5 {\n margin-left: 3rem !important;\n }\n .p-md-0 {\n padding: 0 !important;\n }\n .pt-md-0,\n .py-md-0 {\n padding-top: 0 !important;\n }\n .pr-md-0,\n .px-md-0 {\n padding-right: 0 !important;\n }\n .pb-md-0,\n .py-md-0 {\n padding-bottom: 0 !important;\n }\n .pl-md-0,\n .px-md-0 {\n padding-left: 0 !important;\n }\n .p-md-1 {\n padding: 0.25rem !important;\n }\n .pt-md-1,\n .py-md-1 {\n padding-top: 0.25rem !important;\n }\n .pr-md-1,\n .px-md-1 {\n padding-right: 0.25rem !important;\n }\n .pb-md-1,\n .py-md-1 {\n padding-bottom: 0.25rem !important;\n }\n .pl-md-1,\n .px-md-1 {\n padding-left: 0.25rem !important;\n }\n .p-md-2 {\n padding: 0.5rem !important;\n }\n .pt-md-2,\n .py-md-2 {\n padding-top: 0.5rem !important;\n }\n .pr-md-2,\n .px-md-2 {\n padding-right: 0.5rem !important;\n }\n .pb-md-2,\n .py-md-2 {\n padding-bottom: 0.5rem !important;\n }\n .pl-md-2,\n .px-md-2 {\n padding-left: 0.5rem !important;\n }\n .p-md-3 {\n padding: 1rem !important;\n }\n .pt-md-3,\n .py-md-3 {\n padding-top: 1rem !important;\n }\n .pr-md-3,\n .px-md-3 {\n padding-right: 1rem !important;\n }\n .pb-md-3,\n .py-md-3 {\n padding-bottom: 1rem !important;\n }\n .pl-md-3,\n .px-md-3 {\n padding-left: 1rem !important;\n }\n .p-md-4 {\n padding: 1.5rem !important;\n }\n .pt-md-4,\n .py-md-4 {\n padding-top: 1.5rem !important;\n }\n .pr-md-4,\n .px-md-4 {\n padding-right: 1.5rem !important;\n }\n .pb-md-4,\n .py-md-4 {\n padding-bottom: 1.5rem !important;\n }\n .pl-md-4,\n .px-md-4 {\n padding-left: 1.5rem !important;\n }\n .p-md-5 {\n padding: 3rem !important;\n }\n .pt-md-5,\n .py-md-5 {\n padding-top: 3rem !important;\n }\n .pr-md-5,\n .px-md-5 {\n padding-right: 3rem !important;\n }\n .pb-md-5,\n .py-md-5 {\n padding-bottom: 3rem !important;\n }\n .pl-md-5,\n .px-md-5 {\n padding-left: 3rem !important;\n }\n .m-md-auto {\n margin: auto !important;\n }\n .mt-md-auto,\n .my-md-auto {\n margin-top: auto !important;\n }\n .mr-md-auto,\n .mx-md-auto {\n margin-right: auto !important;\n }\n .mb-md-auto,\n .my-md-auto {\n margin-bottom: auto !important;\n }\n .ml-md-auto,\n .mx-md-auto {\n margin-left: auto !important;\n }\n}\n\n@media (min-width: 992px) {\n .m-lg-0 {\n margin: 0 !important;\n }\n .mt-lg-0,\n .my-lg-0 {\n margin-top: 0 !important;\n }\n .mr-lg-0,\n .mx-lg-0 {\n margin-right: 0 !important;\n }\n .mb-lg-0,\n .my-lg-0 {\n margin-bottom: 0 !important;\n }\n .ml-lg-0,\n .mx-lg-0 {\n margin-left: 0 !important;\n }\n .m-lg-1 {\n margin: 0.25rem !important;\n }\n .mt-lg-1,\n .my-lg-1 {\n margin-top: 0.25rem !important;\n }\n .mr-lg-1,\n .mx-lg-1 {\n margin-right: 0.25rem !important;\n }\n .mb-lg-1,\n .my-lg-1 {\n margin-bottom: 0.25rem !important;\n }\n .ml-lg-1,\n .mx-lg-1 {\n margin-left: 0.25rem !important;\n }\n .m-lg-2 {\n margin: 0.5rem !important;\n }\n .mt-lg-2,\n .my-lg-2 {\n margin-top: 0.5rem !important;\n }\n .mr-lg-2,\n .mx-lg-2 {\n margin-right: 0.5rem !important;\n }\n .mb-lg-2,\n .my-lg-2 {\n margin-bottom: 0.5rem !important;\n }\n .ml-lg-2,\n .mx-lg-2 {\n margin-left: 0.5rem !important;\n }\n .m-lg-3 {\n margin: 1rem !important;\n }\n .mt-lg-3,\n .my-lg-3 {\n margin-top: 1rem !important;\n }\n .mr-lg-3,\n .mx-lg-3 {\n margin-right: 1rem !important;\n }\n .mb-lg-3,\n .my-lg-3 {\n margin-bottom: 1rem !important;\n }\n .ml-lg-3,\n .mx-lg-3 {\n margin-left: 1rem !important;\n }\n .m-lg-4 {\n margin: 1.5rem !important;\n }\n .mt-lg-4,\n .my-lg-4 {\n margin-top: 1.5rem !important;\n }\n .mr-lg-4,\n .mx-lg-4 {\n margin-right: 1.5rem !important;\n }\n .mb-lg-4,\n .my-lg-4 {\n margin-bottom: 1.5rem !important;\n }\n .ml-lg-4,\n .mx-lg-4 {\n margin-left: 1.5rem !important;\n }\n .m-lg-5 {\n margin: 3rem !important;\n }\n .mt-lg-5,\n .my-lg-5 {\n margin-top: 3rem !important;\n }\n .mr-lg-5,\n .mx-lg-5 {\n margin-right: 3rem !important;\n }\n .mb-lg-5,\n .my-lg-5 {\n margin-bottom: 3rem !important;\n }\n .ml-lg-5,\n .mx-lg-5 {\n margin-left: 3rem !important;\n }\n .p-lg-0 {\n padding: 0 !important;\n }\n .pt-lg-0,\n .py-lg-0 {\n padding-top: 0 !important;\n }\n .pr-lg-0,\n .px-lg-0 {\n padding-right: 0 !important;\n }\n .pb-lg-0,\n .py-lg-0 {\n padding-bottom: 0 !important;\n }\n .pl-lg-0,\n .px-lg-0 {\n padding-left: 0 !important;\n }\n .p-lg-1 {\n padding: 0.25rem !important;\n }\n .pt-lg-1,\n .py-lg-1 {\n padding-top: 0.25rem !important;\n }\n .pr-lg-1,\n .px-lg-1 {\n padding-right: 0.25rem !important;\n }\n .pb-lg-1,\n .py-lg-1 {\n padding-bottom: 0.25rem !important;\n }\n .pl-lg-1,\n .px-lg-1 {\n padding-left: 0.25rem !important;\n }\n .p-lg-2 {\n padding: 0.5rem !important;\n }\n .pt-lg-2,\n .py-lg-2 {\n padding-top: 0.5rem !important;\n }\n .pr-lg-2,\n .px-lg-2 {\n padding-right: 0.5rem !important;\n }\n .pb-lg-2,\n .py-lg-2 {\n padding-bottom: 0.5rem !important;\n }\n .pl-lg-2,\n .px-lg-2 {\n padding-left: 0.5rem !important;\n }\n .p-lg-3 {\n padding: 1rem !important;\n }\n .pt-lg-3,\n .py-lg-3 {\n padding-top: 1rem !important;\n }\n .pr-lg-3,\n .px-lg-3 {\n padding-right: 1rem !important;\n }\n .pb-lg-3,\n .py-lg-3 {\n padding-bottom: 1rem !important;\n }\n .pl-lg-3,\n .px-lg-3 {\n padding-left: 1rem !important;\n }\n .p-lg-4 {\n padding: 1.5rem !important;\n }\n .pt-lg-4,\n .py-lg-4 {\n padding-top: 1.5rem !important;\n }\n .pr-lg-4,\n .px-lg-4 {\n padding-right: 1.5rem !important;\n }\n .pb-lg-4,\n .py-lg-4 {\n padding-bottom: 1.5rem !important;\n }\n .pl-lg-4,\n .px-lg-4 {\n padding-left: 1.5rem !important;\n }\n .p-lg-5 {\n padding: 3rem !important;\n }\n .pt-lg-5,\n .py-lg-5 {\n padding-top: 3rem !important;\n }\n .pr-lg-5,\n .px-lg-5 {\n padding-right: 3rem !important;\n }\n .pb-lg-5,\n .py-lg-5 {\n padding-bottom: 3rem !important;\n }\n .pl-lg-5,\n .px-lg-5 {\n padding-left: 3rem !important;\n }\n .m-lg-auto {\n margin: auto !important;\n }\n .mt-lg-auto,\n .my-lg-auto {\n margin-top: auto !important;\n }\n .mr-lg-auto,\n .mx-lg-auto {\n margin-right: auto !important;\n }\n .mb-lg-auto,\n .my-lg-auto {\n margin-bottom: auto !important;\n }\n .ml-lg-auto,\n .mx-lg-auto {\n margin-left: auto !important;\n }\n}\n\n@media (min-width: 1200px) {\n .m-xl-0 {\n margin: 0 !important;\n }\n .mt-xl-0,\n .my-xl-0 {\n margin-top: 0 !important;\n }\n .mr-xl-0,\n .mx-xl-0 {\n margin-right: 0 !important;\n }\n .mb-xl-0,\n .my-xl-0 {\n margin-bottom: 0 !important;\n }\n .ml-xl-0,\n .mx-xl-0 {\n margin-left: 0 !important;\n }\n .m-xl-1 {\n margin: 0.25rem !important;\n }\n .mt-xl-1,\n .my-xl-1 {\n margin-top: 0.25rem !important;\n }\n .mr-xl-1,\n .mx-xl-1 {\n margin-right: 0.25rem !important;\n }\n .mb-xl-1,\n .my-xl-1 {\n margin-bottom: 0.25rem !important;\n }\n .ml-xl-1,\n .mx-xl-1 {\n margin-left: 0.25rem !important;\n }\n .m-xl-2 {\n margin: 0.5rem !important;\n }\n .mt-xl-2,\n .my-xl-2 {\n margin-top: 0.5rem !important;\n }\n .mr-xl-2,\n .mx-xl-2 {\n margin-right: 0.5rem !important;\n }\n .mb-xl-2,\n .my-xl-2 {\n margin-bottom: 0.5rem !important;\n }\n .ml-xl-2,\n .mx-xl-2 {\n margin-left: 0.5rem !important;\n }\n .m-xl-3 {\n margin: 1rem !important;\n }\n .mt-xl-3,\n .my-xl-3 {\n margin-top: 1rem !important;\n }\n .mr-xl-3,\n .mx-xl-3 {\n margin-right: 1rem !important;\n }\n .mb-xl-3,\n .my-xl-3 {\n margin-bottom: 1rem !important;\n }\n .ml-xl-3,\n .mx-xl-3 {\n margin-left: 1rem !important;\n }\n .m-xl-4 {\n margin: 1.5rem !important;\n }\n .mt-xl-4,\n .my-xl-4 {\n margin-top: 1.5rem !important;\n }\n .mr-xl-4,\n .mx-xl-4 {\n margin-right: 1.5rem !important;\n }\n .mb-xl-4,\n .my-xl-4 {\n margin-bottom: 1.5rem !important;\n }\n .ml-xl-4,\n .mx-xl-4 {\n margin-left: 1.5rem !important;\n }\n .m-xl-5 {\n margin: 3rem !important;\n }\n .mt-xl-5,\n .my-xl-5 {\n margin-top: 3rem !important;\n }\n .mr-xl-5,\n .mx-xl-5 {\n margin-right: 3rem !important;\n }\n .mb-xl-5,\n .my-xl-5 {\n margin-bottom: 3rem !important;\n }\n .ml-xl-5,\n .mx-xl-5 {\n margin-left: 3rem !important;\n }\n .p-xl-0 {\n padding: 0 !important;\n }\n .pt-xl-0,\n .py-xl-0 {\n padding-top: 0 !important;\n }\n .pr-xl-0,\n .px-xl-0 {\n padding-right: 0 !important;\n }\n .pb-xl-0,\n .py-xl-0 {\n padding-bottom: 0 !important;\n }\n .pl-xl-0,\n .px-xl-0 {\n padding-left: 0 !important;\n }\n .p-xl-1 {\n padding: 0.25rem !important;\n }\n .pt-xl-1,\n .py-xl-1 {\n padding-top: 0.25rem !important;\n }\n .pr-xl-1,\n .px-xl-1 {\n padding-right: 0.25rem !important;\n }\n .pb-xl-1,\n .py-xl-1 {\n padding-bottom: 0.25rem !important;\n }\n .pl-xl-1,\n .px-xl-1 {\n padding-left: 0.25rem !important;\n }\n .p-xl-2 {\n padding: 0.5rem !important;\n }\n .pt-xl-2,\n .py-xl-2 {\n padding-top: 0.5rem !important;\n }\n .pr-xl-2,\n .px-xl-2 {\n padding-right: 0.5rem !important;\n }\n .pb-xl-2,\n .py-xl-2 {\n padding-bottom: 0.5rem !important;\n }\n .pl-xl-2,\n .px-xl-2 {\n padding-left: 0.5rem !important;\n }\n .p-xl-3 {\n padding: 1rem !important;\n }\n .pt-xl-3,\n .py-xl-3 {\n padding-top: 1rem !important;\n }\n .pr-xl-3,\n .px-xl-3 {\n padding-right: 1rem !important;\n }\n .pb-xl-3,\n .py-xl-3 {\n padding-bottom: 1rem !important;\n }\n .pl-xl-3,\n .px-xl-3 {\n padding-left: 1rem !important;\n }\n .p-xl-4 {\n padding: 1.5rem !important;\n }\n .pt-xl-4,\n .py-xl-4 {\n padding-top: 1.5rem !important;\n }\n .pr-xl-4,\n .px-xl-4 {\n padding-right: 1.5rem !important;\n }\n .pb-xl-4,\n .py-xl-4 {\n padding-bottom: 1.5rem !important;\n }\n .pl-xl-4,\n .px-xl-4 {\n padding-left: 1.5rem !important;\n }\n .p-xl-5 {\n padding: 3rem !important;\n }\n .pt-xl-5,\n .py-xl-5 {\n padding-top: 3rem !important;\n }\n .pr-xl-5,\n .px-xl-5 {\n padding-right: 3rem !important;\n }\n .pb-xl-5,\n .py-xl-5 {\n padding-bottom: 3rem !important;\n }\n .pl-xl-5,\n .px-xl-5 {\n padding-left: 3rem !important;\n }\n .m-xl-auto {\n margin: auto !important;\n }\n .mt-xl-auto,\n .my-xl-auto {\n margin-top: auto !important;\n }\n .mr-xl-auto,\n .mx-xl-auto {\n margin-right: auto !important;\n }\n .mb-xl-auto,\n .my-xl-auto {\n margin-bottom: auto !important;\n }\n .ml-xl-auto,\n .mx-xl-auto {\n margin-left: auto !important;\n }\n}\n\n.text-monospace {\n font-family: SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;\n}\n\n.text-justify {\n text-align: justify !important;\n}\n\n.text-nowrap {\n white-space: nowrap !important;\n}\n\n.text-truncate {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.text-left {\n text-align: left !important;\n}\n\n.text-right {\n text-align: right !important;\n}\n\n.text-center {\n text-align: center !important;\n}\n\n@media (min-width: 576px) {\n .text-sm-left {\n text-align: left !important;\n }\n .text-sm-right {\n text-align: right !important;\n }\n .text-sm-center {\n text-align: center !important;\n }\n}\n\n@media (min-width: 768px) {\n .text-md-left {\n text-align: left !important;\n }\n .text-md-right {\n text-align: right !important;\n }\n .text-md-center {\n text-align: center !important;\n }\n}\n\n@media (min-width: 992px) {\n .text-lg-left {\n text-align: left !important;\n }\n .text-lg-right {\n text-align: right !important;\n }\n .text-lg-center {\n text-align: center !important;\n }\n}\n\n@media (min-width: 1200px) {\n .text-xl-left {\n text-align: left !important;\n }\n .text-xl-right {\n text-align: right !important;\n }\n .text-xl-center {\n text-align: center !important;\n }\n}\n\n.text-lowercase {\n text-transform: lowercase !important;\n}\n\n.text-uppercase {\n text-transform: uppercase !important;\n}\n\n.text-capitalize {\n text-transform: capitalize !important;\n}\n\n.font-weight-light {\n font-weight: 300 !important;\n}\n\n.font-weight-normal {\n font-weight: 400 !important;\n}\n\n.font-weight-bold {\n font-weight: 700 !important;\n}\n\n.font-italic {\n font-style: italic !important;\n}\n\n.text-white {\n color: #fff !important;\n}\n\n.text-primary {\n color: #007bff !important;\n}\n\na.text-primary:hover, a.text-primary:focus {\n color: #0062cc !important;\n}\n\n.text-secondary {\n color: #6c757d !important;\n}\n\na.text-secondary:hover, a.text-secondary:focus {\n color: #545b62 !important;\n}\n\n.text-success {\n color: #28a745 !important;\n}\n\na.text-success:hover, a.text-success:focus {\n color: #1e7e34 !important;\n}\n\n.text-info {\n color: #17a2b8 !important;\n}\n\na.text-info:hover, a.text-info:focus {\n color: #117a8b !important;\n}\n\n.text-warning {\n color: #ffc107 !important;\n}\n\na.text-warning:hover, a.text-warning:focus {\n color: #d39e00 !important;\n}\n\n.text-danger {\n color: #dc3545 !important;\n}\n\na.text-danger:hover, a.text-danger:focus {\n color: #bd2130 !important;\n}\n\n.text-light {\n color: #f8f9fa !important;\n}\n\na.text-light:hover, a.text-light:focus {\n color: #dae0e5 !important;\n}\n\n.text-dark {\n color: #343a40 !important;\n}\n\na.text-dark:hover, a.text-dark:focus {\n color: #1d2124 !important;\n}\n\n.text-body {\n color: #212529 !important;\n}\n\n.text-muted {\n color: #6c757d !important;\n}\n\n.text-black-50 {\n color: rgba(0, 0, 0, 0.5) !important;\n}\n\n.text-white-50 {\n color: rgba(255, 255, 255, 0.5) !important;\n}\n\n.text-hide {\n font: 0/0 a;\n color: transparent;\n text-shadow: none;\n background-color: transparent;\n border: 0;\n}\n\n.visible {\n visibility: visible !important;\n}\n\n.invisible {\n visibility: hidden !important;\n}\n\n@media print {\n *,\n *::before,\n *::after {\n text-shadow: none !important;\n box-shadow: none !important;\n }\n a:not(.btn) {\n text-decoration: underline;\n }\n abbr[title]::after {\n content: \" (\" attr(title) \")\";\n }\n pre {\n white-space: pre-wrap !important;\n }\n pre,\n blockquote {\n border: 1px solid #adb5bd;\n page-break-inside: avoid;\n }\n thead {\n display: table-header-group;\n }\n tr,\n img {\n page-break-inside: avoid;\n }\n p,\n h2,\n h3 {\n orphans: 3;\n widows: 3;\n }\n h2,\n h3 {\n page-break-after: avoid;\n }\n @page {\n size: a3;\n }\n body {\n min-width: 992px !important;\n }\n .container {\n min-width: 992px !important;\n }\n .navbar {\n display: none;\n }\n .badge {\n border: 1px solid #000;\n }\n .table {\n border-collapse: collapse !important;\n }\n .table td,\n .table th {\n background-color: #fff !important;\n }\n .table-bordered th,\n .table-bordered td {\n border: 1px solid #dee2e6 !important;\n }\n .table-dark {\n color: inherit;\n }\n .table-dark th,\n .table-dark td,\n .table-dark thead th,\n .table-dark tbody + tbody {\n border-color: #dee2e6;\n }\n .table .thead-dark th {\n color: inherit;\n border-color: #dee2e6;\n }\n}\n\n/*# sourceMappingURL=bootstrap.css.map */","// Hover mixin and `$enable-hover-media-query` are deprecated.\n//\n// Origally added during our alphas and maintained during betas, this mixin was\n// designed to prevent `:hover` stickiness on iOS-an issue where hover styles\n// would persist after initial touch.\n//\n// For backward compatibility, we've kept these mixins and updated them to\n// always return their regular pseudo-classes instead of a shimmed media query.\n//\n// Issue: https://github.com/twbs/bootstrap/issues/25195\n\n@mixin hover {\n &:hover { @content; }\n}\n\n@mixin hover-focus {\n &:hover,\n &:focus {\n @content;\n }\n}\n\n@mixin plain-hover-focus {\n &,\n &:hover,\n &:focus {\n @content;\n }\n}\n\n@mixin hover-focus-active {\n &:hover,\n &:focus,\n &:active {\n @content;\n }\n}\n","// stylelint-disable declaration-no-important, selector-list-comma-newline-after\n\n//\n// Headings\n//\n\nh1, h2, h3, h4, h5, h6,\n.h1, .h2, .h3, .h4, .h5, .h6 {\n margin-bottom: $headings-margin-bottom;\n font-family: $headings-font-family;\n font-weight: $headings-font-weight;\n line-height: $headings-line-height;\n color: $headings-color;\n}\n\nh1, .h1 { font-size: $h1-font-size; }\nh2, .h2 { font-size: $h2-font-size; }\nh3, .h3 { font-size: $h3-font-size; }\nh4, .h4 { font-size: $h4-font-size; }\nh5, .h5 { font-size: $h5-font-size; }\nh6, .h6 { font-size: $h6-font-size; }\n\n.lead {\n font-size: $lead-font-size;\n font-weight: $lead-font-weight;\n}\n\n// Type display classes\n.display-1 {\n font-size: $display1-size;\n font-weight: $display1-weight;\n line-height: $display-line-height;\n}\n.display-2 {\n font-size: $display2-size;\n font-weight: $display2-weight;\n line-height: $display-line-height;\n}\n.display-3 {\n font-size: $display3-size;\n font-weight: $display3-weight;\n line-height: $display-line-height;\n}\n.display-4 {\n font-size: $display4-size;\n font-weight: $display4-weight;\n line-height: $display-line-height;\n}\n\n\n//\n// Horizontal rules\n//\n\nhr {\n margin-top: $hr-margin-y;\n margin-bottom: $hr-margin-y;\n border: 0;\n border-top: $hr-border-width solid $hr-border-color;\n}\n\n\n//\n// Emphasis\n//\n\nsmall,\n.small {\n font-size: $small-font-size;\n font-weight: $font-weight-normal;\n}\n\nmark,\n.mark {\n padding: $mark-padding;\n background-color: $mark-bg;\n}\n\n\n//\n// Lists\n//\n\n.list-unstyled {\n @include list-unstyled;\n}\n\n// Inline turns list items into inline-block\n.list-inline {\n @include list-unstyled;\n}\n.list-inline-item {\n display: inline-block;\n\n &:not(:last-child) {\n margin-right: $list-inline-padding;\n }\n}\n\n\n//\n// Misc\n//\n\n// Builds on `abbr`\n.initialism {\n font-size: 90%;\n text-transform: uppercase;\n}\n\n// Blockquotes\n.blockquote {\n margin-bottom: $spacer;\n font-size: $blockquote-font-size;\n}\n\n.blockquote-footer {\n display: block;\n font-size: 80%; // back to default font-size\n color: $blockquote-small-color;\n\n &::before {\n content: \"\\2014 \\00A0\"; // em dash, nbsp\n }\n}\n","// Lists\n\n// Unstyled keeps list items block level, just removes default browser padding and list-style\n@mixin list-unstyled {\n padding-left: 0;\n list-style: none;\n}\n","// Responsive images (ensure images don't scale beyond their parents)\n//\n// This is purposefully opt-in via an explicit class rather than being the default for all ``s.\n// We previously tried the \"images are responsive by default\" approach in Bootstrap v2,\n// and abandoned it in Bootstrap v3 because it breaks lots of third-party widgets (including Google Maps)\n// which weren't expecting the images within themselves to be involuntarily resized.\n// See also https://github.com/twbs/bootstrap/issues/18178\n.img-fluid {\n @include img-fluid;\n}\n\n\n// Image thumbnails\n.img-thumbnail {\n padding: $thumbnail-padding;\n background-color: $thumbnail-bg;\n border: $thumbnail-border-width solid $thumbnail-border-color;\n @include border-radius($thumbnail-border-radius);\n @include box-shadow($thumbnail-box-shadow);\n\n // Keep them at most 100% wide\n @include img-fluid;\n}\n\n//\n// Figures\n//\n\n.figure {\n // Ensures the caption's text aligns with the image.\n display: inline-block;\n}\n\n.figure-img {\n margin-bottom: ($spacer / 2);\n line-height: 1;\n}\n\n.figure-caption {\n font-size: $figure-caption-font-size;\n color: $figure-caption-color;\n}\n","// Image Mixins\n// - Responsive image\n// - Retina image\n\n\n// Responsive image\n//\n// Keep images from scaling beyond the width of their parents.\n\n@mixin img-fluid {\n // Part 1: Set a maximum relative to the parent\n max-width: 100%;\n // Part 2: Override the height to auto, otherwise images will be stretched\n // when setting a width and height attribute on the img element.\n height: auto;\n}\n\n\n// Retina image\n//\n// Short retina mixin for setting background-image and -size.\n\n// stylelint-disable indentation, media-query-list-comma-newline-after\n@mixin img-retina($file-1x, $file-2x, $width-1x, $height-1x) {\n background-image: url($file-1x);\n\n // Autoprefixer takes care of adding -webkit-min-device-pixel-ratio and -o-min-device-pixel-ratio,\n // but doesn't convert dppx=>dpi.\n // There's no such thing as unprefixed min-device-pixel-ratio since it's nonstandard.\n // Compatibility info: https://caniuse.com/#feat=css-media-resolution\n @media only screen and (min-resolution: 192dpi), // IE9-11 don't support dppx\n only screen and (min-resolution: 2dppx) { // Standardized\n background-image: url($file-2x);\n background-size: $width-1x $height-1x;\n }\n}\n","// Single side border-radius\n\n@mixin border-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-radius: $radius;\n }\n}\n\n@mixin border-top-radius($radius) {\n @if $enable-rounded {\n border-top-left-radius: $radius;\n border-top-right-radius: $radius;\n }\n}\n\n@mixin border-right-radius($radius) {\n @if $enable-rounded {\n border-top-right-radius: $radius;\n border-bottom-right-radius: $radius;\n }\n}\n\n@mixin border-bottom-radius($radius) {\n @if $enable-rounded {\n border-bottom-right-radius: $radius;\n border-bottom-left-radius: $radius;\n }\n}\n\n@mixin border-left-radius($radius) {\n @if $enable-rounded {\n border-top-left-radius: $radius;\n border-bottom-left-radius: $radius;\n }\n}\n","// Inline code\ncode {\n font-size: $code-font-size;\n color: $code-color;\n word-break: break-word;\n\n // Streamline the style when inside anchors to avoid broken underline and more\n a > & {\n color: inherit;\n }\n}\n\n// User input typically entered via keyboard\nkbd {\n padding: $kbd-padding-y $kbd-padding-x;\n font-size: $kbd-font-size;\n color: $kbd-color;\n background-color: $kbd-bg;\n @include border-radius($border-radius-sm);\n @include box-shadow($kbd-box-shadow);\n\n kbd {\n padding: 0;\n font-size: 100%;\n font-weight: $nested-kbd-font-weight;\n @include box-shadow(none);\n }\n}\n\n// Blocks of code\npre {\n display: block;\n font-size: $code-font-size;\n color: $pre-color;\n\n // Account for some code outputs that place code tags in pre tags\n code {\n font-size: inherit;\n color: inherit;\n word-break: normal;\n }\n}\n\n// Enable scrollable blocks of code\n.pre-scrollable {\n max-height: $pre-scrollable-max-height;\n overflow-y: scroll;\n}\n","// Container widths\n//\n// Set the container width, and override it for fixed navbars in media queries.\n\n@if $enable-grid-classes {\n .container {\n @include make-container();\n @include make-container-max-widths();\n }\n}\n\n// Fluid container\n//\n// Utilizes the mixin meant for fixed width containers, but with 100% width for\n// fluid, full width layouts.\n\n@if $enable-grid-classes {\n .container-fluid {\n @include make-container();\n }\n}\n\n// Row\n//\n// Rows contain and clear the floats of your columns.\n\n@if $enable-grid-classes {\n .row {\n @include make-row();\n }\n\n // Remove the negative margin from default .row, then the horizontal padding\n // from all immediate children columns (to prevent runaway style inheritance).\n .no-gutters {\n margin-right: 0;\n margin-left: 0;\n\n > .col,\n > [class*=\"col-\"] {\n padding-right: 0;\n padding-left: 0;\n }\n }\n}\n\n// Columns\n//\n// Common styles for small and large grid columns\n\n@if $enable-grid-classes {\n @include make-grid-columns();\n}\n","/// Grid system\n//\n// Generate semantic grid columns with these mixins.\n\n@mixin make-container() {\n width: 100%;\n padding-right: ($grid-gutter-width / 2);\n padding-left: ($grid-gutter-width / 2);\n margin-right: auto;\n margin-left: auto;\n}\n\n\n// For each breakpoint, define the maximum width of the container in a media query\n@mixin make-container-max-widths($max-widths: $container-max-widths, $breakpoints: $grid-breakpoints) {\n @each $breakpoint, $container-max-width in $max-widths {\n @include media-breakpoint-up($breakpoint, $breakpoints) {\n max-width: $container-max-width;\n }\n }\n}\n\n@mixin make-row() {\n display: flex;\n flex-wrap: wrap;\n margin-right: ($grid-gutter-width / -2);\n margin-left: ($grid-gutter-width / -2);\n}\n\n@mixin make-col-ready() {\n position: relative;\n // Prevent columns from becoming too narrow when at smaller grid tiers by\n // always setting `width: 100%;`. This works because we use `flex` values\n // later on to override this initial width.\n width: 100%;\n min-height: 1px; // Prevent collapsing\n padding-right: ($grid-gutter-width / 2);\n padding-left: ($grid-gutter-width / 2);\n}\n\n@mixin make-col($size, $columns: $grid-columns) {\n flex: 0 0 percentage($size / $columns);\n // Add a `max-width` to ensure content within each column does not blow out\n // the width of the column. Applies to IE10+ and Firefox. Chrome and Safari\n // do not appear to require this.\n max-width: percentage($size / $columns);\n}\n\n@mixin make-col-offset($size, $columns: $grid-columns) {\n $num: $size / $columns;\n margin-left: if($num == 0, 0, percentage($num));\n}\n","// Breakpoint viewport sizes and media queries.\n//\n// Breakpoints are defined as a map of (name: minimum width), order from small to large:\n//\n// (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px)\n//\n// The map defined in the `$grid-breakpoints` global variable is used as the `$breakpoints` argument by default.\n\n// Name of the next breakpoint, or null for the last breakpoint.\n//\n// >> breakpoint-next(sm)\n// md\n// >> breakpoint-next(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px))\n// md\n// >> breakpoint-next(sm, $breakpoint-names: (xs sm md lg xl))\n// md\n@function breakpoint-next($name, $breakpoints: $grid-breakpoints, $breakpoint-names: map-keys($breakpoints)) {\n $n: index($breakpoint-names, $name);\n @return if($n < length($breakpoint-names), nth($breakpoint-names, $n + 1), null);\n}\n\n// Minimum breakpoint width. Null for the smallest (first) breakpoint.\n//\n// >> breakpoint-min(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px))\n// 576px\n@function breakpoint-min($name, $breakpoints: $grid-breakpoints) {\n $min: map-get($breakpoints, $name);\n @return if($min != 0, $min, null);\n}\n\n// Maximum breakpoint width. Null for the largest (last) breakpoint.\n// The maximum value is calculated as the minimum of the next one less 0.02px\n// to work around the limitations of `min-` and `max-` prefixes and viewports with fractional widths.\n// See https://www.w3.org/TR/mediaqueries-4/#mq-min-max\n// Uses 0.02px rather than 0.01px to work around a current rounding bug in Safari.\n// See https://bugs.webkit.org/show_bug.cgi?id=178261\n//\n// >> breakpoint-max(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px))\n// 767.98px\n@function breakpoint-max($name, $breakpoints: $grid-breakpoints) {\n $next: breakpoint-next($name, $breakpoints);\n @return if($next, breakpoint-min($next, $breakpoints) - .02px, null);\n}\n\n// Returns a blank string if smallest breakpoint, otherwise returns the name with a dash infront.\n// Useful for making responsive utilities.\n//\n// >> breakpoint-infix(xs, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px))\n// \"\" (Returns a blank string)\n// >> breakpoint-infix(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px))\n// \"-sm\"\n@function breakpoint-infix($name, $breakpoints: $grid-breakpoints) {\n @return if(breakpoint-min($name, $breakpoints) == null, \"\", \"-#{$name}\");\n}\n\n// Media of at least the minimum breakpoint width. No query for the smallest breakpoint.\n// Makes the @content apply to the given breakpoint and wider.\n@mixin media-breakpoint-up($name, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($name, $breakpoints);\n @if $min {\n @media (min-width: $min) {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Media of at most the maximum breakpoint width. No query for the largest breakpoint.\n// Makes the @content apply to the given breakpoint and narrower.\n@mixin media-breakpoint-down($name, $breakpoints: $grid-breakpoints) {\n $max: breakpoint-max($name, $breakpoints);\n @if $max {\n @media (max-width: $max) {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Media that spans multiple breakpoint widths.\n// Makes the @content apply between the min and max breakpoints\n@mixin media-breakpoint-between($lower, $upper, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($lower, $breakpoints);\n $max: breakpoint-max($upper, $breakpoints);\n\n @if $min != null and $max != null {\n @media (min-width: $min) and (max-width: $max) {\n @content;\n }\n } @else if $max == null {\n @include media-breakpoint-up($lower, $breakpoints) {\n @content;\n }\n } @else if $min == null {\n @include media-breakpoint-down($upper, $breakpoints) {\n @content;\n }\n }\n}\n\n// Media between the breakpoint's minimum and maximum widths.\n// No minimum for the smallest breakpoint, and no maximum for the largest one.\n// Makes the @content apply only to the given breakpoint, not viewports any wider or narrower.\n@mixin media-breakpoint-only($name, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($name, $breakpoints);\n $max: breakpoint-max($name, $breakpoints);\n\n @if $min != null and $max != null {\n @media (min-width: $min) and (max-width: $max) {\n @content;\n }\n } @else if $max == null {\n @include media-breakpoint-up($name, $breakpoints) {\n @content;\n }\n } @else if $min == null {\n @include media-breakpoint-down($name, $breakpoints) {\n @content;\n }\n }\n}\n","// Framework grid generation\n//\n// Used only by Bootstrap to generate the correct number of grid classes given\n// any value of `$grid-columns`.\n\n@mixin make-grid-columns($columns: $grid-columns, $gutter: $grid-gutter-width, $breakpoints: $grid-breakpoints) {\n // Common properties for all breakpoints\n %grid-column {\n position: relative;\n width: 100%;\n min-height: 1px; // Prevent columns from collapsing when empty\n padding-right: ($gutter / 2);\n padding-left: ($gutter / 2);\n }\n\n @each $breakpoint in map-keys($breakpoints) {\n $infix: breakpoint-infix($breakpoint, $breakpoints);\n\n // Allow columns to stretch full width below their breakpoints\n @for $i from 1 through $columns {\n .col#{$infix}-#{$i} {\n @extend %grid-column;\n }\n }\n .col#{$infix},\n .col#{$infix}-auto {\n @extend %grid-column;\n }\n\n @include media-breakpoint-up($breakpoint, $breakpoints) {\n // Provide basic `.col-{bp}` classes for equal-width flexbox columns\n .col#{$infix} {\n flex-basis: 0;\n flex-grow: 1;\n max-width: 100%;\n }\n .col#{$infix}-auto {\n flex: 0 0 auto;\n width: auto;\n max-width: none; // Reset earlier grid tiers\n }\n\n @for $i from 1 through $columns {\n .col#{$infix}-#{$i} {\n @include make-col($i, $columns);\n }\n }\n\n .order#{$infix}-first { order: -1; }\n\n .order#{$infix}-last { order: $columns + 1; }\n\n @for $i from 0 through $columns {\n .order#{$infix}-#{$i} { order: $i; }\n }\n\n // `$columns - 1` because offsetting by the width of an entire row isn't possible\n @for $i from 0 through ($columns - 1) {\n @if not ($infix == \"\" and $i == 0) { // Avoid emitting useless .offset-0\n .offset#{$infix}-#{$i} {\n @include make-col-offset($i, $columns);\n }\n }\n }\n }\n }\n}\n","//\n// Basic Bootstrap table\n//\n\n.table {\n width: 100%;\n max-width: 100%;\n margin-bottom: $spacer;\n background-color: $table-bg; // Reset for nesting within parents with `background-color`.\n\n th,\n td {\n padding: $table-cell-padding;\n vertical-align: top;\n border-top: $table-border-width solid $table-border-color;\n }\n\n thead th {\n vertical-align: bottom;\n border-bottom: (2 * $table-border-width) solid $table-border-color;\n }\n\n tbody + tbody {\n border-top: (2 * $table-border-width) solid $table-border-color;\n }\n\n .table {\n background-color: $body-bg;\n }\n}\n\n\n//\n// Condensed table w/ half padding\n//\n\n.table-sm {\n th,\n td {\n padding: $table-cell-padding-sm;\n }\n}\n\n\n// Border versions\n//\n// Add or remove borders all around the table and between all the columns.\n\n.table-bordered {\n border: $table-border-width solid $table-border-color;\n\n th,\n td {\n border: $table-border-width solid $table-border-color;\n }\n\n thead {\n th,\n td {\n border-bottom-width: (2 * $table-border-width);\n }\n }\n}\n\n.table-borderless {\n th,\n td,\n thead th,\n tbody + tbody {\n border: 0;\n }\n}\n\n// Zebra-striping\n//\n// Default zebra-stripe styles (alternating gray and transparent backgrounds)\n\n.table-striped {\n tbody tr:nth-of-type(#{$table-striped-order}) {\n background-color: $table-accent-bg;\n }\n}\n\n\n// Hover effect\n//\n// Placed here since it has to come after the potential zebra striping\n\n.table-hover {\n tbody tr {\n @include hover {\n background-color: $table-hover-bg;\n }\n }\n}\n\n\n// Table backgrounds\n//\n// Exact selectors below required to override `.table-striped` and prevent\n// inheritance to nested tables.\n\n@each $color, $value in $theme-colors {\n @include table-row-variant($color, theme-color-level($color, -9));\n}\n\n@include table-row-variant(active, $table-active-bg);\n\n\n// Dark styles\n//\n// Same table markup, but inverted color scheme: dark background and light text.\n\n// stylelint-disable-next-line no-duplicate-selectors\n.table {\n .thead-dark {\n th {\n color: $table-dark-color;\n background-color: $table-dark-bg;\n border-color: $table-dark-border-color;\n }\n }\n\n .thead-light {\n th {\n color: $table-head-color;\n background-color: $table-head-bg;\n border-color: $table-border-color;\n }\n }\n}\n\n.table-dark {\n color: $table-dark-color;\n background-color: $table-dark-bg;\n\n th,\n td,\n thead th {\n border-color: $table-dark-border-color;\n }\n\n &.table-bordered {\n border: 0;\n }\n\n &.table-striped {\n tbody tr:nth-of-type(odd) {\n background-color: $table-dark-accent-bg;\n }\n }\n\n &.table-hover {\n tbody tr {\n @include hover {\n background-color: $table-dark-hover-bg;\n }\n }\n }\n}\n\n\n// Responsive tables\n//\n// Generate series of `.table-responsive-*` classes for configuring the screen\n// size of where your table will overflow.\n\n.table-responsive {\n @each $breakpoint in map-keys($grid-breakpoints) {\n $next: breakpoint-next($breakpoint, $grid-breakpoints);\n $infix: breakpoint-infix($next, $grid-breakpoints);\n\n &#{$infix} {\n @include media-breakpoint-down($breakpoint) {\n display: block;\n width: 100%;\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n -ms-overflow-style: -ms-autohiding-scrollbar; // See https://github.com/twbs/bootstrap/pull/10057\n\n // Prevent double border on horizontal scroll due to use of `display: block;`\n > .table-bordered {\n border: 0;\n }\n }\n }\n }\n}\n","// Tables\n\n@mixin table-row-variant($state, $background) {\n // Exact selectors below required to override `.table-striped` and prevent\n // inheritance to nested tables.\n .table-#{$state} {\n &,\n > th,\n > td {\n background-color: $background;\n }\n }\n\n // Hover states for `.table-hover`\n // Note: this is not available for cells or rows within `thead` or `tfoot`.\n .table-hover {\n $hover-background: darken($background, 5%);\n\n .table-#{$state} {\n @include hover {\n background-color: $hover-background;\n\n > td,\n > th {\n background-color: $hover-background;\n }\n }\n }\n }\n}\n","// Bootstrap functions\n//\n// Utility mixins and functions for evalutating source code across our variables, maps, and mixins.\n\n// Ascending\n// Used to evaluate Sass maps like our grid breakpoints.\n@mixin _assert-ascending($map, $map-name) {\n $prev-key: null;\n $prev-num: null;\n @each $key, $num in $map {\n @if $prev-num == null {\n // Do nothing\n } @else if not comparable($prev-num, $num) {\n @warn \"Potentially invalid value for #{$map-name}: This map must be in ascending order, but key '#{$key}' has value #{$num} whose unit makes it incomparable to #{$prev-num}, the value of the previous key '#{$prev-key}' !\";\n } @else if $prev-num >= $num {\n @warn \"Invalid value for #{$map-name}: This map must be in ascending order, but key '#{$key}' has value #{$num} which isn't greater than #{$prev-num}, the value of the previous key '#{$prev-key}' !\";\n }\n $prev-key: $key;\n $prev-num: $num;\n }\n}\n\n// Starts at zero\n// Another grid mixin that ensures the min-width of the lowest breakpoint starts at 0.\n@mixin _assert-starts-at-zero($map) {\n $values: map-values($map);\n $first-value: nth($values, 1);\n @if $first-value != 0 {\n @warn \"First breakpoint in `$grid-breakpoints` must start at 0, but starts at #{$first-value}.\";\n }\n}\n\n// Replace `$search` with `$replace` in `$string`\n// Used on our SVG icon backgrounds for custom forms.\n//\n// @author Hugo Giraudel\n// @param {String} $string - Initial string\n// @param {String} $search - Substring to replace\n// @param {String} $replace ('') - New value\n// @return {String} - Updated string\n@function str-replace($string, $search, $replace: \"\") {\n $index: str-index($string, $search);\n\n @if $index {\n @return str-slice($string, 1, $index - 1) + $replace + str-replace(str-slice($string, $index + str-length($search)), $search, $replace);\n }\n\n @return $string;\n}\n\n// Color contrast\n@function color-yiq($color) {\n $r: red($color);\n $g: green($color);\n $b: blue($color);\n\n $yiq: (($r * 299) + ($g * 587) + ($b * 114)) / 1000;\n\n @if ($yiq >= $yiq-contrasted-threshold) {\n @return $yiq-text-dark;\n } @else {\n @return $yiq-text-light;\n }\n}\n\n// Retrieve color Sass maps\n@function color($key: \"blue\") {\n @return map-get($colors, $key);\n}\n\n@function theme-color($key: \"primary\") {\n @return map-get($theme-colors, $key);\n}\n\n@function gray($key: \"100\") {\n @return map-get($grays, $key);\n}\n\n// Request a theme color level\n@function theme-color-level($color-name: \"primary\", $level: 0) {\n $color: theme-color($color-name);\n $color-base: if($level > 0, $black, $white);\n $level: abs($level);\n\n @return mix($color-base, $color, $level * $theme-color-interval);\n}\n","// stylelint-disable selector-no-qualifying-type\n\n//\n// Textual form controls\n//\n\n.form-control {\n display: block;\n width: 100%;\n padding: $input-padding-y $input-padding-x;\n font-size: $font-size-base;\n line-height: $input-line-height;\n color: $input-color;\n background-color: $input-bg;\n background-clip: padding-box;\n border: $input-border-width solid $input-border-color;\n\n // Note: This has no effect on `s in CSS.\n @if $enable-rounded {\n // Manually use the if/else instead of the mixin to account for iOS override\n border-radius: $input-border-radius;\n } @else {\n // Otherwise undo the iOS default\n border-radius: 0;\n }\n\n @include box-shadow($input-box-shadow);\n @include transition($input-transition);\n\n // Unstyle the caret on ` receives focus\n // in IE and (under certain conditions) Edge, as it looks bad and cannot be made to\n // match the appearance of the native widget.\n // See https://github.com/twbs/bootstrap/issues/19398.\n color: $input-color;\n background-color: $input-bg;\n }\n}\n\n// Make file inputs better match text inputs by forcing them to new lines.\n.form-control-file,\n.form-control-range {\n display: block;\n width: 100%;\n}\n\n\n//\n// Labels\n//\n\n// For use with horizontal and inline forms, when you need the label (or legend)\n// text to align with the form controls.\n.col-form-label {\n padding-top: calc(#{$input-padding-y} + #{$input-border-width});\n padding-bottom: calc(#{$input-padding-y} + #{$input-border-width});\n margin-bottom: 0; // Override the `
'}),Rn=c({},wi.DefaultType,{content:"(string|element|function)"}),Hn="fade",Fn=".popover-header",Un=".popover-body",Bn={HIDE:"hide"+Ln,HIDDEN:"hidden"+Ln,SHOW:(Wn="show")+Ln,SHOWN:"shown"+Ln,INSERTED:"inserted"+Ln,CLICK:"click"+Ln,FOCUSIN:"focusin"+Ln,FOCUSOUT:"focusout"+Ln,MOUSEENTER:"mouseenter"+Ln,MOUSELEAVE:"mouseleave"+Ln},Kn=function(t){var e,n;function i(){return t.apply(this,arguments)||this}n=t,(e=i).prototype=Object.create(n.prototype),(e.prototype.constructor=e).__proto__=n;var r=i.prototype;return r.isWithContent=function(){return this.getTitle()||this._getContent()},r.addAttachmentClass=function(t){On(this.getTipElement()).addClass(xn+"-"+t)},r.getTipElement=function(){return this.tip=this.tip||On(this.config.template)[0],this.tip},r.setContent=function(){var t=On(this.getTipElement());this.setElementContent(t.find(Fn),this.getTitle());var e=this._getContent();"function"==typeof e&&(e=e.call(this.element)),this.setElementContent(t.find(Un),e),t.removeClass(Hn+" "+Wn)},r._getContent=function(){return this.element.getAttribute("data-content")||this.config.content},r._cleanTipClass=function(){var t=On(this.getTipElement()),e=t.attr("class").match(jn);null!==e&&0=this._offsets[r]&&("undefined"==typeof this._offsets[r+1]||t= 0) {\n timeoutDuration = 1;\n break;\n }\n}\n\nfunction microtaskDebounce(fn) {\n var called = false;\n return function () {\n if (called) {\n return;\n }\n called = true;\n window.Promise.resolve().then(function () {\n called = false;\n fn();\n });\n };\n}\n\nfunction taskDebounce(fn) {\n var scheduled = false;\n return function () {\n if (!scheduled) {\n scheduled = true;\n setTimeout(function () {\n scheduled = false;\n fn();\n }, timeoutDuration);\n }\n };\n}\n\nvar supportsMicroTasks = isBrowser && window.Promise;\n\n/**\n* Create a debounced version of a method, that's asynchronously deferred\n* but called in the minimum time possible.\n*\n* @method\n* @memberof Popper.Utils\n* @argument {Function} fn\n* @returns {Function}\n*/\nvar debounce = supportsMicroTasks ? microtaskDebounce : taskDebounce;\n\n/**\n * Check if the given variable is a function\n * @method\n * @memberof Popper.Utils\n * @argument {Any} functionToCheck - variable to check\n * @returns {Boolean} answer to: is a function?\n */\nfunction isFunction(functionToCheck) {\n var getType = {};\n return functionToCheck && getType.toString.call(functionToCheck) === '[object Function]';\n}\n\n/**\n * Get CSS computed property of the given element\n * @method\n * @memberof Popper.Utils\n * @argument {Eement} element\n * @argument {String} property\n */\nfunction getStyleComputedProperty(element, property) {\n if (element.nodeType !== 1) {\n return [];\n }\n // NOTE: 1 DOM access here\n var css = getComputedStyle(element, null);\n return property ? css[property] : css;\n}\n\n/**\n * Returns the parentNode or the host of the element\n * @method\n * @memberof Popper.Utils\n * @argument {Element} element\n * @returns {Element} parent\n */\nfunction getParentNode(element) {\n if (element.nodeName === 'HTML') {\n return element;\n }\n return element.parentNode || element.host;\n}\n\n/**\n * Returns the scrolling parent of the given element\n * @method\n * @memberof Popper.Utils\n * @argument {Element} element\n * @returns {Element} scroll parent\n */\nfunction getScrollParent(element) {\n // Return body, `getScroll` will take care to get the correct `scrollTop` from it\n if (!element) {\n return document.body;\n }\n\n switch (element.nodeName) {\n case 'HTML':\n case 'BODY':\n return element.ownerDocument.body;\n case '#document':\n return element.body;\n }\n\n // Firefox want us to check `-x` and `-y` variations as well\n\n var _getStyleComputedProp = getStyleComputedProperty(element),\n overflow = _getStyleComputedProp.overflow,\n overflowX = _getStyleComputedProp.overflowX,\n overflowY = _getStyleComputedProp.overflowY;\n\n if (/(auto|scroll|overlay)/.test(overflow + overflowY + overflowX)) {\n return element;\n }\n\n return getScrollParent(getParentNode(element));\n}\n\nvar isIE11 = isBrowser && !!(window.MSInputMethodContext && document.documentMode);\nvar isIE10 = isBrowser && /MSIE 10/.test(navigator.userAgent);\n\n/**\n * Determines if the browser is Internet Explorer\n * @method\n * @memberof Popper.Utils\n * @param {Number} version to check\n * @returns {Boolean} isIE\n */\nfunction isIE(version) {\n if (version === 11) {\n return isIE11;\n }\n if (version === 10) {\n return isIE10;\n }\n return isIE11 || isIE10;\n}\n\n/**\n * Returns the offset parent of the given element\n * @method\n * @memberof Popper.Utils\n * @argument {Element} element\n * @returns {Element} offset parent\n */\nfunction getOffsetParent(element) {\n if (!element) {\n return document.documentElement;\n }\n\n var noOffsetParent = isIE(10) ? document.body : null;\n\n // NOTE: 1 DOM access here\n var offsetParent = element.offsetParent;\n // Skip hidden elements which don't have an offsetParent\n while (offsetParent === noOffsetParent && element.nextElementSibling) {\n offsetParent = (element = element.nextElementSibling).offsetParent;\n }\n\n var nodeName = offsetParent && offsetParent.nodeName;\n\n if (!nodeName || nodeName === 'BODY' || nodeName === 'HTML') {\n return element ? element.ownerDocument.documentElement : document.documentElement;\n }\n\n // .offsetParent will return the closest TD or TABLE in case\n // no offsetParent is present, I hate this job...\n if (['TD', 'TABLE'].indexOf(offsetParent.nodeName) !== -1 && getStyleComputedProperty(offsetParent, 'position') === 'static') {\n return getOffsetParent(offsetParent);\n }\n\n return offsetParent;\n}\n\nfunction isOffsetContainer(element) {\n var nodeName = element.nodeName;\n\n if (nodeName === 'BODY') {\n return false;\n }\n return nodeName === 'HTML' || getOffsetParent(element.firstElementChild) === element;\n}\n\n/**\n * Finds the root node (document, shadowDOM root) of the given element\n * @method\n * @memberof Popper.Utils\n * @argument {Element} node\n * @returns {Element} root node\n */\nfunction getRoot(node) {\n if (node.parentNode !== null) {\n return getRoot(node.parentNode);\n }\n\n return node;\n}\n\n/**\n * Finds the offset parent common to the two provided nodes\n * @method\n * @memberof Popper.Utils\n * @argument {Element} element1\n * @argument {Element} element2\n * @returns {Element} common offset parent\n */\nfunction findCommonOffsetParent(element1, element2) {\n // This check is needed to avoid errors in case one of the elements isn't defined for any reason\n if (!element1 || !element1.nodeType || !element2 || !element2.nodeType) {\n return document.documentElement;\n }\n\n // Here we make sure to give as \"start\" the element that comes first in the DOM\n var order = element1.compareDocumentPosition(element2) & Node.DOCUMENT_POSITION_FOLLOWING;\n var start = order ? element1 : element2;\n var end = order ? element2 : element1;\n\n // Get common ancestor container\n var range = document.createRange();\n range.setStart(start, 0);\n range.setEnd(end, 0);\n var commonAncestorContainer = range.commonAncestorContainer;\n\n // Both nodes are inside #document\n\n if (element1 !== commonAncestorContainer && element2 !== commonAncestorContainer || start.contains(end)) {\n if (isOffsetContainer(commonAncestorContainer)) {\n return commonAncestorContainer;\n }\n\n return getOffsetParent(commonAncestorContainer);\n }\n\n // one of the nodes is inside shadowDOM, find which one\n var element1root = getRoot(element1);\n if (element1root.host) {\n return findCommonOffsetParent(element1root.host, element2);\n } else {\n return findCommonOffsetParent(element1, getRoot(element2).host);\n }\n}\n\n/**\n * Gets the scroll value of the given element in the given side (top and left)\n * @method\n * @memberof Popper.Utils\n * @argument {Element} element\n * @argument {String} side `top` or `left`\n * @returns {number} amount of scrolled pixels\n */\nfunction getScroll(element) {\n var side = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'top';\n\n var upperSide = side === 'top' ? 'scrollTop' : 'scrollLeft';\n var nodeName = element.nodeName;\n\n if (nodeName === 'BODY' || nodeName === 'HTML') {\n var html = element.ownerDocument.documentElement;\n var scrollingElement = element.ownerDocument.scrollingElement || html;\n return scrollingElement[upperSide];\n }\n\n return element[upperSide];\n}\n\n/*\n * Sum or subtract the element scroll values (left and top) from a given rect object\n * @method\n * @memberof Popper.Utils\n * @param {Object} rect - Rect object you want to change\n * @param {HTMLElement} element - The element from the function reads the scroll values\n * @param {Boolean} subtract - set to true if you want to subtract the scroll values\n * @return {Object} rect - The modifier rect object\n */\nfunction includeScroll(rect, element) {\n var subtract = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;\n\n var scrollTop = getScroll(element, 'top');\n var scrollLeft = getScroll(element, 'left');\n var modifier = subtract ? -1 : 1;\n rect.top += scrollTop * modifier;\n rect.bottom += scrollTop * modifier;\n rect.left += scrollLeft * modifier;\n rect.right += scrollLeft * modifier;\n return rect;\n}\n\n/*\n * Helper to detect borders of a given element\n * @method\n * @memberof Popper.Utils\n * @param {CSSStyleDeclaration} styles\n * Result of `getStyleComputedProperty` on the given element\n * @param {String} axis - `x` or `y`\n * @return {number} borders - The borders size of the given axis\n */\n\nfunction getBordersSize(styles, axis) {\n var sideA = axis === 'x' ? 'Left' : 'Top';\n var sideB = sideA === 'Left' ? 'Right' : 'Bottom';\n\n return parseFloat(styles['border' + sideA + 'Width'], 10) + parseFloat(styles['border' + sideB + 'Width'], 10);\n}\n\nfunction getSize(axis, body, html, computedStyle) {\n return Math.max(body['offset' + axis], body['scroll' + axis], html['client' + axis], html['offset' + axis], html['scroll' + axis], isIE(10) ? html['offset' + axis] + computedStyle['margin' + (axis === 'Height' ? 'Top' : 'Left')] + computedStyle['margin' + (axis === 'Height' ? 'Bottom' : 'Right')] : 0);\n}\n\nfunction getWindowSizes() {\n var body = document.body;\n var html = document.documentElement;\n var computedStyle = isIE(10) && getComputedStyle(html);\n\n return {\n height: getSize('Height', body, html, computedStyle),\n width: getSize('Width', body, html, computedStyle)\n };\n}\n\nvar classCallCheck = function (instance, Constructor) {\n if (!(instance instanceof Constructor)) {\n throw new TypeError(\"Cannot call a class as a function\");\n }\n};\n\nvar createClass = function () {\n function defineProperties(target, props) {\n for (var i = 0; i < props.length; i++) {\n var descriptor = props[i];\n descriptor.enumerable = descriptor.enumerable || false;\n descriptor.configurable = true;\n if (\"value\" in descriptor) descriptor.writable = true;\n Object.defineProperty(target, descriptor.key, descriptor);\n }\n }\n\n return function (Constructor, protoProps, staticProps) {\n if (protoProps) defineProperties(Constructor.prototype, protoProps);\n if (staticProps) defineProperties(Constructor, staticProps);\n return Constructor;\n };\n}();\n\n\n\n\n\nvar defineProperty = function (obj, key, value) {\n if (key in obj) {\n Object.defineProperty(obj, key, {\n value: value,\n enumerable: true,\n configurable: true,\n writable: true\n });\n } else {\n obj[key] = value;\n }\n\n return obj;\n};\n\nvar _extends = Object.assign || function (target) {\n for (var i = 1; i < arguments.length; i++) {\n var source = arguments[i];\n\n for (var key in source) {\n if (Object.prototype.hasOwnProperty.call(source, key)) {\n target[key] = source[key];\n }\n }\n }\n\n return target;\n};\n\n/**\n * Given element offsets, generate an output similar to getBoundingClientRect\n * @method\n * @memberof Popper.Utils\n * @argument {Object} offsets\n * @returns {Object} ClientRect like output\n */\nfunction getClientRect(offsets) {\n return _extends({}, offsets, {\n right: offsets.left + offsets.width,\n bottom: offsets.top + offsets.height\n });\n}\n\n/**\n * Get bounding client rect of given element\n * @method\n * @memberof Popper.Utils\n * @param {HTMLElement} element\n * @return {Object} client rect\n */\nfunction getBoundingClientRect(element) {\n var rect = {};\n\n // IE10 10 FIX: Please, don't ask, the element isn't\n // considered in DOM in some circumstances...\n // This isn't reproducible in IE10 compatibility mode of IE11\n try {\n if (isIE(10)) {\n rect = element.getBoundingClientRect();\n var scrollTop = getScroll(element, 'top');\n var scrollLeft = getScroll(element, 'left');\n rect.top += scrollTop;\n rect.left += scrollLeft;\n rect.bottom += scrollTop;\n rect.right += scrollLeft;\n } else {\n rect = element.getBoundingClientRect();\n }\n } catch (e) {}\n\n var result = {\n left: rect.left,\n top: rect.top,\n width: rect.right - rect.left,\n height: rect.bottom - rect.top\n };\n\n // subtract scrollbar size from sizes\n var sizes = element.nodeName === 'HTML' ? getWindowSizes() : {};\n var width = sizes.width || element.clientWidth || result.right - result.left;\n var height = sizes.height || element.clientHeight || result.bottom - result.top;\n\n var horizScrollbar = element.offsetWidth - width;\n var vertScrollbar = element.offsetHeight - height;\n\n // if an hypothetical scrollbar is detected, we must be sure it's not a `border`\n // we make this check conditional for performance reasons\n if (horizScrollbar || vertScrollbar) {\n var styles = getStyleComputedProperty(element);\n horizScrollbar -= getBordersSize(styles, 'x');\n vertScrollbar -= getBordersSize(styles, 'y');\n\n result.width -= horizScrollbar;\n result.height -= vertScrollbar;\n }\n\n return getClientRect(result);\n}\n\nfunction getOffsetRectRelativeToArbitraryNode(children, parent) {\n var fixedPosition = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;\n\n var isIE10 = isIE(10);\n var isHTML = parent.nodeName === 'HTML';\n var childrenRect = getBoundingClientRect(children);\n var parentRect = getBoundingClientRect(parent);\n var scrollParent = getScrollParent(children);\n\n var styles = getStyleComputedProperty(parent);\n var borderTopWidth = parseFloat(styles.borderTopWidth, 10);\n var borderLeftWidth = parseFloat(styles.borderLeftWidth, 10);\n\n // In cases where the parent is fixed, we must ignore negative scroll in offset calc\n if (fixedPosition && parent.nodeName === 'HTML') {\n parentRect.top = Math.max(parentRect.top, 0);\n parentRect.left = Math.max(parentRect.left, 0);\n }\n var offsets = getClientRect({\n top: childrenRect.top - parentRect.top - borderTopWidth,\n left: childrenRect.left - parentRect.left - borderLeftWidth,\n width: childrenRect.width,\n height: childrenRect.height\n });\n offsets.marginTop = 0;\n offsets.marginLeft = 0;\n\n // Subtract margins of documentElement in case it's being used as parent\n // we do this only on HTML because it's the only element that behaves\n // differently when margins are applied to it. The margins are included in\n // the box of the documentElement, in the other cases not.\n if (!isIE10 && isHTML) {\n var marginTop = parseFloat(styles.marginTop, 10);\n var marginLeft = parseFloat(styles.marginLeft, 10);\n\n offsets.top -= borderTopWidth - marginTop;\n offsets.bottom -= borderTopWidth - marginTop;\n offsets.left -= borderLeftWidth - marginLeft;\n offsets.right -= borderLeftWidth - marginLeft;\n\n // Attach marginTop and marginLeft because in some circumstances we may need them\n offsets.marginTop = marginTop;\n offsets.marginLeft = marginLeft;\n }\n\n if (isIE10 && !fixedPosition ? parent.contains(scrollParent) : parent === scrollParent && scrollParent.nodeName !== 'BODY') {\n offsets = includeScroll(offsets, parent);\n }\n\n return offsets;\n}\n\nfunction getViewportOffsetRectRelativeToArtbitraryNode(element) {\n var excludeScroll = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;\n\n var html = element.ownerDocument.documentElement;\n var relativeOffset = getOffsetRectRelativeToArbitraryNode(element, html);\n var width = Math.max(html.clientWidth, window.innerWidth || 0);\n var height = Math.max(html.clientHeight, window.innerHeight || 0);\n\n var scrollTop = !excludeScroll ? getScroll(html) : 0;\n var scrollLeft = !excludeScroll ? getScroll(html, 'left') : 0;\n\n var offset = {\n top: scrollTop - relativeOffset.top + relativeOffset.marginTop,\n left: scrollLeft - relativeOffset.left + relativeOffset.marginLeft,\n width: width,\n height: height\n };\n\n return getClientRect(offset);\n}\n\n/**\n * Check if the given element is fixed or is inside a fixed parent\n * @method\n * @memberof Popper.Utils\n * @argument {Element} element\n * @argument {Element} customContainer\n * @returns {Boolean} answer to \"isFixed?\"\n */\nfunction isFixed(element) {\n var nodeName = element.nodeName;\n if (nodeName === 'BODY' || nodeName === 'HTML') {\n return false;\n }\n if (getStyleComputedProperty(element, 'position') === 'fixed') {\n return true;\n }\n return isFixed(getParentNode(element));\n}\n\n/**\n * Finds the first parent of an element that has a transformed property defined\n * @method\n * @memberof Popper.Utils\n * @argument {Element} element\n * @returns {Element} first transformed parent or documentElement\n */\n\nfunction getFixedPositionOffsetParent(element) {\n // This check is needed to avoid errors in case one of the elements isn't defined for any reason\n if (!element || !element.parentElement || isIE()) {\n return document.documentElement;\n }\n var el = element.parentElement;\n while (el && getStyleComputedProperty(el, 'transform') === 'none') {\n el = el.parentElement;\n }\n return el || document.documentElement;\n}\n\n/**\n * Computed the boundaries limits and return them\n * @method\n * @memberof Popper.Utils\n * @param {HTMLElement} popper\n * @param {HTMLElement} reference\n * @param {number} padding\n * @param {HTMLElement} boundariesElement - Element used to define the boundaries\n * @param {Boolean} fixedPosition - Is in fixed position mode\n * @returns {Object} Coordinates of the boundaries\n */\nfunction getBoundaries(popper, reference, padding, boundariesElement) {\n var fixedPosition = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : false;\n\n // NOTE: 1 DOM access here\n\n var boundaries = { top: 0, left: 0 };\n var offsetParent = fixedPosition ? getFixedPositionOffsetParent(popper) : findCommonOffsetParent(popper, reference);\n\n // Handle viewport case\n if (boundariesElement === 'viewport') {\n boundaries = getViewportOffsetRectRelativeToArtbitraryNode(offsetParent, fixedPosition);\n } else {\n // Handle other cases based on DOM element used as boundaries\n var boundariesNode = void 0;\n if (boundariesElement === 'scrollParent') {\n boundariesNode = getScrollParent(getParentNode(reference));\n if (boundariesNode.nodeName === 'BODY') {\n boundariesNode = popper.ownerDocument.documentElement;\n }\n } else if (boundariesElement === 'window') {\n boundariesNode = popper.ownerDocument.documentElement;\n } else {\n boundariesNode = boundariesElement;\n }\n\n var offsets = getOffsetRectRelativeToArbitraryNode(boundariesNode, offsetParent, fixedPosition);\n\n // In case of HTML, we need a different computation\n if (boundariesNode.nodeName === 'HTML' && !isFixed(offsetParent)) {\n var _getWindowSizes = getWindowSizes(),\n height = _getWindowSizes.height,\n width = _getWindowSizes.width;\n\n boundaries.top += offsets.top - offsets.marginTop;\n boundaries.bottom = height + offsets.top;\n boundaries.left += offsets.left - offsets.marginLeft;\n boundaries.right = width + offsets.left;\n } else {\n // for all the other DOM elements, this one is good\n boundaries = offsets;\n }\n }\n\n // Add paddings\n boundaries.left += padding;\n boundaries.top += padding;\n boundaries.right -= padding;\n boundaries.bottom -= padding;\n\n return boundaries;\n}\n\nfunction getArea(_ref) {\n var width = _ref.width,\n height = _ref.height;\n\n return width * height;\n}\n\n/**\n * Utility used to transform the `auto` placement to the placement with more\n * available space.\n * @method\n * @memberof Popper.Utils\n * @argument {Object} data - The data object generated by update method\n * @argument {Object} options - Modifiers configuration and options\n * @returns {Object} The data object, properly modified\n */\nfunction computeAutoPlacement(placement, refRect, popper, reference, boundariesElement) {\n var padding = arguments.length > 5 && arguments[5] !== undefined ? arguments[5] : 0;\n\n if (placement.indexOf('auto') === -1) {\n return placement;\n }\n\n var boundaries = getBoundaries(popper, reference, padding, boundariesElement);\n\n var rects = {\n top: {\n width: boundaries.width,\n height: refRect.top - boundaries.top\n },\n right: {\n width: boundaries.right - refRect.right,\n height: boundaries.height\n },\n bottom: {\n width: boundaries.width,\n height: boundaries.bottom - refRect.bottom\n },\n left: {\n width: refRect.left - boundaries.left,\n height: boundaries.height\n }\n };\n\n var sortedAreas = Object.keys(rects).map(function (key) {\n return _extends({\n key: key\n }, rects[key], {\n area: getArea(rects[key])\n });\n }).sort(function (a, b) {\n return b.area - a.area;\n });\n\n var filteredAreas = sortedAreas.filter(function (_ref2) {\n var width = _ref2.width,\n height = _ref2.height;\n return width >= popper.clientWidth && height >= popper.clientHeight;\n });\n\n var computedPlacement = filteredAreas.length > 0 ? filteredAreas[0].key : sortedAreas[0].key;\n\n var variation = placement.split('-')[1];\n\n return computedPlacement + (variation ? '-' + variation : '');\n}\n\n/**\n * Get offsets to the reference element\n * @method\n * @memberof Popper.Utils\n * @param {Object} state\n * @param {Element} popper - the popper element\n * @param {Element} reference - the reference element (the popper will be relative to this)\n * @param {Element} fixedPosition - is in fixed position mode\n * @returns {Object} An object containing the offsets which will be applied to the popper\n */\nfunction getReferenceOffsets(state, popper, reference) {\n var fixedPosition = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : null;\n\n var commonOffsetParent = fixedPosition ? getFixedPositionOffsetParent(popper) : findCommonOffsetParent(popper, reference);\n return getOffsetRectRelativeToArbitraryNode(reference, commonOffsetParent, fixedPosition);\n}\n\n/**\n * Get the outer sizes of the given element (offset size + margins)\n * @method\n * @memberof Popper.Utils\n * @argument {Element} element\n * @returns {Object} object containing width and height properties\n */\nfunction getOuterSizes(element) {\n var styles = getComputedStyle(element);\n var x = parseFloat(styles.marginTop) + parseFloat(styles.marginBottom);\n var y = parseFloat(styles.marginLeft) + parseFloat(styles.marginRight);\n var result = {\n width: element.offsetWidth + y,\n height: element.offsetHeight + x\n };\n return result;\n}\n\n/**\n * Get the opposite placement of the given one\n * @method\n * @memberof Popper.Utils\n * @argument {String} placement\n * @returns {String} flipped placement\n */\nfunction getOppositePlacement(placement) {\n var hash = { left: 'right', right: 'left', bottom: 'top', top: 'bottom' };\n return placement.replace(/left|right|bottom|top/g, function (matched) {\n return hash[matched];\n });\n}\n\n/**\n * Get offsets to the popper\n * @method\n * @memberof Popper.Utils\n * @param {Object} position - CSS position the Popper will get applied\n * @param {HTMLElement} popper - the popper element\n * @param {Object} referenceOffsets - the reference offsets (the popper will be relative to this)\n * @param {String} placement - one of the valid placement options\n * @returns {Object} popperOffsets - An object containing the offsets which will be applied to the popper\n */\nfunction getPopperOffsets(popper, referenceOffsets, placement) {\n placement = placement.split('-')[0];\n\n // Get popper node sizes\n var popperRect = getOuterSizes(popper);\n\n // Add position, width and height to our offsets object\n var popperOffsets = {\n width: popperRect.width,\n height: popperRect.height\n };\n\n // depending by the popper placement we have to compute its offsets slightly differently\n var isHoriz = ['right', 'left'].indexOf(placement) !== -1;\n var mainSide = isHoriz ? 'top' : 'left';\n var secondarySide = isHoriz ? 'left' : 'top';\n var measurement = isHoriz ? 'height' : 'width';\n var secondaryMeasurement = !isHoriz ? 'height' : 'width';\n\n popperOffsets[mainSide] = referenceOffsets[mainSide] + referenceOffsets[measurement] / 2 - popperRect[measurement] / 2;\n if (placement === secondarySide) {\n popperOffsets[secondarySide] = referenceOffsets[secondarySide] - popperRect[secondaryMeasurement];\n } else {\n popperOffsets[secondarySide] = referenceOffsets[getOppositePlacement(secondarySide)];\n }\n\n return popperOffsets;\n}\n\n/**\n * Mimics the `find` method of Array\n * @method\n * @memberof Popper.Utils\n * @argument {Array} arr\n * @argument prop\n * @argument value\n * @returns index or -1\n */\nfunction find(arr, check) {\n // use native find if supported\n if (Array.prototype.find) {\n return arr.find(check);\n }\n\n // use `filter` to obtain the same behavior of `find`\n return arr.filter(check)[0];\n}\n\n/**\n * Return the index of the matching object\n * @method\n * @memberof Popper.Utils\n * @argument {Array} arr\n * @argument prop\n * @argument value\n * @returns index or -1\n */\nfunction findIndex(arr, prop, value) {\n // use native findIndex if supported\n if (Array.prototype.findIndex) {\n return arr.findIndex(function (cur) {\n return cur[prop] === value;\n });\n }\n\n // use `find` + `indexOf` if `findIndex` isn't supported\n var match = find(arr, function (obj) {\n return obj[prop] === value;\n });\n return arr.indexOf(match);\n}\n\n/**\n * Loop trough the list of modifiers and run them in order,\n * each of them will then edit the data object.\n * @method\n * @memberof Popper.Utils\n * @param {dataObject} data\n * @param {Array} modifiers\n * @param {String} ends - Optional modifier name used as stopper\n * @returns {dataObject}\n */\nfunction runModifiers(modifiers, data, ends) {\n var modifiersToRun = ends === undefined ? modifiers : modifiers.slice(0, findIndex(modifiers, 'name', ends));\n\n modifiersToRun.forEach(function (modifier) {\n if (modifier['function']) {\n // eslint-disable-line dot-notation\n console.warn('`modifier.function` is deprecated, use `modifier.fn`!');\n }\n var fn = modifier['function'] || modifier.fn; // eslint-disable-line dot-notation\n if (modifier.enabled && isFunction(fn)) {\n // Add properties to offsets to make them a complete clientRect object\n // we do this before each modifier to make sure the previous one doesn't\n // mess with these values\n data.offsets.popper = getClientRect(data.offsets.popper);\n data.offsets.reference = getClientRect(data.offsets.reference);\n\n data = fn(data, modifier);\n }\n });\n\n return data;\n}\n\n/**\n * Updates the position of the popper, computing the new offsets and applying\n * the new style.
\n * Prefer `scheduleUpdate` over `update` because of performance reasons.\n * @method\n * @memberof Popper\n */\nfunction update() {\n // if popper is destroyed, don't perform any further update\n if (this.state.isDestroyed) {\n return;\n }\n\n var data = {\n instance: this,\n styles: {},\n arrowStyles: {},\n attributes: {},\n flipped: false,\n offsets: {}\n };\n\n // compute reference element offsets\n data.offsets.reference = getReferenceOffsets(this.state, this.popper, this.reference, this.options.positionFixed);\n\n // compute auto placement, store placement inside the data object,\n // modifiers will be able to edit `placement` if needed\n // and refer to originalPlacement to know the original value\n data.placement = computeAutoPlacement(this.options.placement, data.offsets.reference, this.popper, this.reference, this.options.modifiers.flip.boundariesElement, this.options.modifiers.flip.padding);\n\n // store the computed placement inside `originalPlacement`\n data.originalPlacement = data.placement;\n\n data.positionFixed = this.options.positionFixed;\n\n // compute the popper offsets\n data.offsets.popper = getPopperOffsets(this.popper, data.offsets.reference, data.placement);\n\n data.offsets.popper.position = this.options.positionFixed ? 'fixed' : 'absolute';\n\n // run the modifiers\n data = runModifiers(this.modifiers, data);\n\n // the first `update` will call `onCreate` callback\n // the other ones will call `onUpdate` callback\n if (!this.state.isCreated) {\n this.state.isCreated = true;\n this.options.onCreate(data);\n } else {\n this.options.onUpdate(data);\n }\n}\n\n/**\n * Helper used to know if the given modifier is enabled.\n * @method\n * @memberof Popper.Utils\n * @returns {Boolean}\n */\nfunction isModifierEnabled(modifiers, modifierName) {\n return modifiers.some(function (_ref) {\n var name = _ref.name,\n enabled = _ref.enabled;\n return enabled && name === modifierName;\n });\n}\n\n/**\n * Get the prefixed supported property name\n * @method\n * @memberof Popper.Utils\n * @argument {String} property (camelCase)\n * @returns {String} prefixed property (camelCase or PascalCase, depending on the vendor prefix)\n */\nfunction getSupportedPropertyName(property) {\n var prefixes = [false, 'ms', 'Webkit', 'Moz', 'O'];\n var upperProp = property.charAt(0).toUpperCase() + property.slice(1);\n\n for (var i = 0; i < prefixes.length; i++) {\n var prefix = prefixes[i];\n var toCheck = prefix ? '' + prefix + upperProp : property;\n if (typeof document.body.style[toCheck] !== 'undefined') {\n return toCheck;\n }\n }\n return null;\n}\n\n/**\n * Destroy the popper\n * @method\n * @memberof Popper\n */\nfunction destroy() {\n this.state.isDestroyed = true;\n\n // touch DOM only if `applyStyle` modifier is enabled\n if (isModifierEnabled(this.modifiers, 'applyStyle')) {\n this.popper.removeAttribute('x-placement');\n this.popper.style.position = '';\n this.popper.style.top = '';\n this.popper.style.left = '';\n this.popper.style.right = '';\n this.popper.style.bottom = '';\n this.popper.style.willChange = '';\n this.popper.style[getSupportedPropertyName('transform')] = '';\n }\n\n this.disableEventListeners();\n\n // remove the popper if user explicity asked for the deletion on destroy\n // do not use `remove` because IE11 doesn't support it\n if (this.options.removeOnDestroy) {\n this.popper.parentNode.removeChild(this.popper);\n }\n return this;\n}\n\n/**\n * Get the window associated with the element\n * @argument {Element} element\n * @returns {Window}\n */\nfunction getWindow(element) {\n var ownerDocument = element.ownerDocument;\n return ownerDocument ? ownerDocument.defaultView : window;\n}\n\nfunction attachToScrollParents(scrollParent, event, callback, scrollParents) {\n var isBody = scrollParent.nodeName === 'BODY';\n var target = isBody ? scrollParent.ownerDocument.defaultView : scrollParent;\n target.addEventListener(event, callback, { passive: true });\n\n if (!isBody) {\n attachToScrollParents(getScrollParent(target.parentNode), event, callback, scrollParents);\n }\n scrollParents.push(target);\n}\n\n/**\n * Setup needed event listeners used to update the popper position\n * @method\n * @memberof Popper.Utils\n * @private\n */\nfunction setupEventListeners(reference, options, state, updateBound) {\n // Resize event listener on window\n state.updateBound = updateBound;\n getWindow(reference).addEventListener('resize', state.updateBound, { passive: true });\n\n // Scroll event listener on scroll parents\n var scrollElement = getScrollParent(reference);\n attachToScrollParents(scrollElement, 'scroll', state.updateBound, state.scrollParents);\n state.scrollElement = scrollElement;\n state.eventsEnabled = true;\n\n return state;\n}\n\n/**\n * It will add resize/scroll events and start recalculating\n * position of the popper element when they are triggered.\n * @method\n * @memberof Popper\n */\nfunction enableEventListeners() {\n if (!this.state.eventsEnabled) {\n this.state = setupEventListeners(this.reference, this.options, this.state, this.scheduleUpdate);\n }\n}\n\n/**\n * Remove event listeners used to update the popper position\n * @method\n * @memberof Popper.Utils\n * @private\n */\nfunction removeEventListeners(reference, state) {\n // Remove resize event listener on window\n getWindow(reference).removeEventListener('resize', state.updateBound);\n\n // Remove scroll event listener on scroll parents\n state.scrollParents.forEach(function (target) {\n target.removeEventListener('scroll', state.updateBound);\n });\n\n // Reset state\n state.updateBound = null;\n state.scrollParents = [];\n state.scrollElement = null;\n state.eventsEnabled = false;\n return state;\n}\n\n/**\n * It will remove resize/scroll events and won't recalculate popper position\n * when they are triggered. It also won't trigger onUpdate callback anymore,\n * unless you call `update` method manually.\n * @method\n * @memberof Popper\n */\nfunction disableEventListeners() {\n if (this.state.eventsEnabled) {\n cancelAnimationFrame(this.scheduleUpdate);\n this.state = removeEventListeners(this.reference, this.state);\n }\n}\n\n/**\n * Tells if a given input is a number\n * @method\n * @memberof Popper.Utils\n * @param {*} input to check\n * @return {Boolean}\n */\nfunction isNumeric(n) {\n return n !== '' && !isNaN(parseFloat(n)) && isFinite(n);\n}\n\n/**\n * Set the style to the given popper\n * @method\n * @memberof Popper.Utils\n * @argument {Element} element - Element to apply the style to\n * @argument {Object} styles\n * Object with a list of properties and values which will be applied to the element\n */\nfunction setStyles(element, styles) {\n Object.keys(styles).forEach(function (prop) {\n var unit = '';\n // add unit if the value is numeric and is one of the following\n if (['width', 'height', 'top', 'right', 'bottom', 'left'].indexOf(prop) !== -1 && isNumeric(styles[prop])) {\n unit = 'px';\n }\n element.style[prop] = styles[prop] + unit;\n });\n}\n\n/**\n * Set the attributes to the given popper\n * @method\n * @memberof Popper.Utils\n * @argument {Element} element - Element to apply the attributes to\n * @argument {Object} styles\n * Object with a list of properties and values which will be applied to the element\n */\nfunction setAttributes(element, attributes) {\n Object.keys(attributes).forEach(function (prop) {\n var value = attributes[prop];\n if (value !== false) {\n element.setAttribute(prop, attributes[prop]);\n } else {\n element.removeAttribute(prop);\n }\n });\n}\n\n/**\n * @function\n * @memberof Modifiers\n * @argument {Object} data - The data object generated by `update` method\n * @argument {Object} data.styles - List of style properties - values to apply to popper element\n * @argument {Object} data.attributes - List of attribute properties - values to apply to popper element\n * @argument {Object} options - Modifiers configuration and options\n * @returns {Object} The same data object\n */\nfunction applyStyle(data) {\n // any property present in `data.styles` will be applied to the popper,\n // in this way we can make the 3rd party modifiers add custom styles to it\n // Be aware, modifiers could override the properties defined in the previous\n // lines of this modifier!\n setStyles(data.instance.popper, data.styles);\n\n // any property present in `data.attributes` will be applied to the popper,\n // they will be set as HTML attributes of the element\n setAttributes(data.instance.popper, data.attributes);\n\n // if arrowElement is defined and arrowStyles has some properties\n if (data.arrowElement && Object.keys(data.arrowStyles).length) {\n setStyles(data.arrowElement, data.arrowStyles);\n }\n\n return data;\n}\n\n/**\n * Set the x-placement attribute before everything else because it could be used\n * to add margins to the popper margins needs to be calculated to get the\n * correct popper offsets.\n * @method\n * @memberof Popper.modifiers\n * @param {HTMLElement} reference - The reference element used to position the popper\n * @param {HTMLElement} popper - The HTML element used as popper\n * @param {Object} options - Popper.js options\n */\nfunction applyStyleOnLoad(reference, popper, options, modifierOptions, state) {\n // compute reference element offsets\n var referenceOffsets = getReferenceOffsets(state, popper, reference, options.positionFixed);\n\n // compute auto placement, store placement inside the data object,\n // modifiers will be able to edit `placement` if needed\n // and refer to originalPlacement to know the original value\n var placement = computeAutoPlacement(options.placement, referenceOffsets, popper, reference, options.modifiers.flip.boundariesElement, options.modifiers.flip.padding);\n\n popper.setAttribute('x-placement', placement);\n\n // Apply `position` to popper before anything else because\n // without the position applied we can't guarantee correct computations\n setStyles(popper, { position: options.positionFixed ? 'fixed' : 'absolute' });\n\n return options;\n}\n\n/**\n * @function\n * @memberof Modifiers\n * @argument {Object} data - The data object generated by `update` method\n * @argument {Object} options - Modifiers configuration and options\n * @returns {Object} The data object, properly modified\n */\nfunction computeStyle(data, options) {\n var x = options.x,\n y = options.y;\n var popper = data.offsets.popper;\n\n // Remove this legacy support in Popper.js v2\n\n var legacyGpuAccelerationOption = find(data.instance.modifiers, function (modifier) {\n return modifier.name === 'applyStyle';\n }).gpuAcceleration;\n if (legacyGpuAccelerationOption !== undefined) {\n console.warn('WARNING: `gpuAcceleration` option moved to `computeStyle` modifier and will not be supported in future versions of Popper.js!');\n }\n var gpuAcceleration = legacyGpuAccelerationOption !== undefined ? legacyGpuAccelerationOption : options.gpuAcceleration;\n\n var offsetParent = getOffsetParent(data.instance.popper);\n var offsetParentRect = getBoundingClientRect(offsetParent);\n\n // Styles\n var styles = {\n position: popper.position\n };\n\n // Avoid blurry text by using full pixel integers.\n // For pixel-perfect positioning, top/bottom prefers rounded\n // values, while left/right prefers floored values.\n var offsets = {\n left: Math.floor(popper.left),\n top: Math.round(popper.top),\n bottom: Math.round(popper.bottom),\n right: Math.floor(popper.right)\n };\n\n var sideA = x === 'bottom' ? 'top' : 'bottom';\n var sideB = y === 'right' ? 'left' : 'right';\n\n // if gpuAcceleration is set to `true` and transform is supported,\n // we use `translate3d` to apply the position to the popper we\n // automatically use the supported prefixed version if needed\n var prefixedProperty = getSupportedPropertyName('transform');\n\n // now, let's make a step back and look at this code closely (wtf?)\n // If the content of the popper grows once it's been positioned, it\n // may happen that the popper gets misplaced because of the new content\n // overflowing its reference element\n // To avoid this problem, we provide two options (x and y), which allow\n // the consumer to define the offset origin.\n // If we position a popper on top of a reference element, we can set\n // `x` to `top` to make the popper grow towards its top instead of\n // its bottom.\n var left = void 0,\n top = void 0;\n if (sideA === 'bottom') {\n top = -offsetParentRect.height + offsets.bottom;\n } else {\n top = offsets.top;\n }\n if (sideB === 'right') {\n left = -offsetParentRect.width + offsets.right;\n } else {\n left = offsets.left;\n }\n if (gpuAcceleration && prefixedProperty) {\n styles[prefixedProperty] = 'translate3d(' + left + 'px, ' + top + 'px, 0)';\n styles[sideA] = 0;\n styles[sideB] = 0;\n styles.willChange = 'transform';\n } else {\n // othwerise, we use the standard `top`, `left`, `bottom` and `right` properties\n var invertTop = sideA === 'bottom' ? -1 : 1;\n var invertLeft = sideB === 'right' ? -1 : 1;\n styles[sideA] = top * invertTop;\n styles[sideB] = left * invertLeft;\n styles.willChange = sideA + ', ' + sideB;\n }\n\n // Attributes\n var attributes = {\n 'x-placement': data.placement\n };\n\n // Update `data` attributes, styles and arrowStyles\n data.attributes = _extends({}, attributes, data.attributes);\n data.styles = _extends({}, styles, data.styles);\n data.arrowStyles = _extends({}, data.offsets.arrow, data.arrowStyles);\n\n return data;\n}\n\n/**\n * Helper used to know if the given modifier depends from another one.
\n * It checks if the needed modifier is listed and enabled.\n * @method\n * @memberof Popper.Utils\n * @param {Array} modifiers - list of modifiers\n * @param {String} requestingName - name of requesting modifier\n * @param {String} requestedName - name of requested modifier\n * @returns {Boolean}\n */\nfunction isModifierRequired(modifiers, requestingName, requestedName) {\n var requesting = find(modifiers, function (_ref) {\n var name = _ref.name;\n return name === requestingName;\n });\n\n var isRequired = !!requesting && modifiers.some(function (modifier) {\n return modifier.name === requestedName && modifier.enabled && modifier.order < requesting.order;\n });\n\n if (!isRequired) {\n var _requesting = '`' + requestingName + '`';\n var requested = '`' + requestedName + '`';\n console.warn(requested + ' modifier is required by ' + _requesting + ' modifier in order to work, be sure to include it before ' + _requesting + '!');\n }\n return isRequired;\n}\n\n/**\n * @function\n * @memberof Modifiers\n * @argument {Object} data - The data object generated by update method\n * @argument {Object} options - Modifiers configuration and options\n * @returns {Object} The data object, properly modified\n */\nfunction arrow(data, options) {\n var _data$offsets$arrow;\n\n // arrow depends on keepTogether in order to work\n if (!isModifierRequired(data.instance.modifiers, 'arrow', 'keepTogether')) {\n return data;\n }\n\n var arrowElement = options.element;\n\n // if arrowElement is a string, suppose it's a CSS selector\n if (typeof arrowElement === 'string') {\n arrowElement = data.instance.popper.querySelector(arrowElement);\n\n // if arrowElement is not found, don't run the modifier\n if (!arrowElement) {\n return data;\n }\n } else {\n // if the arrowElement isn't a query selector we must check that the\n // provided DOM node is child of its popper node\n if (!data.instance.popper.contains(arrowElement)) {\n console.warn('WARNING: `arrow.element` must be child of its popper element!');\n return data;\n }\n }\n\n var placement = data.placement.split('-')[0];\n var _data$offsets = data.offsets,\n popper = _data$offsets.popper,\n reference = _data$offsets.reference;\n\n var isVertical = ['left', 'right'].indexOf(placement) !== -1;\n\n var len = isVertical ? 'height' : 'width';\n var sideCapitalized = isVertical ? 'Top' : 'Left';\n var side = sideCapitalized.toLowerCase();\n var altSide = isVertical ? 'left' : 'top';\n var opSide = isVertical ? 'bottom' : 'right';\n var arrowElementSize = getOuterSizes(arrowElement)[len];\n\n //\n // extends keepTogether behavior making sure the popper and its\n // reference have enough pixels in conjuction\n //\n\n // top/left side\n if (reference[opSide] - arrowElementSize < popper[side]) {\n data.offsets.popper[side] -= popper[side] - (reference[opSide] - arrowElementSize);\n }\n // bottom/right side\n if (reference[side] + arrowElementSize > popper[opSide]) {\n data.offsets.popper[side] += reference[side] + arrowElementSize - popper[opSide];\n }\n data.offsets.popper = getClientRect(data.offsets.popper);\n\n // compute center of the popper\n var center = reference[side] + reference[len] / 2 - arrowElementSize / 2;\n\n // Compute the sideValue using the updated popper offsets\n // take popper margin in account because we don't have this info available\n var css = getStyleComputedProperty(data.instance.popper);\n var popperMarginSide = parseFloat(css['margin' + sideCapitalized], 10);\n var popperBorderSide = parseFloat(css['border' + sideCapitalized + 'Width'], 10);\n var sideValue = center - data.offsets.popper[side] - popperMarginSide - popperBorderSide;\n\n // prevent arrowElement from being placed not contiguously to its popper\n sideValue = Math.max(Math.min(popper[len] - arrowElementSize, sideValue), 0);\n\n data.arrowElement = arrowElement;\n data.offsets.arrow = (_data$offsets$arrow = {}, defineProperty(_data$offsets$arrow, side, Math.round(sideValue)), defineProperty(_data$offsets$arrow, altSide, ''), _data$offsets$arrow);\n\n return data;\n}\n\n/**\n * Get the opposite placement variation of the given one\n * @method\n * @memberof Popper.Utils\n * @argument {String} placement variation\n * @returns {String} flipped placement variation\n */\nfunction getOppositeVariation(variation) {\n if (variation === 'end') {\n return 'start';\n } else if (variation === 'start') {\n return 'end';\n }\n return variation;\n}\n\n/**\n * List of accepted placements to use as values of the `placement` option.
\n * Valid placements are:\n * - `auto`\n * - `top`\n * - `right`\n * - `bottom`\n * - `left`\n *\n * Each placement can have a variation from this list:\n * - `-start`\n * - `-end`\n *\n * Variations are interpreted easily if you think of them as the left to right\n * written languages. Horizontally (`top` and `bottom`), `start` is left and `end`\n * is right.
\n * Vertically (`left` and `right`), `start` is top and `end` is bottom.\n *\n * Some valid examples are:\n * - `top-end` (on top of reference, right aligned)\n * - `right-start` (on right of reference, top aligned)\n * - `bottom` (on bottom, centered)\n * - `auto-right` (on the side with more space available, alignment depends by placement)\n *\n * @static\n * @type {Array}\n * @enum {String}\n * @readonly\n * @method placements\n * @memberof Popper\n */\nvar placements = ['auto-start', 'auto', 'auto-end', 'top-start', 'top', 'top-end', 'right-start', 'right', 'right-end', 'bottom-end', 'bottom', 'bottom-start', 'left-end', 'left', 'left-start'];\n\n// Get rid of `auto` `auto-start` and `auto-end`\nvar validPlacements = placements.slice(3);\n\n/**\n * Given an initial placement, returns all the subsequent placements\n * clockwise (or counter-clockwise).\n *\n * @method\n * @memberof Popper.Utils\n * @argument {String} placement - A valid placement (it accepts variations)\n * @argument {Boolean} counter - Set to true to walk the placements counterclockwise\n * @returns {Array} placements including their variations\n */\nfunction clockwise(placement) {\n var counter = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;\n\n var index = validPlacements.indexOf(placement);\n var arr = validPlacements.slice(index + 1).concat(validPlacements.slice(0, index));\n return counter ? arr.reverse() : arr;\n}\n\nvar BEHAVIORS = {\n FLIP: 'flip',\n CLOCKWISE: 'clockwise',\n COUNTERCLOCKWISE: 'counterclockwise'\n};\n\n/**\n * @function\n * @memberof Modifiers\n * @argument {Object} data - The data object generated by update method\n * @argument {Object} options - Modifiers configuration and options\n * @returns {Object} The data object, properly modified\n */\nfunction flip(data, options) {\n // if `inner` modifier is enabled, we can't use the `flip` modifier\n if (isModifierEnabled(data.instance.modifiers, 'inner')) {\n return data;\n }\n\n if (data.flipped && data.placement === data.originalPlacement) {\n // seems like flip is trying to loop, probably there's not enough space on any of the flippable sides\n return data;\n }\n\n var boundaries = getBoundaries(data.instance.popper, data.instance.reference, options.padding, options.boundariesElement, data.positionFixed);\n\n var placement = data.placement.split('-')[0];\n var placementOpposite = getOppositePlacement(placement);\n var variation = data.placement.split('-')[1] || '';\n\n var flipOrder = [];\n\n switch (options.behavior) {\n case BEHAVIORS.FLIP:\n flipOrder = [placement, placementOpposite];\n break;\n case BEHAVIORS.CLOCKWISE:\n flipOrder = clockwise(placement);\n break;\n case BEHAVIORS.COUNTERCLOCKWISE:\n flipOrder = clockwise(placement, true);\n break;\n default:\n flipOrder = options.behavior;\n }\n\n flipOrder.forEach(function (step, index) {\n if (placement !== step || flipOrder.length === index + 1) {\n return data;\n }\n\n placement = data.placement.split('-')[0];\n placementOpposite = getOppositePlacement(placement);\n\n var popperOffsets = data.offsets.popper;\n var refOffsets = data.offsets.reference;\n\n // using floor because the reference offsets may contain decimals we are not going to consider here\n var floor = Math.floor;\n var overlapsRef = placement === 'left' && floor(popperOffsets.right) > floor(refOffsets.left) || placement === 'right' && floor(popperOffsets.left) < floor(refOffsets.right) || placement === 'top' && floor(popperOffsets.bottom) > floor(refOffsets.top) || placement === 'bottom' && floor(popperOffsets.top) < floor(refOffsets.bottom);\n\n var overflowsLeft = floor(popperOffsets.left) < floor(boundaries.left);\n var overflowsRight = floor(popperOffsets.right) > floor(boundaries.right);\n var overflowsTop = floor(popperOffsets.top) < floor(boundaries.top);\n var overflowsBottom = floor(popperOffsets.bottom) > floor(boundaries.bottom);\n\n var overflowsBoundaries = placement === 'left' && overflowsLeft || placement === 'right' && overflowsRight || placement === 'top' && overflowsTop || placement === 'bottom' && overflowsBottom;\n\n // flip the variation if required\n var isVertical = ['top', 'bottom'].indexOf(placement) !== -1;\n var flippedVariation = !!options.flipVariations && (isVertical && variation === 'start' && overflowsLeft || isVertical && variation === 'end' && overflowsRight || !isVertical && variation === 'start' && overflowsTop || !isVertical && variation === 'end' && overflowsBottom);\n\n if (overlapsRef || overflowsBoundaries || flippedVariation) {\n // this boolean to detect any flip loop\n data.flipped = true;\n\n if (overlapsRef || overflowsBoundaries) {\n placement = flipOrder[index + 1];\n }\n\n if (flippedVariation) {\n variation = getOppositeVariation(variation);\n }\n\n data.placement = placement + (variation ? '-' + variation : '');\n\n // this object contains `position`, we want to preserve it along with\n // any additional property we may add in the future\n data.offsets.popper = _extends({}, data.offsets.popper, getPopperOffsets(data.instance.popper, data.offsets.reference, data.placement));\n\n data = runModifiers(data.instance.modifiers, data, 'flip');\n }\n });\n return data;\n}\n\n/**\n * @function\n * @memberof Modifiers\n * @argument {Object} data - The data object generated by update method\n * @argument {Object} options - Modifiers configuration and options\n * @returns {Object} The data object, properly modified\n */\nfunction keepTogether(data) {\n var _data$offsets = data.offsets,\n popper = _data$offsets.popper,\n reference = _data$offsets.reference;\n\n var placement = data.placement.split('-')[0];\n var floor = Math.floor;\n var isVertical = ['top', 'bottom'].indexOf(placement) !== -1;\n var side = isVertical ? 'right' : 'bottom';\n var opSide = isVertical ? 'left' : 'top';\n var measurement = isVertical ? 'width' : 'height';\n\n if (popper[side] < floor(reference[opSide])) {\n data.offsets.popper[opSide] = floor(reference[opSide]) - popper[measurement];\n }\n if (popper[opSide] > floor(reference[side])) {\n data.offsets.popper[opSide] = floor(reference[side]);\n }\n\n return data;\n}\n\n/**\n * Converts a string containing value + unit into a px value number\n * @function\n * @memberof {modifiers~offset}\n * @private\n * @argument {String} str - Value + unit string\n * @argument {String} measurement - `height` or `width`\n * @argument {Object} popperOffsets\n * @argument {Object} referenceOffsets\n * @returns {Number|String}\n * Value in pixels, or original string if no values were extracted\n */\nfunction toValue(str, measurement, popperOffsets, referenceOffsets) {\n // separate value from unit\n var split = str.match(/((?:\\-|\\+)?\\d*\\.?\\d*)(.*)/);\n var value = +split[1];\n var unit = split[2];\n\n // If it's not a number it's an operator, I guess\n if (!value) {\n return str;\n }\n\n if (unit.indexOf('%') === 0) {\n var element = void 0;\n switch (unit) {\n case '%p':\n element = popperOffsets;\n break;\n case '%':\n case '%r':\n default:\n element = referenceOffsets;\n }\n\n var rect = getClientRect(element);\n return rect[measurement] / 100 * value;\n } else if (unit === 'vh' || unit === 'vw') {\n // if is a vh or vw, we calculate the size based on the viewport\n var size = void 0;\n if (unit === 'vh') {\n size = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);\n } else {\n size = Math.max(document.documentElement.clientWidth, window.innerWidth || 0);\n }\n return size / 100 * value;\n } else {\n // if is an explicit pixel unit, we get rid of the unit and keep the value\n // if is an implicit unit, it's px, and we return just the value\n return value;\n }\n}\n\n/**\n * Parse an `offset` string to extrapolate `x` and `y` numeric offsets.\n * @function\n * @memberof {modifiers~offset}\n * @private\n * @argument {String} offset\n * @argument {Object} popperOffsets\n * @argument {Object} referenceOffsets\n * @argument {String} basePlacement\n * @returns {Array} a two cells array with x and y offsets in numbers\n */\nfunction parseOffset(offset, popperOffsets, referenceOffsets, basePlacement) {\n var offsets = [0, 0];\n\n // Use height if placement is left or right and index is 0 otherwise use width\n // in this way the first offset will use an axis and the second one\n // will use the other one\n var useHeight = ['right', 'left'].indexOf(basePlacement) !== -1;\n\n // Split the offset string to obtain a list of values and operands\n // The regex addresses values with the plus or minus sign in front (+10, -20, etc)\n var fragments = offset.split(/(\\+|\\-)/).map(function (frag) {\n return frag.trim();\n });\n\n // Detect if the offset string contains a pair of values or a single one\n // they could be separated by comma or space\n var divider = fragments.indexOf(find(fragments, function (frag) {\n return frag.search(/,|\\s/) !== -1;\n }));\n\n if (fragments[divider] && fragments[divider].indexOf(',') === -1) {\n console.warn('Offsets separated by white space(s) are deprecated, use a comma (,) instead.');\n }\n\n // If divider is found, we divide the list of values and operands to divide\n // them by ofset X and Y.\n var splitRegex = /\\s*,\\s*|\\s+/;\n var ops = divider !== -1 ? [fragments.slice(0, divider).concat([fragments[divider].split(splitRegex)[0]]), [fragments[divider].split(splitRegex)[1]].concat(fragments.slice(divider + 1))] : [fragments];\n\n // Convert the values with units to absolute pixels to allow our computations\n ops = ops.map(function (op, index) {\n // Most of the units rely on the orientation of the popper\n var measurement = (index === 1 ? !useHeight : useHeight) ? 'height' : 'width';\n var mergeWithPrevious = false;\n return op\n // This aggregates any `+` or `-` sign that aren't considered operators\n // e.g.: 10 + +5 => [10, +, +5]\n .reduce(function (a, b) {\n if (a[a.length - 1] === '' && ['+', '-'].indexOf(b) !== -1) {\n a[a.length - 1] = b;\n mergeWithPrevious = true;\n return a;\n } else if (mergeWithPrevious) {\n a[a.length - 1] += b;\n mergeWithPrevious = false;\n return a;\n } else {\n return a.concat(b);\n }\n }, [])\n // Here we convert the string values into number values (in px)\n .map(function (str) {\n return toValue(str, measurement, popperOffsets, referenceOffsets);\n });\n });\n\n // Loop trough the offsets arrays and execute the operations\n ops.forEach(function (op, index) {\n op.forEach(function (frag, index2) {\n if (isNumeric(frag)) {\n offsets[index] += frag * (op[index2 - 1] === '-' ? -1 : 1);\n }\n });\n });\n return offsets;\n}\n\n/**\n * @function\n * @memberof Modifiers\n * @argument {Object} data - The data object generated by update method\n * @argument {Object} options - Modifiers configuration and options\n * @argument {Number|String} options.offset=0\n * The offset value as described in the modifier description\n * @returns {Object} The data object, properly modified\n */\nfunction offset(data, _ref) {\n var offset = _ref.offset;\n var placement = data.placement,\n _data$offsets = data.offsets,\n popper = _data$offsets.popper,\n reference = _data$offsets.reference;\n\n var basePlacement = placement.split('-')[0];\n\n var offsets = void 0;\n if (isNumeric(+offset)) {\n offsets = [+offset, 0];\n } else {\n offsets = parseOffset(offset, popper, reference, basePlacement);\n }\n\n if (basePlacement === 'left') {\n popper.top += offsets[0];\n popper.left -= offsets[1];\n } else if (basePlacement === 'right') {\n popper.top += offsets[0];\n popper.left += offsets[1];\n } else if (basePlacement === 'top') {\n popper.left += offsets[0];\n popper.top -= offsets[1];\n } else if (basePlacement === 'bottom') {\n popper.left += offsets[0];\n popper.top += offsets[1];\n }\n\n data.popper = popper;\n return data;\n}\n\n/**\n * @function\n * @memberof Modifiers\n * @argument {Object} data - The data object generated by `update` method\n * @argument {Object} options - Modifiers configuration and options\n * @returns {Object} The data object, properly modified\n */\nfunction preventOverflow(data, options) {\n var boundariesElement = options.boundariesElement || getOffsetParent(data.instance.popper);\n\n // If offsetParent is the reference element, we really want to\n // go one step up and use the next offsetParent as reference to\n // avoid to make this modifier completely useless and look like broken\n if (data.instance.reference === boundariesElement) {\n boundariesElement = getOffsetParent(boundariesElement);\n }\n\n // NOTE: DOM access here\n // resets the popper's position so that the document size can be calculated excluding\n // the size of the popper element itself\n var transformProp = getSupportedPropertyName('transform');\n var popperStyles = data.instance.popper.style; // assignment to help minification\n var top = popperStyles.top,\n left = popperStyles.left,\n transform = popperStyles[transformProp];\n\n popperStyles.top = '';\n popperStyles.left = '';\n popperStyles[transformProp] = '';\n\n var boundaries = getBoundaries(data.instance.popper, data.instance.reference, options.padding, boundariesElement, data.positionFixed);\n\n // NOTE: DOM access here\n // restores the original style properties after the offsets have been computed\n popperStyles.top = top;\n popperStyles.left = left;\n popperStyles[transformProp] = transform;\n\n options.boundaries = boundaries;\n\n var order = options.priority;\n var popper = data.offsets.popper;\n\n var check = {\n primary: function primary(placement) {\n var value = popper[placement];\n if (popper[placement] < boundaries[placement] && !options.escapeWithReference) {\n value = Math.max(popper[placement], boundaries[placement]);\n }\n return defineProperty({}, placement, value);\n },\n secondary: function secondary(placement) {\n var mainSide = placement === 'right' ? 'left' : 'top';\n var value = popper[mainSide];\n if (popper[placement] > boundaries[placement] && !options.escapeWithReference) {\n value = Math.min(popper[mainSide], boundaries[placement] - (placement === 'right' ? popper.width : popper.height));\n }\n return defineProperty({}, mainSide, value);\n }\n };\n\n order.forEach(function (placement) {\n var side = ['left', 'top'].indexOf(placement) !== -1 ? 'primary' : 'secondary';\n popper = _extends({}, popper, check[side](placement));\n });\n\n data.offsets.popper = popper;\n\n return data;\n}\n\n/**\n * @function\n * @memberof Modifiers\n * @argument {Object} data - The data object generated by `update` method\n * @argument {Object} options - Modifiers configuration and options\n * @returns {Object} The data object, properly modified\n */\nfunction shift(data) {\n var placement = data.placement;\n var basePlacement = placement.split('-')[0];\n var shiftvariation = placement.split('-')[1];\n\n // if shift shiftvariation is specified, run the modifier\n if (shiftvariation) {\n var _data$offsets = data.offsets,\n reference = _data$offsets.reference,\n popper = _data$offsets.popper;\n\n var isVertical = ['bottom', 'top'].indexOf(basePlacement) !== -1;\n var side = isVertical ? 'left' : 'top';\n var measurement = isVertical ? 'width' : 'height';\n\n var shiftOffsets = {\n start: defineProperty({}, side, reference[side]),\n end: defineProperty({}, side, reference[side] + reference[measurement] - popper[measurement])\n };\n\n data.offsets.popper = _extends({}, popper, shiftOffsets[shiftvariation]);\n }\n\n return data;\n}\n\n/**\n * @function\n * @memberof Modifiers\n * @argument {Object} data - The data object generated by update method\n * @argument {Object} options - Modifiers configuration and options\n * @returns {Object} The data object, properly modified\n */\nfunction hide(data) {\n if (!isModifierRequired(data.instance.modifiers, 'hide', 'preventOverflow')) {\n return data;\n }\n\n var refRect = data.offsets.reference;\n var bound = find(data.instance.modifiers, function (modifier) {\n return modifier.name === 'preventOverflow';\n }).boundaries;\n\n if (refRect.bottom < bound.top || refRect.left > bound.right || refRect.top > bound.bottom || refRect.right < bound.left) {\n // Avoid unnecessary DOM access if visibility hasn't changed\n if (data.hide === true) {\n return data;\n }\n\n data.hide = true;\n data.attributes['x-out-of-boundaries'] = '';\n } else {\n // Avoid unnecessary DOM access if visibility hasn't changed\n if (data.hide === false) {\n return data;\n }\n\n data.hide = false;\n data.attributes['x-out-of-boundaries'] = false;\n }\n\n return data;\n}\n\n/**\n * @function\n * @memberof Modifiers\n * @argument {Object} data - The data object generated by `update` method\n * @argument {Object} options - Modifiers configuration and options\n * @returns {Object} The data object, properly modified\n */\nfunction inner(data) {\n var placement = data.placement;\n var basePlacement = placement.split('-')[0];\n var _data$offsets = data.offsets,\n popper = _data$offsets.popper,\n reference = _data$offsets.reference;\n\n var isHoriz = ['left', 'right'].indexOf(basePlacement) !== -1;\n\n var subtractLength = ['top', 'left'].indexOf(basePlacement) === -1;\n\n popper[isHoriz ? 'left' : 'top'] = reference[basePlacement] - (subtractLength ? popper[isHoriz ? 'width' : 'height'] : 0);\n\n data.placement = getOppositePlacement(placement);\n data.offsets.popper = getClientRect(popper);\n\n return data;\n}\n\n/**\n * Modifier function, each modifier can have a function of this type assigned\n * to its `fn` property.
\n * These functions will be called on each update, this means that you must\n * make sure they are performant enough to avoid performance bottlenecks.\n *\n * @function ModifierFn\n * @argument {dataObject} data - The data object generated by `update` method\n * @argument {Object} options - Modifiers configuration and options\n * @returns {dataObject} The data object, properly modified\n */\n\n/**\n * Modifiers are plugins used to alter the behavior of your poppers.
\n * Popper.js uses a set of 9 modifiers to provide all the basic functionalities\n * needed by the library.\n *\n * Usually you don't want to override the `order`, `fn` and `onLoad` props.\n * All the other properties are configurations that could be tweaked.\n * @namespace modifiers\n */\nvar modifiers = {\n /**\n * Modifier used to shift the popper on the start or end of its reference\n * element.
\n * It will read the variation of the `placement` property.
\n * It can be one either `-end` or `-start`.\n * @memberof modifiers\n * @inner\n */\n shift: {\n /** @prop {number} order=100 - Index used to define the order of execution */\n order: 100,\n /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */\n enabled: true,\n /** @prop {ModifierFn} */\n fn: shift\n },\n\n /**\n * The `offset` modifier can shift your popper on both its axis.\n *\n * It accepts the following units:\n * - `px` or unitless, interpreted as pixels\n * - `%` or `%r`, percentage relative to the length of the reference element\n * - `%p`, percentage relative to the length of the popper element\n * - `vw`, CSS viewport width unit\n * - `vh`, CSS viewport height unit\n *\n * For length is intended the main axis relative to the placement of the popper.
\n * This means that if the placement is `top` or `bottom`, the length will be the\n * `width`. In case of `left` or `right`, it will be the height.\n *\n * You can provide a single value (as `Number` or `String`), or a pair of values\n * as `String` divided by a comma or one (or more) white spaces.
\n * The latter is a deprecated method because it leads to confusion and will be\n * removed in v2.
\n * Additionally, it accepts additions and subtractions between different units.\n * Note that multiplications and divisions aren't supported.\n *\n * Valid examples are:\n * ```\n * 10\n * '10%'\n * '10, 10'\n * '10%, 10'\n * '10 + 10%'\n * '10 - 5vh + 3%'\n * '-10px + 5vh, 5px - 6%'\n * ```\n * > **NB**: If you desire to apply offsets to your poppers in a way that may make them overlap\n * > with their reference element, unfortunately, you will have to disable the `flip` modifier.\n * > More on this [reading this issue](https://github.com/FezVrasta/popper.js/issues/373)\n *\n * @memberof modifiers\n * @inner\n */\n offset: {\n /** @prop {number} order=200 - Index used to define the order of execution */\n order: 200,\n /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */\n enabled: true,\n /** @prop {ModifierFn} */\n fn: offset,\n /** @prop {Number|String} offset=0\n * The offset value as described in the modifier description\n */\n offset: 0\n },\n\n /**\n * Modifier used to prevent the popper from being positioned outside the boundary.\n *\n * An scenario exists where the reference itself is not within the boundaries.
\n * We can say it has \"escaped the boundaries\" — or just \"escaped\".
\n * In this case we need to decide whether the popper should either:\n *\n * - detach from the reference and remain \"trapped\" in the boundaries, or\n * - if it should ignore the boundary and \"escape with its reference\"\n *\n * When `escapeWithReference` is set to`true` and reference is completely\n * outside its boundaries, the popper will overflow (or completely leave)\n * the boundaries in order to remain attached to the edge of the reference.\n *\n * @memberof modifiers\n * @inner\n */\n preventOverflow: {\n /** @prop {number} order=300 - Index used to define the order of execution */\n order: 300,\n /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */\n enabled: true,\n /** @prop {ModifierFn} */\n fn: preventOverflow,\n /**\n * @prop {Array} [priority=['left','right','top','bottom']]\n * Popper will try to prevent overflow following these priorities by default,\n * then, it could overflow on the left and on top of the `boundariesElement`\n */\n priority: ['left', 'right', 'top', 'bottom'],\n /**\n * @prop {number} padding=5\n * Amount of pixel used to define a minimum distance between the boundaries\n * and the popper this makes sure the popper has always a little padding\n * between the edges of its container\n */\n padding: 5,\n /**\n * @prop {String|HTMLElement} boundariesElement='scrollParent'\n * Boundaries used by the modifier, can be `scrollParent`, `window`,\n * `viewport` or any DOM element.\n */\n boundariesElement: 'scrollParent'\n },\n\n /**\n * Modifier used to make sure the reference and its popper stay near eachothers\n * without leaving any gap between the two. Expecially useful when the arrow is\n * enabled and you want to assure it to point to its reference element.\n * It cares only about the first axis, you can still have poppers with margin\n * between the popper and its reference element.\n * @memberof modifiers\n * @inner\n */\n keepTogether: {\n /** @prop {number} order=400 - Index used to define the order of execution */\n order: 400,\n /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */\n enabled: true,\n /** @prop {ModifierFn} */\n fn: keepTogether\n },\n\n /**\n * This modifier is used to move the `arrowElement` of the popper to make\n * sure it is positioned between the reference element and its popper element.\n * It will read the outer size of the `arrowElement` node to detect how many\n * pixels of conjuction are needed.\n *\n * It has no effect if no `arrowElement` is provided.\n * @memberof modifiers\n * @inner\n */\n arrow: {\n /** @prop {number} order=500 - Index used to define the order of execution */\n order: 500,\n /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */\n enabled: true,\n /** @prop {ModifierFn} */\n fn: arrow,\n /** @prop {String|HTMLElement} element='[x-arrow]' - Selector or node used as arrow */\n element: '[x-arrow]'\n },\n\n /**\n * Modifier used to flip the popper's placement when it starts to overlap its\n * reference element.\n *\n * Requires the `preventOverflow` modifier before it in order to work.\n *\n * **NOTE:** this modifier will interrupt the current update cycle and will\n * restart it if it detects the need to flip the placement.\n * @memberof modifiers\n * @inner\n */\n flip: {\n /** @prop {number} order=600 - Index used to define the order of execution */\n order: 600,\n /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */\n enabled: true,\n /** @prop {ModifierFn} */\n fn: flip,\n /**\n * @prop {String|Array} behavior='flip'\n * The behavior used to change the popper's placement. It can be one of\n * `flip`, `clockwise`, `counterclockwise` or an array with a list of valid\n * placements (with optional variations).\n */\n behavior: 'flip',\n /**\n * @prop {number} padding=5\n * The popper will flip if it hits the edges of the `boundariesElement`\n */\n padding: 5,\n /**\n * @prop {String|HTMLElement} boundariesElement='viewport'\n * The element which will define the boundaries of the popper position,\n * the popper will never be placed outside of the defined boundaries\n * (except if keepTogether is enabled)\n */\n boundariesElement: 'viewport'\n },\n\n /**\n * Modifier used to make the popper flow toward the inner of the reference element.\n * By default, when this modifier is disabled, the popper will be placed outside\n * the reference element.\n * @memberof modifiers\n * @inner\n */\n inner: {\n /** @prop {number} order=700 - Index used to define the order of execution */\n order: 700,\n /** @prop {Boolean} enabled=false - Whether the modifier is enabled or not */\n enabled: false,\n /** @prop {ModifierFn} */\n fn: inner\n },\n\n /**\n * Modifier used to hide the popper when its reference element is outside of the\n * popper boundaries. It will set a `x-out-of-boundaries` attribute which can\n * be used to hide with a CSS selector the popper when its reference is\n * out of boundaries.\n *\n * Requires the `preventOverflow` modifier before it in order to work.\n * @memberof modifiers\n * @inner\n */\n hide: {\n /** @prop {number} order=800 - Index used to define the order of execution */\n order: 800,\n /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */\n enabled: true,\n /** @prop {ModifierFn} */\n fn: hide\n },\n\n /**\n * Computes the style that will be applied to the popper element to gets\n * properly positioned.\n *\n * Note that this modifier will not touch the DOM, it just prepares the styles\n * so that `applyStyle` modifier can apply it. This separation is useful\n * in case you need to replace `applyStyle` with a custom implementation.\n *\n * This modifier has `850` as `order` value to maintain backward compatibility\n * with previous versions of Popper.js. Expect the modifiers ordering method\n * to change in future major versions of the library.\n *\n * @memberof modifiers\n * @inner\n */\n computeStyle: {\n /** @prop {number} order=850 - Index used to define the order of execution */\n order: 850,\n /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */\n enabled: true,\n /** @prop {ModifierFn} */\n fn: computeStyle,\n /**\n * @prop {Boolean} gpuAcceleration=true\n * If true, it uses the CSS 3d transformation to position the popper.\n * Otherwise, it will use the `top` and `left` properties.\n */\n gpuAcceleration: true,\n /**\n * @prop {string} [x='bottom']\n * Where to anchor the X axis (`bottom` or `top`). AKA X offset origin.\n * Change this if your popper should grow in a direction different from `bottom`\n */\n x: 'bottom',\n /**\n * @prop {string} [x='left']\n * Where to anchor the Y axis (`left` or `right`). AKA Y offset origin.\n * Change this if your popper should grow in a direction different from `right`\n */\n y: 'right'\n },\n\n /**\n * Applies the computed styles to the popper element.\n *\n * All the DOM manipulations are limited to this modifier. This is useful in case\n * you want to integrate Popper.js inside a framework or view library and you\n * want to delegate all the DOM manipulations to it.\n *\n * Note that if you disable this modifier, you must make sure the popper element\n * has its position set to `absolute` before Popper.js can do its work!\n *\n * Just disable this modifier and define you own to achieve the desired effect.\n *\n * @memberof modifiers\n * @inner\n */\n applyStyle: {\n /** @prop {number} order=900 - Index used to define the order of execution */\n order: 900,\n /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */\n enabled: true,\n /** @prop {ModifierFn} */\n fn: applyStyle,\n /** @prop {Function} */\n onLoad: applyStyleOnLoad,\n /**\n * @deprecated since version 1.10.0, the property moved to `computeStyle` modifier\n * @prop {Boolean} gpuAcceleration=true\n * If true, it uses the CSS 3d transformation to position the popper.\n * Otherwise, it will use the `top` and `left` properties.\n */\n gpuAcceleration: undefined\n }\n};\n\n/**\n * The `dataObject` is an object containing all the informations used by Popper.js\n * this object get passed to modifiers and to the `onCreate` and `onUpdate` callbacks.\n * @name dataObject\n * @property {Object} data.instance The Popper.js instance\n * @property {String} data.placement Placement applied to popper\n * @property {String} data.originalPlacement Placement originally defined on init\n * @property {Boolean} data.flipped True if popper has been flipped by flip modifier\n * @property {Boolean} data.hide True if the reference element is out of boundaries, useful to know when to hide the popper.\n * @property {HTMLElement} data.arrowElement Node used as arrow by arrow modifier\n * @property {Object} data.styles Any CSS property defined here will be applied to the popper, it expects the JavaScript nomenclature (eg. `marginBottom`)\n * @property {Object} data.arrowStyles Any CSS property defined here will be applied to the popper arrow, it expects the JavaScript nomenclature (eg. `marginBottom`)\n * @property {Object} data.boundaries Offsets of the popper boundaries\n * @property {Object} data.offsets The measurements of popper, reference and arrow elements.\n * @property {Object} data.offsets.popper `top`, `left`, `width`, `height` values\n * @property {Object} data.offsets.reference `top`, `left`, `width`, `height` values\n * @property {Object} data.offsets.arrow] `top` and `left` offsets, only one of them will be different from 0\n */\n\n/**\n * Default options provided to Popper.js constructor.
\n * These can be overriden using the `options` argument of Popper.js.
\n * To override an option, simply pass as 3rd argument an object with the same\n * structure of this object, example:\n * ```\n * new Popper(ref, pop, {\n * modifiers: {\n * preventOverflow: { enabled: false }\n * }\n * })\n * ```\n * @type {Object}\n * @static\n * @memberof Popper\n */\nvar Defaults = {\n /**\n * Popper's placement\n * @prop {Popper.placements} placement='bottom'\n */\n placement: 'bottom',\n\n /**\n * Set this to true if you want popper to position it self in 'fixed' mode\n * @prop {Boolean} positionFixed=false\n */\n positionFixed: false,\n\n /**\n * Whether events (resize, scroll) are initially enabled\n * @prop {Boolean} eventsEnabled=true\n */\n eventsEnabled: true,\n\n /**\n * Set to true if you want to automatically remove the popper when\n * you call the `destroy` method.\n * @prop {Boolean} removeOnDestroy=false\n */\n removeOnDestroy: false,\n\n /**\n * Callback called when the popper is created.
\n * By default, is set to no-op.
\n * Access Popper.js instance with `data.instance`.\n * @prop {onCreate}\n */\n onCreate: function onCreate() {},\n\n /**\n * Callback called when the popper is updated, this callback is not called\n * on the initialization/creation of the popper, but only on subsequent\n * updates.
\n * By default, is set to no-op.
\n * Access Popper.js instance with `data.instance`.\n * @prop {onUpdate}\n */\n onUpdate: function onUpdate() {},\n\n /**\n * List of modifiers used to modify the offsets before they are applied to the popper.\n * They provide most of the functionalities of Popper.js\n * @prop {modifiers}\n */\n modifiers: modifiers\n};\n\n/**\n * @callback onCreate\n * @param {dataObject} data\n */\n\n/**\n * @callback onUpdate\n * @param {dataObject} data\n */\n\n// Utils\n// Methods\nvar Popper = function () {\n /**\n * Create a new Popper.js instance\n * @class Popper\n * @param {HTMLElement|referenceObject} reference - The reference element used to position the popper\n * @param {HTMLElement} popper - The HTML element used as popper.\n * @param {Object} options - Your custom options to override the ones defined in [Defaults](#defaults)\n * @return {Object} instance - The generated Popper.js instance\n */\n function Popper(reference, popper) {\n var _this = this;\n\n var options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};\n classCallCheck(this, Popper);\n\n this.scheduleUpdate = function () {\n return requestAnimationFrame(_this.update);\n };\n\n // make update() debounced, so that it only runs at most once-per-tick\n this.update = debounce(this.update.bind(this));\n\n // with {} we create a new object with the options inside it\n this.options = _extends({}, Popper.Defaults, options);\n\n // init state\n this.state = {\n isDestroyed: false,\n isCreated: false,\n scrollParents: []\n };\n\n // get reference and popper elements (allow jQuery wrappers)\n this.reference = reference && reference.jquery ? reference[0] : reference;\n this.popper = popper && popper.jquery ? popper[0] : popper;\n\n // Deep merge modifiers options\n this.options.modifiers = {};\n Object.keys(_extends({}, Popper.Defaults.modifiers, options.modifiers)).forEach(function (name) {\n _this.options.modifiers[name] = _extends({}, Popper.Defaults.modifiers[name] || {}, options.modifiers ? options.modifiers[name] : {});\n });\n\n // Refactoring modifiers' list (Object => Array)\n this.modifiers = Object.keys(this.options.modifiers).map(function (name) {\n return _extends({\n name: name\n }, _this.options.modifiers[name]);\n })\n // sort the modifiers by order\n .sort(function (a, b) {\n return a.order - b.order;\n });\n\n // modifiers have the ability to execute arbitrary code when Popper.js get inited\n // such code is executed in the same order of its modifier\n // they could add new properties to their options configuration\n // BE AWARE: don't add options to `options.modifiers.name` but to `modifierOptions`!\n this.modifiers.forEach(function (modifierOptions) {\n if (modifierOptions.enabled && isFunction(modifierOptions.onLoad)) {\n modifierOptions.onLoad(_this.reference, _this.popper, _this.options, modifierOptions, _this.state);\n }\n });\n\n // fire the first update to position the popper in the right place\n this.update();\n\n var eventsEnabled = this.options.eventsEnabled;\n if (eventsEnabled) {\n // setup event listeners, they will take care of update the position in specific situations\n this.enableEventListeners();\n }\n\n this.state.eventsEnabled = eventsEnabled;\n }\n\n // We can't use class properties because they don't get listed in the\n // class prototype and break stuff like Sinon stubs\n\n\n createClass(Popper, [{\n key: 'update',\n value: function update$$1() {\n return update.call(this);\n }\n }, {\n key: 'destroy',\n value: function destroy$$1() {\n return destroy.call(this);\n }\n }, {\n key: 'enableEventListeners',\n value: function enableEventListeners$$1() {\n return enableEventListeners.call(this);\n }\n }, {\n key: 'disableEventListeners',\n value: function disableEventListeners$$1() {\n return disableEventListeners.call(this);\n }\n\n /**\n * Schedule an update, it will run on the next UI update available\n * @method scheduleUpdate\n * @memberof Popper\n */\n\n\n /**\n * Collection of utilities useful when writing custom modifiers.\n * Starting from version 1.7, this method is available only if you\n * include `popper-utils.js` before `popper.js`.\n *\n * **DEPRECATION**: This way to access PopperUtils is deprecated\n * and will be removed in v2! Use the PopperUtils module directly instead.\n * Due to the high instability of the methods contained in Utils, we can't\n * guarantee them to follow semver. Use them at your own risk!\n * @static\n * @private\n * @type {Object}\n * @deprecated since version 1.8\n * @member Utils\n * @memberof Popper\n */\n\n }]);\n return Popper;\n}();\n\n/**\n * The `referenceObject` is an object that provides an interface compatible with Popper.js\n * and lets you use it as replacement of a real DOM node.
\n * You can use this method to position a popper relatively to a set of coordinates\n * in case you don't have a DOM node to use as reference.\n *\n * ```\n * new Popper(referenceObject, popperNode);\n * ```\n *\n * NB: This feature isn't supported in Internet Explorer 10\n * @name referenceObject\n * @property {Function} data.getBoundingClientRect\n * A function that returns a set of coordinates compatible with the native `getBoundingClientRect` method.\n * @property {number} data.clientWidth\n * An ES6 getter that will return the width of the virtual reference element.\n * @property {number} data.clientHeight\n * An ES6 getter that will return the height of the virtual reference element.\n */\n\n\nPopper.Utils = (typeof window !== 'undefined' ? window : global).PopperUtils;\nPopper.placements = placements;\nPopper.Defaults = Defaults;\n\nexport default Popper;\n//# sourceMappingURL=popper.js.map\n","import $ from 'jquery'\n\n/**\n * --------------------------------------------------------------------------\n * Bootstrap (v4.1.1): util.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nconst Util = (($) => {\n /**\n * ------------------------------------------------------------------------\n * Private TransitionEnd Helpers\n * ------------------------------------------------------------------------\n */\n\n const TRANSITION_END = 'transitionend'\n const MAX_UID = 1000000\n const MILLISECONDS_MULTIPLIER = 1000\n\n // Shoutout AngusCroll (https://goo.gl/pxwQGp)\n function toType(obj) {\n return {}.toString.call(obj).match(/\\s([a-z]+)/i)[1].toLowerCase()\n }\n\n function getSpecialTransitionEndEvent() {\n return {\n bindType: TRANSITION_END,\n delegateType: TRANSITION_END,\n handle(event) {\n if ($(event.target).is(this)) {\n return event.handleObj.handler.apply(this, arguments) // eslint-disable-line prefer-rest-params\n }\n return undefined // eslint-disable-line no-undefined\n }\n }\n }\n\n function transitionEndEmulator(duration) {\n let called = false\n\n $(this).one(Util.TRANSITION_END, () => {\n called = true\n })\n\n setTimeout(() => {\n if (!called) {\n Util.triggerTransitionEnd(this)\n }\n }, duration)\n\n return this\n }\n\n function setTransitionEndSupport() {\n $.fn.emulateTransitionEnd = transitionEndEmulator\n $.event.special[Util.TRANSITION_END] = getSpecialTransitionEndEvent()\n }\n\n /**\n * --------------------------------------------------------------------------\n * Public Util Api\n * --------------------------------------------------------------------------\n */\n\n const Util = {\n\n TRANSITION_END: 'bsTransitionEnd',\n\n getUID(prefix) {\n do {\n // eslint-disable-next-line no-bitwise\n prefix += ~~(Math.random() * MAX_UID) // \"~~\" acts like a faster Math.floor() here\n } while (document.getElementById(prefix))\n return prefix\n },\n\n getSelectorFromElement(element) {\n let selector = element.getAttribute('data-target')\n if (!selector || selector === '#') {\n selector = element.getAttribute('href') || ''\n }\n\n try {\n const $selector = $(document).find(selector)\n return $selector.length > 0 ? selector : null\n } catch (err) {\n return null\n }\n },\n\n getTransitionDurationFromElement(element) {\n if (!element) {\n return 0\n }\n\n // Get transition-duration of the element\n let transitionDuration = $(element).css('transition-duration')\n const floatTransitionDuration = parseFloat(transitionDuration)\n\n // Return 0 if element or transition duration is not found\n if (!floatTransitionDuration) {\n return 0\n }\n\n // If multiple durations are defined, take the first\n transitionDuration = transitionDuration.split(',')[0]\n\n return parseFloat(transitionDuration) * MILLISECONDS_MULTIPLIER\n },\n\n reflow(element) {\n return element.offsetHeight\n },\n\n triggerTransitionEnd(element) {\n $(element).trigger(TRANSITION_END)\n },\n\n // TODO: Remove in v5\n supportsTransitionEnd() {\n return Boolean(TRANSITION_END)\n },\n\n isElement(obj) {\n return (obj[0] || obj).nodeType\n },\n\n typeCheckConfig(componentName, config, configTypes) {\n for (const property in configTypes) {\n if (Object.prototype.hasOwnProperty.call(configTypes, property)) {\n const expectedTypes = configTypes[property]\n const value = config[property]\n const valueType = value && Util.isElement(value)\n ? 'element' : toType(value)\n\n if (!new RegExp(expectedTypes).test(valueType)) {\n throw new Error(\n `${componentName.toUpperCase()}: ` +\n `Option \"${property}\" provided type \"${valueType}\" ` +\n `but expected type \"${expectedTypes}\".`)\n }\n }\n }\n }\n }\n\n setTransitionEndSupport()\n\n return Util\n})($)\n\nexport default Util\n","import $ from 'jquery'\nimport Util from './util'\n\n/**\n * --------------------------------------------------------------------------\n * Bootstrap (v4.1.1): alert.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nconst Alert = (($) => {\n /**\n * ------------------------------------------------------------------------\n * Constants\n * ------------------------------------------------------------------------\n */\n\n const NAME = 'alert'\n const VERSION = '4.1.1'\n const DATA_KEY = 'bs.alert'\n const EVENT_KEY = `.${DATA_KEY}`\n const DATA_API_KEY = '.data-api'\n const JQUERY_NO_CONFLICT = $.fn[NAME]\n\n const Selector = {\n DISMISS : '[data-dismiss=\"alert\"]'\n }\n\n const Event = {\n CLOSE : `close${EVENT_KEY}`,\n CLOSED : `closed${EVENT_KEY}`,\n CLICK_DATA_API : `click${EVENT_KEY}${DATA_API_KEY}`\n }\n\n const ClassName = {\n ALERT : 'alert',\n FADE : 'fade',\n SHOW : 'show'\n }\n\n /**\n * ------------------------------------------------------------------------\n * Class Definition\n * ------------------------------------------------------------------------\n */\n\n class Alert {\n constructor(element) {\n this._element = element\n }\n\n // Getters\n\n static get VERSION() {\n return VERSION\n }\n\n // Public\n\n close(element) {\n let rootElement = this._element\n if (element) {\n rootElement = this._getRootElement(element)\n }\n\n const customEvent = this._triggerCloseEvent(rootElement)\n\n if (customEvent.isDefaultPrevented()) {\n return\n }\n\n this._removeElement(rootElement)\n }\n\n dispose() {\n $.removeData(this._element, DATA_KEY)\n this._element = null\n }\n\n // Private\n\n _getRootElement(element) {\n const selector = Util.getSelectorFromElement(element)\n let parent = false\n\n if (selector) {\n parent = $(selector)[0]\n }\n\n if (!parent) {\n parent = $(element).closest(`.${ClassName.ALERT}`)[0]\n }\n\n return parent\n }\n\n _triggerCloseEvent(element) {\n const closeEvent = $.Event(Event.CLOSE)\n\n $(element).trigger(closeEvent)\n return closeEvent\n }\n\n _removeElement(element) {\n $(element).removeClass(ClassName.SHOW)\n\n if (!$(element).hasClass(ClassName.FADE)) {\n this._destroyElement(element)\n return\n }\n\n const transitionDuration = Util.getTransitionDurationFromElement(element)\n\n $(element)\n .one(Util.TRANSITION_END, (event) => this._destroyElement(element, event))\n .emulateTransitionEnd(transitionDuration)\n }\n\n _destroyElement(element) {\n $(element)\n .detach()\n .trigger(Event.CLOSED)\n .remove()\n }\n\n // Static\n\n static _jQueryInterface(config) {\n return this.each(function () {\n const $element = $(this)\n let data = $element.data(DATA_KEY)\n\n if (!data) {\n data = new Alert(this)\n $element.data(DATA_KEY, data)\n }\n\n if (config === 'close') {\n data[config](this)\n }\n })\n }\n\n static _handleDismiss(alertInstance) {\n return function (event) {\n if (event) {\n event.preventDefault()\n }\n\n alertInstance.close(this)\n }\n }\n }\n\n /**\n * ------------------------------------------------------------------------\n * Data Api implementation\n * ------------------------------------------------------------------------\n */\n\n $(document).on(\n Event.CLICK_DATA_API,\n Selector.DISMISS,\n Alert._handleDismiss(new Alert())\n )\n\n /**\n * ------------------------------------------------------------------------\n * jQuery\n * ------------------------------------------------------------------------\n */\n\n $.fn[NAME] = Alert._jQueryInterface\n $.fn[NAME].Constructor = Alert\n $.fn[NAME].noConflict = function () {\n $.fn[NAME] = JQUERY_NO_CONFLICT\n return Alert._jQueryInterface\n }\n\n return Alert\n})($)\n\nexport default Alert\n","import $ from 'jquery'\n\n/**\n * --------------------------------------------------------------------------\n * Bootstrap (v4.1.1): button.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nconst Button = (($) => {\n /**\n * ------------------------------------------------------------------------\n * Constants\n * ------------------------------------------------------------------------\n */\n\n const NAME = 'button'\n const VERSION = '4.1.1'\n const DATA_KEY = 'bs.button'\n const EVENT_KEY = `.${DATA_KEY}`\n const DATA_API_KEY = '.data-api'\n const JQUERY_NO_CONFLICT = $.fn[NAME]\n\n const ClassName = {\n ACTIVE : 'active',\n BUTTON : 'btn',\n FOCUS : 'focus'\n }\n\n const Selector = {\n DATA_TOGGLE_CARROT : '[data-toggle^=\"button\"]',\n DATA_TOGGLE : '[data-toggle=\"buttons\"]',\n INPUT : 'input',\n ACTIVE : '.active',\n BUTTON : '.btn'\n }\n\n const Event = {\n CLICK_DATA_API : `click${EVENT_KEY}${DATA_API_KEY}`,\n FOCUS_BLUR_DATA_API : `focus${EVENT_KEY}${DATA_API_KEY} ` +\n `blur${EVENT_KEY}${DATA_API_KEY}`\n }\n\n /**\n * ------------------------------------------------------------------------\n * Class Definition\n * ------------------------------------------------------------------------\n */\n\n class Button {\n constructor(element) {\n this._element = element\n }\n\n // Getters\n\n static get VERSION() {\n return VERSION\n }\n\n // Public\n\n toggle() {\n let triggerChangeEvent = true\n let addAriaPressed = true\n const rootElement = $(this._element).closest(\n Selector.DATA_TOGGLE\n )[0]\n\n if (rootElement) {\n const input = $(this._element).find(Selector.INPUT)[0]\n\n if (input) {\n if (input.type === 'radio') {\n if (input.checked &&\n $(this._element).hasClass(ClassName.ACTIVE)) {\n triggerChangeEvent = false\n } else {\n const activeElement = $(rootElement).find(Selector.ACTIVE)[0]\n\n if (activeElement) {\n $(activeElement).removeClass(ClassName.ACTIVE)\n }\n }\n }\n\n if (triggerChangeEvent) {\n if (input.hasAttribute('disabled') ||\n rootElement.hasAttribute('disabled') ||\n input.classList.contains('disabled') ||\n rootElement.classList.contains('disabled')) {\n return\n }\n input.checked = !$(this._element).hasClass(ClassName.ACTIVE)\n $(input).trigger('change')\n }\n\n input.focus()\n addAriaPressed = false\n }\n }\n\n if (addAriaPressed) {\n this._element.setAttribute('aria-pressed',\n !$(this._element).hasClass(ClassName.ACTIVE))\n }\n\n if (triggerChangeEvent) {\n $(this._element).toggleClass(ClassName.ACTIVE)\n }\n }\n\n dispose() {\n $.removeData(this._element, DATA_KEY)\n this._element = null\n }\n\n // Static\n\n static _jQueryInterface(config) {\n return this.each(function () {\n let data = $(this).data(DATA_KEY)\n\n if (!data) {\n data = new Button(this)\n $(this).data(DATA_KEY, data)\n }\n\n if (config === 'toggle') {\n data[config]()\n }\n })\n }\n }\n\n /**\n * ------------------------------------------------------------------------\n * Data Api implementation\n * ------------------------------------------------------------------------\n */\n\n $(document)\n .on(Event.CLICK_DATA_API, Selector.DATA_TOGGLE_CARROT, (event) => {\n event.preventDefault()\n\n let button = event.target\n\n if (!$(button).hasClass(ClassName.BUTTON)) {\n button = $(button).closest(Selector.BUTTON)\n }\n\n Button._jQueryInterface.call($(button), 'toggle')\n })\n .on(Event.FOCUS_BLUR_DATA_API, Selector.DATA_TOGGLE_CARROT, (event) => {\n const button = $(event.target).closest(Selector.BUTTON)[0]\n $(button).toggleClass(ClassName.FOCUS, /^focus(in)?$/.test(event.type))\n })\n\n /**\n * ------------------------------------------------------------------------\n * jQuery\n * ------------------------------------------------------------------------\n */\n\n $.fn[NAME] = Button._jQueryInterface\n $.fn[NAME].Constructor = Button\n $.fn[NAME].noConflict = function () {\n $.fn[NAME] = JQUERY_NO_CONFLICT\n return Button._jQueryInterface\n }\n\n return Button\n})($)\n\nexport default Button\n","import $ from 'jquery'\nimport Util from './util'\n\n/**\n * --------------------------------------------------------------------------\n * Bootstrap (v4.1.1): carousel.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nconst Carousel = (($) => {\n /**\n * ------------------------------------------------------------------------\n * Constants\n * ------------------------------------------------------------------------\n */\n\n const NAME = 'carousel'\n const VERSION = '4.1.1'\n const DATA_KEY = 'bs.carousel'\n const EVENT_KEY = `.${DATA_KEY}`\n const DATA_API_KEY = '.data-api'\n const JQUERY_NO_CONFLICT = $.fn[NAME]\n const ARROW_LEFT_KEYCODE = 37 // KeyboardEvent.which value for left arrow key\n const ARROW_RIGHT_KEYCODE = 39 // KeyboardEvent.which value for right arrow key\n const TOUCHEVENT_COMPAT_WAIT = 500 // Time for mouse compat events to fire after touch\n\n const Default = {\n interval : 5000,\n keyboard : true,\n slide : false,\n pause : 'hover',\n wrap : true\n }\n\n const DefaultType = {\n interval : '(number|boolean)',\n keyboard : 'boolean',\n slide : '(boolean|string)',\n pause : '(string|boolean)',\n wrap : 'boolean'\n }\n\n const Direction = {\n NEXT : 'next',\n PREV : 'prev',\n LEFT : 'left',\n RIGHT : 'right'\n }\n\n const Event = {\n SLIDE : `slide${EVENT_KEY}`,\n SLID : `slid${EVENT_KEY}`,\n KEYDOWN : `keydown${EVENT_KEY}`,\n MOUSEENTER : `mouseenter${EVENT_KEY}`,\n MOUSELEAVE : `mouseleave${EVENT_KEY}`,\n TOUCHEND : `touchend${EVENT_KEY}`,\n LOAD_DATA_API : `load${EVENT_KEY}${DATA_API_KEY}`,\n CLICK_DATA_API : `click${EVENT_KEY}${DATA_API_KEY}`\n }\n\n const ClassName = {\n CAROUSEL : 'carousel',\n ACTIVE : 'active',\n SLIDE : 'slide',\n RIGHT : 'carousel-item-right',\n LEFT : 'carousel-item-left',\n NEXT : 'carousel-item-next',\n PREV : 'carousel-item-prev',\n ITEM : 'carousel-item'\n }\n\n const Selector = {\n ACTIVE : '.active',\n ACTIVE_ITEM : '.active.carousel-item',\n ITEM : '.carousel-item',\n NEXT_PREV : '.carousel-item-next, .carousel-item-prev',\n INDICATORS : '.carousel-indicators',\n DATA_SLIDE : '[data-slide], [data-slide-to]',\n DATA_RIDE : '[data-ride=\"carousel\"]'\n }\n\n /**\n * ------------------------------------------------------------------------\n * Class Definition\n * ------------------------------------------------------------------------\n */\n\n class Carousel {\n constructor(element, config) {\n this._items = null\n this._interval = null\n this._activeElement = null\n\n this._isPaused = false\n this._isSliding = false\n\n this.touchTimeout = null\n\n this._config = this._getConfig(config)\n this._element = $(element)[0]\n this._indicatorsElement = $(this._element).find(Selector.INDICATORS)[0]\n\n this._addEventListeners()\n }\n\n // Getters\n\n static get VERSION() {\n return VERSION\n }\n\n static get Default() {\n return Default\n }\n\n // Public\n\n next() {\n if (!this._isSliding) {\n this._slide(Direction.NEXT)\n }\n }\n\n nextWhenVisible() {\n // Don't call next when the page isn't visible\n // or the carousel or its parent isn't visible\n if (!document.hidden &&\n ($(this._element).is(':visible') && $(this._element).css('visibility') !== 'hidden')) {\n this.next()\n }\n }\n\n prev() {\n if (!this._isSliding) {\n this._slide(Direction.PREV)\n }\n }\n\n pause(event) {\n if (!event) {\n this._isPaused = true\n }\n\n if ($(this._element).find(Selector.NEXT_PREV)[0]) {\n Util.triggerTransitionEnd(this._element)\n this.cycle(true)\n }\n\n clearInterval(this._interval)\n this._interval = null\n }\n\n cycle(event) {\n if (!event) {\n this._isPaused = false\n }\n\n if (this._interval) {\n clearInterval(this._interval)\n this._interval = null\n }\n\n if (this._config.interval && !this._isPaused) {\n this._interval = setInterval(\n (document.visibilityState ? this.nextWhenVisible : this.next).bind(this),\n this._config.interval\n )\n }\n }\n\n to(index) {\n this._activeElement = $(this._element).find(Selector.ACTIVE_ITEM)[0]\n\n const activeIndex = this._getItemIndex(this._activeElement)\n\n if (index > this._items.length - 1 || index < 0) {\n return\n }\n\n if (this._isSliding) {\n $(this._element).one(Event.SLID, () => this.to(index))\n return\n }\n\n if (activeIndex === index) {\n this.pause()\n this.cycle()\n return\n }\n\n const direction = index > activeIndex\n ? Direction.NEXT\n : Direction.PREV\n\n this._slide(direction, this._items[index])\n }\n\n dispose() {\n $(this._element).off(EVENT_KEY)\n $.removeData(this._element, DATA_KEY)\n\n this._items = null\n this._config = null\n this._element = null\n this._interval = null\n this._isPaused = null\n this._isSliding = null\n this._activeElement = null\n this._indicatorsElement = null\n }\n\n // Private\n\n _getConfig(config) {\n config = {\n ...Default,\n ...config\n }\n Util.typeCheckConfig(NAME, config, DefaultType)\n return config\n }\n\n _addEventListeners() {\n if (this._config.keyboard) {\n $(this._element)\n .on(Event.KEYDOWN, (event) => this._keydown(event))\n }\n\n if (this._config.pause === 'hover') {\n $(this._element)\n .on(Event.MOUSEENTER, (event) => this.pause(event))\n .on(Event.MOUSELEAVE, (event) => this.cycle(event))\n if ('ontouchstart' in document.documentElement) {\n // If it's a touch-enabled device, mouseenter/leave are fired as\n // part of the mouse compatibility events on first tap - the carousel\n // would stop cycling until user tapped out of it;\n // here, we listen for touchend, explicitly pause the carousel\n // (as if it's the second time we tap on it, mouseenter compat event\n // is NOT fired) and after a timeout (to allow for mouse compatibility\n // events to fire) we explicitly restart cycling\n $(this._element).on(Event.TOUCHEND, () => {\n this.pause()\n if (this.touchTimeout) {\n clearTimeout(this.touchTimeout)\n }\n this.touchTimeout = setTimeout((event) => this.cycle(event), TOUCHEVENT_COMPAT_WAIT + this._config.interval)\n })\n }\n }\n }\n\n _keydown(event) {\n if (/input|textarea/i.test(event.target.tagName)) {\n return\n }\n\n switch (event.which) {\n case ARROW_LEFT_KEYCODE:\n event.preventDefault()\n this.prev()\n break\n case ARROW_RIGHT_KEYCODE:\n event.preventDefault()\n this.next()\n break\n default:\n }\n }\n\n _getItemIndex(element) {\n this._items = $.makeArray($(element).parent().find(Selector.ITEM))\n return this._items.indexOf(element)\n }\n\n _getItemByDirection(direction, activeElement) {\n const isNextDirection = direction === Direction.NEXT\n const isPrevDirection = direction === Direction.PREV\n const activeIndex = this._getItemIndex(activeElement)\n const lastItemIndex = this._items.length - 1\n const isGoingToWrap = isPrevDirection && activeIndex === 0 ||\n isNextDirection && activeIndex === lastItemIndex\n\n if (isGoingToWrap && !this._config.wrap) {\n return activeElement\n }\n\n const delta = direction === Direction.PREV ? -1 : 1\n const itemIndex = (activeIndex + delta) % this._items.length\n\n return itemIndex === -1\n ? this._items[this._items.length - 1] : this._items[itemIndex]\n }\n\n _triggerSlideEvent(relatedTarget, eventDirectionName) {\n const targetIndex = this._getItemIndex(relatedTarget)\n const fromIndex = this._getItemIndex($(this._element).find(Selector.ACTIVE_ITEM)[0])\n const slideEvent = $.Event(Event.SLIDE, {\n relatedTarget,\n direction: eventDirectionName,\n from: fromIndex,\n to: targetIndex\n })\n\n $(this._element).trigger(slideEvent)\n\n return slideEvent\n }\n\n _setActiveIndicatorElement(element) {\n if (this._indicatorsElement) {\n $(this._indicatorsElement)\n .find(Selector.ACTIVE)\n .removeClass(ClassName.ACTIVE)\n\n const nextIndicator = this._indicatorsElement.children[\n this._getItemIndex(element)\n ]\n\n if (nextIndicator) {\n $(nextIndicator).addClass(ClassName.ACTIVE)\n }\n }\n }\n\n _slide(direction, element) {\n const activeElement = $(this._element).find(Selector.ACTIVE_ITEM)[0]\n const activeElementIndex = this._getItemIndex(activeElement)\n const nextElement = element || activeElement &&\n this._getItemByDirection(direction, activeElement)\n const nextElementIndex = this._getItemIndex(nextElement)\n const isCycling = Boolean(this._interval)\n\n let directionalClassName\n let orderClassName\n let eventDirectionName\n\n if (direction === Direction.NEXT) {\n directionalClassName = ClassName.LEFT\n orderClassName = ClassName.NEXT\n eventDirectionName = Direction.LEFT\n } else {\n directionalClassName = ClassName.RIGHT\n orderClassName = ClassName.PREV\n eventDirectionName = Direction.RIGHT\n }\n\n if (nextElement && $(nextElement).hasClass(ClassName.ACTIVE)) {\n this._isSliding = false\n return\n }\n\n const slideEvent = this._triggerSlideEvent(nextElement, eventDirectionName)\n if (slideEvent.isDefaultPrevented()) {\n return\n }\n\n if (!activeElement || !nextElement) {\n // Some weirdness is happening, so we bail\n return\n }\n\n this._isSliding = true\n\n if (isCycling) {\n this.pause()\n }\n\n this._setActiveIndicatorElement(nextElement)\n\n const slidEvent = $.Event(Event.SLID, {\n relatedTarget: nextElement,\n direction: eventDirectionName,\n from: activeElementIndex,\n to: nextElementIndex\n })\n\n if ($(this._element).hasClass(ClassName.SLIDE)) {\n $(nextElement).addClass(orderClassName)\n\n Util.reflow(nextElement)\n\n $(activeElement).addClass(directionalClassName)\n $(nextElement).addClass(directionalClassName)\n\n const transitionDuration = Util.getTransitionDurationFromElement(activeElement)\n\n $(activeElement)\n .one(Util.TRANSITION_END, () => {\n $(nextElement)\n .removeClass(`${directionalClassName} ${orderClassName}`)\n .addClass(ClassName.ACTIVE)\n\n $(activeElement).removeClass(`${ClassName.ACTIVE} ${orderClassName} ${directionalClassName}`)\n\n this._isSliding = false\n\n setTimeout(() => $(this._element).trigger(slidEvent), 0)\n })\n .emulateTransitionEnd(transitionDuration)\n } else {\n $(activeElement).removeClass(ClassName.ACTIVE)\n $(nextElement).addClass(ClassName.ACTIVE)\n\n this._isSliding = false\n $(this._element).trigger(slidEvent)\n }\n\n if (isCycling) {\n this.cycle()\n }\n }\n\n // Static\n\n static _jQueryInterface(config) {\n return this.each(function () {\n let data = $(this).data(DATA_KEY)\n let _config = {\n ...Default,\n ...$(this).data()\n }\n\n if (typeof config === 'object') {\n _config = {\n ..._config,\n ...config\n }\n }\n\n const action = typeof config === 'string' ? config : _config.slide\n\n if (!data) {\n data = new Carousel(this, _config)\n $(this).data(DATA_KEY, data)\n }\n\n if (typeof config === 'number') {\n data.to(config)\n } else if (typeof action === 'string') {\n if (typeof data[action] === 'undefined') {\n throw new TypeError(`No method named \"${action}\"`)\n }\n data[action]()\n } else if (_config.interval) {\n data.pause()\n data.cycle()\n }\n })\n }\n\n static _dataApiClickHandler(event) {\n const selector = Util.getSelectorFromElement(this)\n\n if (!selector) {\n return\n }\n\n const target = $(selector)[0]\n\n if (!target || !$(target).hasClass(ClassName.CAROUSEL)) {\n return\n }\n\n const config = {\n ...$(target).data(),\n ...$(this).data()\n }\n const slideIndex = this.getAttribute('data-slide-to')\n\n if (slideIndex) {\n config.interval = false\n }\n\n Carousel._jQueryInterface.call($(target), config)\n\n if (slideIndex) {\n $(target).data(DATA_KEY).to(slideIndex)\n }\n\n event.preventDefault()\n }\n }\n\n /**\n * ------------------------------------------------------------------------\n * Data Api implementation\n * ------------------------------------------------------------------------\n */\n\n $(document)\n .on(Event.CLICK_DATA_API, Selector.DATA_SLIDE, Carousel._dataApiClickHandler)\n\n $(window).on(Event.LOAD_DATA_API, () => {\n $(Selector.DATA_RIDE).each(function () {\n const $carousel = $(this)\n Carousel._jQueryInterface.call($carousel, $carousel.data())\n })\n })\n\n /**\n * ------------------------------------------------------------------------\n * jQuery\n * ------------------------------------------------------------------------\n */\n\n $.fn[NAME] = Carousel._jQueryInterface\n $.fn[NAME].Constructor = Carousel\n $.fn[NAME].noConflict = function () {\n $.fn[NAME] = JQUERY_NO_CONFLICT\n return Carousel._jQueryInterface\n }\n\n return Carousel\n})($)\n\nexport default Carousel\n","import $ from 'jquery'\nimport Util from './util'\n\n/**\n * --------------------------------------------------------------------------\n * Bootstrap (v4.1.1): collapse.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nconst Collapse = (($) => {\n /**\n * ------------------------------------------------------------------------\n * Constants\n * ------------------------------------------------------------------------\n */\n\n const NAME = 'collapse'\n const VERSION = '4.1.1'\n const DATA_KEY = 'bs.collapse'\n const EVENT_KEY = `.${DATA_KEY}`\n const DATA_API_KEY = '.data-api'\n const JQUERY_NO_CONFLICT = $.fn[NAME]\n\n const Default = {\n toggle : true,\n parent : ''\n }\n\n const DefaultType = {\n toggle : 'boolean',\n parent : '(string|element)'\n }\n\n const Event = {\n SHOW : `show${EVENT_KEY}`,\n SHOWN : `shown${EVENT_KEY}`,\n HIDE : `hide${EVENT_KEY}`,\n HIDDEN : `hidden${EVENT_KEY}`,\n CLICK_DATA_API : `click${EVENT_KEY}${DATA_API_KEY}`\n }\n\n const ClassName = {\n SHOW : 'show',\n COLLAPSE : 'collapse',\n COLLAPSING : 'collapsing',\n COLLAPSED : 'collapsed'\n }\n\n const Dimension = {\n WIDTH : 'width',\n HEIGHT : 'height'\n }\n\n const Selector = {\n ACTIVES : '.show, .collapsing',\n DATA_TOGGLE : '[data-toggle=\"collapse\"]'\n }\n\n /**\n * ------------------------------------------------------------------------\n * Class Definition\n * ------------------------------------------------------------------------\n */\n\n class Collapse {\n constructor(element, config) {\n this._isTransitioning = false\n this._element = element\n this._config = this._getConfig(config)\n this._triggerArray = $.makeArray($(\n `[data-toggle=\"collapse\"][href=\"#${element.id}\"],` +\n `[data-toggle=\"collapse\"][data-target=\"#${element.id}\"]`\n ))\n const tabToggles = $(Selector.DATA_TOGGLE)\n for (let i = 0; i < tabToggles.length; i++) {\n const elem = tabToggles[i]\n const selector = Util.getSelectorFromElement(elem)\n if (selector !== null && $(selector).filter(element).length > 0) {\n this._selector = selector\n this._triggerArray.push(elem)\n }\n }\n\n this._parent = this._config.parent ? this._getParent() : null\n\n if (!this._config.parent) {\n this._addAriaAndCollapsedClass(this._element, this._triggerArray)\n }\n\n if (this._config.toggle) {\n this.toggle()\n }\n }\n\n // Getters\n\n static get VERSION() {\n return VERSION\n }\n\n static get Default() {\n return Default\n }\n\n // Public\n\n toggle() {\n if ($(this._element).hasClass(ClassName.SHOW)) {\n this.hide()\n } else {\n this.show()\n }\n }\n\n show() {\n if (this._isTransitioning ||\n $(this._element).hasClass(ClassName.SHOW)) {\n return\n }\n\n let actives\n let activesData\n\n if (this._parent) {\n actives = $.makeArray(\n $(this._parent)\n .find(Selector.ACTIVES)\n .filter(`[data-parent=\"${this._config.parent}\"]`)\n )\n if (actives.length === 0) {\n actives = null\n }\n }\n\n if (actives) {\n activesData = $(actives).not(this._selector).data(DATA_KEY)\n if (activesData && activesData._isTransitioning) {\n return\n }\n }\n\n const startEvent = $.Event(Event.SHOW)\n $(this._element).trigger(startEvent)\n if (startEvent.isDefaultPrevented()) {\n return\n }\n\n if (actives) {\n Collapse._jQueryInterface.call($(actives).not(this._selector), 'hide')\n if (!activesData) {\n $(actives).data(DATA_KEY, null)\n }\n }\n\n const dimension = this._getDimension()\n\n $(this._element)\n .removeClass(ClassName.COLLAPSE)\n .addClass(ClassName.COLLAPSING)\n\n this._element.style[dimension] = 0\n\n if (this._triggerArray.length > 0) {\n $(this._triggerArray)\n .removeClass(ClassName.COLLAPSED)\n .attr('aria-expanded', true)\n }\n\n this.setTransitioning(true)\n\n const complete = () => {\n $(this._element)\n .removeClass(ClassName.COLLAPSING)\n .addClass(ClassName.COLLAPSE)\n .addClass(ClassName.SHOW)\n\n this._element.style[dimension] = ''\n\n this.setTransitioning(false)\n\n $(this._element).trigger(Event.SHOWN)\n }\n\n const capitalizedDimension = dimension[0].toUpperCase() + dimension.slice(1)\n const scrollSize = `scroll${capitalizedDimension}`\n const transitionDuration = Util.getTransitionDurationFromElement(this._element)\n\n $(this._element)\n .one(Util.TRANSITION_END, complete)\n .emulateTransitionEnd(transitionDuration)\n\n this._element.style[dimension] = `${this._element[scrollSize]}px`\n }\n\n hide() {\n if (this._isTransitioning ||\n !$(this._element).hasClass(ClassName.SHOW)) {\n return\n }\n\n const startEvent = $.Event(Event.HIDE)\n $(this._element).trigger(startEvent)\n if (startEvent.isDefaultPrevented()) {\n return\n }\n\n const dimension = this._getDimension()\n\n this._element.style[dimension] = `${this._element.getBoundingClientRect()[dimension]}px`\n\n Util.reflow(this._element)\n\n $(this._element)\n .addClass(ClassName.COLLAPSING)\n .removeClass(ClassName.COLLAPSE)\n .removeClass(ClassName.SHOW)\n\n if (this._triggerArray.length > 0) {\n for (let i = 0; i < this._triggerArray.length; i++) {\n const trigger = this._triggerArray[i]\n const selector = Util.getSelectorFromElement(trigger)\n if (selector !== null) {\n const $elem = $(selector)\n if (!$elem.hasClass(ClassName.SHOW)) {\n $(trigger).addClass(ClassName.COLLAPSED)\n .attr('aria-expanded', false)\n }\n }\n }\n }\n\n this.setTransitioning(true)\n\n const complete = () => {\n this.setTransitioning(false)\n $(this._element)\n .removeClass(ClassName.COLLAPSING)\n .addClass(ClassName.COLLAPSE)\n .trigger(Event.HIDDEN)\n }\n\n this._element.style[dimension] = ''\n const transitionDuration = Util.getTransitionDurationFromElement(this._element)\n\n $(this._element)\n .one(Util.TRANSITION_END, complete)\n .emulateTransitionEnd(transitionDuration)\n }\n\n setTransitioning(isTransitioning) {\n this._isTransitioning = isTransitioning\n }\n\n dispose() {\n $.removeData(this._element, DATA_KEY)\n\n this._config = null\n this._parent = null\n this._element = null\n this._triggerArray = null\n this._isTransitioning = null\n }\n\n // Private\n\n _getConfig(config) {\n config = {\n ...Default,\n ...config\n }\n config.toggle = Boolean(config.toggle) // Coerce string values\n Util.typeCheckConfig(NAME, config, DefaultType)\n return config\n }\n\n _getDimension() {\n const hasWidth = $(this._element).hasClass(Dimension.WIDTH)\n return hasWidth ? Dimension.WIDTH : Dimension.HEIGHT\n }\n\n _getParent() {\n let parent = null\n if (Util.isElement(this._config.parent)) {\n parent = this._config.parent\n\n // It's a jQuery object\n if (typeof this._config.parent.jquery !== 'undefined') {\n parent = this._config.parent[0]\n }\n } else {\n parent = $(this._config.parent)[0]\n }\n\n const selector =\n `[data-toggle=\"collapse\"][data-parent=\"${this._config.parent}\"]`\n\n $(parent).find(selector).each((i, element) => {\n this._addAriaAndCollapsedClass(\n Collapse._getTargetFromElement(element),\n [element]\n )\n })\n\n return parent\n }\n\n _addAriaAndCollapsedClass(element, triggerArray) {\n if (element) {\n const isOpen = $(element).hasClass(ClassName.SHOW)\n\n if (triggerArray.length > 0) {\n $(triggerArray)\n .toggleClass(ClassName.COLLAPSED, !isOpen)\n .attr('aria-expanded', isOpen)\n }\n }\n }\n\n // Static\n\n static _getTargetFromElement(element) {\n const selector = Util.getSelectorFromElement(element)\n return selector ? $(selector)[0] : null\n }\n\n static _jQueryInterface(config) {\n return this.each(function () {\n const $this = $(this)\n let data = $this.data(DATA_KEY)\n const _config = {\n ...Default,\n ...$this.data(),\n ...typeof config === 'object' && config ? config : {}\n }\n\n if (!data && _config.toggle && /show|hide/.test(config)) {\n _config.toggle = false\n }\n\n if (!data) {\n data = new Collapse(this, _config)\n $this.data(DATA_KEY, data)\n }\n\n if (typeof config === 'string') {\n if (typeof data[config] === 'undefined') {\n throw new TypeError(`No method named \"${config}\"`)\n }\n data[config]()\n }\n })\n }\n }\n\n /**\n * ------------------------------------------------------------------------\n * Data Api implementation\n * ------------------------------------------------------------------------\n */\n\n $(document).on(Event.CLICK_DATA_API, Selector.DATA_TOGGLE, function (event) {\n // preventDefault only for
elements (which change the URL) not inside the collapsible element\n if (event.currentTarget.tagName === 'A') {\n event.preventDefault()\n }\n\n const $trigger = $(this)\n const selector = Util.getSelectorFromElement(this)\n $(selector).each(function () {\n const $target = $(this)\n const data = $target.data(DATA_KEY)\n const config = data ? 'toggle' : $trigger.data()\n Collapse._jQueryInterface.call($target, config)\n })\n })\n\n /**\n * ------------------------------------------------------------------------\n * jQuery\n * ------------------------------------------------------------------------\n */\n\n $.fn[NAME] = Collapse._jQueryInterface\n $.fn[NAME].Constructor = Collapse\n $.fn[NAME].noConflict = function () {\n $.fn[NAME] = JQUERY_NO_CONFLICT\n return Collapse._jQueryInterface\n }\n\n return Collapse\n})($)\n\nexport default Collapse\n","import $ from 'jquery'\nimport Popper from 'popper.js'\nimport Util from './util'\n\n/**\n * --------------------------------------------------------------------------\n * Bootstrap (v4.1.1): dropdown.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nconst Dropdown = (($) => {\n /**\n * ------------------------------------------------------------------------\n * Constants\n * ------------------------------------------------------------------------\n */\n\n const NAME = 'dropdown'\n const VERSION = '4.1.1'\n const DATA_KEY = 'bs.dropdown'\n const EVENT_KEY = `.${DATA_KEY}`\n const DATA_API_KEY = '.data-api'\n const JQUERY_NO_CONFLICT = $.fn[NAME]\n const ESCAPE_KEYCODE = 27 // KeyboardEvent.which value for Escape (Esc) key\n const SPACE_KEYCODE = 32 // KeyboardEvent.which value for space key\n const TAB_KEYCODE = 9 // KeyboardEvent.which value for tab key\n const ARROW_UP_KEYCODE = 38 // KeyboardEvent.which value for up arrow key\n const ARROW_DOWN_KEYCODE = 40 // KeyboardEvent.which value for down arrow key\n const RIGHT_MOUSE_BUTTON_WHICH = 3 // MouseEvent.which value for the right button (assuming a right-handed mouse)\n const REGEXP_KEYDOWN = new RegExp(`${ARROW_UP_KEYCODE}|${ARROW_DOWN_KEYCODE}|${ESCAPE_KEYCODE}`)\n\n const Event = {\n HIDE : `hide${EVENT_KEY}`,\n HIDDEN : `hidden${EVENT_KEY}`,\n SHOW : `show${EVENT_KEY}`,\n SHOWN : `shown${EVENT_KEY}`,\n CLICK : `click${EVENT_KEY}`,\n CLICK_DATA_API : `click${EVENT_KEY}${DATA_API_KEY}`,\n KEYDOWN_DATA_API : `keydown${EVENT_KEY}${DATA_API_KEY}`,\n KEYUP_DATA_API : `keyup${EVENT_KEY}${DATA_API_KEY}`\n }\n\n const ClassName = {\n DISABLED : 'disabled',\n SHOW : 'show',\n DROPUP : 'dropup',\n DROPRIGHT : 'dropright',\n DROPLEFT : 'dropleft',\n MENURIGHT : 'dropdown-menu-right',\n MENULEFT : 'dropdown-menu-left',\n POSITION_STATIC : 'position-static'\n }\n\n const Selector = {\n DATA_TOGGLE : '[data-toggle=\"dropdown\"]',\n FORM_CHILD : '.dropdown form',\n MENU : '.dropdown-menu',\n NAVBAR_NAV : '.navbar-nav',\n VISIBLE_ITEMS : '.dropdown-menu .dropdown-item:not(.disabled):not(:disabled)'\n }\n\n const AttachmentMap = {\n TOP : 'top-start',\n TOPEND : 'top-end',\n BOTTOM : 'bottom-start',\n BOTTOMEND : 'bottom-end',\n RIGHT : 'right-start',\n RIGHTEND : 'right-end',\n LEFT : 'left-start',\n LEFTEND : 'left-end'\n }\n\n const Default = {\n offset : 0,\n flip : true,\n boundary : 'scrollParent',\n reference : 'toggle',\n display : 'dynamic'\n }\n\n const DefaultType = {\n offset : '(number|string|function)',\n flip : 'boolean',\n boundary : '(string|element)',\n reference : '(string|element)',\n display : 'string'\n }\n\n /**\n * ------------------------------------------------------------------------\n * Class Definition\n * ------------------------------------------------------------------------\n */\n\n class Dropdown {\n constructor(element, config) {\n this._element = element\n this._popper = null\n this._config = this._getConfig(config)\n this._menu = this._getMenuElement()\n this._inNavbar = this._detectNavbar()\n\n this._addEventListeners()\n }\n\n // Getters\n\n static get VERSION() {\n return VERSION\n }\n\n static get Default() {\n return Default\n }\n\n static get DefaultType() {\n return DefaultType\n }\n\n // Public\n\n toggle() {\n if (this._element.disabled || $(this._element).hasClass(ClassName.DISABLED)) {\n return\n }\n\n const parent = Dropdown._getParentFromElement(this._element)\n const isActive = $(this._menu).hasClass(ClassName.SHOW)\n\n Dropdown._clearMenus()\n\n if (isActive) {\n return\n }\n\n const relatedTarget = {\n relatedTarget: this._element\n }\n const showEvent = $.Event(Event.SHOW, relatedTarget)\n\n $(parent).trigger(showEvent)\n\n if (showEvent.isDefaultPrevented()) {\n return\n }\n\n // Disable totally Popper.js for Dropdown in Navbar\n if (!this._inNavbar) {\n /**\n * Check for Popper dependency\n * Popper - https://popper.js.org\n */\n if (typeof Popper === 'undefined') {\n throw new TypeError('Bootstrap dropdown require Popper.js (https://popper.js.org)')\n }\n\n let referenceElement = this._element\n\n if (this._config.reference === 'parent') {\n referenceElement = parent\n } else if (Util.isElement(this._config.reference)) {\n referenceElement = this._config.reference\n\n // Check if it's jQuery element\n if (typeof this._config.reference.jquery !== 'undefined') {\n referenceElement = this._config.reference[0]\n }\n }\n\n // If boundary is not `scrollParent`, then set position to `static`\n // to allow the menu to \"escape\" the scroll parent's boundaries\n // https://github.com/twbs/bootstrap/issues/24251\n if (this._config.boundary !== 'scrollParent') {\n $(parent).addClass(ClassName.POSITION_STATIC)\n }\n this._popper = new Popper(referenceElement, this._menu, this._getPopperConfig())\n }\n\n // If this is a touch-enabled device we add extra\n // empty mouseover listeners to the body's immediate children;\n // only needed because of broken event delegation on iOS\n // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html\n if ('ontouchstart' in document.documentElement &&\n $(parent).closest(Selector.NAVBAR_NAV).length === 0) {\n $(document.body).children().on('mouseover', null, $.noop)\n }\n\n this._element.focus()\n this._element.setAttribute('aria-expanded', true)\n\n $(this._menu).toggleClass(ClassName.SHOW)\n $(parent)\n .toggleClass(ClassName.SHOW)\n .trigger($.Event(Event.SHOWN, relatedTarget))\n }\n\n dispose() {\n $.removeData(this._element, DATA_KEY)\n $(this._element).off(EVENT_KEY)\n this._element = null\n this._menu = null\n if (this._popper !== null) {\n this._popper.destroy()\n this._popper = null\n }\n }\n\n update() {\n this._inNavbar = this._detectNavbar()\n if (this._popper !== null) {\n this._popper.scheduleUpdate()\n }\n }\n\n // Private\n\n _addEventListeners() {\n $(this._element).on(Event.CLICK, (event) => {\n event.preventDefault()\n event.stopPropagation()\n this.toggle()\n })\n }\n\n _getConfig(config) {\n config = {\n ...this.constructor.Default,\n ...$(this._element).data(),\n ...config\n }\n\n Util.typeCheckConfig(\n NAME,\n config,\n this.constructor.DefaultType\n )\n\n return config\n }\n\n _getMenuElement() {\n if (!this._menu) {\n const parent = Dropdown._getParentFromElement(this._element)\n this._menu = $(parent).find(Selector.MENU)[0]\n }\n return this._menu\n }\n\n _getPlacement() {\n const $parentDropdown = $(this._element).parent()\n let placement = AttachmentMap.BOTTOM\n\n // Handle dropup\n if ($parentDropdown.hasClass(ClassName.DROPUP)) {\n placement = AttachmentMap.TOP\n if ($(this._menu).hasClass(ClassName.MENURIGHT)) {\n placement = AttachmentMap.TOPEND\n }\n } else if ($parentDropdown.hasClass(ClassName.DROPRIGHT)) {\n placement = AttachmentMap.RIGHT\n } else if ($parentDropdown.hasClass(ClassName.DROPLEFT)) {\n placement = AttachmentMap.LEFT\n } else if ($(this._menu).hasClass(ClassName.MENURIGHT)) {\n placement = AttachmentMap.BOTTOMEND\n }\n return placement\n }\n\n _detectNavbar() {\n return $(this._element).closest('.navbar').length > 0\n }\n\n _getPopperConfig() {\n const offsetConf = {}\n if (typeof this._config.offset === 'function') {\n offsetConf.fn = (data) => {\n data.offsets = {\n ...data.offsets,\n ...this._config.offset(data.offsets) || {}\n }\n return data\n }\n } else {\n offsetConf.offset = this._config.offset\n }\n const popperConfig = {\n placement: this._getPlacement(),\n modifiers: {\n offset: offsetConf,\n flip: {\n enabled: this._config.flip\n },\n preventOverflow: {\n boundariesElement: this._config.boundary\n }\n }\n }\n\n // Disable Popper.js if we have a static display\n if (this._config.display === 'static') {\n popperConfig.modifiers.applyStyle = {\n enabled: false\n }\n }\n return popperConfig\n }\n\n // Static\n\n static _jQueryInterface(config) {\n return this.each(function () {\n let data = $(this).data(DATA_KEY)\n const _config = typeof config === 'object' ? config : null\n\n if (!data) {\n data = new Dropdown(this, _config)\n $(this).data(DATA_KEY, data)\n }\n\n if (typeof config === 'string') {\n if (typeof data[config] === 'undefined') {\n throw new TypeError(`No method named \"${config}\"`)\n }\n data[config]()\n }\n })\n }\n\n static _clearMenus(event) {\n if (event && (event.which === RIGHT_MOUSE_BUTTON_WHICH ||\n event.type === 'keyup' && event.which !== TAB_KEYCODE)) {\n return\n }\n\n const toggles = $.makeArray($(Selector.DATA_TOGGLE))\n for (let i = 0; i < toggles.length; i++) {\n const parent = Dropdown._getParentFromElement(toggles[i])\n const context = $(toggles[i]).data(DATA_KEY)\n const relatedTarget = {\n relatedTarget: toggles[i]\n }\n\n if (!context) {\n continue\n }\n\n const dropdownMenu = context._menu\n if (!$(parent).hasClass(ClassName.SHOW)) {\n continue\n }\n\n if (event && (event.type === 'click' &&\n /input|textarea/i.test(event.target.tagName) || event.type === 'keyup' && event.which === TAB_KEYCODE) &&\n $.contains(parent, event.target)) {\n continue\n }\n\n const hideEvent = $.Event(Event.HIDE, relatedTarget)\n $(parent).trigger(hideEvent)\n if (hideEvent.isDefaultPrevented()) {\n continue\n }\n\n // If this is a touch-enabled device we remove the extra\n // empty mouseover listeners we added for iOS support\n if ('ontouchstart' in document.documentElement) {\n $(document.body).children().off('mouseover', null, $.noop)\n }\n\n toggles[i].setAttribute('aria-expanded', 'false')\n\n $(dropdownMenu).removeClass(ClassName.SHOW)\n $(parent)\n .removeClass(ClassName.SHOW)\n .trigger($.Event(Event.HIDDEN, relatedTarget))\n }\n }\n\n static _getParentFromElement(element) {\n let parent\n const selector = Util.getSelectorFromElement(element)\n\n if (selector) {\n parent = $(selector)[0]\n }\n\n return parent || element.parentNode\n }\n\n // eslint-disable-next-line complexity\n static _dataApiKeydownHandler(event) {\n // If not input/textarea:\n // - And not a key in REGEXP_KEYDOWN => not a dropdown command\n // If input/textarea:\n // - If space key => not a dropdown command\n // - If key is other than escape\n // - If key is not up or down => not a dropdown command\n // - If trigger inside the menu => not a dropdown command\n if (/input|textarea/i.test(event.target.tagName)\n ? event.which === SPACE_KEYCODE || event.which !== ESCAPE_KEYCODE &&\n (event.which !== ARROW_DOWN_KEYCODE && event.which !== ARROW_UP_KEYCODE ||\n $(event.target).closest(Selector.MENU).length) : !REGEXP_KEYDOWN.test(event.which)) {\n return\n }\n\n event.preventDefault()\n event.stopPropagation()\n\n if (this.disabled || $(this).hasClass(ClassName.DISABLED)) {\n return\n }\n\n const parent = Dropdown._getParentFromElement(this)\n const isActive = $(parent).hasClass(ClassName.SHOW)\n\n if (!isActive && (event.which !== ESCAPE_KEYCODE || event.which !== SPACE_KEYCODE) ||\n isActive && (event.which === ESCAPE_KEYCODE || event.which === SPACE_KEYCODE)) {\n if (event.which === ESCAPE_KEYCODE) {\n const toggle = $(parent).find(Selector.DATA_TOGGLE)[0]\n $(toggle).trigger('focus')\n }\n\n $(this).trigger('click')\n return\n }\n\n const items = $(parent).find(Selector.VISIBLE_ITEMS).get()\n\n if (items.length === 0) {\n return\n }\n\n let index = items.indexOf(event.target)\n\n if (event.which === ARROW_UP_KEYCODE && index > 0) { // Up\n index--\n }\n\n if (event.which === ARROW_DOWN_KEYCODE && index < items.length - 1) { // Down\n index++\n }\n\n if (index < 0) {\n index = 0\n }\n\n items[index].focus()\n }\n }\n\n /**\n * ------------------------------------------------------------------------\n * Data Api implementation\n * ------------------------------------------------------------------------\n */\n\n $(document)\n .on(Event.KEYDOWN_DATA_API, Selector.DATA_TOGGLE, Dropdown._dataApiKeydownHandler)\n .on(Event.KEYDOWN_DATA_API, Selector.MENU, Dropdown._dataApiKeydownHandler)\n .on(`${Event.CLICK_DATA_API} ${Event.KEYUP_DATA_API}`, Dropdown._clearMenus)\n .on(Event.CLICK_DATA_API, Selector.DATA_TOGGLE, function (event) {\n event.preventDefault()\n event.stopPropagation()\n Dropdown._jQueryInterface.call($(this), 'toggle')\n })\n .on(Event.CLICK_DATA_API, Selector.FORM_CHILD, (e) => {\n e.stopPropagation()\n })\n\n /**\n * ------------------------------------------------------------------------\n * jQuery\n * ------------------------------------------------------------------------\n */\n\n $.fn[NAME] = Dropdown._jQueryInterface\n $.fn[NAME].Constructor = Dropdown\n $.fn[NAME].noConflict = function () {\n $.fn[NAME] = JQUERY_NO_CONFLICT\n return Dropdown._jQueryInterface\n }\n\n return Dropdown\n})($, Popper)\n\nexport default Dropdown\n","import $ from 'jquery'\nimport Util from './util'\n\n/**\n * --------------------------------------------------------------------------\n * Bootstrap (v4.1.1): modal.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nconst Modal = (($) => {\n /**\n * ------------------------------------------------------------------------\n * Constants\n * ------------------------------------------------------------------------\n */\n\n const NAME = 'modal'\n const VERSION = '4.1.1'\n const DATA_KEY = 'bs.modal'\n const EVENT_KEY = `.${DATA_KEY}`\n const DATA_API_KEY = '.data-api'\n const JQUERY_NO_CONFLICT = $.fn[NAME]\n const ESCAPE_KEYCODE = 27 // KeyboardEvent.which value for Escape (Esc) key\n\n const Default = {\n backdrop : true,\n keyboard : true,\n focus : true,\n show : true\n }\n\n const DefaultType = {\n backdrop : '(boolean|string)',\n keyboard : 'boolean',\n focus : 'boolean',\n show : 'boolean'\n }\n\n const Event = {\n HIDE : `hide${EVENT_KEY}`,\n HIDDEN : `hidden${EVENT_KEY}`,\n SHOW : `show${EVENT_KEY}`,\n SHOWN : `shown${EVENT_KEY}`,\n FOCUSIN : `focusin${EVENT_KEY}`,\n RESIZE : `resize${EVENT_KEY}`,\n CLICK_DISMISS : `click.dismiss${EVENT_KEY}`,\n KEYDOWN_DISMISS : `keydown.dismiss${EVENT_KEY}`,\n MOUSEUP_DISMISS : `mouseup.dismiss${EVENT_KEY}`,\n MOUSEDOWN_DISMISS : `mousedown.dismiss${EVENT_KEY}`,\n CLICK_DATA_API : `click${EVENT_KEY}${DATA_API_KEY}`\n }\n\n const ClassName = {\n SCROLLBAR_MEASURER : 'modal-scrollbar-measure',\n BACKDROP : 'modal-backdrop',\n OPEN : 'modal-open',\n FADE : 'fade',\n SHOW : 'show'\n }\n\n const Selector = {\n DIALOG : '.modal-dialog',\n DATA_TOGGLE : '[data-toggle=\"modal\"]',\n DATA_DISMISS : '[data-dismiss=\"modal\"]',\n FIXED_CONTENT : '.fixed-top, .fixed-bottom, .is-fixed, .sticky-top',\n STICKY_CONTENT : '.sticky-top',\n NAVBAR_TOGGLER : '.navbar-toggler'\n }\n\n /**\n * ------------------------------------------------------------------------\n * Class Definition\n * ------------------------------------------------------------------------\n */\n\n class Modal {\n constructor(element, config) {\n this._config = this._getConfig(config)\n this._element = element\n this._dialog = $(element).find(Selector.DIALOG)[0]\n this._backdrop = null\n this._isShown = false\n this._isBodyOverflowing = false\n this._ignoreBackdropClick = false\n this._scrollbarWidth = 0\n }\n\n // Getters\n\n static get VERSION() {\n return VERSION\n }\n\n static get Default() {\n return Default\n }\n\n // Public\n\n toggle(relatedTarget) {\n return this._isShown ? this.hide() : this.show(relatedTarget)\n }\n\n show(relatedTarget) {\n if (this._isTransitioning || this._isShown) {\n return\n }\n\n if ($(this._element).hasClass(ClassName.FADE)) {\n this._isTransitioning = true\n }\n\n const showEvent = $.Event(Event.SHOW, {\n relatedTarget\n })\n\n $(this._element).trigger(showEvent)\n\n if (this._isShown || showEvent.isDefaultPrevented()) {\n return\n }\n\n this._isShown = true\n\n this._checkScrollbar()\n this._setScrollbar()\n\n this._adjustDialog()\n\n $(document.body).addClass(ClassName.OPEN)\n\n this._setEscapeEvent()\n this._setResizeEvent()\n\n $(this._element).on(\n Event.CLICK_DISMISS,\n Selector.DATA_DISMISS,\n (event) => this.hide(event)\n )\n\n $(this._dialog).on(Event.MOUSEDOWN_DISMISS, () => {\n $(this._element).one(Event.MOUSEUP_DISMISS, (event) => {\n if ($(event.target).is(this._element)) {\n this._ignoreBackdropClick = true\n }\n })\n })\n\n this._showBackdrop(() => this._showElement(relatedTarget))\n }\n\n hide(event) {\n if (event) {\n event.preventDefault()\n }\n\n if (this._isTransitioning || !this._isShown) {\n return\n }\n\n const hideEvent = $.Event(Event.HIDE)\n\n $(this._element).trigger(hideEvent)\n\n if (!this._isShown || hideEvent.isDefaultPrevented()) {\n return\n }\n\n this._isShown = false\n const transition = $(this._element).hasClass(ClassName.FADE)\n\n if (transition) {\n this._isTransitioning = true\n }\n\n this._setEscapeEvent()\n this._setResizeEvent()\n\n $(document).off(Event.FOCUSIN)\n\n $(this._element).removeClass(ClassName.SHOW)\n\n $(this._element).off(Event.CLICK_DISMISS)\n $(this._dialog).off(Event.MOUSEDOWN_DISMISS)\n\n\n if (transition) {\n const transitionDuration = Util.getTransitionDurationFromElement(this._element)\n\n $(this._element)\n .one(Util.TRANSITION_END, (event) => this._hideModal(event))\n .emulateTransitionEnd(transitionDuration)\n } else {\n this._hideModal()\n }\n }\n\n dispose() {\n $.removeData(this._element, DATA_KEY)\n\n $(window, document, this._element, this._backdrop).off(EVENT_KEY)\n\n this._config = null\n this._element = null\n this._dialog = null\n this._backdrop = null\n this._isShown = null\n this._isBodyOverflowing = null\n this._ignoreBackdropClick = null\n this._scrollbarWidth = null\n }\n\n handleUpdate() {\n this._adjustDialog()\n }\n\n // Private\n\n _getConfig(config) {\n config = {\n ...Default,\n ...config\n }\n Util.typeCheckConfig(NAME, config, DefaultType)\n return config\n }\n\n _showElement(relatedTarget) {\n const transition = $(this._element).hasClass(ClassName.FADE)\n\n if (!this._element.parentNode ||\n this._element.parentNode.nodeType !== Node.ELEMENT_NODE) {\n // Don't move modal's DOM position\n document.body.appendChild(this._element)\n }\n\n this._element.style.display = 'block'\n this._element.removeAttribute('aria-hidden')\n this._element.scrollTop = 0\n\n if (transition) {\n Util.reflow(this._element)\n }\n\n $(this._element).addClass(ClassName.SHOW)\n\n if (this._config.focus) {\n this._enforceFocus()\n }\n\n const shownEvent = $.Event(Event.SHOWN, {\n relatedTarget\n })\n\n const transitionComplete = () => {\n if (this._config.focus) {\n this._element.focus()\n }\n this._isTransitioning = false\n $(this._element).trigger(shownEvent)\n }\n\n if (transition) {\n const transitionDuration = Util.getTransitionDurationFromElement(this._element)\n\n $(this._dialog)\n .one(Util.TRANSITION_END, transitionComplete)\n .emulateTransitionEnd(transitionDuration)\n } else {\n transitionComplete()\n }\n }\n\n _enforceFocus() {\n $(document)\n .off(Event.FOCUSIN) // Guard against infinite focus loop\n .on(Event.FOCUSIN, (event) => {\n if (document !== event.target &&\n this._element !== event.target &&\n $(this._element).has(event.target).length === 0) {\n this._element.focus()\n }\n })\n }\n\n _setEscapeEvent() {\n if (this._isShown && this._config.keyboard) {\n $(this._element).on(Event.KEYDOWN_DISMISS, (event) => {\n if (event.which === ESCAPE_KEYCODE) {\n event.preventDefault()\n this.hide()\n }\n })\n } else if (!this._isShown) {\n $(this._element).off(Event.KEYDOWN_DISMISS)\n }\n }\n\n _setResizeEvent() {\n if (this._isShown) {\n $(window).on(Event.RESIZE, (event) => this.handleUpdate(event))\n } else {\n $(window).off(Event.RESIZE)\n }\n }\n\n _hideModal() {\n this._element.style.display = 'none'\n this._element.setAttribute('aria-hidden', true)\n this._isTransitioning = false\n this._showBackdrop(() => {\n $(document.body).removeClass(ClassName.OPEN)\n this._resetAdjustments()\n this._resetScrollbar()\n $(this._element).trigger(Event.HIDDEN)\n })\n }\n\n _removeBackdrop() {\n if (this._backdrop) {\n $(this._backdrop).remove()\n this._backdrop = null\n }\n }\n\n _showBackdrop(callback) {\n const animate = $(this._element).hasClass(ClassName.FADE)\n ? ClassName.FADE : ''\n\n if (this._isShown && this._config.backdrop) {\n this._backdrop = document.createElement('div')\n this._backdrop.className = ClassName.BACKDROP\n\n if (animate) {\n $(this._backdrop).addClass(animate)\n }\n\n $(this._backdrop).appendTo(document.body)\n\n $(this._element).on(Event.CLICK_DISMISS, (event) => {\n if (this._ignoreBackdropClick) {\n this._ignoreBackdropClick = false\n return\n }\n if (event.target !== event.currentTarget) {\n return\n }\n if (this._config.backdrop === 'static') {\n this._element.focus()\n } else {\n this.hide()\n }\n })\n\n if (animate) {\n Util.reflow(this._backdrop)\n }\n\n $(this._backdrop).addClass(ClassName.SHOW)\n\n if (!callback) {\n return\n }\n\n if (!animate) {\n callback()\n return\n }\n\n const backdropTransitionDuration = Util.getTransitionDurationFromElement(this._backdrop)\n\n $(this._backdrop)\n .one(Util.TRANSITION_END, callback)\n .emulateTransitionEnd(backdropTransitionDuration)\n } else if (!this._isShown && this._backdrop) {\n $(this._backdrop).removeClass(ClassName.SHOW)\n\n const callbackRemove = () => {\n this._removeBackdrop()\n if (callback) {\n callback()\n }\n }\n\n if ($(this._element).hasClass(ClassName.FADE)) {\n const backdropTransitionDuration = Util.getTransitionDurationFromElement(this._backdrop)\n\n $(this._backdrop)\n .one(Util.TRANSITION_END, callbackRemove)\n .emulateTransitionEnd(backdropTransitionDuration)\n } else {\n callbackRemove()\n }\n } else if (callback) {\n callback()\n }\n }\n\n // ----------------------------------------------------------------------\n // the following methods are used to handle overflowing modals\n // todo (fat): these should probably be refactored out of modal.js\n // ----------------------------------------------------------------------\n\n _adjustDialog() {\n const isModalOverflowing =\n this._element.scrollHeight > document.documentElement.clientHeight\n\n if (!this._isBodyOverflowing && isModalOverflowing) {\n this._element.style.paddingLeft = `${this._scrollbarWidth}px`\n }\n\n if (this._isBodyOverflowing && !isModalOverflowing) {\n this._element.style.paddingRight = `${this._scrollbarWidth}px`\n }\n }\n\n _resetAdjustments() {\n this._element.style.paddingLeft = ''\n this._element.style.paddingRight = ''\n }\n\n _checkScrollbar() {\n const rect = document.body.getBoundingClientRect()\n this._isBodyOverflowing = rect.left + rect.right < window.innerWidth\n this._scrollbarWidth = this._getScrollbarWidth()\n }\n\n _setScrollbar() {\n if (this._isBodyOverflowing) {\n // Note: DOMNode.style.paddingRight returns the actual value or '' if not set\n // while $(DOMNode).css('padding-right') returns the calculated value or 0 if not set\n\n // Adjust fixed content padding\n $(Selector.FIXED_CONTENT).each((index, element) => {\n const actualPadding = $(element)[0].style.paddingRight\n const calculatedPadding = $(element).css('padding-right')\n $(element).data('padding-right', actualPadding).css('padding-right', `${parseFloat(calculatedPadding) + this._scrollbarWidth}px`)\n })\n\n // Adjust sticky content margin\n $(Selector.STICKY_CONTENT).each((index, element) => {\n const actualMargin = $(element)[0].style.marginRight\n const calculatedMargin = $(element).css('margin-right')\n $(element).data('margin-right', actualMargin).css('margin-right', `${parseFloat(calculatedMargin) - this._scrollbarWidth}px`)\n })\n\n // Adjust navbar-toggler margin\n $(Selector.NAVBAR_TOGGLER).each((index, element) => {\n const actualMargin = $(element)[0].style.marginRight\n const calculatedMargin = $(element).css('margin-right')\n $(element).data('margin-right', actualMargin).css('margin-right', `${parseFloat(calculatedMargin) + this._scrollbarWidth}px`)\n })\n\n // Adjust body padding\n const actualPadding = document.body.style.paddingRight\n const calculatedPadding = $(document.body).css('padding-right')\n $(document.body).data('padding-right', actualPadding).css('padding-right', `${parseFloat(calculatedPadding) + this._scrollbarWidth}px`)\n }\n }\n\n _resetScrollbar() {\n // Restore fixed content padding\n $(Selector.FIXED_CONTENT).each((index, element) => {\n const padding = $(element).data('padding-right')\n if (typeof padding !== 'undefined') {\n $(element).css('padding-right', padding).removeData('padding-right')\n }\n })\n\n // Restore sticky content and navbar-toggler margin\n $(`${Selector.STICKY_CONTENT}, ${Selector.NAVBAR_TOGGLER}`).each((index, element) => {\n const margin = $(element).data('margin-right')\n if (typeof margin !== 'undefined') {\n $(element).css('margin-right', margin).removeData('margin-right')\n }\n })\n\n // Restore body padding\n const padding = $(document.body).data('padding-right')\n if (typeof padding !== 'undefined') {\n $(document.body).css('padding-right', padding).removeData('padding-right')\n }\n }\n\n _getScrollbarWidth() { // thx d.walsh\n const scrollDiv = document.createElement('div')\n scrollDiv.className = ClassName.SCROLLBAR_MEASURER\n document.body.appendChild(scrollDiv)\n const scrollbarWidth = scrollDiv.getBoundingClientRect().width - scrollDiv.clientWidth\n document.body.removeChild(scrollDiv)\n return scrollbarWidth\n }\n\n // Static\n\n static _jQueryInterface(config, relatedTarget) {\n return this.each(function () {\n let data = $(this).data(DATA_KEY)\n const _config = {\n ...Default,\n ...$(this).data(),\n ...typeof config === 'object' && config ? config : {}\n }\n\n if (!data) {\n data = new Modal(this, _config)\n $(this).data(DATA_KEY, data)\n }\n\n if (typeof config === 'string') {\n if (typeof data[config] === 'undefined') {\n throw new TypeError(`No method named \"${config}\"`)\n }\n data[config](relatedTarget)\n } else if (_config.show) {\n data.show(relatedTarget)\n }\n })\n }\n }\n\n /**\n * ------------------------------------------------------------------------\n * Data Api implementation\n * ------------------------------------------------------------------------\n */\n\n $(document).on(Event.CLICK_DATA_API, Selector.DATA_TOGGLE, function (event) {\n let target\n const selector = Util.getSelectorFromElement(this)\n\n if (selector) {\n target = $(selector)[0]\n }\n\n const config = $(target).data(DATA_KEY)\n ? 'toggle' : {\n ...$(target).data(),\n ...$(this).data()\n }\n\n if (this.tagName === 'A' || this.tagName === 'AREA') {\n event.preventDefault()\n }\n\n const $target = $(target).one(Event.SHOW, (showEvent) => {\n if (showEvent.isDefaultPrevented()) {\n // Only register focus restorer if modal will actually get shown\n return\n }\n\n $target.one(Event.HIDDEN, () => {\n if ($(this).is(':visible')) {\n this.focus()\n }\n })\n })\n\n Modal._jQueryInterface.call($(target), config, this)\n })\n\n /**\n * ------------------------------------------------------------------------\n * jQuery\n * ------------------------------------------------------------------------\n */\n\n $.fn[NAME] = Modal._jQueryInterface\n $.fn[NAME].Constructor = Modal\n $.fn[NAME].noConflict = function () {\n $.fn[NAME] = JQUERY_NO_CONFLICT\n return Modal._jQueryInterface\n }\n\n return Modal\n})($)\n\nexport default Modal\n","import $ from 'jquery'\nimport Popper from 'popper.js'\nimport Util from './util'\n\n/**\n * --------------------------------------------------------------------------\n * Bootstrap (v4.1.1): tooltip.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nconst Tooltip = (($) => {\n /**\n * ------------------------------------------------------------------------\n * Constants\n * ------------------------------------------------------------------------\n */\n\n const NAME = 'tooltip'\n const VERSION = '4.1.1'\n const DATA_KEY = 'bs.tooltip'\n const EVENT_KEY = `.${DATA_KEY}`\n const JQUERY_NO_CONFLICT = $.fn[NAME]\n const CLASS_PREFIX = 'bs-tooltip'\n const BSCLS_PREFIX_REGEX = new RegExp(`(^|\\\\s)${CLASS_PREFIX}\\\\S+`, 'g')\n\n const DefaultType = {\n animation : 'boolean',\n template : 'string',\n title : '(string|element|function)',\n trigger : 'string',\n delay : '(number|object)',\n html : 'boolean',\n selector : '(string|boolean)',\n placement : '(string|function)',\n offset : '(number|string)',\n container : '(string|element|boolean)',\n fallbackPlacement : '(string|array)',\n boundary : '(string|element)'\n }\n\n const AttachmentMap = {\n AUTO : 'auto',\n TOP : 'top',\n RIGHT : 'right',\n BOTTOM : 'bottom',\n LEFT : 'left'\n }\n\n const Default = {\n animation : true,\n template : '
' +\n '
' +\n '
',\n trigger : 'hover focus',\n title : '',\n delay : 0,\n html : false,\n selector : false,\n placement : 'top',\n offset : 0,\n container : false,\n fallbackPlacement : 'flip',\n boundary : 'scrollParent'\n }\n\n const HoverState = {\n SHOW : 'show',\n OUT : 'out'\n }\n\n const Event = {\n HIDE : `hide${EVENT_KEY}`,\n HIDDEN : `hidden${EVENT_KEY}`,\n SHOW : `show${EVENT_KEY}`,\n SHOWN : `shown${EVENT_KEY}`,\n INSERTED : `inserted${EVENT_KEY}`,\n CLICK : `click${EVENT_KEY}`,\n FOCUSIN : `focusin${EVENT_KEY}`,\n FOCUSOUT : `focusout${EVENT_KEY}`,\n MOUSEENTER : `mouseenter${EVENT_KEY}`,\n MOUSELEAVE : `mouseleave${EVENT_KEY}`\n }\n\n const ClassName = {\n FADE : 'fade',\n SHOW : 'show'\n }\n\n const Selector = {\n TOOLTIP : '.tooltip',\n TOOLTIP_INNER : '.tooltip-inner',\n ARROW : '.arrow'\n }\n\n const Trigger = {\n HOVER : 'hover',\n FOCUS : 'focus',\n CLICK : 'click',\n MANUAL : 'manual'\n }\n\n\n /**\n * ------------------------------------------------------------------------\n * Class Definition\n * ------------------------------------------------------------------------\n */\n\n class Tooltip {\n constructor(element, config) {\n /**\n * Check for Popper dependency\n * Popper - https://popper.js.org\n */\n if (typeof Popper === 'undefined') {\n throw new TypeError('Bootstrap tooltips require Popper.js (https://popper.js.org)')\n }\n\n // private\n this._isEnabled = true\n this._timeout = 0\n this._hoverState = ''\n this._activeTrigger = {}\n this._popper = null\n\n // Protected\n this.element = element\n this.config = this._getConfig(config)\n this.tip = null\n\n this._setListeners()\n }\n\n // Getters\n\n static get VERSION() {\n return VERSION\n }\n\n static get Default() {\n return Default\n }\n\n static get NAME() {\n return NAME\n }\n\n static get DATA_KEY() {\n return DATA_KEY\n }\n\n static get Event() {\n return Event\n }\n\n static get EVENT_KEY() {\n return EVENT_KEY\n }\n\n static get DefaultType() {\n return DefaultType\n }\n\n // Public\n\n enable() {\n this._isEnabled = true\n }\n\n disable() {\n this._isEnabled = false\n }\n\n toggleEnabled() {\n this._isEnabled = !this._isEnabled\n }\n\n toggle(event) {\n if (!this._isEnabled) {\n return\n }\n\n if (event) {\n const dataKey = this.constructor.DATA_KEY\n let context = $(event.currentTarget).data(dataKey)\n\n if (!context) {\n context = new this.constructor(\n event.currentTarget,\n this._getDelegateConfig()\n )\n $(event.currentTarget).data(dataKey, context)\n }\n\n context._activeTrigger.click = !context._activeTrigger.click\n\n if (context._isWithActiveTrigger()) {\n context._enter(null, context)\n } else {\n context._leave(null, context)\n }\n } else {\n if ($(this.getTipElement()).hasClass(ClassName.SHOW)) {\n this._leave(null, this)\n return\n }\n\n this._enter(null, this)\n }\n }\n\n dispose() {\n clearTimeout(this._timeout)\n\n $.removeData(this.element, this.constructor.DATA_KEY)\n\n $(this.element).off(this.constructor.EVENT_KEY)\n $(this.element).closest('.modal').off('hide.bs.modal')\n\n if (this.tip) {\n $(this.tip).remove()\n }\n\n this._isEnabled = null\n this._timeout = null\n this._hoverState = null\n this._activeTrigger = null\n if (this._popper !== null) {\n this._popper.destroy()\n }\n\n this._popper = null\n this.element = null\n this.config = null\n this.tip = null\n }\n\n show() {\n if ($(this.element).css('display') === 'none') {\n throw new Error('Please use show on visible elements')\n }\n\n const showEvent = $.Event(this.constructor.Event.SHOW)\n if (this.isWithContent() && this._isEnabled) {\n $(this.element).trigger(showEvent)\n\n const isInTheDom = $.contains(\n this.element.ownerDocument.documentElement,\n this.element\n )\n\n if (showEvent.isDefaultPrevented() || !isInTheDom) {\n return\n }\n\n const tip = this.getTipElement()\n const tipId = Util.getUID(this.constructor.NAME)\n\n tip.setAttribute('id', tipId)\n this.element.setAttribute('aria-describedby', tipId)\n\n this.setContent()\n\n if (this.config.animation) {\n $(tip).addClass(ClassName.FADE)\n }\n\n const placement = typeof this.config.placement === 'function'\n ? this.config.placement.call(this, tip, this.element)\n : this.config.placement\n\n const attachment = this._getAttachment(placement)\n this.addAttachmentClass(attachment)\n\n const container = this.config.container === false ? document.body : $(this.config.container)\n\n $(tip).data(this.constructor.DATA_KEY, this)\n\n if (!$.contains(this.element.ownerDocument.documentElement, this.tip)) {\n $(tip).appendTo(container)\n }\n\n $(this.element).trigger(this.constructor.Event.INSERTED)\n\n this._popper = new Popper(this.element, tip, {\n placement: attachment,\n modifiers: {\n offset: {\n offset: this.config.offset\n },\n flip: {\n behavior: this.config.fallbackPlacement\n },\n arrow: {\n element: Selector.ARROW\n },\n preventOverflow: {\n boundariesElement: this.config.boundary\n }\n },\n onCreate: (data) => {\n if (data.originalPlacement !== data.placement) {\n this._handlePopperPlacementChange(data)\n }\n },\n onUpdate: (data) => {\n this._handlePopperPlacementChange(data)\n }\n })\n\n $(tip).addClass(ClassName.SHOW)\n\n // If this is a touch-enabled device we add extra\n // empty mouseover listeners to the body's immediate children;\n // only needed because of broken event delegation on iOS\n // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html\n if ('ontouchstart' in document.documentElement) {\n $(document.body).children().on('mouseover', null, $.noop)\n }\n\n const complete = () => {\n if (this.config.animation) {\n this._fixTransition()\n }\n const prevHoverState = this._hoverState\n this._hoverState = null\n\n $(this.element).trigger(this.constructor.Event.SHOWN)\n\n if (prevHoverState === HoverState.OUT) {\n this._leave(null, this)\n }\n }\n\n if ($(this.tip).hasClass(ClassName.FADE)) {\n const transitionDuration = Util.getTransitionDurationFromElement(this.tip)\n\n $(this.tip)\n .one(Util.TRANSITION_END, complete)\n .emulateTransitionEnd(transitionDuration)\n } else {\n complete()\n }\n }\n }\n\n hide(callback) {\n const tip = this.getTipElement()\n const hideEvent = $.Event(this.constructor.Event.HIDE)\n const complete = () => {\n if (this._hoverState !== HoverState.SHOW && tip.parentNode) {\n tip.parentNode.removeChild(tip)\n }\n\n this._cleanTipClass()\n this.element.removeAttribute('aria-describedby')\n $(this.element).trigger(this.constructor.Event.HIDDEN)\n if (this._popper !== null) {\n this._popper.destroy()\n }\n\n if (callback) {\n callback()\n }\n }\n\n $(this.element).trigger(hideEvent)\n\n if (hideEvent.isDefaultPrevented()) {\n return\n }\n\n $(tip).removeClass(ClassName.SHOW)\n\n // If this is a touch-enabled device we remove the extra\n // empty mouseover listeners we added for iOS support\n if ('ontouchstart' in document.documentElement) {\n $(document.body).children().off('mouseover', null, $.noop)\n }\n\n this._activeTrigger[Trigger.CLICK] = false\n this._activeTrigger[Trigger.FOCUS] = false\n this._activeTrigger[Trigger.HOVER] = false\n\n if ($(this.tip).hasClass(ClassName.FADE)) {\n const transitionDuration = Util.getTransitionDurationFromElement(tip)\n\n $(tip)\n .one(Util.TRANSITION_END, complete)\n .emulateTransitionEnd(transitionDuration)\n } else {\n complete()\n }\n\n this._hoverState = ''\n }\n\n update() {\n if (this._popper !== null) {\n this._popper.scheduleUpdate()\n }\n }\n\n // Protected\n\n isWithContent() {\n return Boolean(this.getTitle())\n }\n\n addAttachmentClass(attachment) {\n $(this.getTipElement()).addClass(`${CLASS_PREFIX}-${attachment}`)\n }\n\n getTipElement() {\n this.tip = this.tip || $(this.config.template)[0]\n return this.tip\n }\n\n setContent() {\n const $tip = $(this.getTipElement())\n this.setElementContent($tip.find(Selector.TOOLTIP_INNER), this.getTitle())\n $tip.removeClass(`${ClassName.FADE} ${ClassName.SHOW}`)\n }\n\n setElementContent($element, content) {\n const html = this.config.html\n if (typeof content === 'object' && (content.nodeType || content.jquery)) {\n // Content is a DOM node or a jQuery\n if (html) {\n if (!$(content).parent().is($element)) {\n $element.empty().append(content)\n }\n } else {\n $element.text($(content).text())\n }\n } else {\n $element[html ? 'html' : 'text'](content)\n }\n }\n\n getTitle() {\n let title = this.element.getAttribute('data-original-title')\n\n if (!title) {\n title = typeof this.config.title === 'function'\n ? this.config.title.call(this.element)\n : this.config.title\n }\n\n return title\n }\n\n // Private\n\n _getAttachment(placement) {\n return AttachmentMap[placement.toUpperCase()]\n }\n\n _setListeners() {\n const triggers = this.config.trigger.split(' ')\n\n triggers.forEach((trigger) => {\n if (trigger === 'click') {\n $(this.element).on(\n this.constructor.Event.CLICK,\n this.config.selector,\n (event) => this.toggle(event)\n )\n } else if (trigger !== Trigger.MANUAL) {\n const eventIn = trigger === Trigger.HOVER\n ? this.constructor.Event.MOUSEENTER\n : this.constructor.Event.FOCUSIN\n const eventOut = trigger === Trigger.HOVER\n ? this.constructor.Event.MOUSELEAVE\n : this.constructor.Event.FOCUSOUT\n\n $(this.element)\n .on(\n eventIn,\n this.config.selector,\n (event) => this._enter(event)\n )\n .on(\n eventOut,\n this.config.selector,\n (event) => this._leave(event)\n )\n }\n\n $(this.element).closest('.modal').on(\n 'hide.bs.modal',\n () => this.hide()\n )\n })\n\n if (this.config.selector) {\n this.config = {\n ...this.config,\n trigger: 'manual',\n selector: ''\n }\n } else {\n this._fixTitle()\n }\n }\n\n _fixTitle() {\n const titleType = typeof this.element.getAttribute('data-original-title')\n if (this.element.getAttribute('title') ||\n titleType !== 'string') {\n this.element.setAttribute(\n 'data-original-title',\n this.element.getAttribute('title') || ''\n )\n this.element.setAttribute('title', '')\n }\n }\n\n _enter(event, context) {\n const dataKey = this.constructor.DATA_KEY\n\n context = context || $(event.currentTarget).data(dataKey)\n\n if (!context) {\n context = new this.constructor(\n event.currentTarget,\n this._getDelegateConfig()\n )\n $(event.currentTarget).data(dataKey, context)\n }\n\n if (event) {\n context._activeTrigger[\n event.type === 'focusin' ? Trigger.FOCUS : Trigger.HOVER\n ] = true\n }\n\n if ($(context.getTipElement()).hasClass(ClassName.SHOW) ||\n context._hoverState === HoverState.SHOW) {\n context._hoverState = HoverState.SHOW\n return\n }\n\n clearTimeout(context._timeout)\n\n context._hoverState = HoverState.SHOW\n\n if (!context.config.delay || !context.config.delay.show) {\n context.show()\n return\n }\n\n context._timeout = setTimeout(() => {\n if (context._hoverState === HoverState.SHOW) {\n context.show()\n }\n }, context.config.delay.show)\n }\n\n _leave(event, context) {\n const dataKey = this.constructor.DATA_KEY\n\n context = context || $(event.currentTarget).data(dataKey)\n\n if (!context) {\n context = new this.constructor(\n event.currentTarget,\n this._getDelegateConfig()\n )\n $(event.currentTarget).data(dataKey, context)\n }\n\n if (event) {\n context._activeTrigger[\n event.type === 'focusout' ? Trigger.FOCUS : Trigger.HOVER\n ] = false\n }\n\n if (context._isWithActiveTrigger()) {\n return\n }\n\n clearTimeout(context._timeout)\n\n context._hoverState = HoverState.OUT\n\n if (!context.config.delay || !context.config.delay.hide) {\n context.hide()\n return\n }\n\n context._timeout = setTimeout(() => {\n if (context._hoverState === HoverState.OUT) {\n context.hide()\n }\n }, context.config.delay.hide)\n }\n\n _isWithActiveTrigger() {\n for (const trigger in this._activeTrigger) {\n if (this._activeTrigger[trigger]) {\n return true\n }\n }\n\n return false\n }\n\n _getConfig(config) {\n config = {\n ...this.constructor.Default,\n ...$(this.element).data(),\n ...typeof config === 'object' && config ? config : {}\n }\n\n if (typeof config.delay === 'number') {\n config.delay = {\n show: config.delay,\n hide: config.delay\n }\n }\n\n if (typeof config.title === 'number') {\n config.title = config.title.toString()\n }\n\n if (typeof config.content === 'number') {\n config.content = config.content.toString()\n }\n\n Util.typeCheckConfig(\n NAME,\n config,\n this.constructor.DefaultType\n )\n\n return config\n }\n\n _getDelegateConfig() {\n const config = {}\n\n if (this.config) {\n for (const key in this.config) {\n if (this.constructor.Default[key] !== this.config[key]) {\n config[key] = this.config[key]\n }\n }\n }\n\n return config\n }\n\n _cleanTipClass() {\n const $tip = $(this.getTipElement())\n const tabClass = $tip.attr('class').match(BSCLS_PREFIX_REGEX)\n if (tabClass !== null && tabClass.length > 0) {\n $tip.removeClass(tabClass.join(''))\n }\n }\n\n _handlePopperPlacementChange(data) {\n this._cleanTipClass()\n this.addAttachmentClass(this._getAttachment(data.placement))\n }\n\n _fixTransition() {\n const tip = this.getTipElement()\n const initConfigAnimation = this.config.animation\n if (tip.getAttribute('x-placement') !== null) {\n return\n }\n $(tip).removeClass(ClassName.FADE)\n this.config.animation = false\n this.hide()\n this.show()\n this.config.animation = initConfigAnimation\n }\n\n // Static\n\n static _jQueryInterface(config) {\n return this.each(function () {\n let data = $(this).data(DATA_KEY)\n const _config = typeof config === 'object' && config\n\n if (!data && /dispose|hide/.test(config)) {\n return\n }\n\n if (!data) {\n data = new Tooltip(this, _config)\n $(this).data(DATA_KEY, data)\n }\n\n if (typeof config === 'string') {\n if (typeof data[config] === 'undefined') {\n throw new TypeError(`No method named \"${config}\"`)\n }\n data[config]()\n }\n })\n }\n }\n\n /**\n * ------------------------------------------------------------------------\n * jQuery\n * ------------------------------------------------------------------------\n */\n\n $.fn[NAME] = Tooltip._jQueryInterface\n $.fn[NAME].Constructor = Tooltip\n $.fn[NAME].noConflict = function () {\n $.fn[NAME] = JQUERY_NO_CONFLICT\n return Tooltip._jQueryInterface\n }\n\n return Tooltip\n})($, Popper)\n\nexport default Tooltip\n","import $ from 'jquery'\nimport Tooltip from './tooltip'\n\n/**\n * --------------------------------------------------------------------------\n * Bootstrap (v4.1.1): popover.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nconst Popover = (($) => {\n /**\n * ------------------------------------------------------------------------\n * Constants\n * ------------------------------------------------------------------------\n */\n\n const NAME = 'popover'\n const VERSION = '4.1.1'\n const DATA_KEY = 'bs.popover'\n const EVENT_KEY = `.${DATA_KEY}`\n const JQUERY_NO_CONFLICT = $.fn[NAME]\n const CLASS_PREFIX = 'bs-popover'\n const BSCLS_PREFIX_REGEX = new RegExp(`(^|\\\\s)${CLASS_PREFIX}\\\\S+`, 'g')\n\n const Default = {\n ...Tooltip.Default,\n placement : 'right',\n trigger : 'click',\n content : '',\n template : '
' +\n '
' +\n '

' +\n '
'\n }\n\n const DefaultType = {\n ...Tooltip.DefaultType,\n content : '(string|element|function)'\n }\n\n const ClassName = {\n FADE : 'fade',\n SHOW : 'show'\n }\n\n const Selector = {\n TITLE : '.popover-header',\n CONTENT : '.popover-body'\n }\n\n const Event = {\n HIDE : `hide${EVENT_KEY}`,\n HIDDEN : `hidden${EVENT_KEY}`,\n SHOW : `show${EVENT_KEY}`,\n SHOWN : `shown${EVENT_KEY}`,\n INSERTED : `inserted${EVENT_KEY}`,\n CLICK : `click${EVENT_KEY}`,\n FOCUSIN : `focusin${EVENT_KEY}`,\n FOCUSOUT : `focusout${EVENT_KEY}`,\n MOUSEENTER : `mouseenter${EVENT_KEY}`,\n MOUSELEAVE : `mouseleave${EVENT_KEY}`\n }\n\n /**\n * ------------------------------------------------------------------------\n * Class Definition\n * ------------------------------------------------------------------------\n */\n\n class Popover extends Tooltip {\n // Getters\n\n static get VERSION() {\n return VERSION\n }\n\n static get Default() {\n return Default\n }\n\n static get NAME() {\n return NAME\n }\n\n static get DATA_KEY() {\n return DATA_KEY\n }\n\n static get Event() {\n return Event\n }\n\n static get EVENT_KEY() {\n return EVENT_KEY\n }\n\n static get DefaultType() {\n return DefaultType\n }\n\n // Overrides\n\n isWithContent() {\n return this.getTitle() || this._getContent()\n }\n\n addAttachmentClass(attachment) {\n $(this.getTipElement()).addClass(`${CLASS_PREFIX}-${attachment}`)\n }\n\n getTipElement() {\n this.tip = this.tip || $(this.config.template)[0]\n return this.tip\n }\n\n setContent() {\n const $tip = $(this.getTipElement())\n\n // We use append for html objects to maintain js events\n this.setElementContent($tip.find(Selector.TITLE), this.getTitle())\n let content = this._getContent()\n if (typeof content === 'function') {\n content = content.call(this.element)\n }\n this.setElementContent($tip.find(Selector.CONTENT), content)\n\n $tip.removeClass(`${ClassName.FADE} ${ClassName.SHOW}`)\n }\n\n // Private\n\n _getContent() {\n return this.element.getAttribute('data-content') ||\n this.config.content\n }\n\n _cleanTipClass() {\n const $tip = $(this.getTipElement())\n const tabClass = $tip.attr('class').match(BSCLS_PREFIX_REGEX)\n if (tabClass !== null && tabClass.length > 0) {\n $tip.removeClass(tabClass.join(''))\n }\n }\n\n // Static\n\n static _jQueryInterface(config) {\n return this.each(function () {\n let data = $(this).data(DATA_KEY)\n const _config = typeof config === 'object' ? config : null\n\n if (!data && /destroy|hide/.test(config)) {\n return\n }\n\n if (!data) {\n data = new Popover(this, _config)\n $(this).data(DATA_KEY, data)\n }\n\n if (typeof config === 'string') {\n if (typeof data[config] === 'undefined') {\n throw new TypeError(`No method named \"${config}\"`)\n }\n data[config]()\n }\n })\n }\n }\n\n /**\n * ------------------------------------------------------------------------\n * jQuery\n * ------------------------------------------------------------------------\n */\n\n $.fn[NAME] = Popover._jQueryInterface\n $.fn[NAME].Constructor = Popover\n $.fn[NAME].noConflict = function () {\n $.fn[NAME] = JQUERY_NO_CONFLICT\n return Popover._jQueryInterface\n }\n\n return Popover\n})($)\n\nexport default Popover\n","import $ from 'jquery'\nimport Util from './util'\n\n/**\n * --------------------------------------------------------------------------\n * Bootstrap (v4.1.1): scrollspy.js\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n * --------------------------------------------------------------------------\n */\n\nconst ScrollSpy = (($) => {\n /**\n * ------------------------------------------------------------------------\n * Constants\n * ------------------------------------------------------------------------\n */\n\n const NAME = 'scrollspy'\n const VERSION = '4.1.1'\n const DATA_KEY = 'bs.scrollspy'\n const EVENT_KEY = `.${DATA_KEY}`\n const DATA_API_KEY = '.data-api'\n const JQUERY_NO_CONFLICT = $.fn[NAME]\n\n const Default = {\n offset : 10,\n method : 'auto',\n target : ''\n }\n\n const DefaultType = {\n offset : 'number',\n method : 'string',\n target : '(string|element)'\n }\n\n const Event = {\n ACTIVATE : `activate${EVENT_KEY}`,\n SCROLL : `scroll${EVENT_KEY}`,\n LOAD_DATA_API : `load${EVENT_KEY}${DATA_API_KEY}`\n }\n\n const ClassName = {\n DROPDOWN_ITEM : 'dropdown-item',\n DROPDOWN_MENU : 'dropdown-menu',\n ACTIVE : 'active'\n }\n\n const Selector = {\n DATA_SPY : '[data-spy=\"scroll\"]',\n ACTIVE : '.active',\n NAV_LIST_GROUP : '.nav, .list-group',\n NAV_LINKS : '.nav-link',\n NAV_ITEMS : '.nav-item',\n LIST_ITEMS : '.list-group-item',\n DROPDOWN : '.dropdown',\n DROPDOWN_ITEMS : '.dropdown-item',\n DROPDOWN_TOGGLE : '.dropdown-toggle'\n }\n\n const OffsetMethod = {\n OFFSET : 'offset',\n POSITION : 'position'\n }\n\n /**\n * ------------------------------------------------------------------------\n * Class Definition\n * ------------------------------------------------------------------------\n */\n\n class ScrollSpy {\n constructor(element, config) {\n this._element = element\n this._scrollElement = element.tagName === 'BODY' ? window : element\n this._config = this._getConfig(config)\n this._selector = `${this._config.target} ${Selector.NAV_LINKS},` +\n `${this._config.target} ${Selector.LIST_ITEMS},` +\n `${this._config.target} ${Selector.DROPDOWN_ITEMS}`\n this._offsets = []\n this._targets = []\n this._activeTarget = null\n this._scrollHeight = 0\n\n $(this._scrollElement).on(Event.SCROLL, (event) => this._process(event))\n\n this.refresh()\n this._process()\n }\n\n // Getters\n\n static get VERSION() {\n return VERSION\n }\n\n static get Default() {\n return Default\n }\n\n // Public\n\n refresh() {\n const autoMethod = this._scrollElement === this._scrollElement.window\n ? OffsetMethod.OFFSET : OffsetMethod.POSITION\n\n const offsetMethod = this._config.method === 'auto'\n ? autoMethod : this._config.method\n\n const offsetBase = offsetMethod === OffsetMethod.POSITION\n ? this._getScrollTop() : 0\n\n this._offsets = []\n this._targets = []\n\n this._scrollHeight = this._getScrollHeight()\n\n const targets = $.makeArray($(this._selector))\n\n targets\n .map((element) => {\n let target\n const targetSelector = Util.getSelectorFromElement(element)\n\n if (targetSelector) {\n target = $(targetSelector)[0]\n }\n\n if (target) {\n const targetBCR = target.getBoundingClientRect()\n if (targetBCR.width || targetBCR.height) {\n // TODO (fat): remove sketch reliance on jQuery position/offset\n return [\n $(target)[offsetMethod]().top + offsetBase,\n targetSelector\n ]\n }\n }\n return null\n })\n .filter((item) => item)\n .sort((a, b) => a[0] - b[0])\n .forEach((item) => {\n this._offsets.push(item[0])\n this._targets.push(item[1])\n })\n }\n\n dispose() {\n $.removeData(this._element, DATA_KEY)\n $(this._scrollElement).off(EVENT_KEY)\n\n this._element = null\n this._scrollElement = null\n this._config = null\n this._selector = null\n this._offsets = null\n this._targets = null\n this._activeTarget = null\n this._scrollHeight = null\n }\n\n // Private\n\n _getConfig(config) {\n config = {\n ...Default,\n ...typeof config === 'object' && config ? config : {}\n }\n\n if (typeof config.target !== 'string') {\n let id = $(config.target).attr('id')\n if (!id) {\n id = Util.getUID(NAME)\n $(config.target).attr('id', id)\n }\n config.target = `#${id}`\n }\n\n Util.typeCheckConfig(NAME, config, DefaultType)\n\n return config\n }\n\n _getScrollTop() {\n return this._scrollElement === window\n ? this._scrollElement.pageYOffset : this._scrollElement.scrollTop\n }\n\n _getScrollHeight() {\n return this._scrollElement.scrollHeight || Math.max(\n document.body.scrollHeight,\n document.documentElement.scrollHeight\n )\n }\n\n _getOffsetHeight() {\n return this._scrollElement === window\n ? window.innerHeight : this._scrollElement.getBoundingClientRect().height\n }\n\n _process() {\n const scrollTop = this._getScrollTop() + this._config.offset\n const scrollHeight = this._getScrollHeight()\n const maxScroll = this._config.offset +\n scrollHeight -\n this._getOffsetHeight()\n\n if (this._scrollHeight !== scrollHeight) {\n this.refresh()\n }\n\n if (scrollTop >= maxScroll) {\n const target = this._targets[this._targets.length - 1]\n\n if (this._activeTarget !== target) {\n this._activate(target)\n }\n return\n }\n\n if (this._activeTarget && scrollTop < this._offsets[0] && this._offsets[0] > 0) {\n this._activeTarget = null\n this._clear()\n return\n }\n\n for (let i = this._offsets.length; i--;) {\n const isActiveTarget = this._activeTarget !== this._targets[i] &&\n scrollTop >= this._offsets[i] &&\n (typeof this._offsets[i + 1] === 'undefined' ||\n scrollTop < this._offsets[i + 1])\n\n if (isActiveTarget) {\n this._activate(this._targets[i])\n }\n }\n }\n\n _activate(target) {\n this._activeTarget = target\n\n this._clear()\n\n let queries = this._selector.split(',')\n // eslint-disable-next-line arrow-body-style\n queries = queries.map((selector) => {\n return `${selector}[data-target=\"${target}\"],` +\n `${selector}[href=\"${target}\"]`\n })\n\n const $link = $(queries.join(','))\n\n if ($link.hasClass(ClassName.DROPDOWN_ITEM)) {\n $link.closest(Selector.DROPDOWN).find(Selector.DROPDOWN_TOGGLE).addClass(ClassName.ACTIVE)\n $link.addClass(ClassName.ACTIVE)\n } else {\n // Set triggered link as active\n $link.addClass(ClassName.ACTIVE)\n // Set triggered links parents as active\n // With both