mirror of https://gitlab.com/openlp/web-remote.git
Compare commits
199 Commits
Author | SHA1 | Date |
---|---|---|
Chris Witterholt | 2a480e8c3e | |
Chris Witterholt | 38149d2279 | |
Tim Bentley | ed39a01b67 | |
Chris Witterholt | f4f3d7b444 | |
Chris Witterholt | 7020ae0bde | |
Chris Witterholt | a58c13d82d | |
Chris Witterholt | 55610e623f | |
Chris Witterholt | 06a172fcc1 | |
Chris Witterholt | b29cad5225 | |
Chris Witterholt | 1144564c2c | |
Chris Witterholt | bf185b0f92 | |
Raoul Snyman | 40c4d9e363 | |
Raoul Snyman | 4a25c28f29 | |
Chris Witterholt | 7b245d248b | |
Chris Witterholt | da749d7ec8 | |
Raoul Snyman | 08d097dd0a | |
Raoul Snyman | fcc1f543d0 | |
Raoul Snyman | 4fd8fe0f54 | |
Chris Witterholt | f657a24e99 | |
Chris Witterholt | 037d0ccb40 | |
Chris Witterholt | ff8c01c874 | |
Chris Witterholt | 86d85870e9 | |
Chris Witterholt | a6b4ea23c8 | |
Chris Witterholt | 75c11d4543 | |
Chris Witterholt | ece665dbf2 | |
Chris Witterholt | b62f21bae0 | |
Chris Witterholt | 18ba69088b | |
Chris Witterholt | 89c908ef4a | |
Chris Witterholt | ee9392cc01 | |
Chris Witterholt | ea39bbc3d7 | |
Raoul Snyman | 544b400829 | |
Raoul Snyman | db8202983f | |
Raoul Snyman | ce9fa57d9e | |
Raoul Snyman | a6f348d71b | |
Raoul Snyman | 55cccff3ed | |
Raoul Snyman | 3be49dbd74 | |
Chris Witterholt | ed3b71ef9e | |
Chris Witterholt | 5a9e1b56ac | |
Chris Witterholt | 5550648d20 | |
Chris Witterholt | 0fdcbff737 | |
Chris Witterholt | ce064facda | |
Chris Witterholt | fe07f78415 | |
Chris Witterholt | 4e09f0a08d | |
Chris Witterholt | 2e58a547ac | |
Chris Witterholt | 87aefcdbf0 | |
Chris Witterholt | b44be59d06 | |
Chris Witterholt | a255c0f00e | |
Chris Witterholt | 64b4713e43 | |
Chris Witterholt | f34830dee6 | |
Chris Witterholt | c9ff11dd58 | |
Chris Witterholt | f0554b8e53 | |
Chris Witterholt | 93a7786d8a | |
Chris Witterholt | 6bc707dc46 | |
Raoul Snyman | fcbc845ae6 | |
Raoul Snyman | 3ad18e5427 | |
Chris Witterholt | 0ef9e8fb2a | |
Chris Witterholt | f0227e049a | |
Chris Witterholt | a14eb0d1c6 | |
Chris Witterholt | dc253e74ec | |
Chris Witterholt | 9d9df01d3e | |
Chris Witterholt | bf0e14caee | |
Chris Witterholt | 5d674aa3be | |
Chris Witterholt | 1b36340d57 | |
Chris Witterholt | 30f44cb543 | |
Chris Witterholt | 4104033b4b | |
Chris Witterholt | dff8c49a81 | |
Chris Witterholt | bdfd1c1ab1 | |
Chris Witterholt | 80c54bee88 | |
Raoul Snyman | 01724bb136 | |
Chris Witterholt | 7ad8daff62 | |
Raoul Snyman | ed9db1d295 | |
Chris Witterholt | b92175d024 | |
Chris Witterholt | 2346fdc62a | |
Chris Witterholt | cb536702e1 | |
Raoul Snyman | 07086a39fa | |
Chris Witterholt | 551e8dca13 | |
Raoul Snyman | cd29766127 | |
Chris Witterholt | cbc8cd260e | |
Tim Bentley | 2833875e19 | |
Chris Witterholt | 07462f98c1 | |
Raoul Snyman | a5753112af | |
Chris Witterholt | 20aea00c85 | |
Chris Witterholt | 6bc5dc3c91 | |
Chris Witterholt | 6ba817de63 | |
Raoul Snyman | 8837bd2d93 | |
Chris Witterholt | 3d475fd5fc | |
Chris Witterholt | 96adf2c86f | |
Chris Witterholt | 24e25ba872 | |
Chris Witterholt | b70bd51ecf | |
Chris Witterholt | 4b7212959d | |
Tim Bentley | 15c27ed23a | |
Raoul Snyman | 7709ead085 | |
Tim Bentley | f0109844ab | |
Raoul Snyman | b2da690563 | |
Mateus Meyer Jiacomelli | b85900e8ec | |
Raoul Snyman | 7c7a0bd024 | |
Raoul Snyman | ecd6b6f88f | |
Chris Witterholt | 181fd67035 | |
Raoul Snyman | a78f485a1d | |
Raoul Snyman | 2b9b985ba8 | |
Raoul Snyman | e068db00db | |
Chris Witterholt | 5691809791 | |
Raoul Snyman | ead7179a96 | |
Chris Witterholt | 605cfd4a13 | |
Chris Witterholt | 87159eb637 | |
Chris Witterholt | c9858eda89 | |
Chris Witterholt | 4c1f4fb2e3 | |
Mateus Meyer Jiacomelli | 555519faac | |
Chris Witterholt | c2d45869e8 | |
Mateus Meyer Jiacomelli | 21fdfec9ac | |
Chris Witterholt | 911966ae1f | |
Mateus Meyer Jiacomelli | 0e9567fc76 | |
Mateus Meyer Jiacomelli | 1ac5016974 | |
Raoul Snyman | 6b75b00319 | |
Jenda | e19187d281 | |
Raoul Snyman | d29eddb781 | |
Mateus Meyer Jiacomelli | d33f131479 | |
Raoul Snyman | ca9ca91ac9 | |
Raoul Snyman | 23b0203813 | |
Raoul Snyman | 4eab9a080a | |
Raoul Snyman | 84b452e5a7 | |
Mateus Meyer Jiacomelli | 6fee673ce4 | |
Mateus Meyer Jiacomelli | 66f60aa358 | |
Mateus Meyer Jiacomelli | 31c6736c54 | |
Mateus Meyer Jiacomelli | 1feaacaa28 | |
Raoul Snyman | 460df54a7d | |
Mateus Meyer Jiacomelli | b37470bf5c | |
Mateus Meyer Jiacomelli | 5d71d1c3ed | |
Raoul Snyman | 265aafa67d | |
Chris Witterholt | ec9515d692 | |
Raoul Snyman | b0a8dd2b27 | |
Chris Witterholt | 8e2caacd36 | |
Raoul Snyman | 2b8ffdc075 | |
Martin Price | d2b0afeff6 | |
Raoul Snyman | 7fa3e88dc8 | |
Raoul Snyman | d50501cddc | |
Raoul Snyman | 11fc8bedaa | |
Mateus Meyer Jiacomelli | 8503d57e31 | |
Raoul Snyman | a54ff79ca1 | |
Daniel Martin | cec351f2fa | |
Raoul Snyman | cde10571a7 | |
Mateus Meyer Jiacomelli | 9bd4d6aec6 | |
Tim Bentley | b319373541 | |
Mateus Meyer Jiacomelli | 26d39760db | |
Tim Bentley | b9d3f3caab | |
Raoul Snyman | 2ad25d163a | |
Raoul Snyman | a83d129630 | |
Mateus Meyer Jiacomelli | 278aca6277 | |
Tim Bentley | 75d0007239 | |
Tomas Groth | 90d1da5090 | |
Tim Bentley | a46d117649 | |
Raoul Snyman | 845b270e06 | |
Tim Bentley | 62dfb688e7 | |
Mateus Meyer Jiacomelli | ff03ca521f | |
Raoul Snyman | b915f4004c | |
Exkywor | 35fb9149f0 | |
Tim Bentley | 7af4f684eb | |
Raoul Snyman | ba1fbe8fc5 | |
Joe Schneider | 03c76cd3ce | |
Raoul Snyman | c5948a4111 | |
Raoul Snyman | 96eed0785a | |
Martin Price | 9286f039af | |
Tim Bentley | cdf03beb45 | |
Daniel | 36ca78ecc7 | |
Tim Bentley | 145329f65f | |
Daniel | ed24a1e1f3 | |
Daniel | 86c602b41b | |
Raoul Snyman | e6366ce12e | |
Daniel Martin | 2482cbb1b3 | |
Raoul Snyman | 2812e383e6 | |
Fernando Quant | 16886d6e30 | |
Tomas Groth | 341405e715 | |
Daniel Martin | 0b55941314 | |
Raoul Snyman | d9997b93d1 | |
Raoul Snyman | 7a09dd85d6 | |
Raoul Snyman | d8a81bd9ad | |
Raoul Snyman | 066116398b | |
Tomas Groth | 29f9ba4366 | |
Raoul Snyman | 2f0cdae201 | |
Exkywor | 2d45c33dc7 | |
Tim | b22df76bd4 | |
Raoul Snyman | 5169db2d0d | |
Fernando Quant | b33cf7e781 | |
Raoul Snyman | fae84f278b | |
Fernando Quant | e1de5c329e | |
Tim Bentley | 5df672eda0 | |
Raoul Snyman | 2d10b7a363 | |
Raoul Snyman | 29041b7f1e | |
Daniel | 4cff5fbc47 | |
Tim Bentley | 7ad0d39871 | |
Tim Bentley | 2d8ee13fc7 | |
Raoul Snyman | 24c74ab3c9 | |
Tim Bentley | ad75ca76ee | |
Tim Bentley | 22a1a1cab3 | |
Raoul Snyman | ac4a99d75e | |
Tomas Groth | 83747dc1cf | |
Tim | 9f665256d6 | |
Tim Bentley | 4e3567864a | |
Daniel | 4f694dd71a |
|
@ -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
|
|
@ -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": {}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
61
angular.json
61
angular.json
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 } }));
|
||||
}
|
||||
};
|
|
@ -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!');
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -2,8 +2,8 @@
|
|||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../out-tsc/app",
|
||||
"module": "commonjs",
|
||||
"target": "es5",
|
||||
"module": "CommonJS",
|
||||
"target": "ES2022",
|
||||
"types": [
|
||||
"jasmine",
|
||||
"jasminewd2",
|
||||
|
|
98
package.json
98
package.json
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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();
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
describe('AppComponent', () => {
|
||||
it('has a dummy test', () => {
|
||||
expect().nothing();
|
||||
expect(null).toBe(null);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
})
|
||||
|
|
|
@ -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)],
|
||||
|
|
|
@ -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};
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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 })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
.mat-icon {
|
||||
vertical-align: text-top;
|
||||
}
|
||||
|
||||
.small-icon {
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
div.caption {
|
||||
font-size: 20px;
|
||||
font-weight: bolder;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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 })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -1,3 +1,3 @@
|
|||
<div class="overlay">
|
||||
<img src="{{ img }}">
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 ? ' ' : '');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>'));
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -0,0 +1,3 @@
|
|||
mat-form-field {
|
||||
width: 100%;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -0,0 +1,9 @@
|
|||
.selected {
|
||||
background-color: rgb(235, 235, 235);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Align icons with text */
|
||||
.mat-icon {
|
||||
line-height: inherit !important;
|
||||
}
|
|
@ -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';
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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}`;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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": "Потребителски интерфейс"
|
||||
}
|
|
@ -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í"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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": "Διεπαφή Χρήστη"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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
Loading…
Reference in New Issue