Compare commits

...

199 Commits

Author SHA1 Message Date
Chris Witterholt 2a480e8c3e Merge branch 'release-9.21' into 'master'
Release 0.9.21.

See merge request openlp/web-remote!117
2024-05-18 17:39:42 +00:00
Chris Witterholt 38149d2279
Release 0.9.21. 2024-05-18 19:33:53 +02:00
Tim Bentley ed39a01b67 Merge branch 'add-missing-translation-english-autodetect' into 'master'
Add missing translation English autodetect

See merge request openlp/web-remote!116
2024-05-18 12:41:33 +00:00
Chris Witterholt f4f3d7b444 Add missing translation English autodetect 2024-05-18 12:41:33 +00:00
Chris Witterholt 7020ae0bde Merge branch 'release-9.20' into 'master'
Release 0.9.20.

See merge request openlp/web-remote!115
2024-05-16 12:51:19 +00:00
Chris Witterholt a58c13d82d
Release 0.9.20. 2024-05-16 07:47:34 +02:00
Chris Witterholt 55610e623f Merge branch 'update-angular' into 'master'
Update several packages including Angular.

See merge request openlp/web-remote!114
2024-05-16 05:34:00 +00:00
Chris Witterholt 06a172fcc1
Update several packages including Angular. 2024-05-16 07:27:07 +02:00
Chris Witterholt b29cad5225 Merge branch 'force-capitalization' into 'master'
Force capitalization in translations.

See merge request openlp/web-remote!113
2024-05-07 05:46:40 +00:00
Chris Witterholt 1144564c2c Force capitalization in translations. 2024-05-07 05:46:40 +00:00
Chris Witterholt bf185b0f92 Merge branch 'release-9.19' into 'master'
Release 0.9.19.

See merge request openlp/web-remote!110
2024-04-27 17:05:30 +00:00
Raoul Snyman 40c4d9e363 Merge branch 'remove-i18n-comments' into 'master'
Remove comments before pushing translation source

See merge request openlp/web-remote!112
2024-04-27 17:00:08 +00:00
Raoul Snyman 4a25c28f29 Remove comments before pushing translation source 2024-04-27 09:55:12 -07:00
Chris Witterholt 7b245d248b
Release 0.9.19. 2024-04-27 09:54:13 +02:00
Chris Witterholt da749d7ec8 Merge branch 'add-i18n-source-push' into 'master'
Automatically push up newest source strings to Transifex

See merge request openlp/web-remote!109
2024-04-27 07:40:00 +00:00
Raoul Snyman 08d097dd0a Automatically push up newest source strings to Transifex 2024-04-27 07:39:59 +00:00
Raoul Snyman fcc1f543d0 Merge branch 'dont-install-dev-deps' into 'master'
Don't install dev dependencies when building

See merge request openlp/web-remote!103
2024-04-26 15:54:19 +00:00
Raoul Snyman 4fd8fe0f54 Don't install dev dependencies when building 2024-04-26 15:54:18 +00:00
Chris Witterholt f657a24e99 Merge branch 'refactor-tests' into 'master'
Refactor tests.

See merge request openlp/web-remote!108
2024-04-25 19:35:14 +00:00
Chris Witterholt 037d0ccb40
Refactor tests. 2024-04-25 21:31:35 +02:00
Chris Witterholt ff8c01c874 Merge branch 'update-angular' into 'master'
Update Angular.

See merge request openlp/web-remote!107
2024-04-25 19:11:35 +00:00
Chris Witterholt 86d85870e9
Update Angular. 2024-04-25 21:07:48 +02:00
Chris Witterholt a6b4ea23c8 Merge branch 'refactor-login-component' into 'master'
Refactor login component.

See merge request openlp/web-remote!106
2024-04-25 18:55:52 +00:00
Chris Witterholt 75c11d4543
Refactor login component. 2024-04-25 20:52:04 +02:00
Chris Witterholt ece665dbf2 Merge branch 'fix-live-button' into 'master'
Fix the live button.

See merge request openlp/web-remote!105
2024-04-25 18:32:58 +00:00
Chris Witterholt b62f21bae0
Fix the live button. 2024-04-25 20:28:53 +02:00
Chris Witterholt 18ba69088b Merge branch 'add-missing-translations' into 'master'
Add missing translations.

See merge request openlp/web-remote!104
2024-04-25 04:06:46 +00:00
Chris Witterholt 89c908ef4a Add missing translations. 2024-04-25 04:06:46 +00:00
Chris Witterholt ee9392cc01 Merge branch 'use-angular17-template-syntax' into 'master'
Use the new Angular 17 template syntax and property 'styleUrl'

See merge request openlp/web-remote!102
2024-04-24 16:44:06 +00:00
Chris Witterholt ea39bbc3d7 Use the new Angular 17 template syntax and property 'styleUrl' 2024-04-24 16:44:06 +00:00
Raoul Snyman 544b400829 Merge branch 'update-translations' into 'master'
Update translations from Transifex

See merge request openlp/web-remote!101
2024-04-20 22:12:30 +00:00
Raoul Snyman db8202983f Update translations from Transifex 2024-04-19 23:13:27 -07:00
Raoul Snyman ce9fa57d9e Merge branch 'update-arg-parsing' into 'master'
Some minor updates

See merge request openlp/web-remote!100
2024-04-20 05:36:11 +00:00
Raoul Snyman a6f348d71b Some minor updates 2024-04-20 05:36:11 +00:00
Raoul Snyman 55cccff3ed Merge branch 'integrate-with-transifex' into 'master'
Create a script to upload/download translations from Transifex

See merge request openlp/web-remote!98
2024-04-19 23:30:08 +00:00
Raoul Snyman 3be49dbd74 Create a script to upload/download translations from Transifex 2024-04-19 11:35:14 -07:00
Chris Witterholt ed3b71ef9e Merge branch 'update-packages' into 'master'
Update several packages including Angular.

See merge request openlp/web-remote!99
2024-04-19 16:12:30 +00:00
Chris Witterholt 5a9e1b56ac Update several packages including Angular. 2024-04-19 16:12:29 +00:00
Chris Witterholt 5550648d20 Merge branch 'correct-file-name-of-english-translation' into 'master'
Correct file name of English translation.

See merge request openlp/web-remote!97
2024-04-16 16:55:53 +00:00
Chris Witterholt 0fdcbff737
Correct file name of English translation. 2024-04-16 18:51:47 +02:00
Chris Witterholt ce064facda Merge branch 'release-9.18' into 'master'
Release 0.9.18.

See merge request openlp/web-remote!96
2024-04-15 17:16:19 +00:00
Chris Witterholt fe07f78415
Release 0.9.18. 2024-04-15 19:11:31 +02:00
Chris Witterholt 4e09f0a08d Merge branch 'remove-hammerjs' into 'master'
Remove Hammerjs.

See merge request openlp/web-remote!95
2024-04-15 16:12:06 +00:00
Chris Witterholt 2e58a547ac
Remove Hammerjs. 2024-04-15 17:42:25 +02:00
Chris Witterholt 87aefcdbf0 Merge branch 'workaround-for-not-found-english-translation-file' into 'master'
Add workaround for missing default English translation file when using Web API 2.4 or older.

See merge request openlp/web-remote!94
2024-04-15 15:24:28 +00:00
Chris Witterholt b44be59d06 Add workaround for missing default English translation file when using Web API 2.4 or older. 2024-04-15 15:24:28 +00:00
Chris Witterholt a255c0f00e Merge branch 'release-9.17' into 'master'
Release 0.9.17.

See merge request openlp/web-remote!93
2024-04-14 18:10:16 +00:00
Chris Witterholt 64b4713e43
Release 0.9.17. 2024-04-14 20:05:14 +02:00
Chris Witterholt f34830dee6 Merge branch 'workaround-for-not-found-english-translation-file' into 'master'
Add workaround for missing default English translation file when using Web API 2.4 or older.

See merge request openlp/web-remote!92
2024-04-14 18:01:48 +00:00
Chris Witterholt c9ff11dd58
Add workaround for missing default English translation file when using Web API 2.4 or older. 2024-04-14 19:14:22 +02:00
Chris Witterholt f0554b8e53 Merge branch 'correct-slovenian-translation' into 'master'
Correct Slovenian translation

See merge request openlp/web-remote!91
2024-04-14 13:13:39 +00:00
Chris Witterholt 93a7786d8a Correct Slovenian translation 2024-04-14 13:13:38 +00:00
Chris Witterholt 6bc707dc46 Merge branch 'upload-to-new-server' into 'master'
Change the server we upload releases to

See merge request openlp/web-remote!90
2024-04-13 18:09:33 +00:00
Raoul Snyman fcbc845ae6 Change the server we upload releases to 2024-04-13 11:05:48 -07:00
Raoul Snyman 3ad18e5427 Merge branch 'release-9.16' into 'master'
Release 0.9.16.

See merge request openlp/web-remote!89
2024-04-13 17:47:53 +00:00
Chris Witterholt 0ef9e8fb2a
Release 0.9.16. 2024-04-13 19:43:59 +02:00
Chris Witterholt f0227e049a Merge branch 'add-missing-translations' into 'master'
Add missing translations

See merge request openlp/web-remote!88
2024-04-13 17:02:44 +00:00
Chris Witterholt a14eb0d1c6 Add missing translations 2024-04-13 17:02:44 +00:00
Chris Witterholt dc253e74ec Merge branch 'add-missing-translations' into 'master'
Add missing translations

See merge request openlp/web-remote!87
2024-04-12 19:10:55 +00:00
Chris Witterholt 9d9df01d3e Add missing translations 2024-04-12 19:10:55 +00:00
Chris Witterholt bf0e14caee Merge branch 'use-translation-for-configured-language' into 'master'
Make use of the configured language in OpenLP in order to use translations in Web Remote

See merge request openlp/web-remote!86
2024-04-11 18:04:54 +00:00
Chris Witterholt 5d674aa3be Make use of the configured language in OpenLP in order to use translations in Web Remote 2024-04-11 18:04:54 +00:00
Chris Witterholt 1b36340d57 Merge branch 'release-9.15' into 'master'
Release 0.9.15.

See merge request openlp/web-remote!85
2024-04-08 18:26:53 +00:00
Chris Witterholt 30f44cb543
Release 0.9.15. 2024-04-08 20:22:36 +02:00
Chris Witterholt 4104033b4b Merge branch 'larger-display-buttons' into 'master'
Change display of buttons.

See merge request openlp/web-remote!81
2024-04-08 14:32:29 +00:00
Chris Witterholt dff8c49a81 Change display of buttons. 2024-04-08 14:32:29 +00:00
Chris Witterholt bdfd1c1ab1 Merge branch 'release-9.14' into 'master'
Release 0.9.14.

See merge request openlp/web-remote!84
2024-04-02 14:10:47 +00:00
Chris Witterholt 80c54bee88 Release 0.9.14. 2024-04-02 14:10:47 +00:00
Raoul Snyman 01724bb136 Merge branch 'use-configured-shortcuts' into 'master'
Make use of the configured shortcuts in OpenLP.

See merge request openlp/web-remote!83
2024-04-02 04:54:12 +00:00
Chris Witterholt 7ad8daff62 Make use of the configured shortcuts in OpenLP. 2024-04-02 04:54:12 +00:00
Raoul Snyman ed9db1d295 Merge branch 'scroll-to-active-slide' into 'master'
Make sure that all the clients auto scroll to the selected slide.

See merge request openlp/web-remote!82
2024-03-25 14:55:49 +00:00
Chris Witterholt b92175d024 Make sure that all the clients auto scroll to the selected slide. 2024-03-25 14:55:48 +00:00
Chris Witterholt 2346fdc62a Merge branch 'update-karma' into 'master'
Update Karma.

See merge request openlp/web-remote!80
2024-03-16 08:37:22 +00:00
Chris Witterholt cb536702e1
Update Karma. 2024-03-16 09:33:04 +01:00
Raoul Snyman 07086a39fa Merge branch 'release-9.13' into 'master'
Release 0.9.13.

See merge request openlp/web-remote!79
2024-03-06 15:25:55 +00:00
Chris Witterholt 551e8dca13 Release 0.9.13. 2024-03-06 15:25:54 +00:00
Raoul Snyman cd29766127 Merge branch 'change-detection-of-unsupported-browser' into 'master'
Change detection of unsupported browser

See merge request openlp/web-remote!78
2024-03-06 05:30:08 +00:00
Chris Witterholt cbc8cd260e Change detection of unsupported browser 2024-03-06 05:30:08 +00:00
Tim Bentley 2833875e19 Merge branch 'release-9.12' into 'master'
Release 0.9.12.

See merge request openlp/web-remote!77
2024-02-29 12:53:20 +00:00
Chris Witterholt 07462f98c1 Release 0.9.12. 2024-02-29 12:53:20 +00:00
Raoul Snyman a5753112af Merge branch 'show-browser-not-supported-message' into 'master'
Display a message to inform the user to download and install an older version...

See merge request openlp/web-remote!76
2024-02-27 22:04:40 +00:00
Chris Witterholt 20aea00c85 Display a message to inform the user to download and install an older version... 2024-02-27 22:04:40 +00:00
Chris Witterholt 6bc5dc3c91 Merge branch 'release-9.11' into 'master'
1. Change version. 2. Update CHANGELOG.

See merge request openlp/web-remote!75
2024-02-15 16:04:29 +00:00
Chris Witterholt 6ba817de63 1. Change version. 2. Update CHANGELOG. 2024-02-15 16:04:27 +00:00
Raoul Snyman 8837bd2d93 Merge branch 'update-to-angular-v17' into 'master'
Update to Angular v17 and update other packages as well.

See merge request openlp/web-remote!74
2024-02-14 03:42:36 +00:00
Chris Witterholt 3d475fd5fc Update to Angular v17 and update other packages as well. 2024-02-14 03:42:35 +00:00
Chris Witterholt 96adf2c86f Merge branch 'update-to-angular-v16' into 'master'
Update to Angular v16

See merge request openlp/web-remote!73
2024-02-08 19:37:40 +00:00
Chris Witterholt 24e25ba872 Update to Angular v16 2024-02-08 19:37:40 +00:00
Chris Witterholt b70bd51ecf Merge branch 'add-tab-panel' into 'master'
Add tab panel element.

See merge request openlp/web-remote!72
2024-02-07 15:26:15 +00:00
Chris Witterholt 4b7212959d
Add tab panel element. 2024-02-05 12:28:09 +01:00
Tim Bentley 15c27ed23a Merge branch 'fix-audit-20230519' into 'master'
Fix vulnerabilities highlighted by audit

See merge request openlp/web-remote!70
2023-05-19 18:35:25 +00:00
Raoul Snyman 7709ead085 Fix vulnerabilities highlighted by audit 2023-05-19 10:18:26 -07:00
Tim Bentley f0109844ab Merge branch 'use-gitlab-registry' into 'master'
Use GitLab's registry instead of Docker Hub

See merge request openlp/web-remote!69
2023-05-19 16:58:56 +00:00
Raoul Snyman b2da690563 Merge branch 'add-message-websocket-endpoint' into 'master'
Using new /messages websocket endpoint if available

See merge request openlp/web-remote!68
2023-05-19 16:28:13 +00:00
Mateus Meyer Jiacomelli b85900e8ec Using new /messages websocket endpoint if available 2023-05-19 16:28:12 +00:00
Raoul Snyman 7c7a0bd024 Use GitLab's registry instead of Docker Hub 2023-05-19 09:27:18 -07:00
Raoul Snyman ecd6b6f88f Merge branch 'changelog-0.9.10' into 'master'
Add changes for release 0.9.10 to change log.

See merge request openlp/web-remote!67
2023-05-02 20:44:35 +00:00
Chris Witterholt 181fd67035
Add changes for release 0.9.10 to change log. 2023-05-02 22:23:50 +02:00
Raoul Snyman a78f485a1d Merge branch 'allow-audit-failures' into 'master'
Allow the audit step to fail without failing the entire build

See merge request openlp/web-remote!66
2023-05-02 06:03:53 +00:00
Raoul Snyman 2b9b985ba8 Allow the audit step to fail without failing the entire build 2023-05-01 22:22:27 -07:00
Raoul Snyman e068db00db Merge branch 'issue-15-display-next-service-item-stageview' into 'master'
Display the next service item in Stage View.

See merge request openlp/web-remote!64
2023-05-02 05:14:43 +00:00
Chris Witterholt 5691809791 Display the next service item in Stage View. 2023-05-02 05:14:43 +00:00
Raoul Snyman ead7179a96 Merge branch 'issue-41-remove-protractor' into 'master'
Remove Protractor.

See merge request openlp/web-remote!65
2023-05-02 04:34:53 +00:00
Chris Witterholt 605cfd4a13 Remove Protractor. 2023-05-02 04:34:53 +00:00
Chris Witterholt 87159eb637 Update .gitlab-ci.yml file: integrate audit job in test stage. 2023-04-28 18:29:21 +00:00
Chris Witterholt c9858eda89 Update .gitlab-ci.yml file: don't stop when "yarn audit" reports vulnerabilities. 2023-04-28 18:25:07 +00:00
Chris Witterholt 4c1f4fb2e3 Update .gitlab-ci.yml file: add audit stage. 2023-04-28 18:13:53 +00:00
Mateus Meyer Jiacomelli 555519faac Merge branch 'issue-40-missing-tooltips-stageview' into 'master'
Make sure tooltips are visible in Stage View.

See merge request openlp/web-remote!63
2023-04-27 13:39:13 +00:00
Chris Witterholt c2d45869e8
Make sure tooltips are visible in Stage View. 2023-04-24 12:34:17 +02:00
Mateus Meyer Jiacomelli 21fdfec9ac Merge branch 'issue-37-missing-scroll-to-active-slide' into 'master'
Use auto scroll when moving to next or previous slide.

See merge request openlp/web-remote!62
2023-03-04 01:34:44 +00:00
Chris Witterholt 911966ae1f Use auto scroll when moving to next or previous slide. 2023-03-04 01:34:44 +00:00
Mateus Meyer Jiacomelli 0e9567fc76 Merge branch 'small-adjustments' into 'master'
Various small adjustments

Closes #28 e #29

See merge request openlp/web-remote!60
2023-03-01 03:19:16 +00:00
Mateus Meyer Jiacomelli 1ac5016974 Various small adjustments 2023-03-01 03:19:16 +00:00
Raoul Snyman 6b75b00319 Merge branch 'master' into 'master'
lower thirds/green screen view

See merge request openlp/web-remote!58
2023-02-16 16:29:08 +00:00
Jenda e19187d281 lower thirds/green screen view 2023-02-16 16:29:08 +00:00
Raoul Snyman d29eddb781 Merge branch 'fix-login' into 'master'
Reinserting the login button

Closes #34

See merge request openlp/web-remote!59
2023-02-16 16:25:30 +00:00
Mateus Meyer Jiacomelli d33f131479 Reinserting the login button 2023-02-16 16:25:30 +00:00
Raoul Snyman ca9ca91ac9 Merge branch 'add-changelog' into 'master'
Add a changelog

See merge request openlp/web-remote!56
2023-02-11 05:51:17 +00:00
Raoul Snyman 23b0203813 Add a changelog 2023-02-10 22:45:18 -07:00
Raoul Snyman 4eab9a080a Merge branch 'settings-section' into 'master'
Adding Font Scaling support to Stage and Chord View + Creating Settings Page

See merge request openlp/web-remote!54
2023-02-10 18:55:09 +00:00
Raoul Snyman 84b452e5a7 Merge branch 'use-new-service-item-api-transpose-endpoint-fix' into 'master'
Fixing small bug in !53

See merge request openlp/web-remote!55
2023-02-10 18:51:05 +00:00
Mateus Meyer Jiacomelli 6fee673ce4 lint fix 2023-02-09 16:00:56 -03:00
Mateus Meyer Jiacomelli 66f60aa358 Fixing small bug in !53 2023-02-09 15:58:21 -03:00
Mateus Meyer Jiacomelli 31c6736c54 Merge branch 'master' into settings-section 2023-02-09 15:52:03 -03:00
Mateus Meyer Jiacomelli 1feaacaa28 Creating Settings Service + Settings Page; moving Fast Switching to there; adding CSS + Component support + Setting to allow Stage and Chords font scaling 2023-02-09 15:31:24 -03:00
Raoul Snyman 460df54a7d Merge branch 'use-new-service-item-api-transpose-endpoint' into 'master'
Using the new response_format=service_item on Transpose API Endpoint while preserving old behavior

See merge request openlp/web-remote!53
2023-02-09 17:08:26 +00:00
Mateus Meyer Jiacomelli b37470bf5c Fixing api version checks and 2.2 workaround 2023-02-08 19:01:02 -03:00
Mateus Meyer Jiacomelli 5d71d1c3ed Using the new response_format=service_item on Transpose API Endpoint while preserving old behavior 2023-02-08 18:40:40 -03:00
Raoul Snyman 265aafa67d Merge branch 'issue-33-missing-hotkeys' into 'master'
Fix linting issues.

See merge request openlp/web-remote!52
2023-02-08 17:41:37 +00:00
Chris Witterholt ec9515d692
Fix linting issues. 2023-02-07 20:20:01 +01:00
Raoul Snyman b0a8dd2b27 Merge branch 'issue-33-missing-hotkeys' into 'master'
Issue 33: Shortcut keys (hotkeys) in Web Remote

See merge request openlp/web-remote!51
2023-02-07 18:10:14 +00:00
Chris Witterholt 8e2caacd36
Add hot keys 2023-02-07 17:05:38 +01:00
Raoul Snyman 2b8ffdc075 Merge branch 'issue-30-image-width' into 'master'
Fix presentation image width

Closes #30

See merge request openlp/web-remote!50
2023-01-24 22:29:08 +00:00
Martin Price d2b0afeff6 Fix presentation image width 2023-01-23 20:32:34 +00:00
Raoul Snyman 7fa3e88dc8 Merge branch 'fix-gitlab-build' into 'master'
Fix up some build issues before they happen

See merge request openlp/web-remote!49
2022-12-31 21:48:15 +00:00
Raoul Snyman d50501cddc Fix up some build issues before they happen 2022-12-31 13:42:21 -07:00
Raoul Snyman 11fc8bedaa Merge branch 'fix-owndisplay-items-errors' into 'master'
Fixing media + images on Chord and Stage View + refactoring individal slides of Chord and Stage

See merge request openlp/web-remote!48
2022-12-21 19:51:50 +00:00
Mateus Meyer Jiacomelli 8503d57e31 Fixing media + images on Chord and Stage View + refactoring individal slides of Chord and Stage 2022-12-21 19:51:50 +00:00
Raoul Snyman a54ff79ca1 Merge branch 'update-dependencies' into 'master'
Update dependencies

See merge request openlp/web-remote!47
2022-12-21 16:27:47 +00:00
Daniel Martin cec351f2fa Update dependencies 2022-12-21 16:27:47 +00:00
Raoul Snyman cde10571a7 Merge branch 'chord-stage-view-adjustments' into 'master'
Remaking the chordpro routines and the chord + stage viewer

See merge request openlp/web-remote!46
2022-12-17 07:09:43 +00:00
Mateus Meyer Jiacomelli 9bd4d6aec6 Remaking the chordpro routines and the chord + stage viewer 2022-12-17 07:09:43 +00:00
Tim Bentley b319373541 Merge branch 'fix-stage-sticky-navbar' into 'master'
Fixing the new sticky navbar overlapping Stage, Chord and Main views

See merge request openlp/web-remote!45
2022-12-15 17:30:46 +00:00
Mateus Meyer Jiacomelli 26d39760db Fixing the new sticky navbar overlapping Stage, Chord and Main views 2022-12-15 17:30:46 +00:00
Tim Bentley b9d3f3caab Merge branch 'issue-26-22' into 'master'
Fixes for issues 26 and 22

Closes #22 and #26

See merge request openlp/web-remote!44
2022-12-01 14:22:48 +00:00
Raoul Snyman 2ad25d163a Fixes for issues 26 and 22 2022-12-01 14:22:48 +00:00
Raoul Snyman a83d129630 Merge branch 'websocket-dropout-fix' into 'master'
Websocket reconnection routines

See merge request openlp/web-remote!43
2022-11-14 16:17:51 +00:00
Mateus Meyer Jiacomelli 278aca6277 adding websocket reconnection routines + UI indicator; upgrading material-icons to use more recent package 2022-11-14 16:17:51 +00:00
Tim Bentley 75d0007239 Merge branch 'transpose-web-api' into 'master'
First step in using the new transpose web api endpoint.

See merge request openlp/web-remote!42
2022-10-19 11:51:04 +00:00
Tomas Groth 90d1da5090 First step in using the new transpose web api endpoint. 2022-10-19 11:51:03 +00:00
Tim Bentley a46d117649 Merge branch 'inject-version-number' into 'master'
Maintain the version number in assets/version.js

See merge request openlp/web-remote!40
2022-02-10 22:16:37 +00:00
Raoul Snyman 845b270e06 Maintain the version number in assets/version.js 2022-02-10 22:16:36 +00:00
Tim Bentley 62dfb688e7 Merge branch 'component-refactor' into 'master'
Service + Slide Component refactor

See merge request openlp/web-remote!39
2021-09-06 07:11:20 +00:00
Mateus Meyer Jiacomelli ff03ca521f Service + Slide Component refactor 2021-09-06 07:11:19 +00:00
Raoul Snyman b915f4004c Merge branch 'icon-fix' into 'master'
Fix theme icon in side-bar and footer

See merge request openlp/web-remote!38
2021-06-29 03:22:24 +00:00
Exkywor 35fb9149f0
Fix theme icon in side-bar and footer 2021-06-28 20:47:53 -06:00
Tim Bentley 7af4f684eb Merge branch 'issue-19-local-resources' into 'master'
Add Material icons and Roboto font for local resources (closes #19)

Closes #19

See merge request openlp/web-remote!37
2021-06-28 06:17:33 +00:00
Raoul Snyman ba1fbe8fc5 Merge branch 'master' into 'master'
Added thumbnails to Stage and Slides views

See merge request openlp/web-remote!36
2021-06-28 04:52:51 +00:00
Joe Schneider 03c76cd3ce Added thumbnails to Stage and Slides views 2021-06-28 04:52:51 +00:00
Raoul Snyman c5948a4111 Add Material icons and Roboto font for local resources (closes #19) 2021-06-27 21:44:50 -07:00
Raoul Snyman 96eed0785a Merge branch 'yorkshire-pudding-master-patch-10045' into 'master'
fixes #16 Text selectable in Web Remote Control v0.9.5 2.9.2

Closes #16

See merge request openlp/web-remote!35
2021-03-31 00:36:07 +00:00
Martin Price 9286f039af Update request to reflect suggestions by @ninjakiwi:
Added class for no-select in styles.  Added reference to class in slides.component.html and service.component.html as I believe you would never need to select the text in the remote controller here
2021-03-31 00:36:07 +00:00
Tim Bentley cdf03beb45 Merge branch 'fix_openlp_service_scope_for_stages' into 'master'
Fix typescript build error

See merge request openlp/web-remote!34
2021-03-02 07:45:19 +00:00
Daniel 36ca78ecc7 Set openlpService to public in stage js to make typescript happy 2021-03-02 19:50:29 +13:00
Tim Bentley 145329f65f Merge branch 'fix_24_hour_setting' into 'master'
Make stage views use the 12 hour time setting

Closes #14

See merge request openlp/web-remote!33
2021-02-22 17:30:53 +00:00
Daniel ed24a1e1f3 lint fix 2021-02-19 19:16:41 +13:00
Daniel 86c602b41b Make stage views use the 12 hour time setting 2021-02-19 19:09:28 +13:00
Raoul Snyman e6366ce12e Merge branch 'update_for_search_option_api' into 'master'
Fix api calls for new search option api

See merge request openlp/web-remote!32
2021-02-02 02:27:51 +00:00
Daniel Martin 2482cbb1b3 Fix api calls for new search option api 2021-02-02 02:27:51 +00:00
Raoul Snyman 2812e383e6 Merge branch 'theme-changes' into 'master'
Remove theme name and column controllers. Make themes into a fixed sized and the column responsive.

See merge request openlp/web-remote!31
2021-01-30 06:06:10 +00:00
Fernando Quant 16886d6e30 Remove theme name and column controllers. Make themes into a fixed sized and the column responsive. 2021-01-30 06:06:09 +00:00
Tomas Groth 341405e715 Merge branch 'update_angular' into 'master'
Update angular

Closes #11

See merge request openlp/web-remote!30
2021-01-03 18:05:10 +00:00
Daniel Martin 0b55941314 Update angular 2021-01-03 18:05:10 +00:00
Raoul Snyman d9997b93d1 Merge branch 'fix-build' into 'master'
Fix an issue where a method was not expecting a parameter

See merge request openlp/web-remote!29
2020-12-23 05:12:13 +00:00
Raoul Snyman 7a09dd85d6
Fix an issue where a method was not expecting a parameter 2020-12-22 21:49:31 -07:00
Raoul Snyman d8a81bd9ad Merge branch 'release-0.9.5' into 'master'
Release 0.9.5

See merge request openlp/web-remote!28
2020-12-23 04:08:26 +00:00
Raoul Snyman 066116398b
Release 0.9.5 2020-12-22 20:55:19 -07:00
Tomas Groth 29f9ba4366 Merge branch 'change_api' into 'master'
Change api signature

See merge request openlp/web-remote!26
2020-10-19 07:19:38 +00:00
Raoul Snyman 2f0cdae201 Merge branch 'search-selected-plugin' into 'master'
Store last selected search plugin and retrieve it after loading.

See merge request openlp/web-remote!27
2020-10-17 05:30:37 +00:00
Exkywor 2d45c33dc7 Store last selected search plugin and retrieve it after loading. 2020-10-14 12:43:34 -06:00
Tim b22df76bd4
Change signature 2020-10-12 12:03:26 +01:00
Raoul Snyman 5169db2d0d Merge branch 'themes-thumbnails' into 'master'
Themes thumbnails

See merge request openlp/web-remote!25
2020-08-27 05:07:14 +00:00
Fernando Quant b33cf7e781 Themes thumbnails 2020-08-27 05:07:14 +00:00
Raoul Snyman fae84f278b Merge branch 'bibleversionapi' into 'master'
Bible search options

See merge request openlp/web-remote!23
2020-08-27 04:55:12 +00:00
Fernando Quant e1de5c329e Added retrieval and set of search options for the Bibles plugin, allowing to change the Bible version from the remote 2020-08-27 04:55:11 +00:00
Tim Bentley 5df672eda0 Merge branch 'add-notes-to-stage-view' into 'master'
Add notes to stage view, fix some issues

See merge request openlp/web-remote!24
2020-07-25 20:57:41 +00:00
Raoul Snyman 2d10b7a363
Add notes to stage, fix CORS issue
- Use a ServiceItem object rather than just slides to transfer more information at one time
- Add notes to stage view
- Fix CORS issue
2020-07-22 16:41:10 -07:00
Raoul Snyman 29041b7f1e Merge branch 'fix_chord_view' into 'master'
Fix chord view

Closes #10

See merge request openlp/web-remote!22
2020-06-22 00:35:06 +00:00
Daniel 4cff5fbc47 Fix chord view 2020-06-22 00:35:06 +00:00
Tim Bentley 7ad0d39871 Merge branch 'websockets' into 'master'
Update the State Management code

See merge request openlp/web-remote!21
2020-06-20 07:25:21 +00:00
Tim Bentley 2d8ee13fc7 Update the State Management code 2020-06-20 07:25:20 +00:00
Raoul Snyman 24c74ab3c9 Merge branch 'valid' into 'master'
Block requesting invalid Service items from UI.

See merge request openlp/web-remote!20
2020-05-20 17:04:53 +00:00
Tim Bentley ad75ca76ee Block requesting invalid Service items from UI. 2020-05-20 17:04:52 +00:00
Tim Bentley 22a1a1cab3 Merge branch 'issue-8' into 'master'
Fix up some issues in the themes component

Closes #8

See merge request openlp/web-remote!18
2020-05-16 16:45:20 +00:00
Raoul Snyman ac4a99d75e
Improve the UI and fix some bugs
- Get the current theme from OpenLP
- Set the service item by UUID
- Actually set the theme and theme level
- Clean up various bits of code
- Switch to a tab bar at the bottom of the screen
- Save the "fast switching" state to localStorage for persistance
- Clean up some API bits to match OpenLP
2020-05-15 23:03:28 -07:00
Tomas Groth 83747dc1cf Merge branch 'text' into 'master'
Minor text change

See merge request openlp/web-remote!19
2020-05-13 18:15:02 +00:00
Tim 9f665256d6
fix text 2020-05-10 17:43:46 +01:00
Tim Bentley 4e3567864a Merge branch 'fix_main_view_scale' into 'master'
Fix main view scale and positioning

Closes #9

See merge request openlp/web-remote!17
2020-04-19 08:41:23 +00:00
Daniel 4f694dd71a Fix main view scale and positioning 2020-04-17 16:27:07 +12:00
143 changed files with 13279 additions and 6887 deletions

4
.browserslistrc Normal file
View File

@ -0,0 +1,4 @@
# After changes are made to this file run the script 'supportedBrowsers' to update the JS file,
# see: https://www.npmjs.com/package/browserslist-useragent-regexp?activeTab=readme
defaults
fully supports resizeobserver

95
.eslintrc.json Normal file
View File

@ -0,0 +1,95 @@
{
"root": true,
"ignorePatterns": [
"projects/**/*"
],
"overrides": [
{
"files": [
"*.ts"
],
"parserOptions": {
"project": [
"tsconfig.json",
"e2e/tsconfig.json"
],
"createDefaultProgram": true
},
"extends": [
"plugin:@typescript-eslint/recommended",
"plugin:@angular-eslint/recommended",
"plugin:@angular-eslint/template/process-inline-templates"
],
"rules": {
"@typescript-eslint/consistent-type-definitions": "error",
"@typescript-eslint/dot-notation": "off",
"@typescript-eslint/explicit-member-accessibility": [
"off",
{
"accessibility": "explicit"
}
],
"@typescript-eslint/member-ordering": [
"error",
{
"default": [
"static-field",
"instance-field",
"static-method",
"instance-method"
]
}
],
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": ["app", "openlp"],
"style": "kebab-case"
}
],
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": ["app", "openlp"],
"style": "camelCase"
}
],
"@angular-eslint/no-empty-lifecycle-method": "off",
"@typescript-eslint/naming-convention": [
"error",
{
"selector": ["variable"],
"modifiers": ["readonly"],
"format": ["UPPER_CASE"]
}
],
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-explicit-any": "off",
"jsdoc/no-types": [
"off"
],
"prefer-arrow/prefer-arrow-functions": [
"off"
],
"brace-style": "off",
"@typescript-eslint/brace-style": [
"off"
],
"id-blacklist": "off",
"id-match": "off",
"no-underscore-dangle": "off"
}
},
{
"files": [
"*.html"
],
"extends": [
"plugin:@angular-eslint/template/recommended"
],
"rules": {}
}
]
}

6
.gitignore vendored
View File

@ -21,10 +21,11 @@
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
.vscode/launch.json
!.vscode/extensions.json
# misc
/.nx
/.sass-cache
/connect.lock
/coverage
@ -33,7 +34,10 @@ npm-debug.log
yarn-error.log
testem.log
/typings
/.angular/cache
# System Files
.DS_Store
Thumbs.db
src/assets/version.js

View File

@ -1,54 +1,97 @@
image: openlp/angular
image: $CI_REGISTRY/openlp/runners/angular
stages:
- test
- build
- deploy
audit:
stage: test
script:
- yarn install
- yarn audit
rules:
- when: always
allow_failure: true
lint:
stage: test
script:
- yarn install
- yarn lint
except:
- tags
rules:
- when: always
test:
stage: test
script:
- yarn install
- yarn test --no-progress --no-watch --browsers=ChromiumHeadlessCI
except:
- tags
rules:
- when: always
build:
push-i18n-source:
stage: build
script:
- yarn install
- yarn build --no-progress --prod --aot
- apk add npm
- npm install @transifex/api
- npm run tx push
rules:
- changes:
- src/assets/en.json
build-branch:
stage: build
script:
# Temporarily install npm manually until we get it added to the runner image
- apk add npm
- yarn install --production
- npm install @angular/cli
- yarn build --no-progress --configuration production --aot
- export APP_VERSION=`git describe --dirty --tags --long --match '*[0-9]*'`
- 'echo "window.appVersion = \"$APP_VERSION\";" > dist/web-remote/assets/version.js'
artifacts:
paths:
- dist/
only:
- tags
rules:
- if: $CI_COMMIT_BRANCH != "master"
build-tag:
stage: build
script:
# Temporarily install npm manually until we get it added to the runner image
- apk add npm
- yarn install --production
- npm install @angular/cli
- yarn build --no-progress --configuration production --aot
- 'echo "window.appVersion = \"$CI_COMMIT_TAG\";" > dist/web-remote/assets/version.js'
artifacts:
paths:
- dist/
rules:
- if: $CI_COMMIT_TAG
deploy:
stage: deploy
variables:
OPENLP_HOST: "new.openlp.io"
script:
- cd dist/web-remote
- zip -r ../../remote-$CI_COMMIT_TAG.zip *
- cd ../../
- export CHECK_SUM=`sha256sum remote-$CI_COMMIT_TAG.zip | cut -d' ' -f1`
- export FILE_SIZE=`stat -c '%s' remote-$CI_COMMIT_TAG.zip`
- 'echo -e "{\"latest\": {\"version\": \"$CI_COMMIT_TAG\", \"sha256\": \"$CHECK_SUM\", \"filename\": \"remote-$CI_COMMIT_TAG.zip\", \"size\": $FILE_SIZE}}" > version.json'
- 'echo -e "{\"latest\": {\"version\": \"$CI_COMMIT_TAG\", \"sha256\": \"$CHECK_SUM\", \"filename\": \"remote-$CI_COMMIT_TAG.zip\", \"size\": $FILE_SIZE}}" > version-$CI_COMMIT_TAG.json'
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- eval $(ssh-agent -s)
- ssh-add ~/.ssh/id_rsa
- '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
- ssh openlp@openlp.io "mkdir -p /home/openlp/sites/get.openlp.org/www/remote/$CI_COMMIT_TAG"
- scp remote-$CI_COMMIT_TAG.zip openlp@openlp.io:/home/openlp/sites/get.openlp.org/www/remote/$CI_COMMIT_TAG/
- scp version.json openlp@openlp.io:/home/openlp/sites/get.openlp.org/www/remote/
only:
- tags
- ssh openlp@$OPENLP_HOST "mkdir -p /home/openlp/sites/get.openlp.org/www/remote/$CI_COMMIT_TAG"
- scp remote-$CI_COMMIT_TAG.zip openlp@$OPENLP_HOST:/home/openlp/sites/get.openlp.org/www/remote/$CI_COMMIT_TAG/
- scp version-$CI_COMMIT_TAG.json openlp@$OPENLP_HOST:/home/openlp/sites/get.openlp.org/www/remote/
- ssh openlp@$OPENLP_HOST "rm /home/openlp/sites/get.openlp.org/www/remote/version.json"
- scp version-$CI_COMMIT_TAG.json openlp@$OPENLP_HOST:/home/openlp/sites/get.openlp.org/www/remote/version.json
rules:
- if: $CI_COMMIT_TAG

173
CHANGELOG.rst Normal file
View File

@ -0,0 +1,173 @@
Changes in OpenLP Web Remote
============================
Version 0.9.21
--------------
* Force capitalization in translations when version 3.1.2 or newer is installed.
* Updated zone.js.
Version 0.9.20
--------------
* Force capitalization in translations when version 3.1.2 or newer is installed.
* Updated dependencies including Angular.
Version 0.9.19
--------------
* Hook Web Remote up to Transifex regarding the translations.
* Add some missing translations.
* Make sure that the English translation will function in Web Remote when version 3.1.2 or newer is installed.
* The page title is not capitalized anymore.
* Prevent a TypeError in the browser console when Settings is displayed and there are no live items.
* Updated dependencies including Angular.
Version 0.9.18
--------------
* Prevent HTTP 404 error in browser console when using Web API 2.4 or older.
* Remove Hammerjs.
Version 0.9.17
--------------
* Add workaround for missing default English translation file when using Web API 2.4 or older.
* Correct Slovenian translation.
Version 0.9.16
--------------
* Make use of the configured language in OpenLP in order to use translations in Web Remote when version 3.1.2 or newer is installed.
* Updated dependencies including Angular.
Version 0.9.15
--------------
* Added a Web Remote setting to switch larger display buttons on or off (off by default).
* Updated dependencies including Angular.
Version 0.9.14
--------------
* Make sure that all the clients auto scroll to the selected slide.
* Make use of the configured shortcuts in OpenLP when version 3.1.2 or newer is installed.
* Updated dependencies including Angular.
Version 0.9.13
--------------
* Change detection of unsupported browser version.
* Display the instructions how to downgrade to a previous version when the browser is not supported.
Version 0.9.12
--------------
* Display a download link for previous releases instead of crashing when the browser is not supported.
* Minor update of Angular.
Version 0.9.11
--------------
* Updated dependencies including Angular.
Version 0.9.10
--------------
* Add a login button.
* Add lower thirds / green screen view.
* Use auto scroll when moving to next or previous slide.
* Add a "No items" placeholder on Slides and Service.
* Implement some small optimizations.
* Let the menu preserve it's position when opened.
* Make sure tooltips are visible in Stage View.
* Display the next service item in Stage View and Chord View.
* Remove package "Protractor".
Version 0.9.9
-------------
* Create a Settings page and service
* Move Fast Switching to Settings page
* Add CSS, Component support, and a Setting to allow Stage and Chords font scaling
* Fix API version check
* Add a workaround for a bug in new chord transposition endpoint in OpenLP 3.0.2 (API version 2.2)
* Using the new response_format=service_item on Transpose API Endpoint while preserving old behavior
* Add keyboard shortcuts (AKA hot keys) for most actions
* Fix presentation image width
Version 0.9.8
-------------
* Fix media and images on Chord and Stage views
* Refactoring individual slides of Chord and Stage views
* Update dependencies
* Refactor the ChordPro routines and the Chord and Stage views
* Fix the new sticky navbar overlapping Stage, Chord and Main views
* Make top bar sticky so that it is easier to access the menu
* Add WebSocket reconnection routines
* Add WebSocket UI indicator
* Upgrade to the latest Material icons
* Use the new transpose API endpoint
* Maintain the version number in assets/version.js
Version 0.9.7
-------------
* Refactor some internal components
* Fix theme icon in side-bar and footer
* Add thumbnails to Stage and Slides views
* Add Material icons and Roboto font for local resources (closes #19)
* Update some styling
Version 0.9.6
-------------
* Lint fix
* Make stage views use the 12 hour time setting
* Fix API calls for new search option API
* Remove theme name and column controllers
* Make themes into a fixed sized and the column responsive
* Update Angular
Version 0.9.5
-------------
* Store last selected search plugin and retrieve it after loading.
* Change a method signature
* Themes thumbnails
* Added retrieval and set of search options for the Bibles plugin, allowing to change the Bible version from the remote
* Use a ServiceItem object rather than just slides to transfer more information at one time
* Add notes to stage view
* Fix a CORS issue
Version 0.9.4
-------------
* Fix chord view
* Update the State Management code
* Block requesting invalid Service items from UI
Version 0.9.3
-------------
No official release
Version 0.9.2
-------------
* Resolve: "Cut off bottom of slides"
* Make live use correct websocket
* Remove the hash from the URLs. I'm not sure why it was added.
* Allow searching other plugins besides songs
Version 0.9.1
-------------
Initial development release:
- Re-implemented in Angular
- Uses web sockets instead of polling
- Material design
- Includes stage views

View File

@ -9,9 +9,9 @@
"sourceRoot": "src",
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"styleext": "scss"
}
"@schematics/angular:component": {
"style": "scss"
}
},
"architect": {
"build": {
@ -26,7 +26,9 @@
"src/assets"
],
"styles": [
"src/styles.scss"
"src/styles.scss",
"node_modules/material-icons/iconfont/filled.css",
"node_modules/@fontsource/roboto/400.css"
],
"scripts": []
},
@ -41,30 +43,41 @@
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "@openlp/web-remote:build"
"buildTarget": "@openlp/web-remote:build"
},
"configurations": {
"production": {
"browserTarget": "@openlp/web-remote:build:production"
"buildTarget": "@openlp/web-remote:build:production"
},
"development": {
"buildTarget": "@openlp/web-remote:build:development"
}
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "@openlp/web-remote:build"
"buildTarget": "@openlp/web-remote:build"
}
},
"test": {
@ -85,14 +98,11 @@
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"builder": "@angular-eslint/builder:lint",
"options": {
"tsConfig": [
"src/tsconfig.app.json",
"src/tsconfig.spec.json"
],
"exclude": [
"**/node_modules/**"
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
}
@ -102,24 +112,19 @@
"root": "e2e/",
"projectType": "application",
"architect": {
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "@openlp/web-remote:serve"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"builder": "@angular-eslint/builder:lint",
"options": {
"tsConfig": "e2e/tsconfig.e2e.json",
"exclude": [
"**/node_modules/**"
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
}
}
}
},
"defaultProject": "@openlp/web-remote"
"cli": {
"analytics": false
}
}

View File

@ -24,14 +24,42 @@ To run the web remote, run the following command:
.. code::
yarn run
yarn start
To build the web remote manually for deployment:
.. code::
yarn build --prod --aot
yarn build --aot
To lint the web remote:
.. code::
yarn lint
To audit the web remote:
.. code::
yarn audit
To run unit tests on the web remote using the Chrome browser:
.. code::
yarn test --browsers Chrome
To run unit tests on the web remote using the Microsoft Edge browser:
.. code::
yarn test --browsers Edge
Deployment

View File

@ -1,28 +0,0 @@
// Protractor configuration file, see link for more information
// https://github.com/angular/protractor/blob/master/lib/config.ts
const { SpecReporter } = require('jasmine-spec-reporter');
exports.config = {
allScriptsTimeout: 11000,
specs: [
'./src/**/*.e2e-spec.ts'
],
capabilities: {
'browserName': 'chrome'
},
directConnect: true,
baseUrl: 'http://localhost:4200/',
framework: 'jasmine',
jasmineNodeOpts: {
showColors: true,
defaultTimeoutInterval: 30000,
print: function() {}
},
onPrepare() {
require('ts-node').register({
project: require('path').join(__dirname, './tsconfig.e2e.json')
});
jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
}
};

View File

@ -1,14 +0,0 @@
import { AppPage } from './app.po';
describe('workspace-project App', () => {
let page: AppPage;
beforeEach(() => {
page = new AppPage();
});
it('should display welcome message', () => {
page.navigateTo();
expect(page.getParagraphText()).toEqual('Welcome to app!');
});
});

View File

@ -1,11 +0,0 @@
import { browser, by, element } from 'protractor';
export class AppPage {
navigateTo() {
return browser.get('/');
}
getParagraphText() {
return element(by.css('app-root h1')).getText();
}
}

View File

@ -2,8 +2,8 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/app",
"module": "commonjs",
"target": "es5",
"module": "CommonJS",
"target": "ES2022",
"types": [
"jasmine",
"jasminewd2",

View File

@ -1,15 +1,19 @@
{
"name": "@openlp/web-remote",
"version": "0.9.2",
"version": "0.9.21",
"description": "The web remote for OpenLP, written in Angular",
"keywords": ["OpenLP", "Angular", "Remote"],
"keywords": [
"OpenLP",
"Angular",
"Remote"
],
"homepage": "https://openlp.org/",
"bugs": "https://gitlab.com/openlp/web-remote",
"license": "GPL-3.0-or-later",
"author": "OpenLP Developers",
"repository": {
"type" : "git",
"url" : "https://gitlab.com/openlp/web-remote.git"
"type": "git",
"url": "https://gitlab.com/openlp/web-remote.git"
},
"scripts": {
"ng": "ng",
@ -17,44 +21,62 @@
"build": "ng build",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e"
"supportedBrowsers": "(echo module.exports = && browserslist-useragent-regexp --allowHigherVersions) > src/assets/supportedBrowsers.js",
"tx": "node scripts/tx.js"
},
"dependencies": {
"@angular/animations": "^8.2.8",
"@angular/cdk": "^8.2.1",
"@angular/common": "^8.2.8",
"@angular/compiler": "^8.2.8",
"@angular/core": "^8.2.8",
"@angular/forms": "^8.2.8",
"@angular/material": "^8.2.1",
"@angular/platform-browser": "^8.2.8",
"@angular/platform-browser-dynamic": "^8.2.8",
"@angular/router": "^8.2.8",
"core-js": "^3.2.1",
"hammerjs": "^2.0.8",
"rxjs": "^6.5.3",
"zone.js": "^0.9.1"
"@angular/animations": "^17.3.9",
"@angular/cdk": "^17.3.9",
"@angular/common": "^17.3.9",
"@angular/compiler": "^17.3.9",
"@angular/core": "^17.3.9",
"@angular/forms": "^17.3.9",
"@angular/material": "^17.3.9",
"@angular/platform-browser": "^17.3.9",
"@angular/platform-browser-dynamic": "^17.3.9",
"@angular/router": "^17.3.9",
"@fontsource/roboto": "^5.0.13",
"@ngx-translate/core": "^15.0.0",
"@ngx-translate/http-loader": "^8.0.0",
"core-js": "^3.37.1",
"material-icons": "^1.13.12",
"rxjs": "^7.8.1",
"zone.js": "^0.14.6"
},
"devDependencies": {
"@angular-devkit/build-angular": "~0.803.6",
"@angular/cli": "~8.3.6",
"@angular/compiler-cli": "^8.2.8",
"@angular/language-service": "^8.2.8",
"@types/jasmine": "~3.4.1",
"@types/jasminewd2": "~2.0.7",
"@types/node": "~12.7.8",
"codelyzer": "~5.1.2",
"jasmine-core": "~3.5.0",
"jasmine-spec-reporter": "~4.2.1",
"karma": "~4.3.0",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage-istanbul-reporter": "~2.1.0",
"karma-jasmine": "~2.0.1",
"karma-jasmine-html-reporter": "^1.4.2",
"protractor": "~5.4.2",
"ts-node": "~8.4.1",
"tslint": "~5.20.0",
"typescript": "~3.5.0"
"@angular-devkit/build-angular": "^17.3.7",
"@angular-eslint/builder": "^17.4.1",
"@angular-eslint/eslint-plugin": "^17.4.1",
"@angular-eslint/eslint-plugin-template": "^17.4.1",
"@angular-eslint/schematics": "^17.4.1",
"@angular-eslint/template-parser": "^17.4.1",
"@angular/cli": "~17.3.7",
"@angular/compiler-cli": "^17.3.9",
"@angular/language-service": "^17.3.9",
"@chiragrupani/karma-chromium-edge-launcher": "^2.3.1",
"@transifex/api": "^7.1.0",
"@types/jasmine": "~5.1.4",
"@types/jasminewd2": "~2.0.13",
"@types/jest": "^29.5.12",
"@types/node": "~20.12.12",
"@typescript-eslint/eslint-plugin": "7.9.0",
"@typescript-eslint/parser": "7.9.0",
"axios": "^1.6.8",
"browserslist": "^4.23.0",
"browserslist-useragent-regexp": "^4.1.3",
"eslint": "^8.57.0",
"eslint-plugin-import": "~2.29.1",
"eslint-plugin-jsdoc": "~48.2.5",
"eslint-plugin-prefer-arrow": "~1.2.3",
"jasmine-core": "~5.1.2",
"jasmine-spec-reporter": "~7.0.0",
"karma": "~6.4.3",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage-istanbul-reporter": "~3.0.3",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "^2.1.0",
"ts-node": "~10.9.2",
"typescript": "~5.4.5"
},
"private": true
}

158
scripts/tx.js Normal file
View File

@ -0,0 +1,158 @@
const fs = require('fs');
const process = require('process');
const path = require('path');
const { parseArgs } = require('util');
const { transifexApi } = require('@transifex/api');
const axios = require('axios');
const ACTIONS = ['push', 'upload', 'download'];
// Parse the command line arguments
function parseCliArgs() {
const options = {
token: {
type: 'string',
short: 't'
},
verbose: {
type: 'boolean',
short: 'v',
default: false
},
help: {
type: 'boolean',
short: 'h',
default: false
}
};
const { values, positionals } = parseArgs({options: options, allowPositionals: true});
if (values.help) {
console.log(`usage: tx [-h|--help] [-v|--verbose] [-t|--token TOKEN] <action>
positional arguments:
action the action to perform, one of 'push', 'upload' or 'download'
options:
-h, --help show this help message and exit
-e, --verbose show extra logging messages
-t TOKEN, --token TOKEN
specify a Transifex API token, can also use the TX_TOKEN environment variable
`);
process.exit(0);
}
if (!values.token && !process.env.TX_TOKEN) {
console.error("ERROR: Neither --token nor TX_TOKEN was set");
process.exit(1);
}
else if (!values.token && process.env.TX_TOKEN) {
values.token = process.env.TX_TOKEN;
}
if (positionals.length < 1 || !ACTIONS.includes(positionals[0])) {
console.error("ERROR: Action is not valid, please use one of " + ACTIONS.join(", "));
process.exit(1);
}
return {token: values.token, action: positionals[0], verbose: values.verbose};
}
function getPercentage(attributes) {
return (parseFloat(attributes.translated_strings) / parseFloat(attributes.total_strings)) * 100;
}
async function pushSource(resource, verbose) {
const filename = path.join('src', 'assets', 'en.json');
if (!fs.existsSync(filename)) {
console.error(`Source file ${filename} does not exist!`);
process.exit(1);
}
if (verbose) {
console.log('Reading en.json...');
}
let content = fs.readFileSync(filename);
let json = JSON.parse(content);
delete json._COMMENT;
content = JSON.stringify(json);
console.log('Uploading en.json...');
await transifexApi.ResourceStringsAsyncUpload.upload({
resource: resource,
content: content.toString()
});
}
async function uploadFiles(resource, languages, verbose) {
for (const lang of languages) {
const filename = path.join('src', 'assets', 'i18n', `${lang.attributes.code}.json`);
if (!fs.existsSync(filename)) {
continue;
}
if (verbose) {
console.log(`Reading ${lang.attributes.code}.json...`);
}
const content = fs.readFileSync(filename);
console.log(`Uploading ${lang.attributes.code}.json...`);
await transifexApi.ResourceTranslationsAsyncUpload.upload({
resource: resource,
language: lang,
content: content.toString()
});
}
}
async function downloadFiles(org, project, resource, languages, verbose) {
for (const lang of languages) {
if (verbose) {
console.log(`Checking completeness of ${lang.attributes.code}.json...`);
}
const trs = await transifexApi.ResourceLanguageStats.get({
project: project,
resource: resource,
language: lang
});
if (getPercentage(trs.attributes) < 50) {
continue;
}
console.log(`Downloading ${lang.attributes.code}.json...`);
const url = await transifexApi.ResourceTranslationsAsyncDownload.download({
resource: resource,
language: lang
});
const response = await axios.get(url);
if (response.status == 200) {
fs.writeFileSync(path.join('src', 'assets', 'i18n', `${lang.attributes.code}.json`), JSON.stringify(response.data, null, 2));
}
else {
console.log(`Error: ${response.statusText}`);
}
}
}
async function main() {
// Parse the command line arguments
const { token, action, verbose } = parseCliArgs();
// Set up the Transifex API
transifexApi.setup({auth: token});
if (verbose) {
console.log('Fetching organization, project and languages...');
}
const org = await transifexApi.Organization.get({slug: 'openlp'});
const projects = await org.fetch('projects');
const proj = await projects.get({slug: 'web-remote'});
const resource = await transifexApi.Resource.get({project: proj, slug: 'i18n-strings'});
const languages = await proj.fetch('languages');
await languages.fetch();
if (action == 'upload') {
await uploadFiles(resource, languages.data, verbose);
}
else if (action == 'download') {
await downloadFiles(org, proj, resource, languages.data, verbose);
}
else if (action == 'push') {
await pushSource(resource, verbose);
}
}
main();

View File

@ -1,68 +1,317 @@
<mat-toolbar color="primary">
<mat-toolbar-row>
<button mat-icon-button (click)="menu.toggle()"><mat-icon>menu</mat-icon></button>
<span class="page-title">{{pageTitle}}</span>
<button mat-icon-button (click)="menu.toggle()">
<mat-icon>menu</mat-icon>
</button>
<span class="page-title">{{ pageTitle | translate | titlecase }}</span>
<span class="spacer"></span>
@if (showLogin) {
<button
mat-button
(click)="login()">
{{ 'LOGIN' | translate | titlecase }}
</button>
}
@if (webSocketOpen) {
<button
mat-icon-button
(click)="forceWebSocketReconnection()"
class="connection-status"
matTooltip="{{ 'CONNECTED_TO_OPENLP' | translate | titlecase }}">
<mat-icon>link</mat-icon>
</button>
}
@else {
<button
mat-icon-button
(click)="forceWebSocketReconnection()"
class="connection-status"
matTooltip="{{ 'DISCONNECTED' | translate | titlecase }}">
<mat-icon>link_off</mat-icon>
</button>
}
<span class="app-version">v{{appVersion}}</span>
</mat-toolbar-row>
</mat-toolbar>
<mat-sidenav-container>
<mat-sidenav #menu mode="over">
<mat-nav-list>
<a mat-list-item (click)="menu.close()" routerLink="/service">Service</a>
<a mat-list-item (click)="menu.close()" routerLink="/slides">Slides</a>
<a mat-list-item (click)="menu.close()" routerLink="/alerts">Alerts</a>
<a mat-list-item (click)="menu.close()" routerLink="/search">Search</a>
<a mat-list-item (click)="menu.close()" routerLink="/themes">Themes</a>
<a mat-list-item
(click)="menu.close()"
routerLink="/service"
routerLinkActive #serviceRoute="routerLinkActive"
[activated]="serviceRoute.isActive">
<mat-icon>list</mat-icon> {{ 'SERVICE' | translate | titlecase }}
</a>
<a mat-list-item
(click)="menu.close()"
routerLink="/slides"
routerLinkActive #slidesRoute="routerLinkActive"
[activated]="slidesRoute.isActive">
<mat-icon>collections</mat-icon> {{ 'SLIDES' | translate | titlecase }}
</a>
<a mat-list-item
(click)="menu.close()"
routerLink="/alerts"
routerLinkActive #alertsRoute="routerLinkActive"
[activated]="alertsRoute.isActive">
<mat-icon>error</mat-icon> {{ 'ALERTS' | translate | titlecase }}
</a>
<a mat-list-item
(click)="menu.close()"
routerLink="/search"
routerLinkActive #searchRoute="routerLinkActive"
[activated]="searchRoute.isActive">
<mat-icon>search</mat-icon> {{ 'SEARCH' | translate | titlecase }}
</a>
<a mat-list-item
(click)="menu.close()"
routerLink="/themes"
routerLinkActive #themesRoute="routerLinkActive"
[activated]="themesRoute.isActive">
<mat-icon>image</mat-icon> {{ 'THEMES' | translate | titlecase }}
</a>
<mat-divider></mat-divider>
<a mat-list-item (click)="menu.close()" routerLink="/main">Main View</a>
<a mat-list-item (click)="menu.close()" routerLink="/stage">Stage View</a>
<a mat-list-item (click)="menu.close()" routerLink="/chords">Chord View</a>
<a mat-list-item
(click)="menu.close()"
routerLink="/main">
{{ 'MAIN_VIEW' | translate | titlecase }}
</a>
<a mat-list-item
(click)="menu.close()"
routerLink="/stage">
{{ 'STAGE_VIEW' | translate | titlecase }}
</a>
<a mat-list-item
(click)="menu.close()"
routerLink="/chords">
{{ 'CHORD_VIEW' | translate | titlecase }}
</a>
<mat-divider></mat-divider>
<mat-slide-toggle color="primary" [checked]="fastSwitching" (change)="sliderChanged($event)">Fast switching</mat-slide-toggle>
<a mat-list-item (click)="menu.close()"
routerLink="/settings"
routerLinkActive #settingsRoute="routerLinkActive"
[activated]="settingsRoute.isActive">
<mat-icon>settings</mat-icon> {{ 'SETTINGS' | translate | titlecase }}
</a>
</mat-nav-list>
</mat-sidenav>
<mat-sidenav-content>
<main class="content">
<router-outlet></router-outlet>
<mat-tab-nav-panel #tabPanel>
<router-outlet></router-outlet>
</mat-tab-nav-panel>
</main>
<!-- These two toolbars are for padding the content so the real toolbars do not block content when scrolled down -->
<mat-toolbar class="toolbar-padding"></mat-toolbar>
<mat-toolbar *ngIf="fastSwitching" class="toolbar-padding"></mat-toolbar>
@if (fastSwitching) {
<mat-toolbar class="toolbar-padding"></mat-toolbar>
}
<footer>
<mat-toolbar class="footer">
<button mat-icon-button (click)="previousItem()" matTooltip="Previous item">
<mat-icon>first_page</mat-icon>
</button>
<button mat-icon-button (click)="nextItem()" matTooltip="Next item">
<mat-icon>last_page</mat-icon>
</button>
<button mat-icon-button (click)="previousSlide()" matTooltip="Previous slide">
<mat-icon>navigate_before</mat-icon>
</button>
<button mat-icon-button (click)="nextSlide()" matTooltip="Next slide">
<mat-icon>navigate_next</mat-icon>
</button>
<button mat-icon-button (click)="blankDisplay()" class="displayButton" [class.active]="state.blank" [disabled]="state.blank" matTooltip="Show black">
<mat-icon>videocam_off</mat-icon>
</button>
<button mat-icon-button (click)="themeDisplay()" class="displayButton" [class.active]="state.theme" [disabled]="state.theme" matTooltip="Show background">
<mat-icon>wallpaper</mat-icon>
</button>
<button mat-icon-button (click)="desktopDisplay()" class="displayButton" [class.active]="state.display" [disabled]="state.display" matTooltip="Show Desktop">
<mat-icon>desktop_windows</mat-icon>
</button>
<button mat-icon-button (click)="showDisplay()" class="displayButton" [class.active]="state.display" [disabled]="state.live()" matTooltip="Show Presentation">
<mat-icon>videocam</mat-icon>
</button>
</mat-toolbar>
<mat-toolbar *ngIf="fastSwitching" class="fast-access">
<button mat-icon-button routerLink="/service"><mat-icon>list</mat-icon></button>
<button mat-icon-button routerLink="/slides"><mat-icon>collections</mat-icon></button>
<button mat-icon-button routerLink="/alerts"><mat-icon>error</mat-icon></button>
<button mat-icon-button routerLink="/search"><mat-icon>search</mat-icon></button>
<button mat-icon-button routerLink="/themes"><mat-icon>image_search</mat-icon></button>
@if (bigDisplayButtons) {
<button
mat-fab color="primary"
(click)="previousItem()"
matTooltip="{{ 'PREVIOUS_ITEM' | translate | titlecase }}"
matTooltipPosition="above">
<mat-icon>first_page</mat-icon>
</button>
<button
mat-fab color="primary"
(click)="nextItem()"
matTooltip="{{ 'NEXT_ITEM' | translate | titlecase }}"
matTooltipPosition="above">
<mat-icon>last_page</mat-icon>
</button>
<button
mat-fab color="primary"
(click)="previousSlide()"
matTooltip="{{ 'PREVIOUS_SLIDE' | translate | titlecase }}"
matTooltipPosition="above">
<mat-icon>navigate_before</mat-icon>
</button>
<button
mat-fab color="primary"
(click)="nextSlide()"
matTooltip="{{ 'NEXT_SLIDE' | translate | titlecase }}"
matTooltipPosition="above">
<mat-icon>navigate_next</mat-icon>
</button>
<button
mat-fab color="primary"
#squashedDisplayButton
(click)="openDisplayModeSelector()"
class="squashed-display-button"
matTooltip="{{ 'CHANGE_DISPLAY_MODE' | translate | titlecase }}"
matTooltipPosition="above">
@if (state.blank) {
<mat-icon>videocam_off</mat-icon>
}
@else if (state.theme) {
<mat-icon>wallpaper</mat-icon>
}
@else if (state.display) {
<mat-icon>desktop_windows</mat-icon>
}
@else if (state.live()) {
<mat-icon>videocam</mat-icon>
}
</button>
<button
mat-fab color="primary"
(click)="blankDisplay()"
class="displayButton"
[class.active]="state.blank"
[disabled]="state.blank"
matTooltip="{{ 'SHOW_BLACK' | translate | titlecase }}"
matTooltipPosition="above">
<mat-icon>videocam_off</mat-icon>
</button>
<button
mat-fab color="primary"
(click)="themeDisplay()"
class="displayButton"
[class.active]="state.theme"
[disabled]="state.theme"
matTooltip="{{ 'SHOW_BACKGROUND' | translate | titlecase }}"
matTooltipPosition="above">
<mat-icon>wallpaper</mat-icon>
</button>
<button
mat-fab color="primary"
(click)="desktopDisplay()"
class="displayButton"
[class.active]="state.display"
[disabled]="state.display"
matTooltip="{{ 'SHOW_DESKTOP' | translate | titlecase }}"
matTooltipPosition="above">
<mat-icon>desktop_windows</mat-icon>
</button>
<button
mat-fab color="primary"
(click)="showDisplay()"
class="displayButton"
[class.active]="state.display"
[disabled]="state.live()"
matTooltip="{{ 'SHOW_PRESENTATION' | translate | titlecase }}"
matTooltipPosition="above">
<mat-icon>videocam</mat-icon>
</button>
}
@else {
<button
mat-icon-button
(click)="previousItem()"
matTooltip="{{ 'PREVIOUS_ITEM' | translate | titlecase }}"
matTooltipPosition="above">
<mat-icon>first_page</mat-icon>
</button>
<button
mat-icon-button
(click)="nextItem()"
matTooltip="{{ 'NEXT_ITEM' | translate | titlecase }}"
matTooltipPosition="above">
<mat-icon>last_page</mat-icon>
</button>
<button
mat-icon-button (click)="previousSlide()"
matTooltip="{{ 'PREVIOUS_SLIDE' | translate | titlecase }}"
matTooltipPosition="above">
<mat-icon>navigate_before</mat-icon>
</button>
<button
mat-icon-button (click)="nextSlide()"
matTooltip="{{ 'NEXT_SLIDE' | translate | titlecase }}"
matTooltipPosition="above">
<mat-icon>navigate_next</mat-icon>
</button>
<button
mat-icon-button
#squashedDisplayButton
(click)="openDisplayModeSelector()"
class="squashed-display-button"
matTooltip="{{ 'CHANGE_DISPLAY_MODE' | translate | titlecase }}"
matTooltipPosition="above">
@if (state.blank) {
<mat-icon>videocam_off</mat-icon>
}
@else if (state.theme) {
<mat-icon>wallpaper</mat-icon>
}
@else if (state.display) {
<mat-icon>desktop_windows</mat-icon>
}
@else if (state.live()) {
<mat-icon>videocam</mat-icon>
}
</button>
<button
mat-icon-button (click)="blankDisplay()"
class="displayButton"
[class.active]="state.blank"
[disabled]="state.blank"
matTooltip="{{ 'SHOW_BLACK' | translate | titlecase }}"
matTooltipPosition="above">
<mat-icon>videocam_off</mat-icon>
</button>
<button
mat-icon-button
(click)="themeDisplay()"
class="displayButton"
[class.active]="state.theme"
[disabled]="state.theme"
matTooltip="{{ 'SHOW_BACKGROUND' | translate | titlecase }}"
matTooltipPosition="above">
<mat-icon>wallpaper</mat-icon>
</button>
<button
mat-icon-button (click)="desktopDisplay()"
class="displayButton"
[class.active]="state.display"
[disabled]="state.display"
matTooltip="{{ 'SHOW_DESKTOP' | translate | titlecase }}"
matTooltipPosition="above">
<mat-icon>desktop_windows</mat-icon>
</button>
<button
mat-icon-button (click)="showDisplay()"
class="displayButton"
[class.active]="state.display"
[disabled]="state.live()"
matTooltip="{{ 'SHOW_PRESENTATION' | translate | titlecase }}"
matTooltipPosition="above">
<mat-icon>videocam</mat-icon>
</button>
}
</mat-toolbar>
@if (fastSwitching) {
<nav
mat-tab-nav-bar mat-stretch-tabs
class="fast-switcher"
[tabPanel]="tabPanel">
<a mat-tab-link
routerLink="/service"
routerLinkActive #serviceRoute="routerLinkActive"
[active]="serviceRoute.isActive"><mat-icon>list</mat-icon></a>
<a mat-tab-link
routerLink="/slides"
routerLinkActive #slidesRoute="routerLinkActive"
[active]="slidesRoute.isActive"><mat-icon>collections</mat-icon></a>
<a mat-tab-link
routerLink="/alerts"
routerLinkActive #alertsRoute="routerLinkActive"
[active]="alertsRoute.isActive"><mat-icon>error</mat-icon></a>
<a mat-tab-link
routerLink="/search"
routerLinkActive #searchRoute="routerLinkActive"
[active]="searchRoute.isActive"><mat-icon>search</mat-icon></a>
<a mat-tab-link
routerLink="/themes"
routerLinkActive #themesRoute="routerLinkActive"
[active]="themesRoute.isActive"><mat-icon>image</mat-icon></a>
</nav>
}
</footer>
</mat-sidenav-content>
</mat-sidenav-container>

View File

@ -1,3 +1,24 @@
$small-toolbar-breakpoint: 500px;
// To allow the inner overlay (and other items) to use z-indexes greater than 1
.mat-sidenav {
&-container, &-content {
z-index: auto;
}
}
mat-toolbar {
position: -webkit-sticky;
position: sticky;
top: 0;
z-index: 1020;
/* Fix icon button alignment on some Firefox configurations */
[mat-icon-button] {
line-height: 1;
}
}
mat-divider {
border-color: rgb(175, 175, 175);
}
@ -18,7 +39,12 @@ mat-sidenav-container {
flex: 1;
}
mat-slide-toggle {
/* Align icons with text */
mat-sidenav-container .mat-icon {
vertical-align: text-top;
}
.mat-mdc-slide-toggle {
margin-top: 0.8rem;
margin-left: 1rem;
font-size: 80%;
@ -28,9 +54,16 @@ mat-slide-toggle {
visibility: hidden;
}
.fast-access {
display: flex;
justify-content: space-around;
.connection-status {
margin-right: 1rem;
}
.fast-switcher {
background-color: whitesmoke;
}
.fast-switcher a.mat-mdc-tab-link > span.text {
margin-left: 0.3rem;
}
.footer {
@ -39,9 +72,22 @@ mat-slide-toggle {
justify-content: space-evenly;
}
.displayButton {
display: none;
}
@media screen and (min-width: $small-toolbar-breakpoint) {
.squashed-display-button {
display: none;
}
.displayButton {
display: block;
}
}
/*
* Make the Component injected by Router Outlet full height:
*/
* Make the Component injected by Router Outlet full height:
*/
main {
display: flex;
flex-direction: column;

View File

@ -1,5 +1,5 @@
describe('AppComponent', () => {
it('has a dummy test', () => {
expect().nothing();
expect(null).toBe(null);
});
});

View File

@ -1,32 +1,139 @@
import { Component, OnInit } from '@angular/core';
import { MatSlideToggleChange, MatDialog } from '@angular/material';
import { MatDialog } from '@angular/material/dialog';
import { MatBottomSheet } from '@angular/material/bottom-sheet';
import { State } from './responses';
import { OpenLPService } from './openlp.service';
import { TranslateService } from '@ngx-translate/core';
import { State, Display, DisplayMode } from './responses';
import { OpenLPService, WebSocketStatus } from './openlp.service';
import { WindowRef } from './window-ref.service';
import { PageTitleService } from './page-title.service';
import { LoginComponent } from './components/login/login.component';
import { version } from '../../package.json';
import { fromEvent } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import { DisplayModeSelectorComponent } from './components/display-mode-selector/display-mode-selector.component';
import { Shortcuts, ShortcutsService } from './shortcuts.service';
import { ShortcutPipe } from './components/pipes/shortcut.pipe';
import { SettingsService } from './settings.service';
import * as supportedBrowsers from '../assets/supportedBrowsers';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
styleUrl: './app.component.scss'
})
export class AppComponent implements OnInit {
fastSwitching = false;
// Make DisplayMode enum visible to html code
DisplayMode = DisplayMode;
state = new State();
showLogin = false;
pageTitle = 'OpenLP Remote';
appVersion = version;
appVersion = '0.0';
webSocketOpen = false;
fastSwitching = false;
bigDisplayButtons = false;
useShortcutsFromOpenlp = false;
useLanguageFromOpenlp = false;
constructor(private pageTitleService: PageTitleService, private openlpService: OpenLPService,
private dialog: MatDialog) {
pageTitleService.pageTitleChanged$.subscribe(pageTitle => this.pageTitle = pageTitle);
openlpService.stateChanged$.subscribe(item => this.state = item);
constructor(private translateService: TranslateService, private pageTitleService: PageTitleService,
private openlpService: OpenLPService, private dialog: MatDialog, private bottomSheet: MatBottomSheet,
private windowRef: WindowRef, private shortcutsService: ShortcutsService, private settingsService: SettingsService) {
this.pageTitleService.pageTitleChanged$.subscribe(pageTitle => this.pageTitle = pageTitle);
this.openlpService.stateChanged$.subscribe(item => this.state = item);
this.openlpService.webSocketStateChanged$.subscribe(status => this.webSocketOpen = status === WebSocketStatus.Open);
this.shortcutsService.shortcutsChanged$.subscribe(shortcuts => this.addShortcuts(shortcuts));
this.appVersion = this.windowRef.nativeWindow.appVersion || '0.0';
this.webSocketOpen = openlpService.webSocketStatus === WebSocketStatus.Open;
// Try to force websocket reconnection as user is now focused on window and will try to interact soon
// Adding a debounce to avoid event flooding
fromEvent(window, 'focus')
.pipe(debounceTime(300))
.subscribe(() => this.forceWebSocketReconnection());
}
ngOnInit(): void {
this.openlpService.retrieveSystemInformation().subscribe(res => this.showLogin = res.login_required);
if (!(supportedBrowsers.test(navigator.userAgent))) {
window.location.replace("/assets/notsupported.html");
}
this.openlpService.retrieveSystemInformation().subscribe(res => {
this.showLogin = res.login_required
this.useLanguageFromOpenlp = this.openlpService.assertApiVersionMinimum(2, 5)
if (this.useLanguageFromOpenlp) {
this.openlpService.getLanguage().subscribe(res => {
this.translateService.use(res.language);
});
} else {
this.translateService.use('default');
}
this.useShortcutsFromOpenlp = this.openlpService.assertApiVersionMinimum(2, 5)
this.shortcutsService.getShortcuts(this.useShortcutsFromOpenlp);
}
);
this.fastSwitching = this.settingsService.get('fastSwitching');
this.settingsService.onPropertyChanged('fastSwitching').subscribe(value => this.fastSwitching = value);
this.bigDisplayButtons = this.settingsService.get('bigDisplayButtons');
this.settingsService.onPropertyChanged('bigDisplayButtons').subscribe(value => this.bigDisplayButtons = value);
}
addShortcuts(shortcuts: Shortcuts): void {
const shortcutPipe = new ShortcutPipe();
shortcuts.previousSlide.forEach((key) => {
this.shortcutsService.addShortcut({ keys: shortcutPipe.transform(key) }).subscribe(() =>
this.previousSlide()
)
});
shortcuts.nextSlide.forEach((key) => {
this.shortcutsService.addShortcut({ keys: shortcutPipe.transform(key) }).subscribe(() =>
this.nextSlide()
)
});
shortcuts.previousItem.forEach((key) => {
this.shortcutsService.addShortcut({ keys: shortcutPipe.transform(key) }).subscribe(() =>
this.previousItem()
)
});
shortcuts.nextItem.forEach((key) => {
this.shortcutsService.addShortcut({ keys: shortcutPipe.transform(key) }).subscribe(() =>
this.nextItem()
)
});
shortcuts.showDisplay.forEach((key) => {
this.shortcutsService.addShortcut({ keys: shortcutPipe.transform(key) }).subscribe(() => {
if (this.state.displayMode !== DisplayMode.Presentation) {
this.showDisplay();
}
})
});
shortcuts.themeDisplay.forEach((key) => {
this.shortcutsService.addShortcut({ keys: shortcutPipe.transform(key) }).subscribe(() =>
this.state.displayMode === DisplayMode.Theme ? this.showDisplay() : this.themeDisplay()
)
});
shortcuts.blankDisplay.forEach((key) => {
this.shortcutsService.addShortcut({ keys: shortcutPipe.transform(key) }).subscribe(() =>
this.state.displayMode === DisplayMode.Blank ? this.showDisplay() : this.blankDisplay()
)
});
shortcuts.desktopDisplay.forEach((key) => {
this.shortcutsService.addShortcut({ keys: shortcutPipe.transform(key) }).subscribe(() =>
this.state.displayMode === DisplayMode.Desktop ? this.showDisplay() : this.desktopDisplay()
)
});
}
openDisplayModeSelector(): void {
const display = new Display();
display.displayMode = this.state.displayMode;
display.bigDisplayButtons = this.bigDisplayButtons;
const selectorRef = this.bottomSheet.open(DisplayModeSelectorComponent, {data: display});
selectorRef.afterDismissed().subscribe(result => {
if (result === DisplayMode.Blank) {this.blankDisplay();}
else if (result === DisplayMode.Desktop) {this.desktopDisplay();}
else if (result === DisplayMode.Theme) {this.themeDisplay();}
else if (result === DisplayMode.Presentation) {this.showDisplay();}
});
}
login() {
@ -74,7 +181,7 @@ export class AppComponent implements OnInit {
this.openlpService.showDisplay().subscribe();
}
sliderChanged(event: MatSlideToggleChange) {
this.fastSwitching = event.checked;
forceWebSocketReconnection() {
this.openlpService.reconnectWebSocketIfNeeded();
}
}

View File

@ -1,83 +1,125 @@
import { BrowserModule, Title } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { BrowserModule, Title } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { TitleCasePipe } from '@angular/common';
import { MatCardModule, MatDialogModule, MatSnackBarModule } from '@angular/material';
import { MatListModule } from '@angular/material/list';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatIconModule } from '@angular/material/icon';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatGridListModule } from '@angular/material/grid-list';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatDialogModule } from '@angular/material/dialog';
import { MatCardModule } from '@angular/material/card';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatGridListModule } from '@angular/material/grid-list';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatSelectModule } from '@angular/material/select';
import { MatButtonModule, MatButtonToggleModule } from '@angular/material';
import { MatInputModule } from '@angular/material';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatTabsModule } from '@angular/material/tabs';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatBottomSheetModule } from '@angular/material/bottom-sheet';
import { MatSliderModule } from '@angular/material/slider';
import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
import { AppComponent } from './app.component';
import { PageTitleService } from './page-title.service';
import { OpenLPService } from './openlp.service';
import { HttpClientModule } from '@angular/common/http';
import { TranslationService } from './translation.service';
import { WindowRef } from './window-ref.service';
import { AppRoutingModule } from './app.routing';
import { ServiceComponent } from './components/service/service.component';
import { AlertComponent } from './components/alert/alert.component';
import { SearchComponent } from './components/search/search.component';
import { SearchOptionsComponent } from './components/search/search-options/search-options.component';
import { SlidesComponent } from './components/slides/slides.component';
import { FormsModule } from '@angular/forms';
import { ChordViewComponent } from './components/chord-view/chord-view.component';
import { StageViewComponent } from './components/stage-view/stage-view.component';
import { Nl2BrPipe } from './components/pipes/nl2br.pipe';
import { MainViewComponent } from './components/main-view/main-view.component';
import { ChordProPipe } from './components/chord-view/chordpro.pipe';
import { ChordProPipe } from './components/pipes/chordpro.pipe';
import { LoginComponent } from './components/login/login.component';
import { ThemesComponent } from './components/themes/themes.component';
import { SlideListComponent } from './components/slides/slide-list/slide-list.component';
import { SlideItemComponent } from './components/slides/slide-item/slide-item.component';
import { ServiceItemComponent } from './components/service/service-item/service-item.component';
import { ServiceListComponent } from './components/service/service-list/service-list.component';
import { ChordViewItemComponent } from './components/chord-view/chord-view-item/chord-view-item.component';
import { StageViewItemComponent } from './components/stage-view/stage-view-item/stage-view-item.component';
import { DisplayModeSelectorComponent } from './components/display-mode-selector/display-mode-selector.component';
import { SentenceCasePipe } from './components/pipes/sentence-case.pipe';
import { SettingsComponent } from './components/settings/settings.component';
import { StageChordPreviewComponent } from './components/settings/stage-chord-preview/stage-chord-preview.component';
@NgModule({
declarations: [
AppComponent,
ChordViewComponent,
StageViewComponent,
StageViewItemComponent,
ChordViewItemComponent,
Nl2BrPipe,
MainViewComponent,
ChordProPipe,
LoginComponent,
ServiceComponent,
ServiceListComponent,
ServiceItemComponent,
AlertComponent,
SearchComponent,
SearchOptionsComponent,
SentenceCasePipe,
SlidesComponent,
ThemesComponent
SlideListComponent,
SlideItemComponent,
ThemesComponent,
DisplayModeSelectorComponent,
SettingsComponent,
StageChordPreviewComponent
],
imports: [
BrowserModule,
BrowserAnimationsModule,
HttpClientModule,
AppRoutingModule,
MatButtonToggleModule,
MatListModule,
MatSidenavModule,
MatIconModule,
MatToolbarModule,
MatGridListModule,
FormsModule,
MatFormFieldModule,
MatSelectModule,
AppRoutingModule,
MatButtonModule,
MatInputModule,
MatTooltipModule,
MatSlideToggleModule,
MatButtonToggleModule,
MatCardModule,
MatDialogModule,
MatSnackBarModule
MatFormFieldModule,
MatGridListModule,
MatIconModule,
MatInputModule,
MatListModule,
MatSelectModule,
MatSidenavModule,
MatSlideToggleModule,
MatSnackBarModule,
MatTabsModule,
MatToolbarModule,
MatTooltipModule,
MatBottomSheetModule,
MatSliderModule,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslationService
}
})
],
providers: [
PageTitleService,
OpenLPService,
Title
],
entryComponents: [
LoginComponent
TranslationService,
SentenceCasePipe,
Title,
TitleCasePipe,
WindowRef
],
bootstrap: [AppComponent]
})

View File

@ -9,6 +9,8 @@ import { ChordViewComponent } from './components/chord-view/chord-view.component
import { MainViewComponent } from './components/main-view/main-view.component';
import { StageViewComponent } from './components/stage-view/stage-view.component';
import { ThemesComponent } from './components/themes/themes.component';
import { LowerThirdComponent } from './components/lower-third/lower-third.component';
import { SettingsComponent } from './components/settings/settings.component';
const routes: Routes = [
{ path: '', redirectTo: '/service', pathMatch: 'full' },
@ -19,7 +21,9 @@ const routes: Routes = [
{ path: 'chords', component: ChordViewComponent },
{ path: 'main', component: MainViewComponent },
{ path: 'stage', component: StageViewComponent },
{ path: 'themes', component: ThemesComponent}
{ path: 'themes', component: ThemesComponent},
{ path: 'lower-third', component: LowerThirdComponent},
{ path: 'settings', component: SettingsComponent}
];
@NgModule({
imports: [RouterModule.forRoot(routes)],

View File

@ -0,0 +1,20 @@
$mobile-breakpoint: 1024px;
@mixin slide-font-size($scale: 1, $desktop-scale: $scale, $stage-var-name: stage) {
$var-sentence: var(--openlp-#{$stage-var-name}-font-scale);
font-size: calc((#{4vw * $scale} * #{$var-sentence}) + (#{1.5vh * $scale} * #{$var-sentence}));
@media (orientation: landscape) {
font-size: calc(#{6vmin * $scale} * #{$var-sentence});
}
@media (orientation: landscape) and (max-aspect-ratio: 16/9) {
font-size: calc(#{3vw * $scale} * #{$var-sentence});
}
@media screen and (min-width: $mobile-breakpoint) {
font-size: calc((#{3.1vw * $desktop-scale} * #{$var-sentence}) + (#{1.5vh * $desktop-scale} * #{$var-sentence}));
//font-size: #{4vw * $scale};
//font-size: #{5.6vmin * $scale};
}
}

View File

@ -1,7 +1,19 @@
<h3>Send an Alert</h3>
<h3>{{ 'SEND_AN_ALERT' | translate | sentencecase }}</h3>
<form #alertForm="ngForm">
<mat-form-field>
<input matInput [(ngModel)]="alert" type="text" name="alert" placeholder="Alert" required>
<input
matInput
[(ngModel)]="alert"
type="text"
name="alert"
placeholder="{{ 'ALERT' | translate | titlecase }}"
required>
</mat-form-field>
<button mat-raised-button color="primary" id="sendButton" [disabled]="!alertForm.form.valid" (click)="onSubmit(); alertForm.reset()">Send</button>
</form>
<button
mat-raised-button color="primary"
id="sendButton"
[disabled]="!alertForm.form.valid"
(click)="onSubmit(); alertForm.reset()">
{{ 'SEND' | translate | titlecase }}
</button>
</form>

View File

@ -1,5 +1,7 @@
import { Component } from '@angular/core';
import { MatSnackBar } from '@angular/material';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TitleCasePipe } from '@angular/common';
import { TranslateService } from '@ngx-translate/core';
import { PageTitleService } from '../../page-title.service';
import { OpenLPService } from '../../openlp.service';
@ -7,20 +9,32 @@ import { OpenLPService } from '../../openlp.service';
@Component({
selector: 'openlp-alert',
templateUrl: './alert.component.html',
styleUrls: ['./alert.component.scss'],
styleUrl: './alert.component.scss',
providers: [OpenLPService]
})
export class AlertComponent {
public alert: string;
public alertMessage: string;
constructor(private pageTitleService: PageTitleService, private openlpService: OpenLPService,
private snackBar: MatSnackBar) {
pageTitleService.changePageTitle('Alerts');
constructor(
private pageTitleService: PageTitleService,
private openlpService: OpenLPService,
private snackBar: MatSnackBar,
private titleCasePipe: TitleCasePipe,
private translateService: TranslateService) {
this.translateService.stream('ALERTS').subscribe(res => {
this.pageTitleService.changePageTitle(res);
});
this.translateService.stream('ALERT_SUBMITTED').subscribe(res => {
this.alertMessage = this.titleCasePipe.transform(res);
});
}
onSubmit() {
this.openlpService.showAlert(this.alert).subscribe(res => this.snackBar.open('Alert submitted', '', {duration: 2000}));
this.openlpService.showAlert(this.alert).subscribe(
() => this.snackBar.open(this.alertMessage, '', { duration: 2000 })
);
}
}

View File

@ -0,0 +1,7 @@
<div
class="slide song"
[class.currentSlide]="active"
[class.mat-headline-2]="active"
[class.first]="!active && slide.first_slide_of_tag"
[innerHTML]="chordproFormatted(slide)|chordpro">
</div>

View File

@ -0,0 +1,22 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { Slide } from '../../../responses';
@Component({
selector: 'app-chord-view-item',
templateUrl: './chord-view-item.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChordViewItemComponent {
@Input() slide: Slide;
@Input() active = false;
chordproFormatted(slide: Slide): string {
if (!slide) {
return '';
}
let chordpro: string = slide.chords;
chordpro = chordpro.replace(/<br>/g, '\n');
return chordpro;
}
}

View File

@ -1,26 +1,74 @@
<div class="overlay">
<div>
<div
class="overlay"
[class.embedded]="embedded"
[style.--openlp-stage-font-scale]="fontScale">
<div class="overlay-content">
<div class="tags">
<span *ngFor="let tag of tags" [class.active]="tag.active">{{ tag.text }}</span>
@for (tag of tags; track tag) {
<span [class.active]="tag.active">{{ tag.text }}</span>
}
</div>
<div class="container">
<div class="slide currentSlide song" [innerHTML]="chordproFormatted(currentSlides[0])|chordpro:transpose"></div>
@if (currentSlides[activeSlide]?.chords) {
<app-chord-view-item
[slide]="currentSlides[activeSlide]"
[active]="true">
</app-chord-view-item>
}
@else {
<app-stage-view-item
[slide]="currentSlides[activeSlide]"
[active]="true">
</app-stage-view-item>
}
<div class="nextSlides">
<div class="slide song" [class.first]="slide.first_slide_of_tag" *ngFor="let slide of nextSlides" [innerHTML]="chordproFormatted(slide)|chordpro:transpose"></div>
@for (slide of nextSlides; track trackByIndex) {
<ng-container>
@if (slide?.chords) {
<app-chord-view-item
[slide]="slide">
</app-chord-view-item>
}
@else {
<app-stage-view-item
[slide]="slide">
</app-stage-view-item>
}
</ng-container>
}
</div>
</div>
</div>
<div class="sidebar">
<div class="time">{{ time|date:'HH:mm' }}</div>
<div class="toolbar">
@if (!embedded) {
<a
mat-mini-fab color=""
class="back-button"
routerLink="/"
matTooltip="{{ 'GO_BACK_TO_CONTROLLER' | translate | titlecase }}">
<mat-icon>arrow_back</mat-icon>
</a>
}
<div class="transpose">
<button mat-icon-button (click)="transposeUp()">
<mat-icon>keyboard_arrow_up</mat-icon>
</button>
<span>{{ transpose }}</span>
<button mat-icon-button (click)="transposeDown()">
<button
mat-icon-button
(click)="transposeDown()">
<mat-icon>keyboard_arrow_down</mat-icon>
</button>
<span>{{ transposeLevel }}</span>
<button
mat-icon-button
(click)="transposeUp()">
<mat-icon>keyboard_arrow_up</mat-icon>
</button>
</div>
<button mat-raised-button routerLink="/">Close</button>
@if (!embedded && activeSlide+1 === currentSlides.length) {
<div
class="next-service-item"
matTooltip="{{ 'NEXT_ITEM' | translate | titlecase }}">
{{ nextServiceItemTitle }}
</div>
}
<div class="time">{{ (openlpService.getIsTwelveHourTime()) ? (time|date:'h:mm a') : (time|date:'HH:mm') }}</div>
</div>
</div>
</div>

View File

@ -1,12 +1,19 @@
@import '../overlay-common';
.transpose {
margin-left: 25px;
display: flex;
flex-direction: column;
font-size: 3rem;
justify-content: center;
align-items: center;
font-size: 2rem;
mat-icon {
font-size: 3rem;
transform: scale(1.5);
}
span {
margin-left: 17px;
@media screen and (min-width: $mobile-breakpoint) {
font-size: 3rem;
mat-icon {
transform: scale(2);
}
}
}

View File

@ -1,7 +1,5 @@
import { Component, OnInit, ViewEncapsulation } from '@angular/core';
import { OpenLPService } from '../../openlp.service';
import { Component, ViewEncapsulation } from '@angular/core';
import { Slide } from '../../responses';
import { Observable } from 'rxjs';
import { StageViewComponent } from '../stage-view/stage-view.component';
@Component({
@ -12,27 +10,107 @@ import { StageViewComponent } from '../stage-view/stage-view.component';
})
export class ChordViewComponent extends StageViewComponent {
transpose = 0;
// Map with the song id and transpose value so the chord-view remembers the value for each song
songTransposeMap = new Map();
// current songs transpose level
transposeLevel = 0;
stageProperty = 'chords';
currentSlide = 0;
useNewTransposeEndpoint = this.openlpService.assertApiVersionMinimum(2, 2);
updateCurrentSlides(serviceItemId: string, currentSlide: number): void {
this.currentSlide = currentSlide;
this.useNewTransposeEndpoint = this.openlpService.assertApiVersionMinimum(2, 2);
this.serviceItemSubscription$?.unsubscribe();
const newServiceItemTransposeLevel = this.songTransposeMap.get(serviceItemId);
if (this.useNewTransposeEndpoint && newServiceItemTransposeLevel && (newServiceItemTransposeLevel !== 0)) {
this.transposeLevel = newServiceItemTransposeLevel;
this.serviceItemSubscription$ = this.openlpService
.transposeSong(newServiceItemTransposeLevel, 'service_item')
.subscribe(serviceItem => {
this.serviceItem = serviceItem;
if (serviceItem instanceof Array) {
this.setNewSlides(serviceItem, currentSlide);
}
else {
this.setNewSlides(serviceItem.slides, currentSlide);
this.setNotes(serviceItem.notes);
}
});
} else {
if (this.useNewTransposeEndpoint) {
this.songTransposeMap.set(serviceItemId, 0);
this.transposeLevel = 0;
}
super.updateCurrentSlides(serviceItemId, currentSlide);
}
}
setNewSlides(slides: Slide[], currentSlide: number): void {
if (this.openlpService.assertApiVersionExact(2, 2)) {
// API Version 2.2 released on OpenLP 3.0.2 contains a bug on which 'selected' is not set correctly
// on Transponse Service Item response.
if (slides[currentSlide]) {
slides[currentSlide].selected = true;
}
}
super.setNewSlides(slides, currentSlide);
// if this song is already known
if (this.songTransposeMap.has(this.serviceItem.id)) {
const transposeLevel = this.songTransposeMap.get(this.serviceItem.id);
if (transposeLevel) {
if (!this.useNewTransposeEndpoint) {
this.transposeChords();
}
} else {
this.transposeLevel = this.songTransposeMap.get(this.serviceItem.id);
}
} else {
this.songTransposeMap.set(this.serviceItem.id, 0);
this.transposeLevel = 0;
}
}
transposeUp(): void {
this.transpose++;
if (this.songTransposeMap.has(this.serviceItem.id)) {
const tmpTranspose = this.songTransposeMap.get(this.serviceItem.id) + 1;
this.songTransposeMap.set(this.serviceItem.id, tmpTranspose);
} else {
this.songTransposeMap.set(this.serviceItem.id, 1);
}
this.transposeChords();
}
transposeDown(): void {
this.transpose--;
if (this.songTransposeMap.has(this.serviceItem.id)) {
const tmpTranspose = this.songTransposeMap.get(this.serviceItem.id) - 1;
this.songTransposeMap.set(this.serviceItem.id, tmpTranspose);
} else {
this.songTransposeMap.set(this.serviceItem.id, -1);
}
this.transposeChords();
}
chordproFormatted(slide: Slide): string {
if (!slide) {
return '';
transposeChords(): void {
const tmpTranspose = this.songTransposeMap.get(this.serviceItem.id);
this.transposeLevel = tmpTranspose;
if (this.useNewTransposeEndpoint) {
this.updateCurrentSlides(this.serviceItem.id, this.currentSlide);
} else {
this.transposeChordsLegacy(tmpTranspose);
}
let chordpro: string = slide.chords_text;
chordpro = chordpro.replace(/<span class="\w*\s*\w*">/g, '');
chordpro = chordpro.replace(/<span>/g, '');
chordpro = chordpro.replace(/<\/span>/g, '');
chordpro = chordpro.replace(/<strong>/g, '[');
chordpro = chordpro.replace(/<\/strong>/g, ']');
}
return chordpro;
transposeChordsLegacy(transposeLevel): void {
this.openlpService.transposeSong(transposeLevel).subscribe(transposedLyrics => {
// Replace the chords in the currentSlides with the returned transposed chords
if (transposedLyrics instanceof Array) {
for (let i = 0; i < transposedLyrics.length; ++i) {
this.currentSlides[i] = {...this.currentSlides[i], chords: transposedLyrics[i].chords};
}
}
});
}
}

View File

@ -1,23 +1,93 @@
@import '../overlay-common';
.song {
white-space: pre-wrap;
.with-chords {
line-height: 2;
line-height: 1;
font-family: monospace;
padding-top: 0.45em; // To avoid chord overlapping top bar
@include slide-font-size(0.85, 0.725);
> span {
vertical-align: bottom;
}
}
span[data-chord]:before {
position: relative;
top: -1em;
&-row {
display: inline-block;
content: attr(data-chord);
width: 0;
color: yellow;
}
span[data-chord]{
display: inline;
position: relative;
white-space: nowrap;
.text, .chord {
line-height: 1em;
height: 1em;
line-height: 2.4em;
}
.first-letter {
white-space: pre-wrap;
}
.chord {
color: yellow;
display: inline-block;
transform: translateY(-100%);
white-space: pre;
}
// Chords that invades next text
&.overlap-chord {
position: relative;
.chord {
width: 0;
}
}
&:not(.overlap-chord) {
display: inline-flex;
flex-direction: column;
.text {
position: absolute;
left: 0;
}
}
// Chords without text
&.chord-only {
display: inline-flex;
flex-direction: column;
margin-right: 0.3em;
.chord {
position: static;
}
}
}
}
.nextSlides {
.song {
span[data-chord]:before {
color: gray;
span[data-chord] {
.chord {
color: gray;
}
.fill .fill-inner {
background-color: gray;
}
}
}
.slide .with-chords {
@include slide-font-size(0.75, 0.65);
}
}

View File

@ -0,0 +1,98 @@
@if (display.bigDisplayButtons) {
<mat-grid-list
cols="2"
rowHeight="2:1">
<mat-grid-tile>
<button
mat-fab color="primary"
aria-labelledby="caption-blank"
class="display-button"
(click)="setMode(displayMode.Blank)"
[disabled]="display.displayMode === displayMode.Blank">
<mat-icon class="big-icon">videocam_off</mat-icon>
</button>
</mat-grid-tile>
<mat-grid-tile>
<div id="caption-blank" class="caption">{{ 'SHOW_BLACK' | translate | titlecase }}</div>
</mat-grid-tile>
<mat-grid-tile>
<button
mat-fab color="primary"
aria-labelledby="caption-theme"
class="display-button"
(click)="setMode(displayMode.Theme)"
[disabled]="display.displayMode === displayMode.Theme">
<mat-icon class="big-icon">wallpaper</mat-icon>
</button>
</mat-grid-tile>
<mat-grid-tile>
<div id="caption-theme" class="caption">{{ 'SHOW_BACKGROUND' | translate | titlecase }}</div>
</mat-grid-tile>
<mat-grid-tile>
<button
mat-fab color="primary"
aria-labelledby="caption-desktop"
class="display-button"
(click)="setMode(displayMode.Desktop)"
[disabled]="display.displayMode === displayMode.Desktop">
<mat-icon class="big-icon">desktop_windows</mat-icon>
</button>
</mat-grid-tile>
<mat-grid-tile>
<div id="caption-desktop" class="caption">{{ 'SHOW_DESKTOP' | translate | titlecase }}</div>
</mat-grid-tile>
<mat-grid-tile>
<button
mat-fab color="primary"
aria-labelledby="caption-presentation"
class="display-button"
(click)="setMode(displayMode.Presentation)"
[disabled]="display.displayMode === displayMode.Presentation">
<mat-icon class="big-icon">videocam</mat-icon>
</button>
</mat-grid-tile>
<mat-grid-tile>
<div id="caption-presentation" class="caption">{{ 'SHOW_PRESENTATION' | translate | titlecase }}</div>
</mat-grid-tile>
</mat-grid-list>
}
@else {
<mat-action-list>
<button
mat-list-item
aria-labelledby="caption-blank"
class="display-button"
(click)="setMode(displayMode.Blank)"
[disabled]="display.displayMode === displayMode.Blank">
<mat-icon class="small-icon">videocam_off</mat-icon>
<span id="caption-blank" class="caption">{{ 'SHOW_BLACK' | translate | titlecase }}</span>
</button>
<button
mat-list-item
aria-labelledby="caption-theme"
class="display-button"
(click)="setMode(displayMode.Theme)"
[disabled]="display.displayMode === displayMode.Theme">
<mat-icon class="small-icon">wallpaper</mat-icon>
<span id="caption-theme" class="caption">{{ 'SHOW_BACKGROUND' | translate | titlecase }}</span>
</button>
<button
mat-list-item
aria-labelledby="caption-desktop"
class="display-button"
(click)="setMode(displayMode.Desktop)"
[disabled]="display.displayMode === displayMode.Desktop">
<mat-icon class="small-icon">desktop_windows</mat-icon>
<span id="caption-desktop" class="caption">{{ 'SHOW_DESKTOP' | translate | titlecase }}</span>
</button>
<button
mat-list-item
aria-labelledby="caption-presentation"
class="display-button"
(click)="setMode(displayMode.Presentation)"
[disabled]="display.displayMode === displayMode.Presentation">
<mat-icon class="small-icon">videocam</mat-icon>
<span id="caption-presentation" class="caption">{{ 'SHOW_PRESENTATION' | translate | titlecase }}</span>
</button>
</mat-action-list>
}

View File

@ -0,0 +1,12 @@
.mat-icon {
vertical-align: text-top;
}
.small-icon {
padding-right: 10px;
}
div.caption {
font-size: 20px;
font-weight: bolder;
}

View File

@ -0,0 +1,21 @@
import { Component, Inject } from '@angular/core';
import { MatBottomSheetRef, MAT_BOTTOM_SHEET_DATA } from '@angular/material/bottom-sheet';
import { Display, DisplayMode } from 'src/app/responses';
@Component({
selector: 'openlp-display-mode-sheet',
templateUrl: 'display-mode-selector.component.html',
styleUrl: './display-mode-selector.component.scss'
})
export class DisplayModeSelectorComponent {
// Make DisplayMode enum visible in HTML template.
displayMode = DisplayMode;
constructor(
private bottomSheetRef: MatBottomSheetRef<DisplayModeSelectorComponent>,
@Inject(MAT_BOTTOM_SHEET_DATA) public display: Display) {}
setMode(mode: DisplayMode): void {
this.bottomSheetRef.dismiss(mode);
}
}

View File

@ -1,15 +1,31 @@
<h1 mat-dialog-title>Login</h1>
<h1 mat-dialog-title>{{ 'LOGIN' | translate | titlecase }}</h1>
<form #loginForm="ngForm">
<div mat-dialog-content>
<mat-form-field>
<input matInput placeholder="Username" [(ngModel)]="username" name="username" required>
<input
matInput
placeholder="{{ 'USER_NAME' | translate | titlecase }}"
[(ngModel)]="username"
name="username"
required>
</mat-form-field>
<mat-form-field>
<input matInput placeholder="password" type="password" [(ngModel)]="password" name="password" required>
<input
matInput
placeholder="{{ 'PASSWORD' | translate | titlecase }}"
type="password"
[(ngModel)]="password"
name="password"
required>
</mat-form-field>
</div>
<div mat-dialog-actions>
<button mat-raised-button id="loginButton" color="primary" [disabled]="!loginForm.form.valid" (click)="performLogin()">Login</button>
<button mat-raised-button id="loginButton"
color="primary"
[disabled]="!loginForm.form.valid"
(click)="performLogin()">
{{ 'LOGIN' | translate | titlecase }}
</button>
</div>
</form>
</form>

View File

@ -1,29 +1,43 @@
import { Component, OnInit } from '@angular/core';
import { Credentials } from '../../responses';
import { MatDialogRef, MatSnackBar } from '@angular/material';
import { Component } from '@angular/core';
import { MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TitleCasePipe } from '@angular/common';
import { TranslateService } from '@ngx-translate/core';
import { OpenLPService } from '../../openlp.service';
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.scss']
styleUrl: './login.component.scss'
})
export class LoginComponent implements OnInit {
export class LoginComponent {
username: string;
password: string;
constructor(private dialogRef: MatDialogRef<LoginComponent>, private openlpService: OpenLPService,
private snackBar: MatSnackBar) { }
ngOnInit() {
loginSucceededMessage: string;
loginFailedMessage: string;
constructor(
private dialogRef: MatDialogRef<LoginComponent>,
private openlpService: OpenLPService,
private snackBar: MatSnackBar,
private titleCasePipe: TitleCasePipe,
private translateService: TranslateService) {
this.translateService.stream('LOGIN_SUCCEEDED').subscribe(res => {
this.loginSucceededMessage = this.titleCasePipe.transform(res);
});
this.translateService.stream('LOGIN_FAILED').subscribe(res => {
this.loginFailedMessage = this.titleCasePipe.transform(res);
});
}
performLogin() {
this.openlpService.login({username: this.username, password: this.password}).subscribe(
result => {
this.snackBar.open('Successfully logged in', '', {duration: 2000});
this.openlpService.login({ username: this.username, password: this.password }).subscribe({
next: result => {
this.snackBar.open(this.loginSucceededMessage, '', { duration: 2000 });
this.dialogRef.close(result);
},
err => this.snackBar.open('Login failed', '', {duration: 2000})
);
error: () => this.snackBar.open(this.loginFailedMessage, '', { duration: 2000 })
});
}
}

View File

@ -0,0 +1,10 @@
<div class="lower-third">
<div class="slide">
<div
class="slide-item"
[style.font-size]="fontSize"
[style.font-family]="fontFamily">
{{currentSlides[activeSlide]?.text}}
</div>
</div>
</div>

View File

@ -0,0 +1,32 @@
.lower-third {
background:#00FF00;
min-height: 100vh;
justify-content: flex-end;
flex-direction: column;
display: flex;
width: 100%;
height: 100%;
&:not(.embedded) {
position: fixed;
left: 0;
top: 0;
z-index: 1200;
}
.slide {
margin-left: 2.5rem;
margin-right: 2.5rem;
white-space: pre-wrap;
margin-bottom: 2rem;
flex: 0;
text-align: center;
}
.slide-item {
font-size: 28pt;
line-height: 1.15;
font-weight: 700;
color: white;
text-shadow: 5px 5px 8px rgba(0, 0, 0, 0.7);
}
}

View File

@ -0,0 +1,65 @@
import { ChangeDetectorRef, Component, Input, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs';
import { SettingsService } from 'src/app/settings.service';
import { OpenLPService } from '../../openlp.service';
import { ServiceItem, Slide } from '../../responses';
@Component({
selector: 'app-lower-third',
templateUrl: './lower-third.component.html',
styleUrl: './lower-third.component.scss',
encapsulation: ViewEncapsulation.None
})
export class LowerThirdComponent implements OnInit, OnDestroy {
@Input() embedded = false;
serviceItem: ServiceItem = null;
currentSlides: Slide[] = [];
activeSlide = 0;
fontSize = '29pt';
fontFamily = null;
serviceItemSubscription$: Subscription = null;
constructor(
public openlpService: OpenLPService,
protected route: ActivatedRoute,
protected settingsService: SettingsService,
protected ref: ChangeDetectorRef
) {
this.route.queryParams.subscribe(params => {
this.fontSize = params['font-size'];
this.fontFamily = params['font-family'];
});
}
ngOnInit() {
this.updateCurrentSlides();
this.openlpService.stateChanged$.subscribe(() => this.updateCurrentSlides());
}
ngOnDestroy(): void {
}
updateCurrentSlides(): void {
this.serviceItemSubscription$?.unsubscribe();
this.serviceItemSubscription$ = this.openlpService.getServiceItem().subscribe(serviceItem => {
this.serviceItem = serviceItem;
if (serviceItem instanceof Array) {
this.setNewSlides(serviceItem);
}
else {
this.setNewSlides(serviceItem.slides);
}
});
}
setNewSlides(slides: Slide[]): void {
if (slides.length === 0) {
return;
}
this.currentSlides = slides;
this.activeSlide = slides.findIndex(s => s.selected);
}
}

View File

@ -1,3 +1,3 @@
<div class="overlay">
<img src="{{ img }}">
</div>
</div>

View File

@ -1,8 +1,7 @@
img {
position: absolute;
top: 0;
vertical-align: middle;
height: 100%;
max-height: 100%;
max-width: 100%;
margin: auto;
background-size: cover;
background-repeat: no-repeat;
}

View File

@ -12,7 +12,7 @@ export class MainViewComponent implements OnInit {
ngOnInit() {
this.updateImage();
this.openlpService.liveChanged$.subscribe(item => this.updateImage());
this.openlpService.stateChanged$.subscribe(() => this.updateImage());
}
updateImage(): void {

View File

@ -0,0 +1,29 @@
.no-items {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
&-title {
display: flex;
align-items: center;
color: gray;
.icon {
margin-right: 0.25em;
}
}
&-actions {
display: flex;
justify-content: center;
gap: 1em;
margin-top: 0.25em;
button, a {
text-decoration: none;
font-size: 0.813rem;
height: 28px;
}
}
}

View File

@ -1,64 +1,197 @@
@import "./overlay-common";
:root {
--openlp-stage-font-scale: 1;
--openlp-stage-image-scale: 1;
}
.overlay {
background: black;
width: 100%;
height: 100%;
background: black;
width: 100%;
height: 100%;
overflow: hidden;
color: white;
display: flex;
justify-content: flex-start;
flex-direction: row;
&:not(.embedded) {
position: fixed;
left: 0;
top: 0;
z-index: 1;
overflow: hidden;
color: white;
display: flex;
flex-direction: row;
justify-content: space-between;
z-index: 1200;
}
.active-slide-img {
/* properly size current slide thumbnail */
/* relative sizes might not work in real world */
/* If we get larger thumbnail sizes, we want to limit their size */
max-height: calc(75% * var(--openlp-stage-image-scale));
min-height: calc(250px * var(--openlp-stage-image-scale));
}
.active-slide-img-text {
@include slide-font-size(0.75);
}
.next-slides-img {
/* properly size thumbnail displayed in 2nd and subsequent slides */
/* If we get larger thumbnail sizes, we want to limit their size */
max-height: 20%;
min-height: 100px;
}
.next-slides-text {
@include slide-font-size(0.5);
}
&-content {
width: 100%;
max-width: 100%;
flex: 1;
@media (orientation: portrait) {
overflow: hidden;
flex: 1;
}
.tags {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
display: flex;
flex-direction: row;
justify-content: flex-start;
color: green;
align-items: center;
@include slide-font-size(1);
@media screen and (min-width: $mobile-breakpoint) {
margin-top: 1rem;
margin-bottom: 1rem;
font-size: 3rem;
}
span {
margin-left: 1rem;
&.active {
color: lightgreen;
font-weight: bold;
}
}
}
}
.sidebar {
padding: 0.8rem;
max-width: 30%;
margin-top: 1em;
overflow-y: auto;
.notes {
@include slide-font-size(0.9);
line-height: 1;
color: salmon;
text-align: right;
}
@media screen and (min-width: $mobile-breakpoint) {
margin-top: 4rem;
}
@media (orientation: portrait) {
max-width: none;
max-height: 20%;
margin-bottom: 3.75rem;
background-color: rgb(64, 64, 64);
.notes {
text-align: left;
}
}
}
@media (orientation: portrait) {
flex-direction: column;
flex-wrap: wrap;
}
.slide {
line-height: 1.2;
white-space: pre-line;
margin: 0;
&.first {
margin-top: 1rem;
}
@include slide-font-size();
}
}
.sidebar {
margin: 1rem;
display: flex;
flex-direction: column;
justify-content: space-between;
.toolbar {
padding: 0.8rem;
display: flex;
justify-content: flex-start;
align-items: center;
width: auto;
gap: 1.5rem;
.back-button {
background: #fff;
color: #000;
}
@media screen and (max-width: ($mobile-breakpoint - 0.125px)){
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(64, 64, 64, 0.5);
backdrop-filter: blur(2px);
padding: 0.6rem;
}
@media screen and (min-width: $mobile-breakpoint) {
position: absolute;
top: 0;
right: 0;
}
@media screen and (max-width: ($mobile-breakpoint - 0.125px)) and (orientation: landscape) {
left: auto;
border-top-left-radius: 0.5rem;
}
}
.close {
text-align: right;
}
.time {
font-size: 2rem;
color: gray;
}
color: yellow;
text-align: right;
white-space: nowrap;
flex: 1;
display: flex;
justify-content: flex-end;
align-items: center;
padding-right: 0.8rem;
font-size: 2rem;
.tags {
margin-top: 1rem;
margin-bottom: 1rem;
display: flex;
flex-direction: row;
justify-content: flex-start;
color: gray;
font-size: 4rem;
span {
margin-left: 1rem;
&.active {
color: white;
}
}
}
.slide {
@media screen and (min-width: $mobile-breakpoint) {
font-size: 3rem;
white-space: pre-line;
margin: 0;
&.first {
margin-top: 1rem;
}
}
}
.container {
margin-left: 1rem;
margin: 0 1rem;
}
.nextSlides {
font-size: 2rem;
margin-top: 1rem;
color: gray;
.slide {
font-size: 2rem;
}
}
margin-top: 1rem;
color: grey;
.slide {
@include slide-font-size(0.75);
}
}

View File

@ -10,7 +10,6 @@
* @licence Use this in any way you like, with no constraints.
*/
import { Pipe, PipeTransform } from '@angular/core';
import { MAT_AUTOCOMPLETE_SCROLL_STRATEGY_FACTORY } from '@angular/material';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@Pipe({ name: 'chordpro' })
@ -52,6 +51,8 @@ export class ChordProPipe implements PipeTransform {
notesSharpNotation = {};
notesFlatNotation = {};
private fillHtml = `<span class="fill"><span class="fill-inner"></span></span>`;
decodeHTML(value: string) {
const tempElement = document.createElement('div');
tempElement.innerHTML = value;
@ -60,11 +61,14 @@ export class ChordProPipe implements PipeTransform {
/**
* Pipe transformation for ChordPro-formatted song texts.
*
* @param {string} song
* @param {number} nHalfSteps
* @returns {string}
*/
transform(song: string, nHalfSteps: number): string|SafeHtml {
transform(song: string/*, nHalfSteps: number*/): string|SafeHtml {
// we hardcode nHalfSteps to 0, since the transposing is not used in OpenLP web
const nHalfSteps = 0;
try {
if (song !== undefined && song) {
return this.sanitizer.bypassSecurityTrustHtml(this.parseToHTML(song, nHalfSteps));
@ -104,15 +108,16 @@ export class ChordProPipe implements PipeTransform {
/**
* Transpose the given chord the given (positive or negative) number of half steps.
*
* @param {string} chordRoot
* @param {number} nHalfSteps
* @returns {string}
*/
transposeChord(chordRoot, nHalfSteps) {
let pos = -1;
for (let i = 0; i < this.keys.length; i++) {
if (this.keys[i].name === chordRoot) {
pos = this.keys[i].value;
for (const key of this.keys) {
if (key.name === chordRoot) {
pos = key.value;
break;
}
}
@ -124,9 +129,9 @@ export class ChordProPipe implements PipeTransform {
else if (pos > this.MAX_HALF_STEPS) {
pos -= this.MAX_HALF_STEPS + 1;
}
for (let i = 0; i < this.keys.length; i++) {
if (this.keys[i].value === pos) {
return this.keys[i].name;
for (const key of this.keys) {
if (key.value === pos) {
return key.name;
}
}
}
@ -144,40 +149,109 @@ export class ChordProPipe implements PipeTransform {
// becuase it gets messed up when a chord is placed on it..
// shouldn't be relevant if we actually get chordpro format
song = this.decodeHTML(song);
const comp = this;
if (!song) {
return '';
}
let chordText = '';
let lastChord = '';
if (!song.match(comp.chordRegex)) {
if (!song.match(this.chordRegex)) {
return `<div class="no-chords">${song}</div>`;
}
song.split(comp.chordRegex).forEach((part, index) => {
if (index % 2 === 0) {
// text
if (lastChord) {
chordText += `<span data-chord="${lastChord}">${part.substring(0, 1)}</span>${part.substring(1)}`;
lastChord = '';
} else {
chordText += part;
// Processing backwards so we can better identify where chords should overlap lyric letters
// or insert a space in lyrics
song.split(/\n/).reverse().forEach((row, index) => {
chordText = `</div>${index > 0 ? '<br>' : ''}` + chordText;
const rowParts = row.split(this.chordRegex);
let lastPart;
for (let i = rowParts.length - 1, r = 0, isFirst = true; i >= -1; i--, r++ ) {
if (!isFirst) {
chordText = this._processChordRow(nHalfSteps, rowParts[i], i, lastPart, r) + chordText;
}
} else {
lastPart = rowParts[i];
isFirst = false;
}
chordText = '<div class="song-row">' + chordText;
});
return `<div class="with-chords">${chordText}</div>`;
}
protected _processChordRow(nHalfSteps, part, index, lastPart, reverseIndex) {
if (index % 2 !== 0) {
if (index > 0) {
// chord
lastChord = part.replace(/[[]]/, '');
let chord = part.replace(/[[]]/, '');
if (nHalfSteps !== 0) {
lastChord = lastChord.split('/').map(chord => {
const chordRoot = comp.chordRoot(chord);
const newRoot = comp.transposeChord(chordRoot, nHalfSteps);
return newRoot + comp.restOfChord(chord);
chord = chord.split('/').map(chordPart => {
const chordRoot = this.chordRoot(chordPart);
const newRoot = this.transposeChord(chordRoot, nHalfSteps);
return newRoot + this.restOfChord(chordPart);
}).join('/');
}
// use proper symbols
lastChord = lastChord.replace(/b/g, '♭');
lastChord = lastChord.replace(/#/g, '♯');
chord = chord.replace(/b/g, '♭');
chord = chord.replace(/#/g, '♯');
const textFirstLetter = `${lastPart.substring(0, 1)}`;
const textRest = lastPart.substring(1);
const isChordMusicKey = chord.startsWith('=');
const chordLength = chord.length;
const isFirstChordAfterRealWord = (lastPart.length && (reverseIndex === 1));
const shouldOverlapChord = (!isChordMusicKey) && (isFirstChordAfterRealWord || (lastPart.length > chord.length));
const isChordOnly = !lastPart.trim().length;
const chordClass = (shouldOverlapChord && 'overlap-chord' || '') + (isChordOnly && ' chord-only' || '');
const textLength = 1 + textRest.length;
const fillHtmlNeededLength = (!shouldOverlapChord && (textLength < chordLength)) ? chordLength - 1 : (chordLength - 1);
let fillHtml = !shouldOverlapChord && !isChordMusicKey ? this._makeFillHtml(fillHtmlNeededLength || 1) : '';
let fillHtmlLength = fillHtml?.length ?? 0;
const finalTextLength = 1 + textRest.length + fillHtmlLength;
const needExtraFill = (!shouldOverlapChord && (finalTextLength <= chordLength));
if (needExtraFill) {
fillHtml = this._makeFillHtml(fillHtmlLength + 1);
fillHtmlLength++;
chord += ' ';
}
if (!lastPart || (textFirstLetter === ' ')) {
fillHtml = ' '.repeat(fillHtmlLength);
}
if ((fillHtmlLength === 1 && chordLength < 2)) {
// To match text separator
chord += ' ';
}
return `<span data-chord="${chord}" class="${chordClass}">` +
`<span class="chord">${chord}</span>` +
`<span class="text ${fillHtml ? 'with-fill' : ''}">` +
`<span class="first-letter">${textFirstLetter}</span>` +
`${fillHtml}` +
`</span>` +
`</span>${textRest}`;
} else {
return `${lastPart}`;
}
});
return `<div class="with-chords">${chordText}</div>`;
}
return '';
}
protected _makeFillHtml(length) {
if (length >= 3) {
let text = '';
const middle = Math.floor(length / 2);
for (let i = 0; i < length; i++) {
text += (i === middle) ? '\u2014' : ' ';
}
return text;
} else {
return '\u00B7' + (length === 2 ? ' ' : '');
}
}
}

View File

@ -0,0 +1,17 @@
import { Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@Pipe({name: 'nl2br'})
export class Nl2BrPipe implements PipeTransform {
constructor(private sanitizer: DomSanitizer) { }
transform(value: string): string | SafeHtml {
if (!value) {
return value;
}
if (typeof value !== 'string') {
throw Error(`Invalid pipe argument: '${value}' for pipe 'Nl2BrPipe'`);
}
return this.sanitizer.bypassSecurityTrustHtml(value.replace(/(?:\r\n|\r|\n)/g, '<br>'));
}
}

View File

@ -0,0 +1,17 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({name: 'sentencecase'})
export class SentenceCasePipe implements PipeTransform {
transform(value: string): string {
if (!value) {
return value;
}
if (typeof value !== 'string') {
throw Error(`Invalid pipe argument: '${value}' for pipe 'SentenceCasePipe'`);
}
const sentenceEndMarker: string = '. '
return value.split(sentenceEndMarker).map(
(sentence) => sentence = sentence.charAt(0).toUpperCase() + sentence.slice(1).toLowerCase()
).join(sentenceEndMarker);
}
}

View File

@ -0,0 +1,23 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({name: 'shortcut'})
export class ShortcutPipe implements PipeTransform {
transform(value: string): string {
if (!value) {
return value;
}
if (typeof value !== 'string') {
throw Error(`Invalid pipe argument: '${value}' for pipe 'ShortcutPipe'`);
}
value = value.replace('.', 'code.period');
value = value.replace('PgUp', 'pageup');
value = value.replace('PgDown', 'pagedown');
value = value.replace('Up', 'arrowup');
value = value.replace('Down', 'arrowdown');
value = value.replace('Left', 'arrowleft');
value = value.replace('Right', 'arrowright');
value = value.replace(/(Alt|Shift)\+/, '$1.');
value = value.replace(/Ctrl\+/, 'control.');
return value.toLowerCase();
}
}

View File

@ -0,0 +1,16 @@
<mat-form-field >
<mat-select
[(ngModel)]="selectedSearchOption"
(selectionChange)="setSearchOption($event)"
name="selectedSearchOption"
[placeholder]="searchOptionsTitle">
@for (option of searchOptions; track option) {
<mat-option
name="searchOptions"
[value]="option">
{{ option }}
</mat-option>
}
</mat-select>
</mat-form-field>
<br>

View File

@ -0,0 +1,3 @@
mat-form-field {
width: 100%;
}

View File

@ -0,0 +1,53 @@
import { Component } from '@angular/core';
import { TitleCasePipe } from '@angular/common';
import { TranslateService } from '@ngx-translate/core';
import { OpenLPService } from '../../../openlp.service';
@Component({
selector: 'openlp-search-options',
templateUrl: './search-options.component.html',
styleUrl: './search-options.component.scss',
providers: [OpenLPService]
})
export class SearchOptionsComponent {
public selectedPlugin: string;
public searchOptions: Array<string>;
public selectedSearchOption: string;
public searchOptionsTitle: string;
constructor(
private openlpService: OpenLPService,
private titleCasePipe: TitleCasePipe,
private translateService: TranslateService) { }
// Used to display search-options for certain plugins
onPluginChange(plugin) {
this.selectedPlugin = plugin;
if (this.selectedPlugin === 'bibles') {
this.translateService.stream('BIBLE_VERSION').subscribe(res => {
this.searchOptionsTitle = this.titleCasePipe.transform(res) + ':';
});
this.getSearchOptions();
}
}
getSearchOptions() {
this.openlpService.getSearchOptions(this.selectedPlugin).subscribe(res => {
if (this.selectedPlugin === 'bibles') {
for (const option of res) {
if (option.name === 'primary bible') {
this.searchOptions = option['list'];
this.selectedSearchOption = option['selected'];
break;
}
}
}
});
}
setSearchOption(target) {
this.openlpService.setSearchOption(this.selectedPlugin, 'primary bible', target.value).subscribe(() => {});
this.selectedSearchOption = target.value;
}
}

View File

@ -1,29 +1,72 @@
<h3>Search</h3>
<h3>{{ 'SEARCH' | translate | titlecase }}</h3>
<form #searchForm="ngForm">
<mat-form-field>
<mat-select [(ngModel)]="selectedPlugin" name="selectedPlugin" placeholder="Search for:">
<mat-option *ngFor="let plugin of searchPlugins" name="searchPlugins" [value]="plugin.key">
{{plugin.name}}
</mat-option>
</mat-select>
</mat-form-field>
<br>
<mat-form-field>
<input matInput [(ngModel)]="searchText" name="searchText" placeholder="Search Text" required>
</mat-form-field>
<mat-form-field>
<mat-select
[(ngModel)]="selectedPlugin"
(selectionChange)="onPluginChange()"
name="selectedPlugin"
placeholder="Search for:">
@for (plugin of searchPlugins; track plugin.name) {
<mat-option
name="searchPlugins"
[value]="plugin.key">
{{ plugin.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
<br>
<button mat-raised-button id="searchButton" color="primary" [disabled]="!searchForm.form.valid" (click)="onSubmit()">Search</button>
</form>
<div *ngIf="searchResults">
<h3>Search Results:</h3>
<div *ngIf="!searchResults.length">
No Results matching your search were found...
<div [hidden]="!displaySearchOptions">
<openlp-search-options></openlp-search-options>
</div>
<table *ngIf="searchResults.length">
<tr *ngFor="let item of searchResults">
<td>{{item[1]}}</td>
<td><button mat-button color="primary" (click)="addToService(item[0])">Add</button></td>
<td><button mat-button color="accent" (click)="sendLive(item[0])">Send Live</button></td>
</tr>
</table>
</div>
<mat-form-field>
<input
matInput
[(ngModel)]="searchText"
name="searchText"
placeholder="{{ 'SEARCH_TEXT' | translate | titlecase }}"
required>
</mat-form-field>
<br>
<button
mat-raised-button
id="searchButton"
color="primary"
[disabled]="!searchForm.form.valid"
(click)="onSubmit()">
{{ 'SEARCH' | translate | titlecase }}
</button>
</form>
@if (searchResults) {
<div>
<h3>{{ 'SEARCH_RESULTS' | translate | titlecase }}:</h3>
@if (searchResults.length) {
<table>
@for (item of searchResults; track item) {
<tr>
<td>{{ item[1] }}</td>
<td>
<button
mat-button color="primary"
(click)="addToService(item[0])">
{{ 'ADD_TO_SERVICE' | translate | sentencecase }}
</button>
</td>
<td>
<button
mat-button color="accent"
(click)="sendLive(item[0])">
{{ 'SEND_LIVE' | translate | sentencecase }}
</button>
</td>
</tr>
}
</table>
}
@else {
<div>
{{ 'NO_SEARCH_RESULTS' | translate | sentencecase }}...
</div>
}
</div>
}

View File

@ -1,24 +1,34 @@
import { Component, OnInit } from '@angular/core';
import { AfterViewInit, ChangeDetectorRef, Component, OnInit, ViewChild } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { OpenLPService } from '../../openlp.service';
import { PageTitleService } from '../../page-title.service';
import { PluginDescription } from '../../responses';
import { SearchOptionsComponent } from './search-options/search-options.component';
@Component({
selector: 'openlp-search',
templateUrl: './search.component.html',
styleUrls: ['./search.component.scss'],
styleUrl: './search.component.scss',
providers: [OpenLPService]
})
export class SearchComponent implements OnInit {
export class SearchComponent implements OnInit, AfterViewInit {
public searchPlugins: PluginDescription[] = [];
public searchText = null;
public searchResults = null;
public selectedPlugin = 'songs';
public selectedPlugin: string;
public currentPlugin: string;
public displaySearchOptions = false;
@ViewChild(SearchOptionsComponent, {static: false}) searchOptions: SearchOptionsComponent;
constructor(private pageTitleService: PageTitleService, private openlpService: OpenLPService) {
pageTitleService.changePageTitle('Search');
constructor(
private pageTitleService: PageTitleService,
private openlpService: OpenLPService,
private cdr: ChangeDetectorRef,
private translateService: TranslateService) {
this.translateService.stream('SEARCH').subscribe(res => {
this.pageTitleService.changePageTitle(res);
});
}
onSubmit() {
@ -26,15 +36,40 @@ export class SearchComponent implements OnInit {
this.openlpService.search(this.currentPlugin, this.searchText).subscribe(items => this.searchResults = items);
}
// Used to display search-options for certain plugins
onPluginChange() {
if (this.selectedPlugin === 'bibles') {
this.searchOptions.onPluginChange(this.selectedPlugin);
this.displaySearchOptions = true;
}
else {
if (this.displaySearchOptions) {
this.displaySearchOptions = false;
}
}
localStorage.setItem('selectedPlugin', this.selectedPlugin);
}
sendLive(id) {
this.openlpService.sendItemLive(this.currentPlugin, id).subscribe(res => {});
this.openlpService.sendItemLive(this.currentPlugin, id).subscribe(() => {});
}
addToService(id) {
this.openlpService.addItemToService(this.currentPlugin, id).subscribe(res => {});
this.openlpService.addItemToService(this.currentPlugin, id).subscribe(() => {});
}
ngOnInit() {
this.openlpService.getSearchablePlugins().subscribe(items => this.searchPlugins = items);
// Retrieve the last selected plugin. Set to 'songs' if it isn't set.
if (localStorage.getItem('selectedPlugin') === null) {
localStorage.setItem('selectedPlugin', 'songs');
}
this.selectedPlugin = localStorage.getItem('selectedPlugin');
}
ngAfterViewInit() {
this.onPluginChange();
this.cdr.detectChanges();
}
}

View File

@ -0,0 +1,8 @@
<mat-card
(click)="onItemSelected(item)"
class="service-item no-select"
[class.selected]="selected">
<mat-card-content>
<mat-icon>{{ getIcon(item) }}</mat-icon> {{ item.title }}
</mat-card-content>
</mat-card>

View File

@ -0,0 +1,9 @@
.selected {
background-color: rgb(235, 235, 235);
font-weight: 700;
}
/* Align icons with text */
.mat-icon {
line-height: inherit !important;
}

View File

@ -0,0 +1,40 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { ServiceItem } from '../../../responses';
@Component({
selector: 'openlp-service-item',
templateUrl: './service-item.component.html',
styleUrl: './service-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ServiceItemComponent {
@Input() item: ServiceItem;
@Input() selected = false;
@Output() selectItem = new EventEmitter<ServiceItem>();
onItemSelected(item: ServiceItem) {
this.selectItem.emit(item);
}
getIcon(item: ServiceItem): string {
if (!item.is_valid) {
return 'delete';
} else if (item.plugin === 'songs') {
return 'queue_music';
} else if (item.plugin === 'images') {
return 'image';
} else if (item.plugin === 'bibles') {
return 'book';
} else if (item.plugin === 'media') {
return 'movie';
} else if (item.plugin === 'custom') {
return 'description';
} else if (item.plugin === 'presentations') {
return 'slideshow';
}
return 'crop_square';
}
}

View File

@ -0,0 +1,29 @@
@if (items?.length) {
<ng-container>
@for (item of items; track item) {
<openlp-service-item
[item]="item"
[selected]="item.selected"
(selectItem)="onItemSelected($event)"
[tabindex]="item.id">
</openlp-service-item>
}
</ng-container>
}
@else if (!loading) {
<div class="no-items">
<div class="no-items-title">
<span class="material-icons icon">info</span>
{{ 'NO_SERVICE_ITEMS' | translate | sentencecase }}.
</div>
<div class="no-items-actions">
<a
routerLink="/search"
mat-stroked-button color="primary"
size="small">
<span class="material-icons">add</span>
{{ 'ADD_ITEM' | translate | sentencecase }}
</a>
</div>
</div>
}

View File

@ -0,0 +1,46 @@
import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
import { Subscription } from 'rxjs';
import { OpenLPService } from '../../../openlp.service';
import { ServiceItem } from '../../../responses';
@Component({
selector: 'openlp-service-list',
templateUrl: './service-list.component.html',
styleUrls: ['./service-list.component.scss', '../../no-items.scss']
})
export class ServiceListComponent implements OnInit, OnDestroy {
items: ServiceItem[] = [];
_subscription: Subscription;
loading = false;
@Output() itemSelected = new EventEmitter<ServiceItem>();
ngOnInit() {
this.fetchServiceItems();
}
onItemSelected(item: ServiceItem) {
this.itemSelected.emit(item);
}
fetchServiceItems() {
this.loading = true;
this.openlpService.getServiceItems().subscribe(items => {
this.items = items;
this.loading = false;
});
}
constructor(private openlpService: OpenLPService) {
this._subscription = openlpService.stateChanged$.subscribe(() => {
this.fetchServiceItems();
});
}
ngOnDestroy() {
this._subscription.unsubscribe();
}
}

View File

@ -1,3 +1,3 @@
<mat-card *ngFor="let item of items; let counter = index;" (click)="onItemSelected(counter)" [tabindex]="counter" class="service-item">
<mat-icon>{{ getIcon(item) }}</mat-icon> {{ item.title }}
</mat-card>
<openlp-service-list
(itemSelected)="this.onItemSelected($event)">
</openlp-service-list>

View File

@ -1,5 +1,6 @@
import { Component, OnInit } from '@angular/core';
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { OpenLPService } from '../../openlp.service';
import { PageTitleService } from '../../page-title.service';
@ -8,47 +9,24 @@ import { ServiceItem } from '../../responses';
@Component({
selector: 'openlp-service',
templateUrl: './service.component.html',
styleUrls: ['./service.component.scss'],
providers: [OpenLPService]
styleUrl: './service.component.scss',
})
export class ServiceComponent implements OnInit {
items: ServiceItem[] = [];
ngOnInit() {
this.getServiceItems();
}
onItemSelected(item) {
this.openlpService.setServiceItem(item).subscribe(res => {});
this.router.navigate(['slides']);
}
getServiceItems() {
this.openlpService.getServiceItems().subscribe(items => this.items = items);
}
constructor(private pageTitleService: PageTitleService, private openlpService: OpenLPService,
private router: Router) {
pageTitleService.changePageTitle('Service');
openlpService.stateChanged$.subscribe(item => this.getServiceItems());
}
getIcon(item: ServiceItem): string {
if (item.plugin === 'songs') {
return 'queue_music';
} else if (item.plugin === 'images') {
return 'image';
} else if (item.plugin === 'bibles') {
return 'book';
} else if (item.plugin === 'media') {
return 'movie';
} else if (item.plugin === 'custom') {
return 'description';
} else if (item.plugin === 'presentations') {
return 'slideshow';
export class ServiceComponent {
onItemSelected(item: ServiceItem) {
if (item.is_valid) {
this.openlpService.setServiceItem(item.id).subscribe();
this.router.navigate(['slides']);
}
return 'crop_square';
}
constructor(
protected pageTitleService: PageTitleService,
protected openlpService: OpenLPService,
protected router: Router,
private translateService: TranslateService) {
this.translateService.stream('SERVICE').subscribe(res => {
this.pageTitleService.changePageTitle(res);
});
}
}

View File

@ -0,0 +1,70 @@
<div class="settings-panel">
<mat-card>
<mat-card-header>
{{ 'USER_INTERFACE' | translate | titlecase }}
</mat-card-header>
<mat-card-content>
<div class="settings-item">
<mat-slide-toggle
color="primary"
[checked]="settings.fastSwitching"
(change)="setSetting('fastSwitching', $event.checked)">
{{ 'ENABLE_FAST_SWITCHING_PANEL' | translate | sentencecase }}
</mat-slide-toggle>
</div>
<div class="settings-item">
<mat-slide-toggle
color="primary"
[checked]="settings.bigDisplayButtons"
(change)="setSetting('bigDisplayButtons', $event.checked)">
{{ 'ENABLE_BIG_DISPLAY_BUTTONS' | translate | sentencecase }}
</mat-slide-toggle>
</div>
</mat-card-content>
</mat-card>
<mat-card>
<mat-card-header>
{{ 'STAGE_AND_CHORDS_APPEARANCE' | translate | sentencecase }}
</mat-card-header>
<mat-card-content>
<mat-tab-group>
<mat-tab label="{{ 'STAGE' | translate | titlecase }}">
<ng-template matTabContent>
<ng-container>
<openlp-stage-chord-preview stageType="stage">
</openlp-stage-chord-preview>
<ng-container *ngTemplateOutlet="stageSettings; context: {prefix: 'stage'}">
</ng-container>
</ng-container>
</ng-template>
</mat-tab>
<mat-tab label="{{ 'CHORDS' | translate | titlecase }}">
<ng-template matTabContent>
<openlp-stage-chord-preview stageType="chords">
</openlp-stage-chord-preview>
<ng-container *ngTemplateOutlet="stageSettings; context: {prefix: 'chords'}">
</ng-container>
</ng-template>
</mat-tab>
</mat-tab-group>
</mat-card-content>
</mat-card>
</div>
<ng-template
#stageSettings
let-prefix="prefix">
<div class="stage-settings">
<div class="settings-item">
<label>{{ 'FONT_SCALE' | translate | sentencecase }}: {{settings[prefix + 'FontScale'] ?? 100}}%</label>
<mat-slider
min="25"
max="200"
step="6.25">
<input
matSliderThumb
[value]="settings[prefix + 'FontScale']"
(valueChange)="setSetting(prefix + 'FontScale', $event)">
</mat-slider>
</div>
</div>
</ng-template>

View File

@ -0,0 +1,26 @@
.settings-panel {
max-width: 600px;
margin: 0 auto;
@media screen and (min-height: 836px) {
max-width: 768px;
}
}
.settings-item {
font-size: 1rem;
&:first-child {
margin-top: 1em;
}
label {
width: 100%;
display: block;
margin-bottom: -1em;
}
mat-slider {
width: calc(100% - 3rem);
margin: 0 auto;
}
}

View File

@ -0,0 +1,42 @@
import { Component, OnDestroy } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { Subscription } from 'rxjs';
import { OpenLPService } from '../../openlp.service';
import { PageTitleService } from '../../page-title.service';
import { SettingsProperties, SettingsPropertiesItem, SettingsService } from '../../settings.service';
@Component({
selector: 'openlp-settings',
templateUrl: './settings.component.html',
styleUrl: './settings.component.scss'
})
export class SettingsComponent implements OnDestroy {
constructor(
protected pageTitleService: PageTitleService,
protected openlpService: OpenLPService,
protected settingsService: SettingsService,
private translateService: TranslateService) {
this.settingsSubscription$ = settingsService.settingChanged$.subscribe(this._settingChanged);
this.translateService.stream('SETTINGS').subscribe(res => {
this.pageTitleService.changePageTitle(res);
});
}
protected settingsSubscription$: Subscription;
settings: Partial<SettingsProperties> = this.settingsService.getAll();
setSetting<SP extends keyof SettingsProperties, SV = SettingsProperties[SP]>(property: SP, value: SV) {
this.settingsService.set(property, value);
}
_settingChanged = <SP extends keyof SettingsProperties, SV = SettingsProperties[SP]>(
value: SettingsPropertiesItem<SP, SV>
) => {
this.settings = {...this.settings, [value.property]: value.value};
};
ngOnDestroy(): void {
this.settingsSubscription$.unsubscribe();
}
}

View File

@ -0,0 +1,18 @@
<div
class="stage-preview-container"
#stageViewContainer>
@if (stageType === 'stage') {
<app-stage-view
#stageView
[embedded]="true"
[style.--openlp-stage-font-scale]="fontScale">
</app-stage-view>
}
@else if (stageType === 'chords') {
<app-chord-view
#chordsView
[embedded]="true"
[style.--openlp-stage-font-scale]="fontScale">
</app-chord-view>
}
</div>

View File

@ -0,0 +1,14 @@
.stage-preview-container {
position: relative;
> * {
pointer-events: none;
display: block;
position: absolute;
left: 0;
top: 0;
width: 100%;
border: none;
transform-origin: 0 0;
}
}

View File

@ -0,0 +1,107 @@
import {
AfterViewInit,
ChangeDetectorRef,
Component,
ElementRef,
Input,
OnChanges,
OnDestroy,
OnInit,
SimpleChanges,
ViewChild
} from '@angular/core';
import { fromEvent, Subscription } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import { SettingsProperties, SettingsService } from 'src/app/settings.service';
@Component({
selector: 'openlp-stage-chord-preview',
templateUrl: './stage-chord-preview.component.html',
styleUrl: './stage-chord-preview.component.scss',
})
export class StageChordPreviewComponent implements OnInit, AfterViewInit, OnDestroy, OnChanges {
constructor(
protected settingsService: SettingsService,
protected ref: ChangeDetectorRef
) {
this.windowResizeSubscription$ = fromEvent(window, 'resize')
.pipe(debounceTime(300))
.subscribe(() => this._resizeElement());
}
@Input() stageType: 'stage' | 'chords' = 'stage';
@ViewChild('stageView', {read: ElementRef}) stageView: ElementRef<HTMLElement>;
@ViewChild('chordsView', {read: ElementRef}) chordsView: ElementRef<HTMLElement>;
@ViewChild('stageViewContainer') stageViewContainer: ElementRef<HTMLElement>;
fontScale: number;
protected windowResizeSubscription$: Subscription;
protected settingChangedSubscription$: Subscription;
ngOnInit(): void {
this.fontScale = this.settingsService.get(
this.stageType + 'FontScale' as keyof SettingsProperties
) as number / 100;
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['stageType'].currentValue !== changes['stageType'].previousValue) {
this.settingChangedSubscription$?.unsubscribe();
this.settingChangedSubscription$ = this.settingsService
.onPropertyChanged(changes['stageType'].currentValue + 'FontScale' as keyof SettingsProperties)
.subscribe(value => {
this.fontScale = value as number / 100;
this.ref.detectChanges();
});
}
}
ngAfterViewInit(): void {
if (this._getStageViewElement()?.nativeElement) {
this._resizeElement();
}
}
ngOnDestroy(): void {
this.windowResizeSubscription$?.unsubscribe();
this.settingChangedSubscription$?.unsubscribe();
}
_getStageViewElement() {
switch (this.stageType) {
case 'stage':
return this.stageView;
case 'chords':
return this.chordsView;
}
}
_resizeElement() {
const viewElement = this._getStageViewElement();
if (this.stageViewContainer?.nativeElement && viewElement?.nativeElement) {
// Resetting container to 100% width before calculating
this.stageViewContainer.nativeElement.style.width = '100%';
this.stageViewContainer.nativeElement.style.height = 'auto';
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const {width: containerWidth} = this.stageViewContainer?.nativeElement.getBoundingClientRect();
let zoomScale = containerWidth / windowWidth;
const targetContainerHeight = containerWidth * (windowHeight / windowWidth);
let scaleTranslate = '';
if (targetContainerHeight > (windowHeight * 0.6)) {
zoomScale *= 0.5;
scaleTranslate = ' translateX(50%)';
}
// Setting the container width + height to scale after
this.stageViewContainer.nativeElement.style.width = (windowWidth * zoomScale) + 'px';
this.stageViewContainer.nativeElement.style.height = (windowHeight * zoomScale) + 'px';
viewElement.nativeElement.style.width = windowWidth + 'px';
viewElement.nativeElement.style.height = windowHeight + 'px';
viewElement.nativeElement.style.transform = `scale(${zoomScale})${scaleTranslate}`;
}
}
}

View File

@ -0,0 +1,20 @@
<mat-card
class="slide no-select"
mat-list-item
(click)="onSlideSelected(slide)"
[class.selected]="selected">
<mat-card-content>
<div class="verse-tag">{{ slide?.tag }}</div>
@if (slide?.img) {
<div class="verse-img-container">
<img src="{{ slide?.img }}" />
<div class="img-verse-text">
{{ slide?.text }}
</div>
</div>
}
@else {
<div class="verse-text">{{ slide?.text }}</div>
}
</mat-card-content>
</mat-card>

View File

@ -0,0 +1,35 @@
.slide {
cursor: pointer;
}
.selected {
background-color: rgb(235, 235, 235);
font-weight: 700;
}
.verse-tag {
float: left;
}
.verse-text {
margin-left: 2.5rem;
white-space: pre-wrap;
}
/* Styles for displaying thumbnails */
.verse-img-container {
/* CSS for formatting DIV containing the image */
margin-left: 2.5rem;
display: inline-block;
}
.verse-img-container img {
/* Roughly basing these values off of the current thumbnail size */
/* Images sent from OpenLP should be larger and should be sized based on viewer screen size */
max-height: 75%;
min-height: 88px;
width: 100%;
}
.img-verse-text {
font-size: 1.1rem;
white-space: pre-wrap;
}

View File

@ -0,0 +1,20 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { Slide } from '../../../responses';
@Component({
selector: 'openlp-slide-item',
templateUrl: './slide-item.component.html',
styleUrl: './slide-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SlideItemComponent {
@Input() slide: Slide;
@Input() selected = false;
@Output() selectSlide = new EventEmitter<Slide>();
onSlideSelected(slide: Slide) {
this.selectSlide.emit(slide);
}
}

View File

@ -0,0 +1,32 @@
@if (slides?.length) {
<ng-container>
@for (slide of slides; track slide; let index = $index) {
<openlp-slide-item
[slide]="slide"
[tabindex]="index"
[selected]="slide.selected"
(selectSlide)="onSlideSelected($event, index)">
</openlp-slide-item>
}
</ng-container>
}
@else {
@if (!loading) {
<div class="no-items">
<div class="no-items-title">
<span class="material-icons icon">info</span>
{{ 'NO_SLIDE_ITEMS' | translate | sentencecase }}.
</div>
<div class="no-items-actions">
<a
routerLink="/search"
mat-stroked-button
color="primary"
size="small">
<span class="material-icons">add</span>
{{ 'ADD_ITEM_TO_SERVICE' | translate | sentencecase }}
</a>
</div>
</div>
}
}

View File

@ -0,0 +1,65 @@
import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
import { Subscription } from 'rxjs';
import { Slide } from '../../../responses';
import { OpenLPService } from '../../../openlp.service';
@Component({
selector: 'openlp-slide-list',
templateUrl: './slide-list.component.html',
styleUrls: ['./slide-list.component.scss', '../../no-items.scss'],
})
export class SlideListComponent implements OnInit, OnDestroy {
slides: Slide[] = null;
@Output() slideSelected = new EventEmitter<SlideListItem>();
_subscription: Subscription;
loading = false;
constructor(private openlpService: OpenLPService) {
this._subscription = openlpService.stateChanged$.subscribe(() =>
this.fetchSlides()
);
}
ngOnInit() {
this.fetchSlides();
}
ngOnDestroy() {
this._subscription.unsubscribe();
}
onSlideSelected(slide: Slide, index: number) {
this.slideSelected.emit({slide, index});
}
fetchSlides() {
this.loading = true;
this.openlpService.getServiceItem().subscribe({
next: (serviceItem) => {
this.loading = false;
if (serviceItem instanceof Array) {
this.slides = serviceItem;
} else {
this.slides = serviceItem.slides;
}
},
complete: () => {
setTimeout(() => this.scrollToCurrentItem(), 25);
}
});
}
scrollToCurrentItem() {
document.querySelectorAll('openlp-slide-item .selected')[0]?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}
}
export interface SlideListItem {
slide: Slide;
index: number;
}

View File

@ -1,4 +1,2 @@
<mat-card mat-list-item *ngFor="let slide of slides; let counter = index;" (click)="onSlideSelected(counter)" [class.selected]="slide.selected">
<div class="verse-tag">{{ slide.tag }}</div>
<div class="verse-text">{{ slide.text }}</div>
</mat-card>
<openlp-slide-list (slideSelected)="onSlideSelected($event)">
</openlp-slide-list>

View File

@ -1,17 +0,0 @@
mat-card {
cursor: pointer;
}
.selected {
background-color: rgb(235, 235, 235);
font-weight: 700;
}
.verse-tag {
float: left;
}
.verse-text {
margin-left: 2.5rem;
white-space: pre-wrap;
}

View File

@ -1,32 +1,27 @@
import { Component, OnInit } from '@angular/core';
import { Component } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { OpenLPService } from '../../openlp.service';
import { PageTitleService } from '../../page-title.service';
import { SlideListItem } from './slide-list/slide-list.component';
@Component({
selector: 'openlp-slides',
templateUrl: './slides.component.html',
styleUrls: ['./slides.component.scss'],
providers: [OpenLPService]
styleUrl: './slides.component.scss',
})
export class SlidesComponent implements OnInit {
slides = null;
constructor(private pageTitleService: PageTitleService, private openlpService: OpenLPService) {
pageTitleService.changePageTitle('Slides');
openlpService.stateChanged$.subscribe(item => this.getSlides());
export class SlidesComponent {
constructor(
protected pageTitleService: PageTitleService,
protected openlpService: OpenLPService,
private translateService: TranslateService) {
this.translateService.stream('SLIDES').subscribe(res => {
this.pageTitleService.changePageTitle(res);
});
}
ngOnInit() {
this.getSlides();
}
onSlideSelected(item) {
this.openlpService.setSlide(item).subscribe(res => {});
}
getSlides() {
this.openlpService.getItemSlides().subscribe(slides => this.slides = slides);
onSlideSelected(item: SlideListItem) {
this.openlpService.setSlide(item.index).subscribe();
}
}

View File

@ -0,0 +1,21 @@
<div
class="slide"
[class.mat-headline-2]="active"
[class.currentSlide]="active"
[class.mat-headline-4]="!active"
[class.first]="!active && slide.first_slide_of_tag">
@if (slide?.img) {
<img
src="{{slide?.img}}"
[class.active-slide-img]="active"
[class.next-slides-img]="!active"/>
<div
[class.active-slide-img-text]="active"
[class.next-slides-text]="!active">{{ slide?.text }}</div>
}
@else {
<ng-container>
{{ slide?.text }}
</ng-container>
}
</div>

View File

@ -0,0 +1,12 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { Slide } from '../../../responses';
@Component({
selector: 'app-stage-view-item',
templateUrl: './stage-view-item.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class StageViewItemComponent {
@Input() slide: Slide;
@Input() active = false;
}

View File

@ -1,21 +1,70 @@
<div class="overlay">
<div>
<div
class="overlay"
[class.embedded]="embedded"
[style.--openlp-stage-font-scale]="fontScale">
<div class="overlay-content">
<div class="tags">
<span *ngFor="let tag of tags" [class.active]="tag.active">{{ tag.text }}</span>
@for (tag of tags; track tag) {
<span [class.active]="tag.active">{{ tag.text }}</span>
}
</div>
<div class="container">
<div class="slide currentSlide mat-display-3">
{{ currentSlides[activeSlide]?.text }}
</div>
<app-stage-view-item
[slide]="currentSlides[activeSlide]"
[active]="true">
</app-stage-view-item>
<div class="nextSlides">
<div class="slide mat-display-1" [class.first]="slide.first_slide_of_tag" *ngFor="let slide of nextSlides">
{{ slide.text }}
</div>
@for (slide of nextSlides; track trackByIndex) {
<app-stage-view-item [slide]="slide">
</app-stage-view-item>
}
</div>
</div>
</div>
<div class="sidebar">
<div class="time">{{ time|date:'HH:mm' }}</div>
<button mat-raised-button class="closeButton" routerLink="/">Close</button>
<div class="toolbar">
@if (!embedded) {
<a
class="back-button"
mat-mini-fab color=""
routerLink="/"
matTooltip="{{ 'GO_BACK_TO_CONTROLLER' | translate | titlecase }}">
<mat-icon>arrow_back</mat-icon>
</a>
}
@if (showNotes) {
<button
mat-mini-fab
class="show-notes"
matTooltip="{{ 'HIDE_NOTES' | translate | titlecase }}"
color="primary"
[class.show-notes-disabled]="false"
(click)="showNotes = false">
<mat-icon>sticky_note_2</mat-icon>
</button>
}
@else {
<button
mat-mini-fab
class="show-notes"
matTooltip="{{ 'SHOW_NOTES' | translate | titlecase }}"
color=""
[class.show-notes-disabled]="true"
(click)="showNotes = true">
<mat-icon>sticky_note_2</mat-icon>
</button>
}
@if (!embedded && activeSlide+1 === currentSlides.length) {
<div
class="next-service-item"
matTooltip="{{ 'NEXT_ITEM' | translate | titlecase }}">
{{ nextServiceItemTitle }}
</div>
}
<div class="time">{{ (openlpService.getIsTwelveHourTime()) ? (time|date:'h:mm a') : (time|date:'HH:mm') }}</div>
</div>
</div>
@if ((showNotes || embedded) && notes) {
<div class="sidebar">
<div class="notes" [innerHTML]="notes|nl2br"></div>
</div>
}
</div>

View File

@ -1,6 +1,9 @@
import { Component, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, Input, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs';
import { SettingsProperties, SettingsService } from 'src/app/settings.service';
import { OpenLPService } from '../../openlp.service';
import { Slide } from '../../responses';
import { ServiceItem, Slide } from '../../responses';
interface Tag {
text: string;
@ -10,39 +13,103 @@ interface Tag {
@Component({
selector: 'app-stage-view',
templateUrl: './stage-view.component.html',
styleUrls: ['./stage-view.component.scss', '../overlay.scss']
styleUrls: ['./stage-view.component.scss', '../overlay.scss'],
encapsulation: ViewEncapsulation.None
})
export class StageViewComponent implements OnInit {
export class StageViewComponent implements OnInit, OnDestroy {
@Input() embedded = false;
serviceItem: ServiceItem = null;
nextServiceItemTitle = '';
notes = '';
currentSlides: Slide[] = [];
activeSlide = 0;
tags: Tag[] = [];
time = new Date();
constructor(private openlpService: OpenLPService) {
showNotes = true;
fontScale: number;
serviceItemSubscription$: Subscription = null;
fontScaleSubscription$: Subscription;
stageProperty = 'stage';
constructor(
public openlpService: OpenLPService,
protected route: ActivatedRoute,
protected settingsService: SettingsService,
protected ref: ChangeDetectorRef
) {
setInterval(() => this.time = new Date(), 1000);
}
nextSlides: Slide[] = [];
ngOnInit() {
this.updateCurrentSlides();
this.openlpService.stateChanged$.subscribe(item => this.updateCurrentSlides());
this.updateCurrentSlides(null, null);
this.openlpService.stateChanged$.subscribe((item: { item: string; slide: number }) =>
this.updateCurrentSlides(item.item, item.slide)
);
this.fontScale = this.settingsService.get(
this.stageProperty + 'FontScale' as keyof SettingsProperties
) as number / 100;
this.fontScaleSubscription$ = this.settingsService
.onPropertyChanged(this.stageProperty + 'FontScale' as keyof SettingsProperties)
.subscribe(value => {
this.fontScale = value as number / 100;
this.ref.detectChanges();
});
}
updateCurrentSlides(): void {
this.openlpService.getItemSlides().subscribe(slides => this.setNewSlides(slides));
ngOnDestroy(): void {
this.fontScaleSubscription$?.unsubscribe();
}
get nextSlides(): Slide[] {
return this.currentSlides.slice(this.activeSlide + 1);
updateCurrentSlides(_serviceItemId: string, currentSlide: number): void {
this.serviceItemSubscription$?.unsubscribe();
this.serviceItemSubscription$ = this.openlpService.getServiceItem().subscribe(currentServiceItem => {
this.serviceItem = currentServiceItem;
if (currentServiceItem instanceof Array) {
this.setNewSlides(currentServiceItem, currentSlide);
}
else {
this.setNewSlides(currentServiceItem.slides, currentSlide);
this.setNotes(currentServiceItem.notes);
}
this.getNextServiceItemTitle();
});
}
setNewSlides(slides: Slide[]): void {
if (slides.length === 0) {
getNextServiceItemTitle(): void {
this.nextServiceItemTitle = '';
let doStoreServiceItemTitle = false;
this.openlpService.getServiceItems().subscribe(serviceItems => {
serviceItems.forEach((serviceItem) => {
if (doStoreServiceItemTitle) {
this.nextServiceItemTitle = serviceItem.title;
doStoreServiceItemTitle = false;
}
if (serviceItem.id === this.serviceItem.id) {
doStoreServiceItemTitle = true;
}
});
});
}
setNewSlides(slides: Slide[], _currentSlide: number): void { /* eslint-disable-line @typescript-eslint/no-unused-vars */
if ((slides?.length ?? 0) === 0) {
return;
}
this.currentSlides = slides;
this.activeSlide = slides.findIndex(s => s.selected);
this.nextSlides = this.currentSlides.slice(this.activeSlide + 1);
this.updateTags();
}
setNotes(notes: string): void {
this.notes = notes;
}
/**
* This method updates the tags from the current slides.
*
@ -50,7 +117,7 @@ export class StageViewComponent implements OnInit {
* So we start with the first tag and on each tag change we push the new one.
*
* If we find the same tag, we check to see if the current slide is a repition.
* In case of a repition we also add a new tag.
* In case of a repetition we also add a new tag.
*
* TODO This approach should work for most cases. It is a primary candidate for a test :-)
*/
@ -81,4 +148,8 @@ export class StageViewComponent implements OnInit {
lastIndex = index;
}
}
trackByIndex(index: number) {
return index;
}
}

View File

@ -1,18 +1,35 @@
<p *ngIf="themeLevelSwitching">
<b>Theme level:</b>
<mat-button-toggle-group [value]="theme_level" (change)="onThemeLevelSelected(themeLevelToggle.value)" #themeLevelToggle="matButtonToggleGroup">
<mat-button-toggle value="global">Global</mat-button-toggle>
<mat-button-toggle value="service">Service</mat-button-toggle>
</mat-button-toggle-group>
</p>
<ng-container *ngIf="!unsupportedLevel; else unsupportedLevelWarning">
<mat-card mat-list-item *ngFor="let theme of theme_list;" (click)='onThemeSelected(theme.name)' [class.selected]="theme.selected">
<div class="theme-title">{{ theme.name }}</div>
</mat-card>
</ng-container>
<mat-slide-toggle color="primary" [checked]="themeLevelSwitching" (change)="levelSliderChanged($event)">Change Theme Level</mat-slide-toggle>
<ng-template #unsupportedLevelWarning>
<p style="text-align: center;">Song level theme changing not yet supported.<br>
To continue, change your theme level.
</p>
</ng-template>
<form #themeForm="ngForm">
<h4>{{ 'THEME_OPTIONS' | translate | titlecase }}</h4>
<div>
<mat-form-field>
<mat-label>{{ 'THEME_LEVEL' | translate | titlecase }}</mat-label>
<mat-select [(value)]="themeLevel">
<mat-option value="global">{{ 'GLOBAL' | translate | titlecase }}</mat-option>
<mat-option value="service">{{ 'SERVICE' | translate | titlecase }}</mat-option>
<mat-option value="song">{{ 'SONG' | translate | titlecase }}</mat-option>
</mat-select>
</mat-form-field>
</div>
@if (isThemeLevelSupported()) {
<div class="theme-container content">
@for (theme of themeList; track theme) {
<div>
<mat-card
class="theme-card"
(click)='setTheme(theme.name)'
[class.selected]="theme.selected">
<mat-card-content>
<img [src]="theme.thumbnail"/>
<div class="theme-title">{{ theme.name }}</div>
</mat-card-content>
</mat-card>
</div>
}
</div>
}
@else {
<mat-error>
{{ 'SONG_LEVEL_THEME_CHANGING_NOT_SUPPORTED' | translate | sentencecase }}
</mat-error>
}
</form>

View File

@ -1,11 +1,6 @@
mat-card {
cursor: pointer;
}
mat-divider {
border-color: #afafaf;
}
mat-button-toggle-group {
margin-left: 10px;
img {
width: 100%;
}
.selected {
@ -13,7 +8,25 @@ mat-button-toggle-group {
font-weight: 700;
}
.theme-container {
display: grid;
justify-content: space-evenly;
grid-template-columns: repeat(auto-fill, min(220px, 100%));
gap: 1rem;
}
.theme-card {
cursor: pointer;
justify-content: center;
}
.theme-title {
margin-left: 2.5rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.theme-title:hover {
overflow: visible;
white-space: pre-wrap;
}
}

View File

@ -1,26 +1,28 @@
import { Component, OnInit } from '@angular/core';
import { MatSlideToggleChange } from '@angular/material';
import { TranslateService } from '@ngx-translate/core';
import { OpenLPService } from '../../openlp.service';
import { PageTitleService } from '../../page-title.service';
import { Theme } from '../../responses';
@Component({
selector: 'openlp-themes',
templateUrl: './themes.component.html',
styleUrls: ['./themes.component.scss'],
styleUrl: './themes.component.scss',
providers: [OpenLPService]
})
export class ThemesComponent implements OnInit {
theme_list = null;
theme_level = null;
private _theme = null;
private _themeList = [];
private _themeLevel = null;
themeLevelSwitching = false;
unsupportedLevel = false;
constructor(private pageTitleService: PageTitleService, private openlpService: OpenLPService) {
pageTitleService.changePageTitle('Themes');
constructor(
private pageTitleService: PageTitleService,
private openlpService: OpenLPService,
private translateService: TranslateService) {
this.translateService.stream('THEMES').subscribe(res => {
this.pageTitleService.changePageTitle(res);
});
}
ngOnInit() {
@ -28,37 +30,44 @@ export class ThemesComponent implements OnInit {
this.getThemes();
}
get themeList(): Array<Theme> {
return this._themeList;
}
get themeLevel(): string {
return this._themeLevel;
}
set themeLevel(level: string) {
this._themeLevel = level;
this.openlpService.setThemeLevel(level).subscribe(() => this.getTheme());
}
isThemeLevelSupported(): boolean {
return this._themeLevel !== 'song';
}
getThemeLevel() {
this.openlpService.getThemeLevel().subscribe(theme_level => {
this.theme_level = theme_level;
this.unsupportedLevelCheck(this.theme_level);
});
this.openlpService.getThemeLevel().subscribe(themeLevel => this._themeLevel = themeLevel);
}
getThemes() {
this.openlpService.getThemes().subscribe(theme_list => this.theme_list = theme_list);
this.openlpService.getThemes().subscribe(themeList => this._themeList = themeList);
}
onThemeLevelSelected(level: string) {
this.openlpService.setThemeLevel(level).subscribe(res => this.getThemes());
getTheme() {
this.openlpService.getTheme().subscribe(theme => {
this._theme = theme;
// Modify the theme list with the current theme. We do this instead of getting all the themes again
// We do this here to ensure that the program is the only source of truth for the current theme
for (const i of this._themeList) {
if ((i.name === theme) && (i.selected === false)) { i.selected = true; }
else if ((i.name !== theme) && (i.selected === true)) { i.selected = false; }
}
});
}
onThemeSelected(theme: string) {
this.openlpService.setTheme(theme).subscribe(res => this.getThemes());
}
levelSliderChanged(event: MatSlideToggleChange) {
this.themeLevelSwitching = event.checked;
}
unsupportedLevelCheck(level) {
this.getThemeLevel();
if (level === 'song') {
this.unsupportedLevel = true;
this.themeLevelSwitching = true;
}
else {
this.unsupportedLevel = false;
}
setTheme(themeName: string) {
this.openlpService.setTheme(themeName).subscribe(() => this.getTheme());
}
}

View File

@ -0,0 +1,34 @@
import { Observable } from 'rxjs';
export function createWebSocket<T>(
host: string,
wsPort: number,
deserialize: (input: string) => T,
endpoint = ''
): Observable<T> {
return new Observable((observer) => {
const ws = new WebSocket(`ws://${host}:${wsPort}/${endpoint}`);
ws.onmessage = (e) => {
const reader = new FileReader();
reader.onload = () => {
const data = deserialize(JSON.parse(reader.result as string));
observer.next(data);
};
reader.readAsText(e.data);
};
ws.onerror = () => {
observer.error();
};
ws.onclose = () => {
observer.complete();
};
return () => {
// Removing listeners to avoid loop
ws.onmessage = null;
ws.onclose = null;
ws.onerror = null;
ws.close();
};
});
}

View File

@ -1,6 +1,7 @@
import { Injectable, EventEmitter } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Observable, Subscription } from 'rxjs';
import { finalize, shareReplay, tap } from 'rxjs/operators';
import {
PluginDescription,
@ -8,35 +9,47 @@ import {
Slide,
ServiceItem,
Theme,
Language,
MainView,
Shortcut,
SystemInformation,
Credentials,
AuthToken
AuthToken,
Message,
MessageType
} from './responses';
import { environment } from '../environments/environment';
const deserialize = (json, cls) => {
const inst = new cls();
for (const p in json) {
if (!json.hasOwnProperty(p)) {
continue;
}
inst[p] = json[p];
}
return inst;
};
import { createWebSocket } from './openlp-websocket';
import { deserialize } from './utils';
const httpOptions = {
headers: new HttpHeaders({'Content-Type': 'application/json'})
headers: new HttpHeaders({
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
})
};
const WEBSOCKET_RECONNECT_TIMEOUT = 5 * 1000;
export enum WebSocketStatus {
Open, Closed
}
@Injectable()
export class OpenLPService {
private apiURL: string;
public apiVersion: number | null;
public apiRevision: number | null;
private host: string;
public stateChanged$: EventEmitter<State>;
public liveChanged$: EventEmitter<State>;
public messageReceived$: EventEmitter<Message<MessageType>>;
public webSocketStateChanged$: EventEmitter<WebSocketStatus>;
private isTwelveHourTime = true;
private webSocketTimeoutHandle: any = 0;
private _stateWebSocketSubscription: Subscription;
private _messageWebSocketSubscription: Subscription;
private _retrieveSystemInformationSubscription: Subscription;
constructor(private http: HttpClient) {
const host = window.location.hostname;
@ -48,130 +61,271 @@ export class OpenLPService {
port = '4316';
}
this.apiURL = `http://${host}:${port}/api/v2`;
this.host = host;
this.stateChanged$ = new EventEmitter<State>();
this.retrieveSystemInformation().subscribe(info => {
const ws = new WebSocket(`ws://${host}:${info.websocket_port}/state`);
ws.onmessage = (event) => {
const reader = new FileReader();
reader.onload = () => {
const state = deserialize(JSON.parse(reader.result as string).results, State);
this.stateChanged$.emit(state);
};
reader.readAsText(event.data);
};
});
this.liveChanged$ = new EventEmitter<State>();
this.retrieveSystemInformation().subscribe(info => {
const ws = new WebSocket(`ws://${host}:${info.websocket_port}/live_changed`);
ws.onmessage = (event) => {
const reader = new FileReader();
reader.onload = () => {
const state = deserialize(JSON.parse(reader.result as string).results, State);
this.liveChanged$.emit(state);
};
reader.readAsText(event.data);
};
});
this.webSocketStateChanged$ = new EventEmitter<WebSocketStatus>();
this.messageReceived$ = new EventEmitter<Message<MessageType>>();
this.createWebSocket();
}
assertApiVersionExact(version: number, revision: number) {
return version === this.apiVersion && revision === this.apiRevision;
}
assertApiVersionMinimum(version: number, revision: number) {
return this.apiVersion >= version && this.apiRevision >= revision;
}
setAuthToken(token: string): void {
httpOptions.headers = httpOptions.headers.set('Authorization', token);
}
getIsTwelveHourTime(): boolean {
return this.isTwelveHourTime;
}
retrieveSystemInformation(): Observable<SystemInformation> {
return this.http.get<SystemInformation>(`${this.apiURL}/core/system`, httpOptions);
return this.doGet<SystemInformation>(`${this.apiURL}/core/system`)
.pipe(tap(systemInfo => {
if (systemInfo.api_version) {
this.apiVersion = systemInfo.api_version;
this.apiRevision = systemInfo.api_revision;
}
}));
}
getLanguage(): Observable<Language> {
return this.doGet(`${this.apiURL}/core/language`);
}
getMainImage(): Observable<MainView> {
return this.http.get<MainView>(`${this.apiURL}/core/live-image`, httpOptions);
return this.doGet<MainView>(`${this.apiURL}/core/live-image`);
}
getItemSlides(): Observable<Slide[]> {
return this.http.get<Slide[]>(`${this.apiURL}/controller/live-item`, httpOptions);
}
getServiceItems(): Observable<ServiceItem[]> {
return this.http.get<ServiceItem[]>(`${this.apiURL}/service/items`, httpOptions);
}
getThemeLevel(): Observable<any> {
return this.http.get(`${this.apiURL}/controller/theme-level`, httpOptions);
}
getThemes(): Observable<Theme[]> {
return this.http.get<Theme[]>(`${this.apiURL}/controller/themes`, httpOptions);
getShortcuts(): Observable<Shortcut[]> {
return this.doGet(`${this.apiURL}/core/shortcuts`);
}
getSearchablePlugins(): Observable<PluginDescription[]> {
return this.http.get<PluginDescription[]>(`${this.apiURL}/core/plugins`, httpOptions);
}
setServiceItem(id: number): Observable<any> {
return this.http.post(`${this.apiURL}/service/show`, {'id': id}, httpOptions);
return this.doGet<PluginDescription[]>(`${this.apiURL}/core/plugins`);
}
search(plugin, text): Observable<any> {
return this.http.get(`${this.apiURL}/plugins/${plugin}/search?text=${text}`, httpOptions);
return this.doGet(`${this.apiURL}/plugins/${plugin}/search?text=${text}`);
}
setSlide(id): Observable<any> {
return this.http.post(`${this.apiURL}/controller/show`, {'id': id}, httpOptions);
getSearchOptions(plugin): Observable<any> {
return this.doGet(`${this.apiURL}/plugins/${plugin}/search-options`);
}
setThemeLevel(level): Observable<any> {
return this.http.post(`${this.apiURL}/controller/theme-level`, {'level': level}, httpOptions);
setSearchOption(plugin, option, value): Observable<any> {
return this.doPost(`${this.apiURL}/plugins/${plugin}/search-options`, {option, value});
}
setTheme(theme: string): Observable<any> {
return this.http.post(`${this.apiURL}/controller/theme`, {'theme': theme}, httpOptions);
getServiceItems(): Observable<ServiceItem[]> {
return this.doGet<ServiceItem[]>(`${this.apiURL}/service/items`);
}
setServiceItem(id: any): Observable<any> {
return this.doPost(`${this.apiURL}/service/show`, {id});
}
nextItem(): Observable<any> {
return this.http.post(`${this.apiURL}/service/progress`, {'action': 'next'}, httpOptions);
return this.doPost(`${this.apiURL}/service/progress`, {action: 'next'});
}
previousItem(): Observable<any> {
return this.http.post(`${this.apiURL}/service/progress`, {'action': 'previous'}, httpOptions);
return this.doPost(`${this.apiURL}/service/progress`, {action: 'previous'});
}
getServiceItem(): Observable<any> {
return this.doGet<Slide[]>(`${this.apiURL}/controller/live-items`);
}
getNotes(): Observable<any> {
return this.doGet(`${this.apiURL}/controller/notes`);
}
setSlide(id: any): Observable<any> {
return this.doPost(`${this.apiURL}/controller/show`, {id});
}
nextSlide(): Observable<any> {
return this.http.post(`${this.apiURL}/controller/progress`, {'action': 'next'}, httpOptions);
return this.doPost(`${this.apiURL}/controller/progress`, {action: 'next'});
}
previousSlide(): Observable<any> {
return this.http.post(`${this.apiURL}/controller/progress`, {'action': 'previous'}, httpOptions);
return this.doPost(`${this.apiURL}/controller/progress`, {action: 'previous'});
}
getThemeLevel(): Observable<any> {
return this.doGet(`${this.apiURL}/controller/theme-level`);
}
getThemes(): Observable<Theme[]> {
return this.doGet<Theme[]>(`${this.apiURL}/controller/themes`);
}
setThemeLevel(level): Observable<any> {
return this.doPost(`${this.apiURL}/controller/theme-level`, {level});
}
getTheme(): Observable<any> {
return this.doGet(`${this.apiURL}/controller/theme`);
}
setTheme(theme: string): Observable<any> {
return this.doPost(`${this.apiURL}/controller/theme`, {theme});
}
blankDisplay(): Observable<any> {
return this.http.post(`${this.apiURL}/core/display`, {'display': 'blank'}, httpOptions);
return this.doPost(`${this.apiURL}/core/display`, {display: 'blank'});
}
themeDisplay(): Observable<any> {
return this.http.post(`${this.apiURL}/core/display`, {'display': 'theme'}, httpOptions);
return this.doPost(`${this.apiURL}/core/display`, {display: 'theme'});
}
desktopDisplay(): Observable<any> {
return this.http.post(`${this.apiURL}/core/display`, {'display': 'desktop'}, httpOptions);
return this.doPost(`${this.apiURL}/core/display`, {display: 'desktop'});
}
showDisplay(): Observable<any> {
return this.http.post(`${this.apiURL}/core/display`, {'display': 'show'}, httpOptions);
return this.doPost(`${this.apiURL}/core/display`, {display: 'show'});
}
showAlert(text): Observable<any> {
return this.http.post(`${this.apiURL}/plugins/alerts`, {'text': text}, httpOptions);
return this.doPost(`${this.apiURL}/plugins/alerts`, {text});
}
sendItemLive(plugin, id): Observable<any> {
return this.http.post(`${this.apiURL}/plugins/${plugin}/live`, {'id': id}, httpOptions);
return this.doPost(`${this.apiURL}/plugins/${plugin}/live`, {id});
}
addItemToService(plugin, id): Observable<any> {
return this.http.post(`${this.apiURL}/plugins/${plugin}/add`, {'id': id}, httpOptions);
return this.doPost(`${this.apiURL}/plugins/${plugin}/add`, {id});
}
transposeSong(transpose_value, return_format = 'default'): Observable<any> {
return this.doGet(`${this.apiURL}/plugins/songs/transpose-live-item/${transpose_value}?response_format=${return_format}`);
}
login(credentials: Credentials): Observable<AuthToken> {
return this.http.post<AuthToken>(`${this.apiURL}/core/login`, credentials, httpOptions);
return this.doPost<AuthToken>(`${this.apiURL}/core/login`, credentials);
}
protected doGet<T>(url: string): Observable<T> {
return this.http.get<T>(url, httpOptions);
}
protected doPost<T>(url: string, body: any): Observable<T> {
// User is expecting instant response, so we'll accelerate the websocket reconnection process if needed.
this.reconnectWebSocketIfNeeded();
return this.http.post<T>(url, body, httpOptions);
}
get webSocketStatus(): WebSocketStatus {
if (!this._stateWebSocketSubscription || this._stateWebSocketSubscription.closed) {
return WebSocketStatus.Closed;
}
return WebSocketStatus.Open;
}
reconnectWebSocketIfNeeded() {
if (this.webSocketStatus === WebSocketStatus.Closed) {
this.createWebSocket();
}
}
createWebSocket() {
this.clearWebSocketTimeoutHandle();
if (this._retrieveSystemInformationSubscription) {
// Cancels ongoing request to avoid connection flooding
this._retrieveSystemInformationSubscription.unsubscribe();
}
this._retrieveSystemInformationSubscription = this.retrieveSystemInformation()
.pipe(
shareReplay(1),
finalize(() => this._retrieveSystemInformationSubscription = null)
)
.subscribe({
next: info => {
this.createStateWebsocketConnection(info.websocket_port);
if (this.assertApiVersionMinimum(2, 4)) {
this.createMessageWebsocketConnection(info.websocket_port);
}
},
error: () => this.reconnectWebSocket()
});
}
private createStateWebsocketConnection(websocketPort: number) {
if (this._stateWebSocketSubscription) {
this._stateWebSocketSubscription.unsubscribe();
}
let firstMessage = true;
this._stateWebSocketSubscription = createWebSocket(
this.host,
websocketPort,
(input: any) => deserialize(input.results, State)
).subscribe({
next: (state) => {
if (firstMessage) {
this.webSocketStateChanged$.emit(WebSocketStatus.Open);
firstMessage = false;
}
this.handleStateChange(state);
},
error: () => {
this.webSocketStateChanged$.emit(WebSocketStatus.Closed);
this.reconnectWebSocket();
},
complete: () => {
this.webSocketStateChanged$.emit(WebSocketStatus.Closed);
this.reconnectWebSocket();
}
});
}
private createMessageWebsocketConnection(websocketPort: number) {
if (this._messageWebSocketSubscription) {
this._messageWebSocketSubscription.unsubscribe();
}
this._stateWebSocketSubscription = createWebSocket(
this.host,
websocketPort,
(input: any) => deserialize(input, Message),
'messages',
).subscribe({
next: (message) => this.handleMessage(message),
error: () => this.reconnectWebSocket(),
complete: () => this.reconnectWebSocket()
});
}
private reconnectWebSocket = () => {
this.clearWebSocketTimeoutHandle();
this.webSocketTimeoutHandle = setTimeout(() => {
this.createWebSocket();
}, WEBSOCKET_RECONNECT_TIMEOUT);
};
private clearWebSocketTimeoutHandle() {
if (this.webSocketTimeoutHandle) {
clearTimeout(this.webSocketTimeoutHandle);
}
}
handleStateChange(state: State) {
this.isTwelveHourTime = state.twelve;
this.stateChanged$.emit(state);
}
handleMessage(message: Message<MessageType>) {
this.messageReceived$.emit(message);
}
}

View File

@ -1,5 +1,6 @@
import { Injectable } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { TitleCasePipe } from '@angular/common';
import { Subject } from 'rxjs';
@Injectable()
@ -7,10 +8,12 @@ export class PageTitleService {
private pageTitleSource = new Subject<string>();
public pageTitleChanged$ = this.pageTitleSource.asObservable();
constructor(private titleService: Title) {}
constructor(
private titleService: Title,
private titleCasePipe: TitleCasePipe) {}
changePageTitle(pageTitle: string) {
this.pageTitleSource.next(pageTitle);
this.titleService.setTitle(pageTitle + ' | OpenLP Remote');
this.titleService.setTitle(this.titleCasePipe.transform(pageTitle) + ' | OpenLP Remote');
}
}

View File

@ -12,8 +12,33 @@ export class State {
blank: boolean;
twelve: boolean;
theme: boolean;
item: string;
live = () => !(this.blank || this.display || this.theme);
get displayMode() {
if (this.blank) {
return DisplayMode.Blank;
} else if (this.display) {
return DisplayMode.Desktop;
} else if (this.theme) {
return DisplayMode.Theme;
} else {
return DisplayMode.Presentation;
}
}
}
export class Display {
displayMode: DisplayMode;
bigDisplayButtons: boolean;
}
export enum DisplayMode {
Blank,
Theme,
Desktop,
Presentation
}
export interface Slide {
@ -21,31 +46,46 @@ export interface Slide {
html: string;
tag: string;
text: string;
chords_text: string;
chords: string;
lines: string[];
first_slide_of_tag: boolean;
img: string;
}
export interface ServiceItem {
id: string;
notes: string;
plugin: string;
selected: boolean;
title: string;
id: string;
notes: string;
plugin: string;
selected: boolean;
title: string;
is_valid: boolean;
slides: object[];
}
export interface Theme {
selected: boolean;
name: string;
name: string;
selected: boolean;
thumbnail: object;
}
export interface Language {
language: string;
}
export interface MainView {
binary_image: string;
}
export interface Shortcut {
action: string;
shortcut: string[];
}
export interface SystemInformation {
websocket_port: number;
login_required: boolean;
api_version?: number;
api_revision?: number;
}
export interface Credentials {
@ -56,3 +96,15 @@ export interface Credentials {
export interface AuthToken {
token: string;
}
export class Message<T extends MessageType> {
plugin: T['plugin'];
key: T['key'];
value: T['value'];
}
export interface MessageType {
plugin: string;
key: string;
value: any;
}

View File

@ -0,0 +1,90 @@
import { EventEmitter, Injectable } from '@angular/core';
// Set here the default value; if there's none, set as undefined and specify key type.
export class SettingsProperties {
fastSwitching = false;
stageFontScale = 100;
chordsFontScale = 100;
bigDisplayButtons = false;
}
export interface SettingsPropertiesItem<SP extends keyof SettingsProperties, SV = SettingsProperties[SP]> {
property: SP;
value: SV;
}
const LOCAL_STORAGE_PREFIX = 'OpenLP-';
@Injectable({providedIn: 'root'})
export class SettingsService {
constructor() {
window.addEventListener('storage', this._handleStorageEvent);
}
defaultSettingsPropertiesInstance = new SettingsProperties();
settingChanged$: EventEmitter<SettingsPropertiesItem<any, any>> = new EventEmitter<any>();
listenersCache: {[key in keyof Partial<SettingsProperties>]: EventEmitter<any>} = {};
getAll(): Partial<SettingsProperties> {
const output: Partial<SettingsProperties> = {};
for (const key of Object.keys(this.defaultSettingsPropertiesInstance)) {
const value = this.get(key as keyof SettingsProperties);
if (value !== undefined) {
output[key] = value;
}
}
return output;
}
get<SP extends keyof SettingsProperties, SV = SettingsProperties[SP]>(property: SP): SV | undefined {
let propertyValue: any = localStorage.getItem(LOCAL_STORAGE_PREFIX + property);
if ((propertyValue === undefined || propertyValue === null)
&& this.defaultSettingsPropertiesInstance.hasOwnProperty(property)
) {
propertyValue = this.defaultSettingsPropertiesInstance[property];
this.set(property, propertyValue);
}
if (propertyValue) {
return JSON.parse(propertyValue);
}
return undefined;
}
set<SP extends keyof SettingsProperties, SV = SettingsProperties[SP]>(property: SP, value: SV) {
if (value === undefined) {
localStorage.removeItem(LOCAL_STORAGE_PREFIX + property);
this._emitEvent(property, undefined);
} else {
localStorage.setItem(LOCAL_STORAGE_PREFIX + property, JSON.stringify(value));
this._emitEvent(property, value);
}
}
remove<SP extends keyof SettingsProperties>(property: SP) {
this.set(property, undefined);
}
onPropertyChanged<SP extends keyof SettingsProperties, SV = SettingsProperties[SP]>(
property: SP
): EventEmitter<SV> {
if (!this.listenersCache[property]) {
this.listenersCache[property] = new EventEmitter<SV>();
}
return this.listenersCache[property];
}
protected _handleStorageEvent = (event: StorageEvent) => {
if (event.storageArea === localStorage) {
this._emitEvent(event.key.replace(LOCAL_STORAGE_PREFIX, '') as any, JSON.parse(event.newValue));
}
};
protected _emitEvent<SP extends keyof SettingsProperties, SV = SettingsProperties[SP]>(property: SP, value: SV) {
this.settingChanged$.emit({property, value});
this.listenersCache?.[property]?.emit(value);
}
}

View File

@ -0,0 +1,109 @@
import { DOCUMENT } from '@angular/common';
import { EventEmitter, Inject, Injectable } from '@angular/core';
import { EventManager } from '@angular/platform-browser';
import { Observable } from 'rxjs';
import { OpenLPService } from './openlp.service';
export class Shortcuts {
previousSlide = ['Up', 'PgUp'];
nextSlide = ['Down', 'PgDown'];
previousItem = ['Left'];
nextItem = ['Right'];
showDisplay = ['Space'];
themeDisplay = ['t'];
blankDisplay = ['.'];
desktopDisplay = ['d'];
}
interface Options {
element: any;
keys: string;
}
@Injectable({ providedIn: 'root' })
export class ShortcutsService {
defaults: Partial<Options> = {
element: this.document
};
constructor(
@Inject(DOCUMENT) private document: Document,
private eventManager: EventManager,
private openlpService: OpenLPService) {
this.shortcutsChanged$ = new EventEmitter<Shortcuts>();
}
private shortcuts: Shortcuts
public shortcutsChanged$: EventEmitter<Shortcuts>;
getShortcuts(useShortcutsFromOpenlp: boolean) {
const shortcuts: Shortcuts = new Shortcuts()
if (useShortcutsFromOpenlp) {
this.openlpService.getShortcuts().subscribe(res => {
res.forEach((shortcut) => {
switch (shortcut.action) {
case 'blankScreen':
shortcuts.blankDisplay = shortcut.shortcut;
break;
case 'desktopScreen':
shortcuts.desktopDisplay = shortcut.shortcut;
break;
case 'nextItem_live':
shortcuts.nextSlide = shortcut.shortcut;
break;
case 'nextService':
shortcuts.nextItem = shortcut.shortcut;
break;
case 'previousItem_live':
shortcuts.previousSlide = shortcut.shortcut;
break;
case 'previousService':
shortcuts.previousItem = shortcut.shortcut;
break;
case 'showScreen':
shortcuts.showDisplay = shortcut.shortcut;
break;
case 'themeScreen':
shortcuts.themeDisplay = shortcut.shortcut;
break;
}
})
this._handleShortcutsChanged(shortcuts);
})
} else {
this._handleShortcutsChanged(shortcuts);
}
}
addShortcut(options: Partial<Options>) {
const merged = { ...this.defaults, ...options };
const event = `keydown.${merged.keys}`;
return new Observable(observer => {
const handler = (e: KeyboardEvent) => {
const activeElement = this.document.activeElement;
const notOnInput = activeElement?.tagName !== 'INPUT' && activeElement?.tagName !== 'TEXTAREA';
if (notOnInput) {
if (document.URL.endsWith('/slides')) {
e.preventDefault();
observer.next(e);
}
}
};
const dispose = this.eventManager.addEventListener(
merged.element, event, handler
);
return () => {
dispose();
};
});
}
protected _handleShortcutsChanged(shortcuts: Shortcuts) {
this.shortcuts = shortcuts;
this.shortcutsChanged$.emit(this.shortcuts);
}
}

View File

@ -0,0 +1,15 @@
import { TranslateLoader } from '@ngx-translate/core';
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class TranslationService implements TranslateLoader {
constructor(private http: HttpClient) {}
getTranslation(language: string): Observable<any> {
return this.http.get(language === 'default' ? '/assets/en.json' : '/assets/i18n/' + language + '.json');
}
}

12
src/app/utils.ts Normal file
View File

@ -0,0 +1,12 @@
import { Type } from '@angular/core';
export function deserialize<T>(json: any, cls: Type<T>): T {
const inst = new cls();
for (const p in json) {
if (!json.hasOwnProperty(p)) {
continue;
}
inst[p] = json[p];
}
return inst;
}

View File

@ -0,0 +1,15 @@
import { TestBed } from '@angular/core/testing';
import { WindowRef} from './window-ref.service';
describe('WindowRef', () => {
let service: WindowRef;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(WindowRef);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,14 @@
import { Injectable } from '@angular/core';
function _window(): any {
return window;
}
@Injectable({providedIn: 'root'})
export class WindowRef {
get nativeWindow(): any {
return _window();
}
}

57
src/assets/en.json Normal file
View File

@ -0,0 +1,57 @@
{
"_COMMENT": "This file located here instead of in the i18n directory is a workaround for Web API 2.4 or older to prevent HTTP 404 errors while retrieving the en.json file when using Web Remote 0.9.16 or newer.",
"ADD_ITEM": "Add Item",
"ADD_ITEM_TO_SERVICE": "Add Item to Service",
"ADD_TO_SERVICE": "Add to Service",
"ALERT": "Alert",
"ALERT_SUBMITTED": "Alert Submitted",
"ALERTS": "Alerts",
"BIBLE_VERSION": "Bible version",
"CHANGE_DISPLAY_MODE": "Change Display Mode",
"CHORD_VIEW": "Chord View",
"CHORDS": "Chords",
"CONNECTED_TO_OPENLP": "Connected to OpenLP",
"DISCONNECTED": "Disconnected",
"ENABLE_BIG_DISPLAY_BUTTONS": "Enable Big Display Buttons",
"ENABLE_FAST_SWITCHING_PANEL": "Enable Fast Switching Panel",
"FONT_SCALE": "Font Scale",
"GLOBAL": "Global",
"GO_BACK_TO_CONTROLLER": "Go Back to Controller",
"HIDE_NOTES": "Hide Notes",
"LOGIN": "Login",
"LOGIN_FAILED": "Login failed",
"LOGIN_SUCCEEDED": "Successfully logged in",
"MAIN_VIEW": "Main View",
"NEXT_ITEM": "Next Item",
"NEXT_SLIDE": "Next Slide",
"NO_SEARCH_RESULTS": "No results matching your search were found",
"NO_SERVICE_ITEMS": "No Service Items",
"NO_SLIDE_ITEMS": "No Slide Items",
"PASSWORD": "Password",
"PREVIOUS_ITEM": "Previous Item",
"PREVIOUS_SLIDE": "Previous Slide",
"SEARCH": "Search",
"SEARCH_RESULTS": "Search Results",
"SEARCH_TEXT": "Search Text",
"SEND": "Send",
"SEND_AN_ALERT": "Send an Alert",
"SEND_LIVE": "Send Live",
"SERVICE": "Service",
"SETTINGS": "Settings",
"SHOW_BACKGROUND": "Show Background",
"SHOW_BLACK": "Show Black",
"SHOW_DESKTOP": "Show Desktop",
"SHOW_NOTES": "Show Notes",
"SHOW_PRESENTATION": "Show Presentation",
"SLIDES": "Slides",
"SONG": "Song",
"SONG_LEVEL_THEME_CHANGING_NOT_SUPPORTED": "Song level theme changing not supported. Change your theme level to Global or Service",
"STAGE": "Stage",
"STAGE_AND_CHORDS_APPEARANCE": "Stage and Chords Appearance",
"STAGE_VIEW": "Stage View",
"THEMES": "Themes",
"THEME_LEVEL": "Theme Level",
"THEME_OPTIONS": "Theme Options",
"USER_NAME": "User Name",
"USER_INTERFACE": "User Interface"
}

56
src/assets/i18n/af.json Normal file
View File

@ -0,0 +1,56 @@
{
"ADD_ITEM": "Voeg Item By",
"ADD_ITEM_TO_SERVICE": "Voeg Item By Diens",
"ADD_TO_SERVICE": "Voeg By Diens",
"ALERT": "Waarskuwing",
"ALERT_SUBMITTED": "Waarskuwing Ingedien",
"ALERTS": "Waarskuwings",
"BIBLE_VERSION": "Bybel weergawe",
"CHANGE_DISPLAY_MODE": "Verander Vertoonmodus",
"CHORD_VIEW": "Akkoordaansig",
"CHORDS": "Akkoorde",
"CONNECTED_TO_OPENLP": "Gekoppel aan OpenLP",
"DISCONNECTED": "Ontkoppel",
"ENABLE_BIG_DISPLAY_BUTTONS": "Aktiveer Grootskermknoppies",
"ENABLE_FAST_SWITCHING_PANEL": "Aktiveer Vinnige Skakelpaneel",
"FONT_SCALE": "Lettertipe Skaal",
"GLOBAL": "Globale",
"GO_BACK_TO_CONTROLLER": "Gaan Terug na Kontroleerder",
"HIDE_NOTES": "Versteek Notas",
"LOGIN": "Teken In",
"LOGIN_FAILED": "Aanmelding het misluk",
"LOGIN_SUCCEEDED": "Suksesvol aangemeld",
"MAIN_VIEW": "Hoofaansig",
"NEXT_ITEM": "Volgende Item",
"NEXT_SLIDE": "Volgende Skyfie",
"NO_SEARCH_RESULTS": "Geen resultate wat ooreenstem met jou soektog is gevind nie",
"NO_SERVICE_ITEMS": "Geen Diensitems Nie",
"NO_SLIDE_ITEMS": "Geen Skyfie-items Nie",
"PASSWORD": "Wagwoord",
"PREVIOUS_ITEM": "Vorige Item",
"PREVIOUS_SLIDE": "Vorige Skyfie",
"SEARCH": "Soek",
"SEARCH_RESULTS": "Soek Resultate",
"SEARCH_TEXT": "Soek Teks",
"SEND": "Stuur",
"SEND_AN_ALERT": "Stuur 'n Waarskuwing",
"SEND_LIVE": "Stuur Regstreeks",
"SERVICE": "Diens",
"SETTINGS": "Instellings",
"SHOW_BACKGROUND": "Wys Agtergrond",
"SHOW_BLACK": "Wys Swart",
"SHOW_DESKTOP": "Wys Werkskerm",
"SHOW_NOTES": "Wys Notas",
"SHOW_PRESENTATION": "Wys Voorstelling",
"SLIDES": "Skyfies",
"SONG": "Lied",
"SONG_LEVEL_THEME_CHANGING_NOT_SUPPORTED": "Verandering van liedvlaktema word nie ondersteun nie. Verander jou temavlak na Globaal of Diens",
"STAGE": "Verhoog",
"STAGE_AND_CHORDS_APPEARANCE": "Verhoog en Akkoorde Voorkoms",
"STAGE_VIEW": "Verhoogaansig",
"THEMES": "Temas",
"THEME_LEVEL": "Temavlak",
"THEME_OPTIONS": "Tema Opsies",
"USER_NAME": "Gebruikersnaam",
"USER_INTERFACE": "Gebruikerskoppelvlak"
}

56
src/assets/i18n/bg.json Normal file
View File

@ -0,0 +1,56 @@
{
"ADD_ITEM": "Добави елемент",
"ADD_ITEM_TO_SERVICE": "Добави елемент към услугата",
"ADD_TO_SERVICE": "Добави към услугата",
"ALERT": "Предупреждение",
"ALERT_SUBMITTED": "Подадено предупреждение",
"ALERTS": "Предупреждения",
"BIBLE_VERSION": "Библейска версия",
"CHANGE_DISPLAY_MODE": "Промени режима на дисплея",
"CHORD_VIEW": "Преглед на акорди",
"CHORDS": "Акорди",
"CONNECTED_TO_OPENLP": "Свързан с OpenLP",
"DISCONNECTED": "Разкачен",
"ENABLE_BIG_DISPLAY_BUTTONS": "Включи големи бутони на дисплея",
"ENABLE_FAST_SWITCHING_PANEL": "Включи бърз панел за превключване",
"FONT_SCALE": "Мащаб на шрифта",
"GLOBAL": "Глобално",
"GO_BACK_TO_CONTROLLER": "Върни се към контролера",
"HIDE_NOTES": "Скрий бележките",
"LOGIN": "Вход",
"LOGIN_FAILED": "Неуспешно влизане",
"LOGIN_SUCCEEDED": "Успешно влизане",
"MAIN_VIEW": "Основен изглед",
"NEXT_ITEM": "Следващ елемент",
"NEXT_SLIDE": "Следващ слайд",
"NO_SEARCH_RESULTS": "Няма намерени резултати, отговарящи на вашето търсене",
"NO_SERVICE_ITEMS": "Няма елементи за услуга",
"NO_SLIDE_ITEMS": "Няма елементи за слайд",
"PASSWORD": "Парола",
"PREVIOUS_ITEM": "Предишен елемент",
"PREVIOUS_SLIDE": "Предишен слайд",
"SEARCH": "Търсене",
"SEARCH_RESULTS": "Резултати от търсенето",
"SEARCH_TEXT": "Текст за търсене",
"SEND": "Изпрати",
"SEND_AN_ALERT": "Изпрати предупреждение",
"SEND_LIVE": "Изпрати на живо",
"SERVICE": "Услуга",
"SETTINGS": "Настройки",
"SHOW_BACKGROUND": "Покажи фона",
"SHOW_BLACK": "Покажи черно",
"SHOW_DESKTOP": "Покажи работния плот",
"SHOW_NOTES": "Покажи бележките",
"SHOW_PRESENTATION": "Покажи презентацията",
"SLIDES": "Слайдове",
"SONG": "Песен",
"SONG_LEVEL_THEME_CHANGING_NOT_SUPPORTED": "Промяната на темата на ниво песен не се поддържа. Променете нивото на темата си на Глобално или Услуга",
"STAGE": "Сцена",
"STAGE_AND_CHORDS_APPEARANCE": "Външност на сцената и акордите",
"STAGE_VIEW": "Изглед на сцената",
"THEMES": "Теми",
"THEME_LEVEL": "Ниво на темата",
"THEME_OPTIONS": "Опции за темата",
"USER_NAME": "Потребителско име",
"USER_INTERFACE": "Потребителски интерфейс"
}

56
src/assets/i18n/cs.json Normal file
View File

@ -0,0 +1,56 @@
{
"ADD_ITEM": "Přidat Položku",
"ADD_ITEM_TO_SERVICE": "Přidat Položku do Služby",
"ADD_TO_SERVICE": "Přidat do Služby",
"ALERT": "Upozornění",
"ALERT_SUBMITTED": "Výstraha Odeslána",
"ALERTS": "Upozornění",
"BIBLE_VERSION": "Biblická verze",
"CHANGE_DISPLAY_MODE": "Změnit Režim Zobrazení",
"CHORD_VIEW": "Zobrazení Akordů",
"CHORDS": "Akordy",
"CONNECTED_TO_OPENLP": "Připojeno k OpenLP",
"DISCONNECTED": "Odpojeno",
"ENABLE_BIG_DISPLAY_BUTTONS": "Povolit Velká Tlačítka na Displeji",
"ENABLE_FAST_SWITCHING_PANEL": "ovolit Rychlý Přepínač Panelu",
"FONT_SCALE": "Měřítko Písma",
"GLOBAL": "Globální",
"GO_BACK_TO_CONTROLLER": "Vrátit se Zpět k Ovladači",
"HIDE_NOTES": "Skrýt Poznámky",
"LOGIN": "Přihlásit Se",
"LOGIN_FAILED": "Přihlášení se nezdařilo",
"LOGIN_SUCCEEDED": "Úspěšně přihlášeno",
"MAIN_VIEW": "Hlavní Zobrazení",
"NEXT_ITEM": "Další Položka",
"NEXT_SLIDE": "Další Snímek",
"NO_SEARCH_RESULTS": "Nebyly nalezeny žádné výsledky odpovídající vašemu hledání",
"NO_SERVICE_ITEMS": "Žádné Položky Služby",
"NO_SLIDE_ITEMS": "Žádné Položky Snímku",
"PASSWORD": "Heslo",
"PREVIOUS_ITEM": "Předchozí Položka",
"PREVIOUS_SLIDE": "Předchozí Snímek",
"SEARCH": "Hledat",
"SEARCH_RESULTS": "Výsledky Hledání",
"SEARCH_TEXT": "Hledat Text",
"SEND": "Odeslat",
"SEND_AN_ALERT": "Odeslat Upozornění",
"SEND_LIVE": "Odeslat Živě",
"SERVICE": "Služba",
"SETTINGS": "Nastavení",
"SHOW_BACKGROUND": "Zobrazit Pozadí",
"SHOW_BLACK": "Zobrazit Černou",
"SHOW_DESKTOP": "Zobrazit Plochu",
"SHOW_NOTES": "Zobrazit Poznámky",
"SHOW_PRESENTATION": "Zobrazit Prezentaci",
"SLIDES": "Snímky",
"SONG": "Píseň",
"SONG_LEVEL_THEME_CHANGING_NOT_SUPPORTED": "Změna tématu na úrovni písně není podporována. Změňte úroveň svého tématu na Globální nebo Službu",
"STAGE": "Scéna",
"STAGE_AND_CHORDS_APPEARANCE": "Vzhled Scény a Akordů",
"STAGE_VIEW": "Zobrazení Scény",
"THEMES": "Témata",
"THEME_LEVEL": "Úroveň Tématu",
"THEME_OPTIONS": "Možnosti Tématu",
"USER_NAME": "Uživatelské Jméno",
"USER_INTERFACE": "Uživatelské Rozhraní"
}

56
src/assets/i18n/da.json Normal file
View File

@ -0,0 +1,56 @@
{
"ADD_ITEM": "Tilføj Emne",
"ADD_ITEM_TO_SERVICE": "Tilføj Emne til Tjeneste",
"ADD_TO_SERVICE": "Tilføj til Tjeneste",
"ALERT": "Advarsel",
"ALERT_SUBMITTED": "Advarsel Indsendt",
"ALERTS": "Advarsler",
"BIBLE_VERSION": "Bibelsk udgave",
"CHANGE_DISPLAY_MODE": "Skift Visningstilstand",
"CHORD_VIEW": "Akkordvisning",
"CHORDS": "Akkorder",
"CONNECTED_TO_OPENLP": "Tilsluttet OpenLP",
"DISCONNECTED": "Frakoblet",
"ENABLE_BIG_DISPLAY_BUTTONS": "Aktivér Store Skærmknapper",
"ENABLE_FAST_SWITCHING_PANEL": "Aktivér Hurtig Skiftepanel",
"FONT_SCALE": "Skriftstørrelse",
"GLOBAL": "Global",
"GO_BACK_TO_CONTROLLER": "Gå Tilbage til Controller",
"HIDE_NOTES": "Skjul Noter",
"LOGIN": "Log Ind",
"LOGIN_FAILED": "Login mislykkedes",
"LOGIN_SUCCEEDED": "Logget ind",
"MAIN_VIEW": "Hovedvisning",
"NEXT_ITEM": "Næste Emne",
"NEXT_SLIDE": "Næste Dias",
"NO_SEARCH_RESULTS": "Ingen resultater fundet for din søgning",
"NO_SERVICE_ITEMS": "Ingen Tjenesteemner",
"NO_SLIDE_ITEMS": " Ingen Diasemner",
"PASSWORD": "Adgangskode",
"PREVIOUS_ITEM": "Forrige Emne",
"PREVIOUS_SLIDE": "Forrige Dias",
"SEARCH": "Søg",
"SEARCH_RESULTS": "Søgeresultater",
"SEARCH_TEXT": "Søgetekst",
"SEND": "Send",
"SEND_AN_ALERT": "Send en Advarsel",
"SEND_LIVE": "Send Live",
"SERVICE": "Tjeneste",
"SETTINGS": "Indstillinger",
"SHOW_BACKGROUND": "Vis Baggrund",
"SHOW_BLACK": "Vis Sort",
"SHOW_DESKTOP": "Vis Skrivebord",
"SHOW_NOTES": "Vis Noter",
"SHOW_PRESENTATION": "Vis Præsentation",
"SLIDES": "Dias",
"SONG": "Sang",
"SONG_LEVEL_THEME_CHANGING_NOT_SUPPORTED": "Temaændring på sangniveau understøttes ikke. Skift dit temaniveau til Global eller Tjeneste",
"STAGE": "Scene",
"STAGE_AND_CHORDS_APPEARANCE": "Scene og Akkordudseende",
"STAGE_VIEW": "Scenevisning",
"THEMES": "Temaer",
"THEME_LEVEL": "Temaniveau",
"THEME_OPTIONS": "Temaindstillinger",
"USER_NAME": "Brugernavn",
"USER_INTERFACE": "Brugergrænseflade"
}

56
src/assets/i18n/de.json Normal file
View File

@ -0,0 +1,56 @@
{
"ADD_ITEM": "Element Hinzufügen",
"ADD_ITEM_TO_SERVICE": "Element zum Ablauf Hinzufügen",
"ADD_TO_SERVICE": "Zum Ablauf Hinzufügen",
"ALERT": "Hinweise",
"ALERT_SUBMITTED": "Hinweise Übermittelt",
"ALERTS": "Hinweisen",
"BIBLE_VERSION": "Bibelübersetzung",
"CHANGE_DISPLAY_MODE": "Anzeigemodus Ändern",
"CHORD_VIEW": "Akkordansicht",
"CHORDS": "Akkorde",
"CONNECTED_TO_OPENLP": "Mit OpenLP Verbunden",
"DISCONNECTED": "Getrennt",
"ENABLE_BIG_DISPLAY_BUTTONS": "Große Anzeige-Schaltflächen Aktivieren",
"ENABLE_FAST_SWITCHING_PANEL": "Schnelles Umschalten Aktivieren",
"FONT_SCALE": "Schriftgrad",
"GLOBAL": "Global",
"GO_BACK_TO_CONTROLLER": "Zurück zum Controller",
"HIDE_NOTES": "Notizen Ausblenden",
"LOGIN": "Anmelden",
"LOGIN_FAILED": "Anmeldung fehlgeschlagen",
"LOGIN_SUCCEEDED": "Erfolgreich eingeloggt",
"MAIN_VIEW": "Hauptansicht",
"NEXT_ITEM": "Nächstes Element",
"NEXT_SLIDE": "Nächste Folie",
"NO_SEARCH_RESULTS": "Keine Suchergebnisse Gefunden",
"NO_SERVICE_ITEMS": "Keine Ablauf Elemente",
"NO_SLIDE_ITEMS": "Keine Folien-Elemente",
"PASSWORD": "Passwort",
"PREVIOUS_ITEM": "Vorheriges Element",
"PREVIOUS_SLIDE": "Vorherige Folie",
"SEARCH": "Suche",
"SEARCH_RESULTS": "Suchergebnisse",
"SEARCH_TEXT": "Suchtext",
"SEND": "Senden",
"SEND_AN_ALERT": "Hinweise Senden",
"SEND_LIVE": "Live Senden",
"SERVICE": "Ablauf",
"SETTINGS": "Einstellungen",
"SHOW_BACKGROUND": "Hintergrund Anzeigen",
"SHOW_BLACK": "Schwarz Anzeigen",
"SHOW_DESKTOP": "Desktop Anzeigen",
"SHOW_NOTES": "Notizen Anzeigen",
"SHOW_PRESENTATION": "Präsentation Anzeigen",
"SLIDES": "Folien",
"SONG": "Lied",
"SONG_LEVEL_THEME_CHANGING_NOT_SUPPORTED": "Das Ändern des Themas auf Liedebene wird nicht unterstützt. Ändern Sie Ihr Themenlevel auf Global oder Ablauf",
"STAGE": "Bühne",
"STAGE_AND_CHORDS_APPEARANCE": "Bühnen- und Akkorddarstellung",
"STAGE_VIEW": "Bühnenansicht",
"THEMES": "Themen",
"THEME_LEVEL": "Themenlevel",
"THEME_OPTIONS": "Themenoptionen",
"USER_NAME": "Benutzername",
"USER_INTERFACE": "Benutzeroberfläche"
}

56
src/assets/i18n/el.json Normal file
View File

@ -0,0 +1,56 @@
{
"ADD_ITEM": "Προσθήκη στοιχείου",
"ADD_ITEM_TO_SERVICE": "Προσθήκη στοιχείου στην υπηρεσία",
"ADD_TO_SERVICE": "Προσθήκη στην υπηρεσία",
"ALERT": "Ειδοποίηση",
"ALERT_SUBMITTED": "Η ειδοποίηση υποβλήθηκε",
"ALERTS": "Ειδοποιήσεις",
"BIBLE_VERSION": "Βιβλική έκδοση",
"CHANGE_DISPLAY_MODE": "Αλλαγή λειτουργίας εμφάνισης",
"CHORD_VIEW": "Προβολή συγχορδιών",
"CHORDS": "Συγχορδίες",
"CONNECTED_TO_OPENLP": "Συνδέθηκε στο OpenLP",
"DISCONNECTED": "Αποσυνδέθηκε",
"ENABLE_BIG_DISPLAY_BUTTONS": "Ενεργοποίηση μεγάλων κουμπιών εμφάνισης",
"ENABLE_FAST_SWITCHING_PANEL": "Ενεργοποίηση γρήγορου πίνακα μετάβασης",
"FONT_SCALE": "Κλίμακα γραμματοσειράς",
"GLOBAL": "Καθολικό",
"GO_BACK_TO_CONTROLLER": "Επιστροφή στον ελεγκτή",
"HIDE_NOTES": "Απόκρυψη σημειώσεων",
"LOGIN": "Σύνδεση",
"LOGIN_FAILED": "Η σύνδεση απέτυχε",
"LOGIN_SUCCEEDED": "Συνδέθηκε με επιτυχία",
"MAIN_VIEW": "Κύρια προβολή",
"NEXT_ITEM": "Επόμενο στοιχείο",
"NEXT_SLIDE": "Επόμενη διαφάνεια",
"NO_SEARCH_RESULTS": "Δεν βρέθηκαν αποτελέσματα που να ταιριάζουν με την αναζήτησή σας",
"NO_SERVICE_ITEMS": "Δεν υπάρχουν στοιχεία υπηρεσίας",
"NO_SLIDE_ITEMS": "Δεν υπάρχουν στοιχεία διαφανειών",
"PASSWORD": "Κωδικός πρόσβασης",
"PREVIOUS_ITEM": "Προηγούμενο στοιχείο",
"PREVIOUS_SLIDE": "Προηγούμενη διαφάνεια",
"SEARCH": "Αναζήτηση",
"SEARCH_RESULTS": "Αποτελέσματα αναζήτησης",
"SEARCH_TEXT": "Κείμενο αναζήτησης",
"SEND": "Αποστολή",
"SEND_AN_ALERT": "Αποστολή ειδοποίησης",
"SEND_LIVE": "Αποστολή σε πραγματικό χρόνο",
"SERVICE": "Υπηρεσία",
"SETTINGS": "Ρυθμίσεις",
"SHOW_BACKGROUND": "Εμφάνιση φόντου",
"SHOW_BLACK": "Εμφάνιση μαύρου",
"SHOW_DESKTOP": "Εμφάνιση Επιφάνειας Εργασίας",
"SHOW_NOTES": "Εμφάνιση Σημειώσεων",
"SHOW_PRESENTATION": "Εμφάνιση Παρουσίασης",
"SLIDES": "Διαφάνειες",
"SONG": "Τραγούδι",
"SONG_LEVEL_THEME_CHANGING_NOT_SUPPORTED": "Μη Υποστηριζόμενη Αλλαγή Θέματος σε Επίπεδο Τραγουδιού",
"STAGE": "Σκηνή",
"STAGE_AND_CHORDS_APPEARANCE": "Εμφάνιση Σκηνής και Συγχορδιών",
"STAGE_VIEW": "Προβολή Σκηνής",
"THEMES": "Θέματα",
"THEME_LEVEL": "Επίπεδο Θέματος",
"THEME_OPTIONS": "Επιλογές Θέματος",
"USER_NAME": "Όνομα Χρήστη",
"USER_INTERFACE": "Διεπαφή Χρήστη"
}

56
src/assets/i18n/en.json Normal file
View File

@ -0,0 +1,56 @@
{
"ADD_ITEM": "Add Item",
"ADD_ITEM_TO_SERVICE": "Add Item to Service",
"ADD_TO_SERVICE": "Add to Service",
"ALERT": "Alert",
"ALERT_SUBMITTED": "Alert Submitted",
"ALERTS": "Alerts",
"BIBLE_VERSION": "Bible version",
"CHANGE_DISPLAY_MODE": "Change Display Mode",
"CHORD_VIEW": "Chord View",
"CHORDS": "Chords",
"CONNECTED_TO_OPENLP": "Connected to OpenLP",
"DISCONNECTED": "Disconnected",
"ENABLE_BIG_DISPLAY_BUTTONS": "Enable Big Display Buttons",
"ENABLE_FAST_SWITCHING_PANEL": "Enable Fast Switching Panel",
"FONT_SCALE": "Font Scale",
"GLOBAL": "Global",
"GO_BACK_TO_CONTROLLER": "Go Back to Controller",
"HIDE_NOTES": "Hide Notes",
"LOGIN": "Login",
"LOGIN_FAILED": "Login failed",
"LOGIN_SUCCEEDED": "Successfully logged in",
"MAIN_VIEW": "Main View",
"NEXT_ITEM": "Next Item",
"NEXT_SLIDE": "Next Slide",
"NO_SEARCH_RESULTS": "No results matching your search were found",
"NO_SERVICE_ITEMS": "No Service Items",
"NO_SLIDE_ITEMS": "No Slide Items",
"PASSWORD": "Password",
"PREVIOUS_ITEM": "Previous Item",
"PREVIOUS_SLIDE": "Previous Slide",
"SEARCH": "Search",
"SEARCH_RESULTS": "Search Results",
"SEARCH_TEXT": "Search Text",
"SEND": "Send",
"SEND_AN_ALERT": "Send an Alert",
"SEND_LIVE": "Send Live",
"SERVICE": "Service",
"SETTINGS": "Settings",
"SHOW_BACKGROUND": "Show Background",
"SHOW_BLACK": "Show Black",
"SHOW_DESKTOP": "Show Desktop",
"SHOW_NOTES": "Show Notes",
"SHOW_PRESENTATION": "Show Presentation",
"SLIDES": "Slides",
"SONG": "Song",
"SONG_LEVEL_THEME_CHANGING_NOT_SUPPORTED": "Song level theme changing not supported. Change your theme level to Global or Service",
"STAGE": "Stage",
"STAGE_AND_CHORDS_APPEARANCE": "Stage and Chords Appearance",
"STAGE_VIEW": "Stage View",
"THEMES": "Themes",
"THEME_LEVEL": "Theme Level",
"THEME_OPTIONS": "Theme Options",
"USER_NAME": "User Name",
"USER_INTERFACE": "User Interface"
}

View File

@ -0,0 +1,56 @@
{
"ADD_ITEM": "Add Item",
"ADD_ITEM_TO_SERVICE": "Add Item to Service",
"ADD_TO_SERVICE": "Add to Service",
"ALERT": "Alert",
"ALERT_SUBMITTED": "Alert Submitted",
"ALERTS": "Alerts",
"BIBLE_VERSION": "Bible version",
"CHANGE_DISPLAY_MODE": "Change Display Mode",
"CHORD_VIEW": "Chord View",
"CHORDS": "Chords",
"CONNECTED_TO_OPENLP": "Connected to OpenLP",
"DISCONNECTED": "Disconnected",
"ENABLE_BIG_DISPLAY_BUTTONS": "Enable Big Display Buttons",
"ENABLE_FAST_SWITCHING_PANEL": "Enable Fast Switching Panel",
"FONT_SCALE": "Font Scale",
"GLOBAL": "Global",
"GO_BACK_TO_CONTROLLER": "Go Back to Controller",
"HIDE_NOTES": "Hide Notes",
"LOGIN": "Login",
"LOGIN_FAILED": "Login failed",
"LOGIN_SUCCEEDED": "Successfully logged in",
"MAIN_VIEW": "Main View",
"NEXT_ITEM": "Next Item",
"NEXT_SLIDE": "Next Slide",
"NO_SEARCH_RESULTS": "No results matching your search were found",
"NO_SERVICE_ITEMS": "No Service Items",
"NO_SLIDE_ITEMS": "No Slide Items",
"PASSWORD": "Password",
"PREVIOUS_ITEM": "Previous Item",
"PREVIOUS_SLIDE": "Previous Slide",
"SEARCH": "Search",
"SEARCH_RESULTS": "Search Results",
"SEARCH_TEXT": "Search Text",
"SEND": "Send",
"SEND_AN_ALERT": "Send an Alert",
"SEND_LIVE": "Send Live",
"SERVICE": "Service",
"SETTINGS": "Settings",
"SHOW_BACKGROUND": "Show Background",
"SHOW_BLACK": "Show Black",
"SHOW_DESKTOP": "Show Desktop",
"SHOW_NOTES": "Show Notes",
"SHOW_PRESENTATION": "Show Presentation",
"SLIDES": "Slides",
"SONG": "Song",
"SONG_LEVEL_THEME_CHANGING_NOT_SUPPORTED": "Song level theme changing not supported. Change your theme level to Global or Service",
"STAGE": "Stage",
"STAGE_AND_CHORDS_APPEARANCE": "Stage and Chords Appearance",
"STAGE_VIEW": "Stage View",
"THEMES": "Themes",
"THEME_LEVEL": "Theme Level",
"THEME_OPTIONS": "Theme Options",
"USER_NAME": "User Name",
"USER_INTERFACE": "User Interface"
}

View File

@ -0,0 +1,56 @@
{
"ADD_ITEM": "Add Item",
"ADD_ITEM_TO_SERVICE": "Add Item to Service",
"ADD_TO_SERVICE": "Add to Service",
"ALERT": "Alert",
"ALERT_SUBMITTED": "Alert Submitted",
"ALERTS": "Alerts",
"BIBLE_VERSION": "Bible version",
"CHANGE_DISPLAY_MODE": "Change Display Mode",
"CHORD_VIEW": "Chord View",
"CHORDS": "Chords",
"CONNECTED_TO_OPENLP": "Connected to OpenLP",
"DISCONNECTED": "Disconnected",
"ENABLE_BIG_DISPLAY_BUTTONS": "Enable Big Display Buttons",
"ENABLE_FAST_SWITCHING_PANEL": "Enable Fast Switching Panel",
"FONT_SCALE": "Font Scale",
"GLOBAL": "Global",
"GO_BACK_TO_CONTROLLER": "Go Back to Controller",
"HIDE_NOTES": "Hide Notes",
"LOGIN": "Login",
"LOGIN_FAILED": "Login failed",
"LOGIN_SUCCEEDED": "Successfully logged in",
"MAIN_VIEW": "Main View",
"NEXT_ITEM": "Next Item",
"NEXT_SLIDE": "Next Slide",
"NO_SEARCH_RESULTS": "No results matching your search were found",
"NO_SERVICE_ITEMS": "No Service Items",
"NO_SLIDE_ITEMS": "No Slide Items",
"PASSWORD": "Password",
"PREVIOUS_ITEM": "Previous Item",
"PREVIOUS_SLIDE": "Previous Slide",
"SEARCH": "Search",
"SEARCH_RESULTS": "Search Results",
"SEARCH_TEXT": "Search Text",
"SEND": "Send",
"SEND_AN_ALERT": "Send an Alert",
"SEND_LIVE": "Send Live",
"SERVICE": "Service",
"SETTINGS": "Settings",
"SHOW_BACKGROUND": "Show Background",
"SHOW_BLACK": "Show Black",
"SHOW_DESKTOP": "Show Desktop",
"SHOW_NOTES": "Show Notes",
"SHOW_PRESENTATION": "Show Presentation",
"SLIDES": "Slides",
"SONG": "Song",
"SONG_LEVEL_THEME_CHANGING_NOT_SUPPORTED": "Song level theme changing not supported. Change your theme level to Global or Service",
"STAGE": "Stage",
"STAGE_AND_CHORDS_APPEARANCE": "Stage and Chords Appearance",
"STAGE_VIEW": "Stage View",
"THEMES": "Themes",
"THEME_LEVEL": "Theme Level",
"THEME_OPTIONS": "Theme Options",
"USER_NAME": "User Name",
"USER_INTERFACE": "User Interface"
}

Some files were not shown because too many files have changed in this diff Show More