├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .yarnrc.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── app ├── roon-web-api │ ├── Dockerfile │ ├── README.md │ ├── eslint.config.mjs │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── app.ts │ │ ├── data │ │ │ ├── data-converter.mock.ts │ │ │ ├── data-converter.test.ts │ │ │ ├── data-converter.ts │ │ │ ├── index.ts │ │ │ ├── queue-bot-manager.mock.ts │ │ │ ├── queue-bot-manager.test.ts │ │ │ ├── queue-bot-manager.ts │ │ │ ├── queue-manager.mock.ts │ │ │ ├── queue-manager.test.ts │ │ │ ├── queue-manager.ts │ │ │ ├── zone-manager.mock.ts │ │ │ ├── zone-manager.test.ts │ │ │ └── zone-manager.ts │ │ ├── infrastructure │ │ │ ├── host-info.mock.ts │ │ │ ├── host-info.ts │ │ │ ├── index.ts │ │ │ ├── logger.ts │ │ │ ├── roon-extension-settings.test.ts │ │ │ ├── roon-extension-settings.ts │ │ │ ├── roon-extension.mock.ts │ │ │ ├── roon-extension.test.ts │ │ │ └── roon-extension.ts │ │ ├── mock │ │ │ ├── index.ts │ │ │ ├── nanoid.mock.ts │ │ │ ├── pino.mock.ts │ │ │ ├── roon-kit.mock.ts │ │ │ └── ts-retry-promise.mock.ts │ │ ├── roon-kit │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ ├── RoonExtension.ts │ │ │ ├── RoonExtensionSettings.ts │ │ │ ├── RoonKit.ts │ │ │ ├── index.ts │ │ │ └── internals │ │ │ │ ├── TransientObject.ts │ │ │ │ └── index.ts │ │ ├── route │ │ │ ├── api-route.ts │ │ │ └── app-route.ts │ │ └── service │ │ │ ├── client-manager.mock.ts │ │ │ ├── client-manager.test.ts │ │ │ ├── client-manager.ts │ │ │ ├── command-dispatcher.mock.ts │ │ │ ├── command-dispatcher.test.ts │ │ │ ├── command-dispatcher.ts │ │ │ ├── command-executor │ │ │ ├── command-executor-utils.test.ts │ │ │ ├── command-executor-utils.ts │ │ │ ├── control-command-executor.mock.ts │ │ │ ├── control-command-executor.test.ts │ │ │ ├── control-command-executor.ts │ │ │ ├── group-command-executor.mock.ts │ │ │ ├── group-command-executor.test.ts │ │ │ ├── group-command-executor.ts │ │ │ ├── mute-command-executor.mock.ts │ │ │ ├── mute-command-executor.test.ts │ │ │ ├── mute-command-executor.ts │ │ │ ├── mute-grouped-zone-command-executor.mock.ts │ │ │ ├── mute-grouped-zone-command-executor.test.ts │ │ │ ├── mute-grouped-zone-command-executor.ts │ │ │ ├── play-from-here-command-executor.mock.ts │ │ │ ├── play-from-here-command-executor.test.ts │ │ │ ├── play-from-here-command-executor.ts │ │ │ ├── queue-bot-internal-command-executor.mock.ts │ │ │ ├── queue-bot-internal-command-executor.test.ts │ │ │ ├── queue-bot-internal-command-executor.ts │ │ │ ├── shared-config-command-executor.mock.ts │ │ │ ├── shared-config-command-executor.test.ts │ │ │ ├── shared-config-command-executor.ts │ │ │ ├── transfer-zone-command-executor.mock.ts │ │ │ ├── transfer-zone-command-executor.test.ts │ │ │ ├── transfer-zone-command-executor.ts │ │ │ ├── volume-command-executor.mock.ts │ │ │ ├── volume-command-executor.test.ts │ │ │ ├── volume-command-executor.ts │ │ │ ├── volume-grouped-zone-command-executor.mock.ts │ │ │ ├── volume-grouped-zone-command-executor.test.ts │ │ │ └── volume-grouped-zone-command-executor.ts │ │ │ ├── index.ts │ │ │ └── register-graceful-shutdown.ts │ ├── tsconfig.json │ └── webpack.config.js └── roon-web-ng-client │ ├── .browserslistrc │ ├── .editorconfig │ ├── .gitignore │ ├── README.md │ ├── angular.json │ ├── eslint.config.mjs │ ├── jest.config.js │ ├── package.json │ ├── projects │ └── nihilux │ │ └── ngx-spatial-navigable │ │ ├── README.md │ │ ├── eslint.config.mjs │ │ ├── jest.config.js │ │ ├── ng-package.json │ │ ├── package.json │ │ ├── src │ │ ├── lib │ │ │ ├── directives │ │ │ │ ├── index.ts │ │ │ │ ├── ngx-spatial-navigable-container.directive.ts │ │ │ │ ├── ngx-spatial-navigable-element.directive.ts │ │ │ │ ├── ngx-spatial-navigable-root.directive.ts │ │ │ │ └── ngx-spatial-navigable-starter.directive.ts │ │ │ ├── model │ │ │ │ ├── index.ts │ │ │ │ └── ngx-spatial-navigable.constants.ts │ │ │ └── services │ │ │ │ ├── index.ts │ │ │ │ ├── ngx-spatial-navigable.service.spec.ts │ │ │ │ ├── ngx-spatial-navigable.service.ts │ │ │ │ └── ngx-spatial-navigable.utils.ts │ │ └── public-api.ts │ │ ├── tsconfig.lib.json │ │ ├── tsconfig.lib.prod.json │ │ └── tsconfig.spec.json │ ├── setup-jest.ts │ ├── src │ ├── app │ │ ├── components │ │ │ ├── alphabetical-index │ │ │ │ ├── alphabetical-index.component.html │ │ │ │ ├── alphabetical-index.component.scss │ │ │ │ ├── alphabetical-index.component.spec.ts │ │ │ │ └── alphabetical-index.component.ts │ │ │ ├── custom-action-editor │ │ │ │ ├── custom-action-editor.component.html │ │ │ │ ├── custom-action-editor.component.scss │ │ │ │ ├── custom-action-editor.component.spec.ts │ │ │ │ └── custom-action-editor.component.ts │ │ │ ├── custom-action-recorder │ │ │ │ ├── custom-action-recorder.component.html │ │ │ │ ├── custom-action-recorder.component.scss │ │ │ │ ├── custom-action-recorder.component.spec.ts │ │ │ │ └── custom-action-recorder.component.ts │ │ │ ├── custom-actions-manager │ │ │ │ ├── custom-actions-manager.component.html │ │ │ │ ├── custom-actions-manager.component.scss │ │ │ │ ├── custom-actions-manager.component.spec.ts │ │ │ │ └── custom-actions-manager.component.ts │ │ │ ├── extension-not-enabled │ │ │ │ ├── extension-not-enabled.component.html │ │ │ │ ├── extension-not-enabled.component.scss │ │ │ │ ├── extension-not-enabled.component.spec.ts │ │ │ │ └── extension-not-enabled.component.ts │ │ │ ├── full-screen-toggle │ │ │ │ ├── full-screen-toggle.component.html │ │ │ │ ├── full-screen-toggle.component.scss │ │ │ │ ├── full-screen-toggle.component.spec.ts │ │ │ │ └── full-screen-toggle.component.ts │ │ │ ├── roon-browse-dialog │ │ │ │ ├── roon-browse-dialog.component.html │ │ │ │ ├── roon-browse-dialog.component.scss │ │ │ │ ├── roon-browse-dialog.component.spec.ts │ │ │ │ └── roon-browse-dialog.component.ts │ │ │ ├── roon-browse-list │ │ │ │ ├── roon-browse-list.component.html │ │ │ │ ├── roon-browse-list.component.scss │ │ │ │ ├── roon-browse-list.component.spec.ts │ │ │ │ └── roon-browse-list.component.ts │ │ │ ├── roon-image │ │ │ │ ├── roon-image.component.html │ │ │ │ ├── roon-image.component.scss │ │ │ │ ├── roon-image.component.spec.ts │ │ │ │ └── roon-image.component.ts │ │ │ ├── settings-dialog │ │ │ │ ├── settings-dialog.component.html │ │ │ │ ├── settings-dialog.component.scss │ │ │ │ ├── settings-dialog.component.spec.ts │ │ │ │ └── settings-dialog.component.ts │ │ │ ├── zone-actions │ │ │ │ ├── zone-actions.component.html │ │ │ │ ├── zone-actions.component.scss │ │ │ │ ├── zone-actions.component.spec.ts │ │ │ │ └── zone-actions.component.ts │ │ │ ├── zone-commands │ │ │ │ ├── zone-commands.component.html │ │ │ │ ├── zone-commands.component.scss │ │ │ │ ├── zone-commands.component.spec.ts │ │ │ │ └── zone-commands.component.ts │ │ │ ├── zone-container │ │ │ │ ├── zone-container.component.html │ │ │ │ ├── zone-container.component.scss │ │ │ │ ├── zone-container.component.spec.ts │ │ │ │ └── zone-container.component.ts │ │ │ ├── zone-current-track │ │ │ │ ├── zone-current-track.component.html │ │ │ │ ├── zone-current-track.component.scss │ │ │ │ ├── zone-current-track.component.spec.ts │ │ │ │ └── zone-current-track.component.ts │ │ │ ├── zone-grouping-dialog │ │ │ │ ├── zone-grouping-dialog.component.html │ │ │ │ ├── zone-grouping-dialog.component.scss │ │ │ │ ├── zone-grouping-dialog.component.spec.ts │ │ │ │ └── zone-grouping-dialog.component.ts │ │ │ ├── zone-image │ │ │ │ ├── zone-image.component.html │ │ │ │ ├── zone-image.component.scss │ │ │ │ ├── zone-image.component.spec.ts │ │ │ │ └── zone-image.component.ts │ │ │ ├── zone-layouts │ │ │ │ ├── compact-layout │ │ │ │ │ ├── compact-layout.component.html │ │ │ │ │ ├── compact-layout.component.scss │ │ │ │ │ ├── compact-layout.component.spec.ts │ │ │ │ │ └── compact-layout.component.ts │ │ │ │ ├── one-column-layout │ │ │ │ │ ├── one-column-layout.component.html │ │ │ │ │ ├── one-column-layout.component.scss │ │ │ │ │ ├── one-column-layout.component.spec.ts │ │ │ │ │ └── one-column-layout.component.ts │ │ │ │ ├── ten-feet-layout │ │ │ │ │ ├── ten-feet-layout.component.html │ │ │ │ │ ├── ten-feet-layout.component.scss │ │ │ │ │ ├── ten-feet-layout.component.spec.ts │ │ │ │ │ └── ten-feet-layout.component.ts │ │ │ │ └── wide-layout │ │ │ │ │ ├── wide-layout.component.html │ │ │ │ │ ├── wide-layout.component.scss │ │ │ │ │ ├── wide-layout.component.spec.ts │ │ │ │ │ └── wide-layout.component.ts │ │ │ ├── zone-progression │ │ │ │ ├── zone-progression.component.html │ │ │ │ ├── zone-progression.component.scss │ │ │ │ ├── zone-progression.component.spec.ts │ │ │ │ └── zone-progression.component.ts │ │ │ ├── zone-queue-dialog │ │ │ │ ├── zone-queue-dialog.component.html │ │ │ │ ├── zone-queue-dialog.component.scss │ │ │ │ ├── zone-queue-dialog.component.spec.ts │ │ │ │ └── zone-queue-dialog.component.ts │ │ │ ├── zone-queue │ │ │ │ ├── zone-queue.component.html │ │ │ │ ├── zone-queue.component.scss │ │ │ │ ├── zone-queue.component.spec.ts │ │ │ │ └── zone-queue.component.ts │ │ │ ├── zone-selector │ │ │ │ ├── zone-selector.component.html │ │ │ │ ├── zone-selector.component.scss │ │ │ │ ├── zone-selector.component.spec.ts │ │ │ │ └── zone-selector.component.ts │ │ │ ├── zone-transfer-dialog │ │ │ │ ├── zone-transfer-dialog.component.html │ │ │ │ ├── zone-transfer-dialog.component.scss │ │ │ │ ├── zone-transfer-dialog.component.spec.ts │ │ │ │ └── zone-transfer-dialog.component.ts │ │ │ ├── zone-volume-dialog │ │ │ │ ├── zone-volume-dialog.component.html │ │ │ │ ├── zone-volume-dialog.component.scss │ │ │ │ ├── zone-volume-dialog.component.spec.ts │ │ │ │ └── zone-volume-dialog.component.ts │ │ │ └── zone-volume │ │ │ │ ├── zone-volume.component.html │ │ │ │ ├── zone-volume.component.scss │ │ │ │ ├── zone-volume.component.spec.ts │ │ │ │ └── zone-volume.component.ts │ │ ├── model │ │ │ ├── action.model.ts │ │ │ ├── browse.model.ts │ │ │ ├── index.ts │ │ │ ├── layout.model.ts │ │ │ ├── roon-service.model.ts │ │ │ ├── settings.model.ts │ │ │ ├── track.model.ts │ │ │ ├── visibility.model.ts │ │ │ ├── worker.model.ts │ │ │ ├── zone-command.model.ts │ │ │ └── zone-progression.model.ts │ │ ├── nr-root.component.html │ │ ├── nr-root.component.scss │ │ ├── nr-root.component.spec.ts │ │ ├── nr-root.component.ts │ │ ├── nr.config.ts │ │ ├── services │ │ │ ├── custom-actions.service.ts │ │ │ ├── dialog.service.ts │ │ │ ├── fullscreen.service.ts │ │ │ ├── idle.service.ts │ │ │ ├── roon.service.spec.ts │ │ │ ├── roon.service.ts │ │ │ ├── roon.worker.ts │ │ │ ├── settings.service.spec.ts │ │ │ ├── settings.service.ts │ │ │ ├── visibility.service.spec.ts │ │ │ ├── visibility.service.ts │ │ │ ├── volume.service.ts │ │ │ └── worker.utils.ts │ │ └── styles │ │ │ ├── global.scss │ │ │ ├── mixins.scss │ │ │ └── variables.scss │ ├── assets │ │ ├── .gitkeep │ │ └── favicons │ │ │ ├── favicon-114-precomposed.png │ │ │ ├── favicon-120-precomposed.png │ │ │ ├── favicon-144-precomposed.png │ │ │ ├── favicon-152-precomposed.png │ │ │ ├── favicon-180-precomposed.png │ │ │ ├── favicon-192.png │ │ │ ├── favicon-32.png │ │ │ ├── favicon-36.png │ │ │ ├── favicon-48.png │ │ │ ├── favicon-57.png │ │ │ ├── favicon-60.png │ │ │ ├── favicon-72-precomposed.png │ │ │ ├── favicon-72.png │ │ │ ├── favicon-76.png │ │ │ ├── favicon-96.png │ │ │ ├── favicon.ico │ │ │ └── manifest.webmanifest │ ├── index.html │ ├── main.ts │ ├── mock │ │ ├── nanoid.mock.ts │ │ ├── roon-cqrs-client.mock.ts │ │ └── worker.utils.mock.ts │ ├── proxy.conf.json │ └── styles.scss │ ├── tsconfig.app.json │ ├── tsconfig.json │ └── tsconfig.spec.json ├── doc ├── images │ ├── enable-in-roon-extension-settings.png │ ├── first-launch-without-extension-enabled.png │ ├── main-screen.png │ ├── roon-extension-manager.png │ ├── selecting-zone-at-first-launch.gif │ ├── ug-browse-and-library.gif │ ├── ug-display-mode-and-responsive.gif │ ├── ug-grouped-volume-drawer.gif │ ├── ug-live-radio-and-play-from-here.gif │ ├── ug-theme-selection.gif │ ├── ug-volume-drawer.gif │ ├── ug-zone-selection.gif │ └── zone-selection-and-settings.gif ├── stack-choices.md ├── user-guide.md └── what-s-coming.md ├── package.json ├── packages ├── roon-web-client │ ├── README.md │ ├── eslint.config.mjs │ ├── jest.config.cjs │ ├── package.json │ ├── src │ │ ├── client │ │ │ ├── index.ts │ │ │ ├── roon-web-client-factory.test.ts │ │ │ └── roon-web-client-factory.ts │ │ ├── index.ts │ │ └── mock │ │ │ ├── event-source.mock.ts │ │ │ └── index.ts │ ├── tsconfig.json │ └── webpack.config.cjs └── roon-web-model │ ├── README.md │ ├── eslint.config.mjs │ ├── package.json │ ├── src │ ├── api-model │ │ ├── client.d.ts │ │ ├── command.d.ts │ │ ├── common.d.ts │ │ ├── index.d.ts │ │ ├── queue.d.ts │ │ └── zone.d.ts │ ├── client-model │ │ ├── client-model.d.ts │ │ └── index.d.ts │ ├── index.d.ts │ └── roon-kit │ │ ├── LICENSE │ │ ├── README.md │ │ └── index.d.ts │ └── tsconfig.json ├── scripts └── local-release.zsh ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | insert_final_newline = true 4 | end_of_line = lf 5 | indent_style = space 6 | indent_size = 2 7 | max_line_length = 120 8 | trim_trailing_whitespace = true 9 | 10 | [*.md] 11 | max_line_length = off 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | labels: 8 | - "github-actions" 9 | groups: 10 | github-actions: 11 | patterns: 12 | - "*" 13 | - package-ecosystem: "npm" 14 | directory: "/" 15 | schedule: 16 | interval: "daily" 17 | groups: 18 | angular: 19 | patterns: 20 | - "@angular*" 21 | - "ng-packagr" 22 | production-dependencies: 23 | dependency-type: "production" 24 | exclude-patterns: 25 | - "@angular*" 26 | development-dependencies: 27 | dependency-type: "development" 28 | exclude-patterns: 29 | - "@angular*" 30 | - "ng-packagr" 31 | ignore: 32 | - dependency-name: "nanoid" 33 | update-types: 34 | - "version-update:semver-major" 35 | - dependency-name: "typescript" 36 | update-types: 37 | - "version-update:semver-major" 38 | - "version-update:semver-minor" 39 | - dependency-name: "@gquittet/graceful-server" 40 | update-types: 41 | - "version-update:semver-major" 42 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | # set-up build env 15 | - uses: actions/checkout@v4 16 | - name: Set-up yarn version 17 | run: corepack enable yarn 18 | - name: Set-up Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version-file: package.json 22 | cache: yarn 23 | # build 24 | - name: Install 25 | run: yarn install --immutable 26 | - name: Build 27 | run: yarn build 28 | - name: Test 29 | run: yarn test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | bin 3 | coverage 4 | node_modules 5 | **/*.iml 6 | config.json 7 | **/*.DS_Store 8 | .pnp.* 9 | .yarn 10 | **/.nx 11 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /bin 2 | .idea 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "arrowParens": "always", 4 | "trailingComma": "es5", 5 | "semi": true, 6 | "quoteProps": "consistent", 7 | "bracketSpacing": true 8 | } 9 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nmHoistingLimits: workspaces 2 | 3 | nodeLinker: node-modules 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | The main architecture is not supposed to shift (see [stack choices](./doc/stack-choices.md)). 4 | 5 | Even if testing is not complete, the main goal is still to: 6 | - keep `100%` as a target for the [`client`](packages/roon-web-client/README.md) and the [`api`](app/roon-web-api/README.md) (still need to test the `fastify` routes and `app`) 7 | - add real testing to the [`angular app`](app/roon-web-ng-client/README.md) (I've been lazy on this, and wanted to have an MVP product as fast as possible to see the community feedback) 8 | 9 | So there will be discussion if untested `code` is submitted in `PRs`. 10 | 11 | The other rules are hold by the `es-lint` and `prettier` rules in each module, so it's `code`. 12 | To change them, submit a `PR` and let's talk!. 13 | 14 | [Conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) and [squash on merge](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/about-pull-request-merges#squash-and-merge-your-commits) are two of the few things that are not debatable. 15 | 16 | There's [`CI`](./.github/workflows/ci.yml) running on `PRs`: only building `PRs` will be reviewed. 17 | 18 | Discussion must stay polite: we're not building the `linux kernel` (with which most of the servers, IOT and phones in the world run daily... once again, thanks Linus!). 19 | So we must keep it chill, as in fact, no one will really be impacted by the choices we make. 20 | 21 | I've a job, other passions, and a personal life that I don't share on the Internet. If reviews are slow, it's a good sign: or I'm having fun doing something else, or there's a lot of contributors on the project. 22 | 23 | That said, I'll, at least, acknowledge any `PR` passing [`CI`](./.github/workflows/ci.yml) in the first week it's been submitted. 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 nihilux 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/roon-web-api/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG ALPINE_VERSION=3.21 2 | 3 | FROM node:22-alpine${ALPINE_VERSION} AS builder 4 | RUN mkdir -p /usr/src/roon-web-stack 5 | WORKDIR /usr/src/roon-web-stack 6 | COPY . /usr/src/roon-web-stack 7 | RUN corepack enable yarn && \ 8 | yarn workspaces focus @nihilux/roon-web-api --production 9 | 10 | FROM alpine:${ALPINE_VERSION} 11 | WORKDIR /usr/src/app 12 | RUN addgroup -g 1000 node && adduser -u 1000 -G node -s /bin/sh -D node \ 13 | && chown node:node ./ 14 | RUN mkdir /usr/src/app/config \ 15 | && chown node:node /usr/src/app/config \ 16 | && ln -sv /usr/src/app/config/config.json /usr/src/app/config.json 17 | ENV HOST=0.0.0.0 18 | COPY --from=builder /usr/local/bin/node /usr/local/bin/ 19 | COPY --from=builder /usr/local/bin/docker-entrypoint.sh /usr/local/bin/ 20 | ENTRYPOINT ["docker-entrypoint.sh"] 21 | RUN apk add --no-cache libstdc++ dumb-init 22 | COPY --from=builder /usr/src/roon-web-stack/LICENSE ./LICENSE 23 | COPY --from=builder /usr/src/roon-web-stack/app/roon-web-api/node_modules ./node_modules 24 | COPY --from=builder /usr/src/roon-web-stack/app/roon-web-api/bin/app.js ./app.js 25 | COPY --from=builder /usr/src/roon-web-stack/app/roon-web-ng-client/dist/roon-web-ng-client/browser ./web 26 | USER node 27 | CMD ["dumb-init", "node", "app.js"] 28 | -------------------------------------------------------------------------------- /app/roon-web-api/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | collectCoverage: true, 4 | collectCoverageFrom: ["src/**/*.ts"], 5 | coverageDirectory: "coverage", 6 | coveragePathIgnorePatterns: [ 7 | ".*\\.(mock)|d\\.ts", 8 | ".*index(\\.mock)?\\.ts", 9 | "app.ts", 10 | "src/roon-kit/.", 11 | "src/infrastructure/logger.ts", 12 | "src/infrastructure/host-info.ts", 13 | // FIXME: Coverage! 14 | "src/route/.", 15 | "src/service/register-graceful-shutdown.ts", 16 | ], 17 | coverageReporters: ["html", "text", "text-summary", "cobertura"], 18 | // coverageReporters: [ 19 | // "json", 20 | // "text", 21 | // "lcov", 22 | // "clover" 23 | // ], 24 | coverageThreshold: { 25 | global: { 26 | branches: 100, 27 | functions: 100, 28 | lines: 100, 29 | statements: 100, 30 | }, 31 | }, 32 | moduleNameMapper: { 33 | "@data": "/src/data/index.ts", 34 | "@infrastructure": "/src/infrastructure/index.ts", 35 | "@mock": "/src/mock/index.ts", 36 | "@service": "/src/service/index.ts", 37 | "@model": "/../../packages/roon-cqrs-model/src/index.ts", 38 | "@roon-kit": "/src/roon-kit/index.ts", 39 | }, 40 | transform: { 41 | "^.+\\.ts?$": ["ts-jest", {}], 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /app/roon-web-api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nihilux/roon-web-api", 3 | "version": "0.0.11", 4 | "private": true, 5 | "main": "bin/app.js", 6 | "license": "MIT", 7 | "scripts": { 8 | "build": "yarn run clean && yarn webpack", 9 | "clean": "rimraf bin", 10 | "lint": "eslint .", 11 | "lint:fix": "eslint . --fix", 12 | "start": "yarn run clean &&NODE_ENV=development&&LOG_LEVEL=debug&&yarn webpack --watch", 13 | "test": "jest" 14 | }, 15 | "dependencies": { 16 | "@fastify/compress": "8.0.1", 17 | "@fastify/static": "8.2.0", 18 | "@gquittet/graceful-server": "4.0.9", 19 | "fastify": "5.3.3", 20 | "fastify-plugin": "5.0.1", 21 | "fastify-sse-v2": "4.2.1", 22 | "nanoid": "3.3.11", 23 | "node-roon-api": "github:roonlabs/node-roon-api", 24 | "node-roon-api-browse": "github:roonlabs/node-roon-api-browse", 25 | "node-roon-api-image": "github:roonlabs/node-roon-api-image", 26 | "node-roon-api-settings": "github:roonlabs/node-roon-api-settings", 27 | "node-roon-api-status": "github:roonlabs/node-roon-api-status", 28 | "node-roon-api-transport": "github:roonlabs/node-roon-api-transport", 29 | "rxjs": "7.8.2", 30 | "ts-retry-promise": "0.8.1", 31 | "tslib": "2.8.1" 32 | }, 33 | "devDependencies": { 34 | "@eslint/compat": "1.2.9", 35 | "@eslint/eslintrc": "3.3.1", 36 | "@eslint/js": "9.28.0", 37 | "@nihilux/roon-web-model": "workspace:*", 38 | "@types/jest": "29.5.14", 39 | "@types/node": "22.15.30", 40 | "@typescript-eslint/eslint-plugin": "8.33.1", 41 | "@typescript-eslint/parser": "8.33.1", 42 | "eslint": "9.28.0", 43 | "eslint-config-prettier": "10.1.5", 44 | "eslint-config-standard": "17.1.0", 45 | "eslint-import-resolver-typescript": "4.4.3", 46 | "eslint-plugin-import": "2.31.0", 47 | "eslint-plugin-n": "17.19.0", 48 | "eslint-plugin-prettier": "5.4.1", 49 | "eslint-plugin-promise": "7.2.1", 50 | "eslint-plugin-simple-import-sort": "12.1.1", 51 | "eslint-webpack-plugin": "5.0.1", 52 | "globals": "16.2.0", 53 | "jest": "29.7.0", 54 | "jest-junit": "16.0.0", 55 | "nodemon-webpack-plugin": "4.8.2", 56 | "prettier": "3.5.3", 57 | "rimraf": "6.0.1", 58 | "ts-jest": "29.3.4", 59 | "ts-loader": "9.5.2", 60 | "ts-node": "10.9.2", 61 | "tsconfig-paths-webpack-plugin": "4.2.0", 62 | "typescript": "5.8.3", 63 | "webpack": "5.99.9", 64 | "webpack-cli": "6.0.1", 65 | "webpack-node-externals": "3.0.0" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /app/roon-web-api/src/app.ts: -------------------------------------------------------------------------------- 1 | import { fastify } from "fastify"; 2 | import * as process from "process"; 3 | import { buildLoggerOptions, hostInfo } from "@infrastructure"; 4 | import { clientManager, gracefulShutdownHook } from "@service"; 5 | import apiRoute from "./route/api-route"; 6 | import appRoute from "./route/app-route"; 7 | 8 | const init = async (): Promise => { 9 | const server = fastify({ 10 | logger: buildLoggerOptions("debug"), 11 | }); 12 | const gracefulShutDown = gracefulShutdownHook(server); 13 | await server.register(apiRoute); 14 | await server.register(appRoute); 15 | try { 16 | await server.listen({ host: hostInfo.host, port: hostInfo.port }); 17 | gracefulShutDown.setReady(); 18 | await clientManager.start(); 19 | } catch (err: unknown) { 20 | server.log.error(err); 21 | await server.close(); 22 | process.exit(1); 23 | } 24 | }; 25 | 26 | void init(); 27 | -------------------------------------------------------------------------------- /app/roon-web-api/src/data/data-converter.mock.ts: -------------------------------------------------------------------------------- 1 | const convertZone = jest.fn(); 2 | const secondsToTimeString = jest.fn(); 3 | const convertQueue = jest.fn(); 4 | const toRoonSseMessage = jest.fn(); 5 | const buildApiState = jest.fn(); 6 | 7 | export const dataConverterMock = { 8 | buildApiState, 9 | convertQueue, 10 | convertZone, 11 | secondsToTimeString, 12 | toRoonSseMessage, 13 | }; 14 | 15 | jest.mock("./data-converter", () => ({ 16 | dataConverter: { 17 | buildApiState, 18 | convertQueue, 19 | convertZone, 20 | secondsToTimeString, 21 | toRoonSseMessage, 22 | }, 23 | })); 24 | -------------------------------------------------------------------------------- /app/roon-web-api/src/data/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./data-converter"; 2 | export * from "./queue-bot-manager"; 3 | export * from "./queue-manager"; 4 | export * from "./zone-manager"; 5 | -------------------------------------------------------------------------------- /app/roon-web-api/src/data/queue-bot-manager.mock.ts: -------------------------------------------------------------------------------- 1 | const start = jest.fn().mockImplementation(); 2 | const watchQueue = jest.fn().mockImplementation(); 3 | 4 | export const queueBotMock = { 5 | start, 6 | watchQueue, 7 | }; 8 | 9 | jest.mock("./queue-bot-manager", () => ({ 10 | queueBot: queueBotMock, 11 | })); 12 | -------------------------------------------------------------------------------- /app/roon-web-api/src/data/queue-bot-manager.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "@infrastructure"; 2 | import { 3 | ExtensionSettings, 4 | InternalCommandType, 5 | Queue, 6 | QueueBot, 7 | QueueBotCommand, 8 | Roon, 9 | SettingsUpdateListener, 10 | } from "@model"; 11 | import { commandDispatcher } from "@service"; 12 | 13 | interface QueueBotSettings { 14 | enabled: boolean; 15 | artistName: string; 16 | standbyName: string; 17 | pauseName: string; 18 | } 19 | 20 | let _isStarted = false; 21 | let _settings: QueueBotSettings | undefined; 22 | 23 | const start = (roon: Roon): void => { 24 | if (!_isStarted) { 25 | roon.settings()?.onSettings(updateSettings); 26 | _isStarted = true; 27 | } 28 | }; 29 | 30 | const updateSettings: SettingsUpdateListener = (extensionSettings: ExtensionSettings) => { 31 | _settings = { 32 | enabled: extensionSettings.nr_queue_bot_state === "enabled", 33 | artistName: extensionSettings.nr_queue_bot_artist_name, 34 | pauseName: extensionSettings.nr_queue_bot_pause_track_name, 35 | standbyName: extensionSettings.nr_queue_bot_standby_track_name, 36 | }; 37 | logger.debug(`queueBot is ${_settings.enabled ? "enabled" : "disabled"}`); 38 | }; 39 | 40 | const watchQueue = (queue: Queue): void => { 41 | if (_settings?.enabled && queue.items.length > 0) { 42 | const nextTrack = queue.items[0]; 43 | const artist = nextTrack.three_line.line2 ?? nextTrack.two_line.line2; 44 | if (artist === _settings.artistName) { 45 | let type: InternalCommandType | undefined; 46 | switch (nextTrack.three_line.line1) { 47 | case _settings.pauseName: 48 | type = InternalCommandType.STOP_NEXT; 49 | break; 50 | case _settings.standbyName: 51 | type = InternalCommandType.STANDBY_NEXT; 52 | break; 53 | } 54 | if (type !== undefined) { 55 | const command: QueueBotCommand = { 56 | type, 57 | data: { 58 | zone_id: queue.zone_id, 59 | }, 60 | }; 61 | logger.debug(`queueBot is sending '${type}' for zone_id '${queue.zone_id}'`); 62 | commandDispatcher.dispatchInternal(command); 63 | } 64 | } 65 | } 66 | }; 67 | 68 | export const queueBot: QueueBot = { 69 | start, 70 | watchQueue, 71 | }; 72 | -------------------------------------------------------------------------------- /app/roon-web-api/src/data/queue-manager.mock.ts: -------------------------------------------------------------------------------- 1 | const build = jest.fn().mockImplementation(); 2 | 3 | export const queueManagerFactoryMock = { 4 | build, 5 | }; 6 | 7 | jest.mock("./queue-manager.ts", () => ({ 8 | queueManagerFactory: { 9 | build, 10 | }, 11 | })); 12 | -------------------------------------------------------------------------------- /app/roon-web-api/src/data/zone-manager.mock.ts: -------------------------------------------------------------------------------- 1 | const zones = jest.fn(); 2 | const events = jest.fn(); 3 | const start = jest.fn(); 4 | const stop = jest.fn(); 5 | const isStarted = jest.fn(); 6 | 7 | export const zoneManagerMock = { 8 | isStarted, 9 | start, 10 | stop, 11 | events, 12 | zones, 13 | }; 14 | 15 | jest.mock("./zone-manager", () => ({ 16 | zoneManager: zoneManagerMock, 17 | })); 18 | -------------------------------------------------------------------------------- /app/roon-web-api/src/infrastructure/host-info.mock.ts: -------------------------------------------------------------------------------- 1 | import { HostInfo } from "@model"; 2 | 3 | const hostInfo = {}; 4 | 5 | export const hostInfoMock: HostInfo = hostInfo as HostInfo; 6 | 7 | jest.mock("./host-info", () => ({ 8 | hostInfo: hostInfoMock, 9 | })); 10 | -------------------------------------------------------------------------------- /app/roon-web-api/src/infrastructure/host-info.ts: -------------------------------------------------------------------------------- 1 | import * as os from "node:os"; 2 | import process from "process"; 3 | import { HostInfo } from "@model"; 4 | 5 | export const hostInfo: HostInfo = (() => { 6 | const { HOST = "localhost", PORT = "3000" } = process.env; 7 | const host = HOST; 8 | const port = parseInt(PORT, 10); 9 | const hostname = os.hostname(); 10 | if (host !== "localhost") { 11 | let ipV4: string | undefined; 12 | const ifaces = os.networkInterfaces(); 13 | for (const ifaceName in ifaces) { 14 | const iface = ifaces[ifaceName]; 15 | if (iface) { 16 | for (const addr of iface) { 17 | if ( 18 | addr.family === "IPv4" && 19 | !addr.internal && 20 | // exclude docker default bridge 21 | !addr.mac.startsWith("02:42") && 22 | // exclude obviously VPN adresses 23 | addr.mac !== "00:00:00:00:00:00" 24 | ) { 25 | ipV4 = addr.address; 26 | break; 27 | } 28 | } 29 | } 30 | } 31 | ipV4 = ipV4 ?? host; 32 | return { host, port, ipV4, hostname }; 33 | } else { 34 | return { host, port, hostname, ipV4: host }; 35 | } 36 | })(); 37 | -------------------------------------------------------------------------------- /app/roon-web-api/src/infrastructure/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./host-info"; 2 | export * from "./logger"; 3 | export * from "./roon-extension"; 4 | -------------------------------------------------------------------------------- /app/roon-web-api/src/infrastructure/logger.ts: -------------------------------------------------------------------------------- 1 | import { LoggerOptions, pino } from "pino"; 2 | import * as process from "process"; 3 | 4 | export const buildLoggerOptions: (minLevel?: pino.Level) => LoggerOptions | undefined = (minLevel?: pino.Level) => { 5 | const level = process.env["LOG_LEVEL"] ?? "info"; 6 | let shouldConfigureLogger = true; 7 | if (minLevel) { 8 | switch (minLevel) { 9 | case "debug": 10 | shouldConfigureLogger = level === "debug" || level === "trace"; 11 | break; 12 | case "info": 13 | shouldConfigureLogger = level === "debug" || level === "trace" || level === "info"; 14 | break; 15 | case "trace": 16 | shouldConfigureLogger = level === "trace"; 17 | break; 18 | case "warn": 19 | shouldConfigureLogger = level === "debug" || level === "trace" || level === "info" || level === "warn"; 20 | break; 21 | } 22 | } 23 | if (shouldConfigureLogger) { 24 | return { 25 | level, 26 | formatters: { 27 | bindings: (bindings) => ({ hostname: bindings.hostname as string }), 28 | level: (label) => ({ level: label.toUpperCase() }), 29 | }, 30 | timestamp: pino.stdTimeFunctions.isoTime, 31 | }; 32 | } 33 | }; 34 | 35 | export const logger = pino(buildLoggerOptions()); 36 | -------------------------------------------------------------------------------- /app/roon-web-api/src/infrastructure/roon-extension.mock.ts: -------------------------------------------------------------------------------- 1 | const onServerPaired = jest.fn(); 2 | const onServerLost = jest.fn(); 3 | const server = jest.fn(); 4 | const onZones = jest.fn(); 5 | const offZones = jest.fn(); 6 | const onOutputs = jest.fn(); 7 | const offOutputs = jest.fn(); 8 | const startExtension = jest.fn(); 9 | const getImage = jest.fn(); 10 | const browse = jest.fn(); 11 | const load = jest.fn(); 12 | const updateSharedConfig = jest.fn(); 13 | const sharedConfigEvents = jest.fn(); 14 | const settings = jest.fn(); 15 | 16 | export const roonMock = { 17 | onServerPaired, 18 | onServerLost, 19 | server, 20 | onZones, 21 | offZones, 22 | onOutputs, 23 | offOutputs, 24 | startExtension, 25 | getImage, 26 | browse, 27 | load, 28 | updateSharedConfig, 29 | sharedConfigEvents, 30 | settings, 31 | }; 32 | 33 | jest.mock("./roon-extension", () => ({ 34 | roon: roonMock, 35 | })); 36 | -------------------------------------------------------------------------------- /app/roon-web-api/src/mock/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./nanoid.mock"; 2 | export * from "./pino.mock"; 3 | export * from "./roon-kit.mock"; 4 | export * from "./ts-retry-promise.mock"; 5 | -------------------------------------------------------------------------------- /app/roon-web-api/src/mock/nanoid.mock.ts: -------------------------------------------------------------------------------- 1 | const nanoid = jest.fn(); 2 | 3 | export const nanoidMock = nanoid; 4 | 5 | jest.mock("nanoid", () => ({ 6 | nanoid, 7 | })); 8 | -------------------------------------------------------------------------------- /app/roon-web-api/src/mock/pino.mock.ts: -------------------------------------------------------------------------------- 1 | const debug = jest.fn(); 2 | const info = jest.fn(); 3 | const error = jest.fn(); 4 | const warn = jest.fn(); 5 | 6 | export const loggerMock = { 7 | debug, 8 | info, 9 | error, 10 | warn, 11 | }; 12 | 13 | jest.mock("pino", () => { 14 | const pino = () => loggerMock; 15 | pino.stdTimeFunctions = { 16 | isoTime: () => {}, 17 | }; 18 | return { 19 | pino, 20 | }; 21 | }); 22 | -------------------------------------------------------------------------------- /app/roon-web-api/src/mock/roon-kit.mock.ts: -------------------------------------------------------------------------------- 1 | const on = jest.fn(); 2 | const off = jest.fn(); 3 | const get_core = jest.fn(); 4 | const set_status = jest.fn(); 5 | const start_discovery = jest.fn(); 6 | const save_config = jest.fn(); 7 | const load_config = jest.fn(); 8 | const api = () => ({ 9 | save_config, 10 | load_config, 11 | }); 12 | const settings = jest.fn(); 13 | export const extensionMock = { 14 | on, 15 | off, 16 | set_status, 17 | get_core, 18 | start_discovery, 19 | api, 20 | settings, 21 | }; 22 | 23 | jest.mock( 24 | "@roon-kit", 25 | () => 26 | ({ 27 | ...jest.requireActual("@roon-kit"), 28 | Extension: jest.fn().mockImplementation(() => extensionMock), 29 | }) as unknown 30 | ); 31 | -------------------------------------------------------------------------------- /app/roon-web-api/src/mock/ts-retry-promise.mock.ts: -------------------------------------------------------------------------------- 1 | const retryDecorator = jest.fn(); 2 | 3 | export const retryMock = { 4 | retryDecorator, 5 | }; 6 | 7 | jest.mock("ts-retry-promise", () => retryMock); 8 | -------------------------------------------------------------------------------- /app/roon-web-api/src/roon-kit/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Steven Ickman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/roon-web-api/src/roon-kit/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./RoonExtension"; 2 | export * from "./RoonKit"; 3 | -------------------------------------------------------------------------------- /app/roon-web-api/src/roon-kit/internals/TransientObject.ts: -------------------------------------------------------------------------------- 1 | export class TransientObject { 2 | private _isDisposed = false; 3 | private _proxy?: { proxy: TObject; revoke: () => void }; 4 | private _promise: Promise; 5 | private _resolve?: (value: TObject) => void; 6 | private _reject?: (reason?: any) => void; 7 | 8 | constructor() { 9 | this._promise = new Promise((resolve, reject) => { 10 | this._resolve = async (obj) => { 11 | this._proxy = Proxy.revocable(obj, {}); 12 | if (this._isDisposed) { 13 | this._proxy.revoke(); 14 | } 15 | resolve(this._proxy.proxy); 16 | }; 17 | this._reject = reject; 18 | }); 19 | } 20 | 21 | public get isDisposed(): boolean { 22 | return this._isDisposed; 23 | } 24 | 25 | public getObject(): Promise { 26 | return this._promise; 27 | } 28 | 29 | public resolve(obj: TObject): TObject { 30 | this._resolve!(obj); 31 | return this._proxy!.proxy; 32 | } 33 | 34 | public reject(reason?: any): void { 35 | this._reject!(reason); 36 | } 37 | 38 | public dispose(): void { 39 | if (!this._isDisposed) { 40 | this._isDisposed = true; 41 | this._proxy?.revoke(); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/roon-web-api/src/roon-kit/internals/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./TransientObject"; 2 | -------------------------------------------------------------------------------- /app/roon-web-api/src/route/app-route.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance, FastifyPluginAsync } from "fastify"; 2 | import { fastifyPlugin } from "fastify-plugin"; 3 | import * as path from "path"; 4 | import { fastifyCompress } from "@fastify/compress"; 5 | import { fastifyStatic } from "@fastify/static"; 6 | 7 | const appRoute: FastifyPluginAsync = async (server: FastifyInstance): Promise => { 8 | await server.register(fastifyCompress); 9 | return server.register(fastifyStatic, { 10 | root: path.join(__dirname, "web"), 11 | immutable: true, 12 | maxAge: "1 days", 13 | wildcard: true, 14 | setHeaders: (res, requestedPath) => { 15 | if (requestedPath.endsWith("index.html")) { 16 | void res.setHeader("cache-control", "public, max-age=0"); 17 | } 18 | }, 19 | }); 20 | }; 21 | 22 | export default fastifyPlugin(appRoute); 23 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/client-manager.mock.ts: -------------------------------------------------------------------------------- 1 | const register = jest.fn(); 2 | const unregister = jest.fn(); 3 | const get = jest.fn(); 4 | const start = jest.fn(); 5 | const stop = jest.fn(); 6 | const browse = jest.fn(); 7 | const load = jest.fn(); 8 | 9 | export const clientManagerMock = { 10 | register, 11 | unregister, 12 | get, 13 | start, 14 | stop, 15 | browse, 16 | load, 17 | }; 18 | 19 | jest.mock("./client-manager", () => ({ 20 | clientManager: clientManagerMock, 21 | })); 22 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-dispatcher.mock.ts: -------------------------------------------------------------------------------- 1 | const dispatch = jest.fn(); 2 | const dispatchInternal = jest.fn(); 3 | 4 | export const commandDispatcherMock = { 5 | dispatch, 6 | dispatchInternal, 7 | }; 8 | 9 | jest.mock("./command-dispatcher", () => ({ 10 | commandDispatcher: commandDispatcherMock, 11 | })); 12 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-executor/command-executor-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { awaitAll } from "./command-executor-utils"; 2 | 3 | describe("command-executor-utils test suite", () => { 4 | it("awaitAll should return T[] if all promises go fine", async () => { 5 | const values = ["first", "second", "third"]; 6 | const promises = values.map((v) => Promise.resolve(v)); 7 | const awaited = await awaitAll(promises); 8 | expect(awaited).toEqual(values); 9 | }); 10 | it("awaitAll should concat all error reason in a rejected Promise if ant Error happens", async () => { 11 | const values = ["first", "second", "third"]; 12 | const promises = values.map((v, index) => { 13 | if (index % 2 === 0) { 14 | return Promise.reject(new Error(v)); 15 | } else { 16 | return Promise.resolve(v); 17 | } 18 | }); 19 | try { 20 | await awaitAll(promises); 21 | expect(true).toBe(false); 22 | } catch (error) { 23 | expect(error).toBeInstanceOf(Error); 24 | expect((error as Error).message).toBe("first\nthird"); 25 | } 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-executor/command-executor-utils.ts: -------------------------------------------------------------------------------- 1 | interface SettledResult { 2 | values: T[]; 3 | errors: Error[]; 4 | } 5 | 6 | export const awaitAll: (promises: Promise[]) => Promise = async (promises: Promise[]) => { 7 | const settled = await Promise.allSettled(promises); 8 | const result: SettledResult = { 9 | values: [], 10 | errors: [], 11 | }; 12 | const results = settled.reduce((previous, current) => { 13 | if (current.status === "fulfilled") { 14 | previous.values.push(current.value); 15 | } else { 16 | previous.errors.push(current.reason as Error); 17 | } 18 | return previous; 19 | }, result); 20 | if (results.errors.length === 0) { 21 | return results.values; 22 | } else { 23 | throw new Error(result.errors.map((e) => e.message).join("\n")); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-executor/control-command-executor.mock.ts: -------------------------------------------------------------------------------- 1 | const executor = jest.fn(); 2 | 3 | export const controlExecutorMock = executor; 4 | 5 | jest.mock("./control-command-executor", () => ({ 6 | executor, 7 | })); 8 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-executor/control-command-executor.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommandType, 3 | ControlCommand, 4 | FoundZone, 5 | RoonApiTransport, 6 | RoonApiTransportControl, 7 | RoonServer, 8 | Zone, 9 | } from "@model"; 10 | import { executor } from "./control-command-executor"; 11 | 12 | describe("control-command.ts test suite", () => { 13 | let controlApi: jest.Mock; 14 | let server: RoonServer; 15 | let foundZone: FoundZone; 16 | beforeEach(() => { 17 | controlApi = jest.fn().mockImplementation(() => Promise.resolve()); 18 | const roonApiTransport = { 19 | control: controlApi, 20 | } as unknown as RoonApiTransport; 21 | server = { 22 | services: { 23 | RoonApiTransport: roonApiTransport, 24 | }, 25 | } as unknown as RoonServer; 26 | foundZone = { 27 | zone, 28 | server, 29 | }; 30 | }); 31 | 32 | afterEach(() => { 33 | jest.resetAllMocks(); 34 | }); 35 | 36 | it("executor should call RoonApiTransport#control method with expected zone and expected action", () => { 37 | const expectedRoonControls: { [key: string]: RoonApiTransportControl } = { 38 | PLAY: "play", 39 | PAUSE: "pause", 40 | PLAY_PAUSE: "playpause", 41 | STOP: "stop", 42 | NEXT: "next", 43 | PREVIOUS: "previous", 44 | }; 45 | const controlCommandTypes: ( 46 | | CommandType.PLAY 47 | | CommandType.PAUSE 48 | | CommandType.PLAY_PAUSE 49 | | CommandType.STOP 50 | | CommandType.NEXT 51 | | CommandType.PREVIOUS 52 | )[] = [ 53 | CommandType.PLAY, 54 | CommandType.PAUSE, 55 | CommandType.PLAY_PAUSE, 56 | CommandType.STOP, 57 | CommandType.NEXT, 58 | CommandType.PREVIOUS, 59 | ]; 60 | controlCommandTypes 61 | .map( 62 | (type): ControlCommand => ({ 63 | type, 64 | data: { 65 | zone_id: zone.zone_id, 66 | }, 67 | }) 68 | ) 69 | .forEach((command) => { 70 | const expectedRoonControl = expectedRoonControls[command.type]; 71 | const executorPromise = executor(command, foundZone); 72 | void expect(executorPromise).resolves.toBeUndefined(); 73 | expect(controlApi).toHaveBeenCalledWith(zone, expectedRoonControl); 74 | }); 75 | }); 76 | }); 77 | 78 | const zone = { 79 | zone_id: "zone_id", 80 | } as unknown as Zone; 81 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-executor/control-command-executor.ts: -------------------------------------------------------------------------------- 1 | import { CommandExecutor, CommandType, ControlCommand, FoundZone, RoonApiTransportControl } from "@model"; 2 | 3 | export const executor: CommandExecutor = (command, foundZone) => { 4 | let control: RoonApiTransportControl; 5 | switch (command.type) { 6 | case CommandType.PLAY: 7 | control = "play"; 8 | break; 9 | case CommandType.PAUSE: 10 | control = "pause"; 11 | break; 12 | case CommandType.PLAY_PAUSE: 13 | control = "playpause"; 14 | break; 15 | case CommandType.STOP: 16 | control = "stop"; 17 | break; 18 | case CommandType.NEXT: 19 | control = "next"; 20 | break; 21 | case CommandType.PREVIOUS: 22 | control = "previous"; 23 | break; 24 | } 25 | const { server, zone } = foundZone; 26 | return server.services.RoonApiTransport.control(zone, control); 27 | }; 28 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-executor/group-command-executor.mock.ts: -------------------------------------------------------------------------------- 1 | const executor = jest.fn(); 2 | 3 | export const groupCommandExecutorMock = executor; 4 | 5 | jest.mock("./group-command-executor", () => ({ 6 | executor, 7 | })); 8 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-executor/group-command-executor.ts: -------------------------------------------------------------------------------- 1 | import { CommandExecutor, GroupCommand, RoonServer } from "@model"; 2 | 3 | export const executor: CommandExecutor = (command, server) => { 4 | if (command.data.mode === "group") { 5 | return server.services.RoonApiTransport.group_outputs(command.data.outputs); 6 | } else { 7 | return server.services.RoonApiTransport.ungroup_outputs(command.data.outputs); 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-executor/mute-command-executor.mock.ts: -------------------------------------------------------------------------------- 1 | const executor = jest.fn(); 2 | 3 | export const muteCommandExecutorMock = executor; 4 | 5 | jest.mock("./mute-command-executor", () => ({ 6 | executor, 7 | })); 8 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-executor/mute-command-executor.ts: -------------------------------------------------------------------------------- 1 | import { CommandExecutor, FoundZone, MuteCommand, MuteType, RoonMuteHow } from "@model"; 2 | 3 | export const executor: CommandExecutor = (command, foundZone) => { 4 | const { zone, server } = foundZone; 5 | const output = zone.outputs.find((o) => o.output_id === command.data.output_id); 6 | if (output) { 7 | if (output.volume) { 8 | let muteHow: RoonMuteHow; 9 | switch (command.data.type) { 10 | case MuteType.MUTE: 11 | muteHow = "mute"; 12 | break; 13 | case MuteType.UN_MUTE: 14 | muteHow = "unmute"; 15 | break; 16 | case MuteType.TOGGLE: 17 | muteHow = output.volume.is_muted ? "unmute" : "mute"; 18 | break; 19 | } 20 | return server.services.RoonApiTransport.mute(output, muteHow); 21 | } else { 22 | return Promise.resolve(); 23 | } 24 | } else { 25 | return Promise.reject( 26 | new Error(`'${command.data.output_id}' is not a valid 'output_id' for zone '${command.data.zone_id}'`) 27 | ); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-executor/mute-grouped-zone-command-executor.mock.ts: -------------------------------------------------------------------------------- 1 | const executor = jest.fn(); 2 | 3 | export const muteGroupedZoneCommandExecutorMock = executor; 4 | 5 | jest.mock("./mute-grouped-zone-command-executor", () => ({ 6 | executor, 7 | })); 8 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-executor/mute-grouped-zone-command-executor.ts: -------------------------------------------------------------------------------- 1 | import { CommandExecutor, FoundZone, MuteGroupedZoneCommand, MuteType, RoonMuteHow } from "@model"; 2 | import { awaitAll } from "./command-executor-utils"; 3 | 4 | export const executor: CommandExecutor = async (command, foundZone) => { 5 | const { zone, server } = foundZone; 6 | const roonPromises: Promise[] = []; 7 | for (const o of zone.outputs) { 8 | if (o.volume) { 9 | let muteHow: RoonMuteHow; 10 | switch (command.data.type) { 11 | case MuteType.MUTE: 12 | muteHow = "mute"; 13 | break; 14 | case MuteType.UN_MUTE: 15 | muteHow = "unmute"; 16 | break; 17 | case MuteType.TOGGLE: 18 | muteHow = o.volume.is_muted ? "unmute" : "mute"; 19 | break; 20 | } 21 | roonPromises.push(server.services.RoonApiTransport.mute(o, muteHow)); 22 | } 23 | } 24 | await awaitAll(roonPromises); 25 | }; 26 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-executor/play-from-here-command-executor.mock.ts: -------------------------------------------------------------------------------- 1 | const executor = jest.fn(); 2 | 3 | export const playFromHereCommandExecutorMock = executor; 4 | 5 | jest.mock("./play-from-here-command-executor", () => ({ 6 | executor, 7 | })); 8 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-executor/play-from-here-command-executor.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommandType, 3 | FoundZone, 4 | PlayFromHereCommand, 5 | RoonApiTransport, 6 | RoonApiTransportQueue, 7 | RoonServer, 8 | Zone, 9 | } from "@model"; 10 | import { executor } from "./play-from-here-command-executor"; 11 | 12 | describe("play-command-executor test suite", () => { 13 | let playFromHereApi: jest.Mock; 14 | let server: RoonServer; 15 | let foundZone: FoundZone; 16 | beforeEach(() => { 17 | playFromHereApi = jest.fn().mockImplementation(() => Promise.resolve({} as unknown as RoonApiTransportQueue)); 18 | const roonApiTransport: RoonApiTransport = { 19 | play_from_here: playFromHereApi, 20 | } as unknown as RoonApiTransport; 21 | server = { 22 | services: { 23 | RoonApiTransport: roonApiTransport, 24 | }, 25 | } as unknown as RoonServer; 26 | foundZone = { 27 | zone, 28 | server, 29 | }; 30 | }); 31 | 32 | afterEach(() => { 33 | jest.resetAllMocks(); 34 | }); 35 | 36 | it("executor should call RoonApiTransoprt#play_from_here with expected parameters, the returned Promise should be voided", () => { 37 | const command: PlayFromHereCommand = { 38 | type: CommandType.PLAY_FROM_HERE, 39 | data: { 40 | zone_id, 41 | queue_item_id, 42 | }, 43 | }; 44 | const executorPromise = executor(command, foundZone); 45 | void expect(executorPromise).resolves.toBeUndefined(); 46 | expect(playFromHereApi).toHaveBeenCalledWith(zone, queue_item_id); 47 | }); 48 | 49 | it("executor should return a rejected Promise wrapping any error returned by RoonApiTransoprt#play_from_here", () => { 50 | const error = new Error("error"); 51 | playFromHereApi.mockImplementation(() => Promise.reject(error)); 52 | const command: PlayFromHereCommand = { 53 | type: CommandType.PLAY_FROM_HERE, 54 | data: { 55 | zone_id, 56 | queue_item_id, 57 | }, 58 | }; 59 | const executorPromise = executor(command, foundZone); 60 | void expect(executorPromise).rejects.toBe(error); 61 | expect(playFromHereApi).toHaveBeenCalledWith(zone, queue_item_id); 62 | }); 63 | }); 64 | 65 | const queue_item_id = "queue_item_id"; 66 | const zone_id = "zone_id"; 67 | const zone = { 68 | zone_id, 69 | } as unknown as Zone; 70 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-executor/play-from-here-command-executor.ts: -------------------------------------------------------------------------------- 1 | import { CommandExecutor, FoundZone, PlayFromHereCommand } from "@model"; 2 | 3 | export const executor: CommandExecutor = (command, foundZone) => { 4 | const { zone, server } = foundZone; 5 | return server.services.RoonApiTransport.play_from_here(zone, command.data.queue_item_id).then(() => {}); 6 | }; 7 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-executor/queue-bot-internal-command-executor.mock.ts: -------------------------------------------------------------------------------- 1 | const internalExecutor = jest.fn(); 2 | 3 | export const queueBotInternalCommandExecutorMock = internalExecutor; 4 | 5 | jest.mock("./queue-bot-internal-command-executor", () => ({ 6 | internalExecutor, 7 | })); 8 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-executor/queue-bot-internal-command-executor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FoundZone, 3 | InternalCommandExecutor, 4 | InternalCommandType, 5 | Output, 6 | QueueBotCommand, 7 | RoonApiTransport, 8 | } from "@model"; 9 | import { awaitAll } from "./command-executor-utils"; 10 | 11 | export const internalExecutor: InternalCommandExecutor = async (command, foundZone) => { 12 | const { server, zone } = foundZone; 13 | if (zone.state === "playing") { 14 | await server.services.RoonApiTransport.control(zone, "stop"); 15 | await server.services.RoonApiTransport.control(zone, "next"); 16 | await server.services.RoonApiTransport.control(zone, "stop"); 17 | } 18 | await standbyPromise(command.type, zone.outputs, server.services.RoonApiTransport); 19 | }; 20 | 21 | const standbyPromise = async ( 22 | commandType: InternalCommandType, 23 | outputs: Output[], 24 | roonApiTransport: RoonApiTransport 25 | ): Promise => { 26 | if (commandType === InternalCommandType.STANDBY_NEXT && outputs.length > 0) { 27 | const promises = outputs 28 | .flatMap((output) => { 29 | const controls = output.source_controls ?? []; 30 | return controls 31 | .filter((sco) => sco.supports_standby) 32 | .map((sco) => ({ 33 | control_key: sco.control_key, 34 | output, 35 | })); 36 | }) 37 | .map((standby) => roonApiTransport.standby(standby.output, { control_key: standby.control_key })); 38 | await awaitAll(promises); 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-executor/shared-config-command-executor.mock.ts: -------------------------------------------------------------------------------- 1 | const executor = jest.fn(); 2 | 3 | export const sharedConfigCommandExecutor = executor; 4 | 5 | jest.mock("./shared-config-command-executor", () => ({ 6 | executor, 7 | })); 8 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-executor/shared-config-command-executor.test.ts: -------------------------------------------------------------------------------- 1 | import { roonMock } from "../../infrastructure/roon-extension.mock"; 2 | 3 | import { CommandType, RoonServer, SharedConfigCommand, SharedConfigUpdate } from "@model"; 4 | import { executor } from "./shared-config-command-executor"; 5 | 6 | describe("shared-config-command-executor test suite", () => { 7 | it("executor should call roon#saveSharedConfig", () => { 8 | const sharedConfigUpdate: SharedConfigUpdate = { 9 | customActions: [], 10 | }; 11 | const command: SharedConfigCommand = { 12 | type: CommandType.SHARED_CONFIG, 13 | data: { 14 | sharedConfigUpdate, 15 | }, 16 | }; 17 | 18 | const result = executor(command, {} as unknown as RoonServer); 19 | 20 | void expect(result).resolves.toBeUndefined(); 21 | expect(roonMock.updateSharedConfig).toHaveBeenCalledWith(sharedConfigUpdate); 22 | }); 23 | it("executor should return a rejected Promise if any error occured during the call of roon#saveSharedConfig", () => { 24 | const error = new Error("error"); 25 | roonMock.updateSharedConfig.mockImplementation(() => { 26 | throw error; 27 | }); 28 | const command: SharedConfigCommand = { 29 | type: CommandType.SHARED_CONFIG, 30 | data: { 31 | sharedConfigUpdate: { 32 | customActions: [], 33 | }, 34 | }, 35 | }; 36 | 37 | const result = executor(command, {} as unknown as RoonServer); 38 | void expect(result).rejects.toBe(error); 39 | expect(roonMock.updateSharedConfig).toHaveBeenCalledWith(command.data.sharedConfigUpdate); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-executor/shared-config-command-executor.ts: -------------------------------------------------------------------------------- 1 | import { roon } from "@infrastructure"; 2 | import { CommandExecutor, RoonServer, SharedConfigCommand } from "@model"; 3 | 4 | export const executor: CommandExecutor = (command) => { 5 | try { 6 | roon.updateSharedConfig(command.data.sharedConfigUpdate); 7 | return Promise.resolve(); 8 | } catch (err: unknown) { 9 | return Promise.reject(err as Error); 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-executor/transfer-zone-command-executor.mock.ts: -------------------------------------------------------------------------------- 1 | const executor = jest.fn(); 2 | 3 | export const transferZoneCommandExecutorMock = executor; 4 | 5 | jest.mock("./transfer-zone-command-executor", () => ({ 6 | executor, 7 | })); 8 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-executor/transfer-zone-command-executor.ts: -------------------------------------------------------------------------------- 1 | import { CommandExecutor, FoundZone, TransferZoneCommand } from "@model"; 2 | 3 | export const executor: CommandExecutor = (command, foundZone) => { 4 | const { zone, server } = foundZone; 5 | const toZone = server.services.RoonApiTransport.zone_by_zone_id(command.data.to_zone_id); 6 | if (toZone) { 7 | return server.services.RoonApiTransport.transfer_zone(zone, toZone); 8 | } else { 9 | return Promise.reject(new Error(`'${command.data.to_zone_id}' is not a valid zone_id`)); 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-executor/volume-command-executor.mock.ts: -------------------------------------------------------------------------------- 1 | const executor = jest.fn(); 2 | 3 | export const volumeCommandExecutorMock = executor; 4 | 5 | jest.mock("./volume-command-executor", () => ({ 6 | executor, 7 | })); 8 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-executor/volume-command-executor.ts: -------------------------------------------------------------------------------- 1 | import { CommandExecutor, FoundZone, Output, RoonChangeVolumeHow, VolumeCommand, VolumeStrategy } from "@model"; 2 | 3 | export const executor: CommandExecutor = (command, foundZone) => { 4 | const { zone, server } = foundZone; 5 | const output = zone.outputs.find((o: Output) => o.output_id === command.data.output_id); 6 | if (!output) { 7 | return Promise.reject( 8 | new Error(`'${command.data.output_id}' is not a valid 'output_id' for zone '${command.data.zone_id}'`) 9 | ); 10 | } 11 | if (output.volume) { 12 | let roonHow: RoonChangeVolumeHow; 13 | switch (command.data.strategy) { 14 | case VolumeStrategy.ABSOLUTE: 15 | roonHow = "absolute"; 16 | break; 17 | case VolumeStrategy.RELATIVE: 18 | roonHow = "relative"; 19 | break; 20 | case VolumeStrategy.RELATIVE_STEP: 21 | roonHow = "relative_step"; 22 | break; 23 | } 24 | return server.services.RoonApiTransport.change_volume(output, roonHow, command.data.value); 25 | } else { 26 | return Promise.resolve(); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-executor/volume-grouped-zone-command-executor.mock.ts: -------------------------------------------------------------------------------- 1 | const executor = jest.fn(); 2 | 3 | export const volumeGroupedZoneCommandExecutorMock = executor; 4 | 5 | jest.mock("./volume-grouped-zone-command-executor", () => ({ 6 | executor, 7 | })); 8 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/command-executor/volume-grouped-zone-command-executor.ts: -------------------------------------------------------------------------------- 1 | import { CommandExecutor, FoundZone, VolumeGroupedZoneCommand } from "@model"; 2 | import { awaitAll } from "./command-executor-utils"; 3 | 4 | export const executor: CommandExecutor = async (command, foundZone) => { 5 | const { zone, server } = foundZone; 6 | const roonPromises: Promise[] = []; 7 | for (const o of zone.outputs) { 8 | if (o.volume) { 9 | const value = (o.volume.step ?? 1) * (command.data.decrement ? -1 : 1); 10 | roonPromises.push(server.services.RoonApiTransport.change_volume(o, "relative", value)); 11 | } 12 | } 13 | await awaitAll(roonPromises); 14 | }; 15 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./client-manager"; 2 | export * from "./command-dispatcher"; 3 | export * from "./register-graceful-shutdown"; 4 | -------------------------------------------------------------------------------- /app/roon-web-api/src/service/register-graceful-shutdown.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from "fastify"; 2 | import GracefulServer from "@gquittet/graceful-server"; 3 | import IGracefulServer from "@gquittet/graceful-server/lib/types/interface/gracefulServer"; 4 | import { logger } from "@infrastructure"; 5 | import { clientManager } from "./client-manager"; 6 | 7 | const registerGracefulShutdown = (server: FastifyInstance): IGracefulServer => { 8 | const gracefulShutdown = GracefulServer(server.server, { 9 | timeout: 1000, 10 | }); 11 | gracefulShutdown.on(GracefulServer.READY, () => { 12 | logger.debug("roon-web-api is ready"); 13 | }); 14 | gracefulShutdown.on(GracefulServer.SHUTTING_DOWN, () => { 15 | logger.debug("roon-web-api shutdown starts"); 16 | clientManager.stop(); 17 | logger.info("roon-web-api shutdown complete"); 18 | }); 19 | gracefulShutdown.on(GracefulServer.SHUTDOWN, (error) => { 20 | if (error instanceof Error && (error.message === "SIGINT" || error.message === "SIGTERM")) { 21 | logger.debug("server shutdown because of %s", error.message); 22 | } else { 23 | logger.error(error, "fatal error, server shutdown"); 24 | } 25 | }); 26 | return gracefulShutdown; 27 | }; 28 | 29 | export const gracefulShutdownHook = registerGracefulShutdown; 30 | -------------------------------------------------------------------------------- /app/roon-web-api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "baseUrl": "./src", 6 | "paths": { 7 | "@model": [ 8 | "../../../packages/roon-web-model/src/index.d.ts" 9 | ], 10 | "@infrastructure": [ 11 | "infrastructure/index.ts" 12 | ], 13 | "@data": [ 14 | "data/index.ts" 15 | ], 16 | "@mock": [ 17 | "mock/index.ts" 18 | ], 19 | "@service": [ 20 | "service/index.ts" 21 | ], 22 | "@roon-kit": [ 23 | "roon-kit/index.ts" 24 | ] 25 | }, 26 | "outDir": "bin" 27 | }, 28 | "references": [ 29 | { 30 | "path": "../../packages/roon-web-model" 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /app/roon-web-api/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const nodeExternals = require("webpack-node-externals"); 3 | const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin"); 4 | const ESLintPlugin = require("eslint-webpack-plugin"); 5 | const NodemonPlugin = require("nodemon-webpack-plugin"); 6 | 7 | const isProduction = process.env.NODE_ENV !== "development"; 8 | 9 | const config = { 10 | entry: "./src/app.ts", 11 | target: "node", 12 | output: { 13 | path: path.resolve(__dirname, "bin"), 14 | filename: "app.js", 15 | }, 16 | plugins: [ 17 | new ESLintPlugin({ 18 | context: path.resolve(__dirname, "./src"), 19 | emitError: true, 20 | emitWarning: true, 21 | failOnError: true, 22 | failOnWarning: true, 23 | extensions: ["ts", "json", "d.ts"], 24 | fix: false, 25 | cache: false, 26 | configType: "flat", 27 | }), 28 | new NodemonPlugin(), 29 | ], 30 | module: { 31 | rules: [ 32 | { 33 | test: /\.ts$/, 34 | use: require.resolve("ts-loader"), 35 | exclude: /node_modules/, 36 | }, 37 | ], 38 | }, 39 | resolve: { 40 | extensions: [".ts"], 41 | plugins: [new TsconfigPathsPlugin({})], 42 | }, 43 | externals: [nodeExternals()], 44 | externalsPresets: { 45 | node: true 46 | }, 47 | }; 48 | 49 | module.exports = () => { 50 | if (isProduction) { 51 | config.mode = "production"; 52 | config.devtool = "source-map"; 53 | } else { 54 | config.mode = "development"; 55 | config.devtool = "inline-source-map" 56 | } 57 | return config; 58 | }; 59 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/.browserslistrc: -------------------------------------------------------------------------------- 1 | last 5 Chrome version 2 | last 3 Firefox version 3 | last 3 Edge major versions 4 | last 5 Safari major version 5 | iOS >= 13 6 | Firefox ESR 7 | last 2 version 8 | > 0.5% 9 | not dead, not IE 9-11, not op_mini all, not kaios 2.5 10 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/.editorconfig: -------------------------------------------------------------------------------- 1 | [*.html] 2 | max_line_length = off 3 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/README.md: -------------------------------------------------------------------------------- 1 | # roon-web-ng-client 2 | 3 | For now, this part is not really documented, but this is mainly a classic `Angular 17` app. 4 | 5 | If you want to use the `cli` from the `root` of the `monorepo`, don't forget to prefix your `yarn` command: 6 | 7 | ```bash 8 | yarn workspace @nihilux/roon-web-ng-client ng g c components/settings 9 | # this command has been used to generate the scaffolding of the settings component 10 | ``` 11 | 12 | It's the best way to ensure everything works flawlessly, as `yarn` is defined as `package manager` in [angular.json](./angular.json). 13 | 14 | ## Development server 15 | 16 | Run `yarn frontend` from the `root` of this `monorepo` for a dev server. 17 | 18 | ```bash 19 | yarn frontend 20 | ``` 21 | 22 | Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. 23 | 24 | The dev server is configured to proxy `/api` to `localhost:3000/api`, so if you also start the `backend` in dev mode, you should be good to go. 25 | 26 | ## Further help 27 | 28 | To get more help on the Angular CLI use `yarn workspace @nihilux/roon-cqrs-web-ng-client ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. 29 | 30 | 31 | ## Contributing 32 | 33 | See [CONTRIBUTING.md](../../CONTRIBUTING.md). 34 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "jest-preset-angular", 3 | // globalSetup: "jest-preset-angular/global-setup", 4 | setupFilesAfterEnv: ["/setup-jest.ts"], 5 | transform: { 6 | "^.+\\.(ts|js|html)$": [ 7 | "jest-preset-angular", 8 | { 9 | tsconfig: "./tsconfig.spec.json", 10 | stringifyContentPathRegex: "\\.html$", 11 | astTransformers: ["jest-preset-angular/InlineHtmlStripStylesTransformer"], 12 | isolatedModules: true, 13 | preserveSymlinks: true, 14 | }, 15 | ], 16 | }, 17 | moduleFileExtensions: ["ts", "html", "js", "json"], 18 | moduleDirectories: ["node_modules", "src"], 19 | modulePaths: [""], 20 | testPathIgnorePatterns: ["/projects"], 21 | moduleNameMapper: { 22 | "^@app/(.*)$": ["/src/app/$1"], 23 | "^@components/(.*)$": ["/src/app/components/$1"], 24 | "^@mock/(.*)$": ["/src/mock/$1"], 25 | "^@services/(.*)$": ["/src/app/services/$1"], 26 | "@model/client": ["/src/app/model/index.ts"], 27 | "@nihilux/ngx-spatial-navigable": ["/projects/nihilux/ngx-spatial-navigable/src/public-api.ts"], 28 | }, 29 | coveragePathIgnorePatterns: [ 30 | ".*\\.(mock)|d\\.ts", 31 | ], 32 | }; 33 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nihilux/roon-web-ng-client", 3 | "version": "0.0.11", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng lint && ng build", 8 | "watch": "ng build --watch --configuration development", 9 | "test": "ng test --coverage", 10 | "lint": "ng lint", 11 | "lint:fix": "ng lint --fix" 12 | }, 13 | "private": true, 14 | "dependencies": { 15 | "@angular/animations": "20.0.1", 16 | "@angular/common": "20.0.1", 17 | "@angular/compiler": "20.0.1", 18 | "@angular/core": "20.0.1", 19 | "@angular/forms": "20.0.1", 20 | "@angular/material": "20.0.2", 21 | "@angular/platform-browser": "20.0.1", 22 | "@angular/platform-browser-dynamic": "20.0.1", 23 | "@nihilux/roon-web-client": "workspace:*", 24 | "fast-equals": "5.2.2", 25 | "nanoid": "3.3.11", 26 | "ngx-device-detector": "10.0.2", 27 | "rxjs": "~7.8.2", 28 | "tslib": "2.8.1" 29 | }, 30 | "devDependencies": { 31 | "@angular-builders/jest": "19.0.1", 32 | "@angular-devkit/build-angular": "20.0.1", 33 | "@angular-eslint/builder": "19.7.1", 34 | "@angular-eslint/eslint-plugin": "19.7.1", 35 | "@angular-eslint/eslint-plugin-template": "19.7.1", 36 | "@angular-eslint/schematics": "19.7.1", 37 | "@angular-eslint/template-parser": "19.7.1", 38 | "@angular/cdk": "20.0.2", 39 | "@angular/cli": "20.0.1", 40 | "@angular/compiler-cli": "20.0.1", 41 | "@eslint/compat": "1.2.9", 42 | "@eslint/eslintrc": "3.3.1", 43 | "@eslint/js": "9.28.0", 44 | "@nihilux/roon-web-model": "workspace:*", 45 | "@types/jest": "29.5.14", 46 | "@types/node": "22.15.30", 47 | "@typescript-eslint/eslint-plugin": "8.33.1", 48 | "@typescript-eslint/parser": "8.33.1", 49 | "eslint": "9.28.0", 50 | "eslint-config-prettier": "10.1.5", 51 | "eslint-config-standard": "17.1.0", 52 | "eslint-import-resolver-typescript": "4.4.3", 53 | "eslint-plugin-import": "2.31.0", 54 | "eslint-plugin-n": "17.19.0", 55 | "eslint-plugin-prettier": "5.4.1", 56 | "eslint-plugin-promise": "7.2.1", 57 | "eslint-plugin-simple-import-sort": "12.1.1", 58 | "globals": "16.2.0", 59 | "jest": "29.7.0", 60 | "jsdom-testing-mocks": "1.13.1", 61 | "ng-mocks": "14.13.5", 62 | "ng-packagr": "20.0.0", 63 | "prettier": "3.5.3", 64 | "typescript": "5.8.3", 65 | "zone.js": "~0.15.1" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/projects/nihilux/ngx-spatial-navigable/README.md: -------------------------------------------------------------------------------- 1 | # NgxSpatialNavigable 2 | 3 | This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.2.0. 4 | 5 | This library reuse code and logic from [lrud.js](https://github.com/bbc/lrud-spatial/blob/master/lib/lrud.js). 6 | See [ngx-spatial-navigable.utils.ts](./src/lib/services/ngx-spatial-navigable.utils.ts) for more precision. 7 | The file [ngx-spatial-navigable.utils.ts](./src/lib/services/ngx-spatial-navigable.utils.ts) is published under the original license of [lrud.js](https://github.com/bbc/lrud-spatial/blob/master/lib/lrud.js) and should be considered as copyright of its original author. 8 | 9 | ## Code scaffolding 10 | 11 | Run `ng generate component component-name --project ngx-spatial-navigable` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project ngx-spatial-navigable`. 12 | > Note: Don't forget to add `--project ngx-spatial-navigable` or else it will be added to the default project in your `angular.json` file. 13 | 14 | ## Build 15 | 16 | Run `ng build ngx-spatial-navigable` to build the project. The build artifacts will be stored in the `dist/` directory. 17 | 18 | ## Publishing 19 | 20 | After building your library with `ng build ngx-spatial-navigable`, go to the dist folder `cd dist/ngx-spatial-navigable` and run `npm publish`. 21 | 22 | ## Running unit tests 23 | 24 | Run `ng test ngx-spatial-navigable` to execute the unit tests via [Karma](https://karma-runner.github.io). 25 | 26 | ## Further help 27 | 28 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. 29 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/projects/nihilux/ngx-spatial-navigable/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import rootConfig from "../../../eslint.config.mjs"; 3 | 4 | export default [...rootConfig, { 5 | files: ["src/**/*.ts"], 6 | rules: { 7 | "@angular-eslint/directive-selector": [ 8 | "error", 9 | { 10 | type: "attribute", 11 | prefix: "ngx", 12 | style: "camelCase", 13 | }, 14 | ], 15 | "@angular-eslint/component-selector": [ 16 | "error", 17 | { 18 | type: "element", 19 | prefix: "ngx", 20 | style: "kebab-case", 21 | }, 22 | ], 23 | }, 24 | } 25 | ]; 26 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/projects/nihilux/ngx-spatial-navigable/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "jest-preset-angular", 3 | // globalSetup: "jest-preset-angular/global-setup", 4 | setupFilesAfterEnv: ["/setup-jest.ts"], 5 | transform: { 6 | "^.+\\.(ts|js|html)$": [ 7 | "jest-preset-angular", 8 | { 9 | tsconfig: "projects/nihilux/ngx-spatial-navigable/tsconfig.spec.json", 10 | stringifyContentPathRegex: "\\.html$", 11 | astTransformers: ["jest-preset-angular/InlineHtmlStripStylesTransformer"], 12 | isolatedModules: true, 13 | preserveSymlinks: true, 14 | }, 15 | ], 16 | }, 17 | moduleFileExtensions: ["ts", "html", "js", "json"], 18 | moduleDirectories: ["node_modules", "projects/nihilux/ngx-spatial-navigable/src"], 19 | modulePaths: ["/projects/nihilux/ngx-spatial-navigable/src"], 20 | coveragePathIgnorePatterns: [ 21 | ".*\\.(mock)|d\\.ts", 22 | ], 23 | testPathIgnorePatterns: ["/src"], 24 | }; 25 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/projects/nihilux/ngx-spatial-navigable/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../../dist/nihilux/ngx-spatial-navigable", 4 | "lib": { 5 | "entryFile": "src/public-api.ts" 6 | } 7 | } -------------------------------------------------------------------------------- /app/roon-web-ng-client/projects/nihilux/ngx-spatial-navigable/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nihilux/ngx-spatial-navigable", 3 | "version": "0.0.1", 4 | "peerDependencies": { 5 | "@angular/common": "^19.0.0", 6 | "@angular/core": "^19.0.0" 7 | }, 8 | "dependencies": { 9 | "tslib": "^2.3.0" 10 | }, 11 | "sideEffects": false 12 | } 13 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/projects/nihilux/ngx-spatial-navigable/src/lib/directives/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ngx-spatial-navigable-container.directive"; 2 | export * from "./ngx-spatial-navigable-element.directive"; 3 | export * from "./ngx-spatial-navigable-root.directive"; 4 | export * from "./ngx-spatial-navigable-starter.directive"; 5 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/projects/nihilux/ngx-spatial-navigable/src/lib/directives/ngx-spatial-navigable-root.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, ElementRef, inject, OnInit } from "@angular/core"; 2 | import { NgxSpatialNavigableService } from "@nihilux/ngx-spatial-navigable"; 3 | 4 | @Directive({ 5 | selector: "[ngxSnRoot]", 6 | }) 7 | export class NgxSpatialNavigableRootDirective implements OnInit { 8 | private readonly _htmlElement: HTMLElement; 9 | private readonly _spatialNavigableService: NgxSpatialNavigableService; 10 | 11 | constructor() { 12 | this._htmlElement = inject>(ElementRef).nativeElement; 13 | this._spatialNavigableService = inject(NgxSpatialNavigableService); 14 | } 15 | 16 | ngOnInit(): void { 17 | this._spatialNavigableService.registerRoot(this._htmlElement); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/projects/nihilux/ngx-spatial-navigable/src/lib/directives/ngx-spatial-navigable-starter.directive.ts: -------------------------------------------------------------------------------- 1 | import { booleanAttribute, Directive, effect, ElementRef, inject, input, OnDestroy } from "@angular/core"; 2 | import { NgxSpatialNavigableService } from "@nihilux/ngx-spatial-navigable"; 3 | 4 | @Directive({ 5 | selector: "[ngxSnStarter]", 6 | }) 7 | export class NgxSpatialNavigableStarterDirective implements OnDestroy { 8 | readonly ngxSnStarterIgnore = input(false, { 9 | transform: booleanAttribute, 10 | }); 11 | readonly ngxSnStarterFocusOnFirstInput = input(false, { 12 | transform: booleanAttribute, 13 | }); 14 | private readonly _htmlElement: HTMLElement; 15 | private readonly _spatialNavigableService: NgxSpatialNavigableService; 16 | private _htmlElementForFocus: HTMLElement; 17 | 18 | constructor() { 19 | this._htmlElement = inject>(ElementRef).nativeElement; 20 | this._htmlElementForFocus = this._htmlElement; 21 | this._spatialNavigableService = inject(NgxSpatialNavigableService); 22 | effect(() => { 23 | if (this.ngxSnStarterFocusOnFirstInput()) { 24 | this._htmlElementForFocus = this._htmlElement.querySelector("input") ?? this._htmlElement; 25 | } else { 26 | this._htmlElementForFocus = this._htmlElement; 27 | } 28 | if (this.ngxSnStarterIgnore()) { 29 | this._spatialNavigableService.unregisterStarter(this._htmlElementForFocus); 30 | } else { 31 | this._spatialNavigableService.registerStarter(this._htmlElementForFocus); 32 | } 33 | }); 34 | } 35 | 36 | ngOnDestroy(): void { 37 | this._spatialNavigableService.unregisterStarter(this._htmlElement); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/projects/nihilux/ngx-spatial-navigable/src/lib/model/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ngx-spatial-navigable.constants"; 2 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/projects/nihilux/ngx-spatial-navigable/src/lib/model/ngx-spatial-navigable.constants.ts: -------------------------------------------------------------------------------- 1 | export const containerClass = "ngx-sn-container"; 2 | export const ignoredClass = "ngx-sn-ignore"; 3 | export const dataBlockDirectionAttribute = "data-ngx-sn-block-direction"; 4 | export const dataContainerPrioritizedChildrenAttribute = "data-ngx-sn-container-prioritized-children"; 5 | export const dataContainerConsiderDistanceAttribute = "data-ngx-sn-container-consider-distance"; 6 | export const dataOverlapAttribute = "'data-ngx-sn-overlap-threshold'"; 7 | export const dataContainerLastFocusChildId = "data-ngx-sn-container-last-focus-child-id"; 8 | export const dataRememberLastFocusedChildId = "data-ngx-sn-container-remember-last-focused-child-id"; 9 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/projects/nihilux/ngx-spatial-navigable/src/lib/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ngx-spatial-navigable.service"; 2 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/projects/nihilux/ngx-spatial-navigable/src/lib/services/ngx-spatial-navigable.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from "@angular/core/testing"; 2 | import { NgxSpatialNavigableService } from "./ngx-spatial-navigable.service"; 3 | 4 | describe("NgxSpatialNavigableService", () => { 5 | let service: NgxSpatialNavigableService; 6 | 7 | beforeEach(() => { 8 | TestBed.configureTestingModule({}); 9 | service = TestBed.inject(NgxSpatialNavigableService); 10 | }); 11 | 12 | afterEach(() => { 13 | service.ngOnDestroy(); 14 | }); 15 | 16 | it("should be created", () => { 17 | expect(service).toBeTruthy(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/projects/nihilux/ngx-spatial-navigable/src/public-api.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Public API Surface of ngx-spatial-navigable 3 | */ 4 | export * from "./lib/directives"; 5 | export * from "./lib/services"; 6 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/projects/nihilux/ngx-spatial-navigable/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "../../../tsconfig.json", 5 | "compilerOptions": { 6 | "outDir": "../../../out-tsc/lib", 7 | "declaration": true, 8 | "declarationMap": true, 9 | "inlineSources": true, 10 | "types": [] 11 | }, 12 | "exclude": [ 13 | "**/*.spec.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/projects/nihilux/ngx-spatial-navigable/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "./tsconfig.lib.json", 5 | "compilerOptions": { 6 | "declarationMap": false 7 | }, 8 | "angularCompilerOptions": { 9 | "compilationMode": "partial" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/projects/nihilux/ngx-spatial-navigable/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "../../../tsconfig.json", 5 | "compilerOptions": { 6 | "outDir": "../../../out-tsc/spec", 7 | "types": [ 8 | "jest" 9 | ] 10 | }, 11 | "include": [ 12 | "src/**/*.ts", 13 | "src/**/*.spec.ts", 14 | "src/**/*.mock.ts", 15 | "src/**/*.d.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/setup-jest.ts: -------------------------------------------------------------------------------- 1 | import { ngMocks } from 'ng-mocks'; // eslint-disable-line import/order 2 | import { nanoidMock } from "@mock/nanoid.mock"; 3 | 4 | // auto spy 5 | ngMocks.autoSpy('jest'); 6 | 7 | import { CommonModule } from '@angular/common'; // eslint-disable-line import/order 8 | import { ApplicationModule } from '@angular/core'; // eslint-disable-line import/order 9 | import { BrowserModule } from '@angular/platform-browser'; // eslint-disable-line import/order 10 | import { mockRoonWorker } from "@mock/worker.utils.mock"; 11 | import { TestBed } from "@angular/core/testing"; 12 | import { NoopAnimationsModule } from "@angular/platform-browser/animations"; // eslint-disable-line import/order 13 | 14 | ngMocks.globalKeep(ApplicationModule, true); 15 | ngMocks.globalKeep(CommonModule, true); 16 | ngMocks.globalKeep(BrowserModule, true); 17 | TestBed.configureTestingModule({ 18 | imports: [ApplicationModule, CommonModule, BrowserModule, NoopAnimationsModule], 19 | }); 20 | 21 | mockRoonWorker(); 22 | nanoidMock.mockImplementation(() => 1); 23 | 24 | export {} 25 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/alphabetical-index/alphabetical-index.component.html: -------------------------------------------------------------------------------- 1 | @for (letter of alphabet; track letter) { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/alphabetical-index/alphabetical-index.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/mixins" as nr; 2 | 3 | :host { 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: space-evenly; 7 | align-items: center; 8 | button { 9 | @include nr.button-reset(); 10 | padding-left: 10px; 11 | padding-right: 10px; 12 | margin-left: 10px; 13 | width: 1.5em; 14 | text-align: center; 15 | } 16 | &.is-big-fonts { 17 | button { 18 | line-height: 30px; 19 | } 20 | } 21 | @media screen and (max-height: 840px) { 22 | button { 23 | font-size: 10px; 24 | line-height: 16px; 25 | } 26 | } 27 | @media screen and (max-height: 610px) { 28 | button { 29 | font-size: 8px; 30 | line-height: 10px; 31 | } 32 | } 33 | @media screen and (max-height: 440px) { 34 | button { 35 | font-size: 6px; 36 | line-height: 8px; 37 | } 38 | } 39 | @media screen and (max-height: 380px) { 40 | display: none; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/alphabetical-index/alphabetical-index.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from "@angular/core/testing"; 2 | import { AlphabeticalIndexComponent } from "./alphabetical-index.component"; 3 | 4 | describe("AlphabeticalIndexComponent", () => { 5 | let component: AlphabeticalIndexComponent; 6 | let fixture: ComponentFixture; 7 | 8 | beforeEach(async () => { 9 | await TestBed.configureTestingModule({ 10 | imports: [AlphabeticalIndexComponent], 11 | }).compileComponents(); 12 | 13 | fixture = TestBed.createComponent(AlphabeticalIndexComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it("should create", () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/alphabetical-index/alphabetical-index.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, EventEmitter, Output } from "@angular/core"; 2 | 3 | @Component({ 4 | selector: "nr-alphabetical-index", 5 | imports: [], 6 | templateUrl: "./alphabetical-index.component.html", 7 | styleUrl: "./alphabetical-index.component.scss", 8 | changeDetection: ChangeDetectionStrategy.OnPush, 9 | }) 10 | export class AlphabeticalIndexComponent { 11 | @Output() clickedLetter = new EventEmitter(); 12 | readonly alphabet: string[]; 13 | constructor() { 14 | this.alphabet = [ 15 | "A", 16 | "B", 17 | "C", 18 | "D", 19 | "E", 20 | "F", 21 | "G", 22 | "H", 23 | "I", 24 | "J", 25 | "K", 26 | "L", 27 | "M", 28 | "N", 29 | "O", 30 | "P", 31 | "Q", 32 | "R", 33 | "S", 34 | "T", 35 | "U", 36 | "V", 37 | "W", 38 | "X", 39 | "Y", 40 | "Z", 41 | ]; 42 | } 43 | 44 | onLetterClicked(letter: string): void { 45 | this.clickedLetter.emit(letter); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/custom-action-editor/custom-action-editor.component.html: -------------------------------------------------------------------------------- 1 | 2 | Label 3 | 13 | @if (labelInputControl.hasError('required')) { 14 | 15 | Please provide a label 16 | 17 | } 18 | 19 |
20 | 21 | Icon 22 | 31 | @if (iconInputControl.hasError('required')) { 32 | 33 | Please provide an icon 34 | 35 | } 36 | 37 |
38 | 39 |
40 |
41 |

Action Path:

42 |
43 |
{{$hierarchy()}}
44 | @for (p of $path(); track p) { 45 |
46 | 47 | {{p}} 48 |
49 | } @empty { 50 | No path configured yet, please record one. 51 | } 52 | @if ($actionIndex() !== undefined) { 53 |
54 | action {{$actionIndex()! + 1}} 55 |
56 | } 57 |
58 | 66 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/custom-action-editor/custom-action-editor.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | flex-direction: column; 4 | mat-form-field { 5 | margin-bottom: 15px; 6 | } 7 | .icon-input { 8 | display: flex; 9 | justify-items: center; 10 | mat-form-field { 11 | flex-grow: 1; 12 | } 13 | .icon-preview { 14 | height: 56px; 15 | width: 56px; 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | box-sizing: border-box; 20 | margin-left: 20px; 21 | border-radius: 4px; 22 | backdrop-filter: contrast(0.8); 23 | } 24 | } 25 | .action-path-details { 26 | display: flex; 27 | flex-direction: column; 28 | cursor: pointer; 29 | margin-bottom: 20px; 30 | > div { 31 | display: flex; 32 | justify-items: center; 33 | margin-bottom: 5px; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/custom-action-editor/custom-action-editor.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { MockProvider } from "ng-mocks"; 2 | import { signal, WritableSignal } from "@angular/core"; 3 | import { ComponentFixture, TestBed } from "@angular/core/testing"; 4 | import { RoonApiBrowseHierarchy } from "@model"; 5 | import { CustomActionsService } from "@services/custom-actions.service"; 6 | import { DialogService } from "@services/dialog.service"; 7 | import { SettingsService } from "@services/settings.service"; 8 | import { CustomActionEditorComponent } from "./custom-action-editor.component"; 9 | 10 | describe("CustomActionEditorComponent", () => { 11 | let dialogService: { 12 | open: jest.Mock; 13 | }; 14 | let $isBigFonts: WritableSignal; 15 | let $label: WritableSignal; 16 | let $icon: WritableSignal; 17 | let $hierarchy: WritableSignal; 18 | let $path: WritableSignal; 19 | let $actionIndex: WritableSignal; 20 | let saveLabel: jest.Mock; 21 | let saveIcon: jest.Mock; 22 | let component: CustomActionEditorComponent; 23 | let fixture: ComponentFixture; 24 | 25 | beforeEach(() => { 26 | dialogService = { 27 | open: jest.fn(), 28 | }; 29 | $label = signal("label"); 30 | $icon = signal("icon"); 31 | $hierarchy = signal(undefined); 32 | $path = signal([]); 33 | $actionIndex = signal(undefined); 34 | saveLabel = jest.fn().mockImplementation((label: string) => { 35 | $label.set(label); 36 | }); 37 | saveIcon = jest.fn().mockImplementation((icon: string) => { 38 | $icon.set(icon); 39 | }); 40 | $isBigFonts = signal(false); 41 | TestBed.configureTestingModule({ 42 | providers: [ 43 | MockProvider(DialogService, dialogService as Partial), 44 | MockProvider(CustomActionsService, { 45 | label: () => $label, 46 | icon: () => $icon, 47 | hierarchy: () => $hierarchy, 48 | path: () => $path, 49 | actionIndex: () => $actionIndex, 50 | saveLabel, 51 | saveIcon, 52 | }), 53 | MockProvider(SettingsService, { 54 | isBigFonts: () => $isBigFonts, 55 | }), 56 | ], 57 | imports: [CustomActionEditorComponent], 58 | }); 59 | fixture = TestBed.createComponent(CustomActionEditorComponent); 60 | component = fixture.componentInstance; 61 | fixture.detectChanges(); 62 | }); 63 | 64 | it("should create", () => { 65 | expect(component).toBeTruthy(); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/custom-action-recorder/custom-action-recorder.component.html: -------------------------------------------------------------------------------- 1 |

Custom Actions Recorder

2 | 3 |

Choose a kind of content:

4 |
5 | @for (h of recordableHierarchies; track h.hierarchy; let index = $index) { 6 |
7 | 11 |
12 | } 13 |
14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/custom-action-recorder/custom-action-recorder.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/mixins" as nr; 2 | 3 | :host { 4 | .hierarchies-container { 5 | margin-top: 30px; 6 | display: flex; 7 | flex-wrap: wrap; 8 | justify-content: space-between; 9 | button { 10 | width: 200px; 11 | margin-bottom: 20px; 12 | } 13 | } 14 | mat-dialog-actions { 15 | @include nr.mat-dialog-action-bottom-right(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/custom-action-recorder/custom-action-recorder.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { MockProvider } from "ng-mocks"; 2 | import { Subject } from "rxjs"; 3 | import { ComponentFixture, TestBed } from "@angular/core/testing"; 4 | import { MatDialogRef } from "@angular/material/dialog"; 5 | import { DialogService } from "@services/dialog.service"; 6 | import { CustomActionRecorderComponent } from "./custom-action-recorder.component"; 7 | 8 | describe("CustomActionRecorderComponent", () => { 9 | let dialogService: { 10 | open: jest.Mock; 11 | close: jest.Mock; 12 | }; 13 | let afterClosedDialog: jest.Mock; 14 | let afterClosedDialogObservable: Subject; 15 | let component: CustomActionRecorderComponent; 16 | let fixture: ComponentFixture; 17 | 18 | beforeEach(() => { 19 | dialogService = { 20 | open: jest.fn(), 21 | close: jest.fn(), 22 | }; 23 | afterClosedDialogObservable = new Subject(); 24 | afterClosedDialog = jest.fn().mockImplementation(() => afterClosedDialogObservable); 25 | TestBed.configureTestingModule({ 26 | providers: [ 27 | MockProvider(DialogService, dialogService as Partial), 28 | MockProvider(MatDialogRef, { 29 | afterClosed: afterClosedDialog, 30 | }), 31 | ], 32 | imports: [CustomActionRecorderComponent], 33 | }); 34 | fixture = TestBed.createComponent(CustomActionRecorderComponent); 35 | component = fixture.componentInstance; 36 | fixture.detectChanges(); 37 | }); 38 | 39 | it("should create", () => { 40 | expect(component).toBeTruthy(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/custom-actions-manager/custom-actions-manager.component.html: -------------------------------------------------------------------------------- 1 |

Custom Actions

2 | 3 | 4 | 5 | 6 |
7 |
8 | @for (ca of $customActions(); track ca.id; let index = $index) { 9 |
10 | 11 | 12 | {{ca.button.label}} 13 | 14 | 15 | 16 | 17 | 18 |
19 | } @empty { 20 | No custom action has been defined yet 21 | } 22 |
23 |
24 | 25 |
26 |
27 |
28 |
29 | 30 | 31 |
32 |
33 | 34 |
35 |
36 |
37 |
38 |
39 |
40 | 41 | @if ($isEditing()) { 42 | 43 | 44 | } @else { 45 | 46 | } 47 | 48 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/custom-actions-manager/custom-actions-manager.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/mixins" as nr; 2 | 3 | :host { 4 | mat-dialog-content { 5 | width: 100%; 6 | height: 100%; 7 | max-width: 100%; 8 | max-height: 100%; 9 | display: flex; 10 | flex-direction: column; 11 | } 12 | mat-dialog-actions { 13 | @include nr.mat-dialog-action-bottom-right(); 14 | } 15 | .custom-actions-wrapper { 16 | padding: 15px 0; 17 | } 18 | .custom-actions-container { 19 | min-height: 0; 20 | overflow: scroll; 21 | > span { 22 | display: inline-block; 23 | margin: 20px 0 40px 0; 24 | } 25 | } 26 | .custom-action { 27 | width: 100%; 28 | min-width: 0; 29 | padding: 20px 0; 30 | border-bottom: solid 1px; 31 | display: flex; 32 | flex-direction: row; 33 | align-items: center; 34 | justify-content: space-between; 35 | box-sizing: border-box; 36 | .custom-action-label { 37 | display: inline-flex; 38 | flex-direction: row; 39 | align-items: center; 40 | span { 41 | margin-left: 10px; 42 | } 43 | } 44 | } 45 | .custom-action:last-child { 46 | border: none; 47 | } 48 | .custom-action-recorder { 49 | min-height: 0; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/extension-not-enabled/extension-not-enabled.component.html: -------------------------------------------------------------------------------- 1 |
2 |

connection with roon server did not complete

3 |

if you're seeing this message, you need to activate the extension in roon settings and then reload this page

4 |

if the extension is correctly enabled, then check the logs (both in browser console, the extension log and the roon server logs): something is broken

5 |

this page will auto-refresh in 10 seconds

6 |
7 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/extension-not-enabled/extension-not-enabled.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | .extension-not-enabled { 3 | display: flex; 4 | flex-direction: column; 5 | justify-content: flex-start; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/extension-not-enabled/extension-not-enabled.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from "@angular/core/testing"; 2 | import { ExtensionNotEnabledComponent } from "./extension-not-enabled.component"; 3 | 4 | describe("ExtensionNotEnabledComponent", () => { 5 | let component: ExtensionNotEnabledComponent; 6 | let fixture: ComponentFixture; 7 | 8 | beforeEach(async () => { 9 | await TestBed.configureTestingModule({ 10 | imports: [ExtensionNotEnabledComponent], 11 | }).compileComponents(); 12 | 13 | fixture = TestBed.createComponent(ExtensionNotEnabledComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it("should create", () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/extension-not-enabled/extension-not-enabled.component.ts: -------------------------------------------------------------------------------- 1 | import { timer } from "rxjs"; 2 | import { AfterViewInit, ChangeDetectionStrategy, Component } from "@angular/core"; 3 | 4 | @Component({ 5 | selector: "nr-extension-not-enabled", 6 | imports: [], 7 | templateUrl: "./extension-not-enabled.component.html", 8 | styleUrl: "./extension-not-enabled.component.scss", 9 | changeDetection: ChangeDetectionStrategy.OnPush, 10 | }) 11 | export class ExtensionNotEnabledComponent implements AfterViewInit { 12 | ngAfterViewInit(): void { 13 | const timerSubscription = timer(10000).subscribe(() => { 14 | timerSubscription.unsubscribe(); 15 | window.location.reload(); 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/full-screen-toggle/full-screen-toggle.component.html: -------------------------------------------------------------------------------- 1 | @if (supportsFullscreen) { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/full-screen-toggle/full-screen-toggle.component.scss: -------------------------------------------------------------------------------- 1 | :host.top-right { 2 | position: fixed; 3 | top: 6px; 4 | right: 6px; 5 | } 6 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/full-screen-toggle/full-screen-toggle.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from "@angular/core/testing"; 2 | import { FullScreenToggleComponent } from "./full-screen-toggle.component"; 3 | 4 | describe("FullScreenToggleComponent", () => { 5 | let component: FullScreenToggleComponent; 6 | let fixture: ComponentFixture; 7 | 8 | beforeEach(async () => { 9 | await TestBed.configureTestingModule({ 10 | imports: [FullScreenToggleComponent], 11 | }).compileComponents(); 12 | 13 | fixture = TestBed.createComponent(FullScreenToggleComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it("should create", () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/full-screen-toggle/full-screen-toggle.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, inject, Signal } from "@angular/core"; 2 | import { MatIconButton } from "@angular/material/button"; 3 | import { MatIcon } from "@angular/material/icon"; 4 | import { FullscreenService } from "@services/fullscreen.service"; 5 | 6 | @Component({ 7 | selector: "nr-full-screen-toggle", 8 | imports: [MatIcon, MatIconButton], 9 | templateUrl: "./full-screen-toggle.component.html", 10 | styleUrl: "./full-screen-toggle.component.scss", 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | }) 13 | export class FullScreenToggleComponent { 14 | private readonly _fullScreenService: FullscreenService; 15 | readonly supportsFullscreen: boolean; 16 | readonly $icon: Signal; 17 | 18 | constructor() { 19 | this._fullScreenService = inject(FullscreenService); 20 | this.supportsFullscreen = this._fullScreenService.supportsFullScreen(); 21 | this.$icon = this._fullScreenService.icon(); 22 | } 23 | 24 | toggleFullScreen() { 25 | this._fullScreenService.toggleFullScreen(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/roon-browse-dialog/roon-browse-dialog.component.html: -------------------------------------------------------------------------------- 1 |
2 | @if (isRecording) { 3 |

Custom Action Recording:

4 | } 5 | @for (item of $dialogTitle(); track item; let first = $first; let index = $index; let count = $count) { 6 | @if (count >= $itemsInTitle() && index === (count - $itemsInTitle())) { 7 | 10 | } 11 | @if (index > (count - $itemsInTitle())) { 12 | @if (!first && index > (count - $itemsInTitle() + 1)) { 13 |
19 | 20 | @if ($loading()) { 21 |
22 | 23 |
24 | } @else { 25 | 34 | @if (withIndex) { 35 | 36 | } 37 | } 38 |
39 | 40 | @if (isRecording) { 41 | 42 | 43 | } @else { 44 | 45 | } 46 | 47 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/roon-browse-dialog/roon-browse-dialog.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/mixins" as nr; 2 | 3 | :host { 4 | .browse-dialog-title { 5 | display: flex; 6 | justify-content: flex-start; 7 | align-items: center; 8 | padding-top: 24px; 9 | &.is-big-fonts { 10 | padding-bottom: 24px; 11 | @include nr.icon-button-size(48px, 36px, 6px); 12 | --mdc-filled-button-label-text-size: 24px; 13 | --mdc-filled-button-container-color: transparent; 14 | .mat-mdc-unelevated-button { 15 | padding: 26px; 16 | } 17 | > mat-icon { 18 | @include nr.mat-icon-size(48px); 19 | } 20 | } 21 | } 22 | mat-dialog-content { 23 | width: 100%; 24 | height: 100%; 25 | max-width: 100%; 26 | max-height: 100%; 27 | display: flex; 28 | .loading-spinner { 29 | width: 100%; 30 | height: 100%; 31 | display: flex; 32 | justify-content: center; 33 | align-items: center; 34 | } 35 | } 36 | mat-dialog-actions { 37 | @include nr.mat-dialog-action-bottom-right(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/roon-image/roon-image.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/roon-image/roon-image.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: inline-block; 3 | div { 4 | position: relative; 5 | width: 100%; 6 | height: 100%; 7 | img { 8 | object-fit: contain; 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/roon-image/roon-image.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from "@angular/core/testing"; 2 | import { RoonImageComponent } from "./roon-image.component"; 3 | 4 | describe("RoonImageComponent", () => { 5 | let component: RoonImageComponent; 6 | let fixture: ComponentFixture; 7 | 8 | beforeEach(async () => { 9 | await TestBed.configureTestingModule({ 10 | imports: [RoonImageComponent], 11 | }).compileComponents(); 12 | 13 | fixture = TestBed.createComponent(RoonImageComponent); 14 | component = fixture.componentInstance; 15 | component.src = "src"; 16 | component.width = 70; 17 | component.height = 70; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it("should create", () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/roon-image/roon-image.component.ts: -------------------------------------------------------------------------------- 1 | import { IMAGE_LOADER, ImageLoaderConfig, NgOptimizedImage } from "@angular/common"; 2 | import { booleanAttribute, ChangeDetectionStrategy, Component, Input, numberAttribute, OnInit } from "@angular/core"; 3 | 4 | @Component({ 5 | selector: "nr-roon-image", 6 | imports: [NgOptimizedImage], 7 | templateUrl: "./roon-image.component.html", 8 | styleUrl: "./roon-image.component.scss", 9 | changeDetection: ChangeDetectionStrategy.OnPush, 10 | providers: [ 11 | { 12 | provide: IMAGE_LOADER, 13 | useValue: (config: ImageLoaderConfig) => { 14 | const height = config.loaderParams?.["height"] as number; 15 | const width = config.loaderParams?.["width"] as number; 16 | return `/api/image?width=${width}&height=${height}&scale=fit&image_key=${config.src}`; 17 | }, 18 | }, 19 | ], 20 | }) 21 | export class RoonImageComponent implements OnInit { 22 | @Input({ required: true }) src!: string; 23 | @Input({ required: true, transform: numberAttribute }) width!: number; 24 | @Input({ required: true, transform: numberAttribute }) height!: number; 25 | @Input({ required: true }) alt!: string; 26 | @Input({ transform: booleanAttribute }) priority; 27 | loaderParams: { 28 | height: number; 29 | width: number; 30 | }; 31 | 32 | constructor() { 33 | this.priority = false; 34 | this.loaderParams = { 35 | height: 0, 36 | width: 0, 37 | }; 38 | } 39 | 40 | ngOnInit(): void { 41 | this.loaderParams = { 42 | height: this.height * 2, 43 | width: this.width * 2, 44 | }; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-actions/zone-actions.component.html: -------------------------------------------------------------------------------- 1 | @if ($withFullscreen()) { 2 |
3 | 4 |
5 | } 6 |
7 | @if ($isIconsOnly()) { 8 | @for (action of $actions(); track action.id) { 9 | 10 | 15 | 16 | } 17 | } @else { 18 | @for (action of $actions(); track action.id) { 19 | 20 | 25 | 26 | } 27 | } 28 |
29 | @if ($withSettings()) { 30 |
31 | 36 |
37 | } 38 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-actions/zone-actions.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/mixins" as nr; 2 | 3 | :host { 4 | display: flex; 5 | justify-content: flex-end; 6 | margin: 15px 0 0; 7 | width: 100%; 8 | .full-screen-toggle { 9 | margin-right: 10px; 10 | display: flex; 11 | align-items: center; 12 | justify-content: center; 13 | } 14 | .actions { 15 | display: flex; 16 | flex-wrap: wrap-reverse; 17 | justify-content: flex-end; 18 | align-items: center; 19 | flex-grow: 1; 20 | span { 21 | display: inline-block; 22 | min-width: 130px; 23 | } 24 | span.is-small-screen { 25 | min-width: 50px; 26 | } 27 | } 28 | .actions.centered { 29 | justify-content: center; 30 | } 31 | .settings { 32 | margin-left: 10px; 33 | display: flex; 34 | align-items: center; 35 | justify-content: center; 36 | } 37 | } 38 | 39 | :host.ten-feet { 40 | flex-direction: column; 41 | margin: 0; 42 | .actions { 43 | flex-wrap: wrap; 44 | justify-content: space-evenly; 45 | span { 46 | @include nr.ten-feet-button(); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-actions/zone-actions.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from "@angular/core/testing"; 2 | import { ZoneActionsComponent } from "./zone-actions.component"; 3 | 4 | describe("ZoneQueueCommandsComponent", () => { 5 | let component: ZoneActionsComponent; 6 | let fixture: ComponentFixture; 7 | 8 | beforeEach(async () => { 9 | await TestBed.configureTestingModule({ 10 | imports: [ZoneActionsComponent], 11 | }).compileComponents(); 12 | 13 | fixture = TestBed.createComponent(ZoneActionsComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it("should create", () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-commands/zone-commands.component.html: -------------------------------------------------------------------------------- 1 |
2 | @if (zoneVolume !== undefined) { 3 |
4 | 5 |
6 | } 7 |
8 | @if ($zoneCommands().previousTrack !== "ABSENT") { 9 | 18 | } 19 | @if ($zoneCommands().loading !== "ABSENT") { 20 | 30 | } 31 | @if ($zoneCommands().play !== "ABSENT") { 32 | 43 | } 44 | @if ($zoneCommands().pause !== "ABSENT") { 45 | 56 | } 57 | @if ($zoneCommands().nextTrack !== "ABSENT") { 58 | 67 | } 68 |
69 | @if (zoneVolume !== undefined) { 70 | 71 | } 72 |
73 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-commands/zone-commands.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/mixins" as nr; 2 | 3 | :host { 4 | display: block; 5 | padding-top: 15px; 6 | width: 100%; 7 | > div { 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | .zones { 12 | flex-basis: 35%; 13 | text-align: right; 14 | } 15 | .volume { 16 | flex-basis: 35%; 17 | text-align: left; 18 | } 19 | .buttons { 20 | flex-basis: 30%; 21 | text-align: center; 22 | } 23 | } 24 | > div.is-small-screen { 25 | .zones, .volume { 26 | flex-basis: 20%; 27 | } 28 | .buttons { 29 | flex-basis: 60%; 30 | } 31 | } 32 | &.ten-feet { 33 | > div { 34 | .buttons { 35 | flex-basis: inherit; 36 | padding: 10px; 37 | border-radius: 30px; 38 | @include nr.ten-feet-blur(); 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-commands/zone-commands.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { MockProvider } from "ng-mocks"; 2 | import { signal, WritableSignal } from "@angular/core"; 3 | import { ComponentFixture, TestBed } from "@angular/core/testing"; 4 | import { Command } from "@model"; 5 | import { DisplayMode, ZoneCommands, ZoneCommandState } from "@model/client"; 6 | import { RoonService } from "@services/roon.service"; 7 | import { SettingsService } from "@services/settings.service"; 8 | import { ZoneCommandsComponent } from "./zone-commands.component"; 9 | 10 | describe("ZoneCommandsComponent", () => { 11 | let component: ZoneCommandsComponent; 12 | let fixture: ComponentFixture; 13 | let commands: Command[]; 14 | let roonService: { 15 | command: jest.Mock; 16 | }; 17 | let $isSmallScreen: WritableSignal; 18 | let $displayMode: WritableSignal; 19 | let $zoneCommands: WritableSignal; 20 | let settingsService: { 21 | isSmallScreen: jest.Mock; 22 | displayMode: jest.Mock; 23 | }; 24 | 25 | beforeEach(() => { 26 | commands = []; 27 | roonService = { 28 | command: jest.fn().mockImplementation((command: Command) => { 29 | commands.push(command); 30 | }), 31 | }; 32 | $isSmallScreen = signal(false); 33 | $displayMode = signal(DisplayMode.COMPACT); 34 | settingsService = { 35 | isSmallScreen: jest.fn().mockImplementation(() => $isSmallScreen), 36 | displayMode: jest.fn().mockImplementation(() => $displayMode), 37 | }; 38 | $zoneCommands = signal(ZONE_COMMANDS); 39 | TestBed.configureTestingModule({ 40 | providers: [MockProvider(RoonService, roonService), MockProvider(SettingsService, settingsService)], 41 | imports: [ZoneCommandsComponent], 42 | }); 43 | fixture = TestBed.createComponent(ZoneCommandsComponent); 44 | fixture.componentRef.setInput("$zoneCommands", $zoneCommands); 45 | component = fixture.componentInstance; 46 | fixture.detectChanges(); 47 | }); 48 | 49 | it("should create", () => { 50 | expect(component).toBeTruthy(); 51 | }); 52 | }); 53 | 54 | const ZONE_COMMANDS: ZoneCommands = { 55 | zoneId: "zone_id", 56 | previousTrack: ZoneCommandState.ABSENT, 57 | loading: ZoneCommandState.ABSENT, 58 | pause: ZoneCommandState.ABSENT, 59 | play: ZoneCommandState.ABSENT, 60 | nextTrack: ZoneCommandState.ABSENT, 61 | }; 62 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-container/zone-container.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | width: 100%; 3 | height: 100%; 4 | } 5 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-current-track/zone-current-track.component.html: -------------------------------------------------------------------------------- 1 | @if ($trackDisplay().title !== EMPTY_TRACK.title) { 2 |
3 |

{{$trackDisplay().title}}

4 | @if ($trackDisplay().disk?.title || $trackDisplay().artist) { 5 |

6 | @if ($trackDisplay().disk?.title) { 7 | on {{$trackDisplay().disk?.title}} 8 | } 9 | @if ($trackDisplay().artist) { 10 | by {{$trackDisplay().artist}} 11 | } 12 |

13 | } 14 |
15 | } 16 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-current-track/zone-current-track.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/mixins" as nr; 2 | 3 | :host { 4 | width: 100%; 5 | span { 6 | @include nr.block-overflow-ellipsis(); 7 | } 8 | .current-track { 9 | padding: 0 25px; 10 | margin-bottom: 20px; 11 | cursor: default; 12 | h1 { 13 | margin: 10px 0 5px 0; 14 | } 15 | p { 16 | margin: 0; 17 | } 18 | span { 19 | padding-left: 5px; 20 | line-height: 2.0rem; 21 | font-size: 1.8rem; 22 | min-width: 0; 23 | } 24 | } 25 | &.ten-feet { 26 | .current-track { 27 | h1 { 28 | font-size: 4rem; 29 | } 30 | span { 31 | line-height: 3.0rem; 32 | font-size: 2.6rem; 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-current-track/zone-current-track.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { signal, WritableSignal } from "@angular/core"; 2 | import { ComponentFixture, TestBed } from "@angular/core/testing"; 3 | import { TrackDisplay } from "@model/client"; 4 | import { ZoneCurrentTrackComponent } from "./zone-current-track.component"; 5 | 6 | describe("ZoneCurrentTrackComponent", () => { 7 | let component: ZoneCurrentTrackComponent; 8 | let fixture: ComponentFixture; 9 | let $isOneColumn: WritableSignal; 10 | let $trackDisplay: WritableSignal; 11 | 12 | beforeEach(() => { 13 | $isOneColumn = signal(false); 14 | $trackDisplay = signal({ 15 | title: "track_title", 16 | image_key: "track_image_key", 17 | artist: "track_artist", 18 | disk: { 19 | title: "track_disk_title", 20 | artist: "track_artist", 21 | }, 22 | }); 23 | TestBed.configureTestingModule({ 24 | imports: [ZoneCurrentTrackComponent], 25 | }); 26 | 27 | fixture = TestBed.createComponent(ZoneCurrentTrackComponent); 28 | fixture.componentRef.setInput("$isOneColumn", $isOneColumn); 29 | fixture.componentRef.setInput("$trackDisplay", $trackDisplay); 30 | component = fixture.componentInstance; 31 | fixture.detectChanges(); 32 | }); 33 | 34 | it("should create", () => { 35 | expect(component).toBeTruthy(); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-current-track/zone-current-track.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, Input, Signal } from "@angular/core"; 2 | import { EMPTY_TRACK, TrackDisplay } from "@model/client"; 3 | 4 | @Component({ 5 | selector: "nr-zone-current-track", 6 | imports: [], 7 | templateUrl: "./zone-current-track.component.html", 8 | styleUrl: "./zone-current-track.component.scss", 9 | changeDetection: ChangeDetectionStrategy.OnPush, 10 | }) 11 | export class ZoneCurrentTrackComponent { 12 | @Input({ required: true }) $isOneColumn!: Signal; 13 | @Input({ required: true }) $trackDisplay!: Signal; 14 | protected readonly EMPTY_TRACK = EMPTY_TRACK; 15 | } 16 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-grouping-dialog/zone-grouping-dialog.component.html: -------------------------------------------------------------------------------- 1 |

Zone grouping

2 | 3 | 4 | {{mainOutput.display_name}} 5 | 6 | @if ($groupedOutputs().length > 0) { 7 |

Currently in group:

8 | @for (output of $groupedOutputs(); track output.output_id; let index = $index) { 9 |

10 | 23 | {{output.display_name}} 24 | 25 |

26 | } 27 | 28 | } 29 | @if ($canGroupOutputs().length > 0) { 30 |

Add to zone:

31 | @for (output of $canGroupOutputs(); track output.output_id; let index = $index) { 32 |

33 | 45 | {{output.display_name}} 46 | 47 |

48 | } 49 | } 50 |
51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-grouping-dialog/zone-grouping-dialog.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | h2 { 3 | padding-bottom: 18px; 4 | } 5 | mat-dialog-actions { 6 | button { 7 | margin: 0 8px 8px 0; 8 | } 9 | } 10 | } 11 | .zone-grouping { 12 | .from-zone { 13 | display: flex; 14 | margin-bottom: 20px; 15 | align-items: center; 16 | mat-icon { 17 | margin-right: 10px; 18 | } 19 | } 20 | mat-divider { 21 | min-width: 250px; 22 | } 23 | &:not(.is-small-screen) { 24 | min-width: 480px; 25 | } 26 | } 27 | 28 | @media screen and (max-width: 300px) { 29 | .zone-grouping { 30 | mat-divider { 31 | min-width: 170px; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-image/zone-image.component.html: -------------------------------------------------------------------------------- 1 |
2 | @if ($trackDisplay() !== EMPTY_TRACK) { 3 | @if ($image().isReady) { 4 | 5 | } 6 | } @else { 7 |
8 |

{{$trackDisplay().title}}

9 |
10 | } 11 |
12 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-image/zone-image.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | flex-grow: 1; 3 | .zone-image { 4 | display: flex; 5 | text-align: center; 6 | justify-content: center; 7 | align-items: center; 8 | width: 100%; 9 | height: 100%; 10 | nr-roon-image { 11 | width: 100%; 12 | height: 100%; 13 | padding: 10px; 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-image/zone-image.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { signal, WritableSignal } from "@angular/core"; 2 | import { ComponentFixture, TestBed } from "@angular/core/testing"; 3 | import { TrackDisplay } from "@model/client"; 4 | import { ZoneImageComponent } from "./zone-image.component"; 5 | 6 | describe("ZoneImageComponent", () => { 7 | let component: ZoneImageComponent; 8 | let fixture: ComponentFixture; 9 | let $trackDisplay: WritableSignal; 10 | 11 | beforeEach(() => { 12 | $trackDisplay = signal({ 13 | title: "track_title", 14 | image_key: "track_image_key", 15 | artist: "track_artist", 16 | disk: { 17 | title: "track_disk_title", 18 | artist: "track_artist", 19 | }, 20 | }); 21 | TestBed.configureTestingModule({ 22 | imports: [ZoneImageComponent], 23 | }); 24 | 25 | fixture = TestBed.createComponent(ZoneImageComponent); 26 | fixture.componentRef.setInput("$trackDisplay", $trackDisplay); 27 | component = fixture.componentInstance; 28 | fixture.detectChanges(); 29 | }); 30 | 31 | it("should create", () => { 32 | expect(component).toBeTruthy(); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-image/zone-image.component.ts: -------------------------------------------------------------------------------- 1 | import { deepEqual } from "fast-equals"; 2 | import { ChangeDetectionStrategy, Component, computed, Input, Signal } from "@angular/core"; 3 | import { RoonImageComponent } from "@components/roon-image/roon-image.component"; 4 | import { EMPTY_TRACK, TrackDisplay, TrackImage } from "@model/client"; 5 | 6 | @Component({ 7 | selector: "nr-zone-image", 8 | imports: [RoonImageComponent], 9 | templateUrl: "./zone-image.component.html", 10 | styleUrl: "./zone-image.component.scss", 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | }) 13 | export class ZoneImageComponent { 14 | protected readonly EMPTY_TRACK = EMPTY_TRACK; 15 | @Input({ required: true }) $trackDisplay!: Signal; 16 | readonly $image: Signal; 17 | 18 | constructor() { 19 | this.$image = computed( 20 | () => { 21 | const src = this.$trackDisplay().image_key ?? ""; 22 | const size = Math.min(window.innerWidth, window.innerHeight); 23 | const isReady = src !== "" && size > 0; 24 | return { 25 | src, 26 | size, 27 | isReady, 28 | }; 29 | }, 30 | { 31 | equal: deepEqual, 32 | } 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-layouts/compact-layout/compact-layout.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
6 |
7 | 8 | @if(!$isSmallTablet()) { 9 | 10 | } 11 |
12 |
13 | 14 | 15 | 16 |
17 |
18 |
19 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-layouts/compact-layout/compact-layout.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../../styles/mixins" as nr; 2 | 3 | :host { 4 | @include nr.zone-layout(); 5 | .zone-display { 6 | width: 100%; 7 | .zone-info { 8 | box-sizing: border-box; 9 | padding: 12px 0; 10 | max-height: 50svmax; 11 | .main { 12 | display: flex; 13 | flex-direction: column; 14 | width: 100%; 15 | flex-grow: 1; 16 | } 17 | .controls { 18 | width: 100%; 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-layouts/compact-layout/compact-layout.component.ts: -------------------------------------------------------------------------------- 1 | import { NgTemplateOutlet } from "@angular/common"; 2 | import { ChangeDetectionStrategy, Component, inject, Input, Signal } from "@angular/core"; 3 | import { LayoutData } from "@model/client"; 4 | import { SettingsService } from "@services/settings.service"; 5 | 6 | @Component({ 7 | selector: "nr-compact-layout", 8 | imports: [NgTemplateOutlet], 9 | templateUrl: "./compact-layout.component.html", 10 | styleUrl: "./compact-layout.component.scss", 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | }) 13 | export class CompactLayoutComponent { 14 | @Input({ required: true }) layout!: LayoutData; 15 | readonly $isSmallTablet: Signal; 16 | 17 | constructor() { 18 | this.$isSmallTablet = inject(SettingsService).isSmallTablet(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-layouts/one-column-layout/one-column-layout.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | 6 |
7 |
8 | 9 | 10 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-layouts/one-column-layout/one-column-layout.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../../styles/mixins" as nr; 2 | 3 | :host { 4 | @include nr.zone-layout(); 5 | .zone-display { 6 | flex-direction: column; 7 | > div { 8 | width: 100%; 9 | flex-grow: 0; 10 | } 11 | .zone-info { 12 | height: auto; 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-layouts/one-column-layout/one-column-layout.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, Signal, signal, ViewChild, WritableSignal } from "@angular/core"; 2 | import { ComponentFixture, TestBed } from "@angular/core/testing"; 3 | import { CompactLayoutComponent } from "@components/zone-layouts/compact-layout/compact-layout.component"; 4 | import { OneColumnLayoutComponent } from "./one-column-layout.component"; 5 | 6 | @Component({ 7 | template: ` 8 | 9 |
10 |
11 | 12 |
13 |
14 | 15 |
16 |
17 | 18 |
19 |
20 | 21 |
22 |
23 | 24 |
25 |
26 | 27 |
28 |
29 | 43 | `, 44 | imports: [OneColumnLayoutComponent], 45 | }) 46 | class TemplateProducerComponent { 47 | @Input() $layoutClass!: Signal; 48 | @ViewChild(OneColumnLayoutComponent) oneColumnLayout!: CompactLayoutComponent; 49 | } 50 | 51 | describe("OneColumnLayoutComponent", () => { 52 | let component: OneColumnLayoutComponent; 53 | let fixture: ComponentFixture; 54 | let $layoutClass: WritableSignal; 55 | 56 | beforeEach(() => { 57 | $layoutClass = signal("layout-class"); 58 | fixture = TestBed.createComponent(TemplateProducerComponent); 59 | fixture.componentRef.setInput("$layoutClass", $layoutClass); 60 | fixture.detectChanges(); 61 | component = fixture.componentInstance.oneColumnLayout; 62 | }); 63 | 64 | it("should create", () => { 65 | expect(component).toBeTruthy(); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-layouts/one-column-layout/one-column-layout.component.ts: -------------------------------------------------------------------------------- 1 | import { NgTemplateOutlet } from "@angular/common"; 2 | import { ChangeDetectionStrategy, Component, Input } from "@angular/core"; 3 | import { LayoutData } from "@model/client"; 4 | 5 | @Component({ 6 | selector: "nr-one-column-layout", 7 | imports: [NgTemplateOutlet], 8 | templateUrl: "./one-column-layout.component.html", 9 | styleUrl: "./one-column-layout.component.scss", 10 | changeDetection: ChangeDetectionStrategy.OnPush, 11 | }) 12 | export class OneColumnLayoutComponent { 13 | @Input({ required: true }) layout!: LayoutData; 14 | } 15 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-layouts/ten-feet-layout/ten-feet-layout.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
6 |
7 | 8 |
9 |
10 | 11 |
12 |
13 | 14 |
15 |
16 |
17 | 18 |
19 |
20 | 21 |
22 | 23 |
24 |
25 |
26 | 27 |
28 |
29 |
30 |
31 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-layouts/ten-feet-layout/ten-feet-layout.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../../styles/mixins" as nr; 2 | 3 | :host { 4 | width: 100%; 5 | height: 100%; 6 | display: flex; 7 | .zone-display { 8 | display: flex; 9 | width: 100%; 10 | height: 100%; 11 | .layout-overlay { 12 | position: absolute; 13 | top: 0; 14 | left: 0; 15 | width: 100%; 16 | height: 100%; 17 | box-sizing: border-box; 18 | padding: 1rem; 19 | display: flex; 20 | place-content: flex-end; 21 | flex-direction: column; 22 | .zone-info { 23 | max-width: 42%; 24 | border-radius: 16px; 25 | width: fit-content; 26 | @include nr.ten-feet-blur(); 27 | } 28 | .zone-progression { 29 | width: 100%; 30 | } 31 | .zone-commands { 32 | position: fixed; 33 | bottom: 90px; 34 | left: 0; 35 | width: 100%; 36 | } 37 | .zone-actions { 38 | position: fixed; 39 | top: 1rem; 40 | right: 1rem; 41 | width: 40%; 42 | display: flex; 43 | flex-direction: column; 44 | row-gap: 20px; 45 | height: 958px; 46 | > div { 47 | padding: 25px; 48 | border-radius: 16px; 49 | @include nr.ten-feet-blur(); 50 | &:first-child { 51 | padding-bottom: 0; 52 | } 53 | &.settings { 54 | display: flex; 55 | place-content: space-evenly; 56 | nr-zone-selector { 57 | flex-basis: 528px; 58 | } 59 | > div { 60 | flex-basis: 90px; 61 | height: 90px; 62 | border-radius: 16px; 63 | border: 1px solid var(--mat-text-button-state-layer-color); 64 | display: flex; 65 | justify-content: center; 66 | align-items: center; 67 | @include nr.icon-button-size(120px, 48px, 36px); 68 | } 69 | } 70 | } 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-layouts/wide-layout/wide-layout.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
6 | 7 | @if(!$isSmallTablet()) { 8 | 9 | } 10 |
11 | 12 |
13 |
14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-layouts/wide-layout/wide-layout.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../../styles/mixins" as nr; 2 | 3 | :host { 4 | @include nr.zone-layout(); 5 | } 6 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-layouts/wide-layout/wide-layout.component.ts: -------------------------------------------------------------------------------- 1 | import { NgTemplateOutlet } from "@angular/common"; 2 | import { ChangeDetectionStrategy, Component, inject, Input, Signal } from "@angular/core"; 3 | import { LayoutData } from "@model/client"; 4 | import { SettingsService } from "@services/settings.service"; 5 | 6 | @Component({ 7 | selector: "nr-wide-layout", 8 | imports: [NgTemplateOutlet], 9 | templateUrl: "./wide-layout.component.html", 10 | styleUrl: "./wide-layout.component.scss", 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | }) 13 | export class WideLayoutComponent { 14 | @Input({ required: true }) layout!: LayoutData; 15 | readonly $isSmallTablet: Signal; 16 | 17 | constructor() { 18 | this.$isSmallTablet = inject(SettingsService).isSmallTablet(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-progression/zone-progression.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 |
6 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-progression/zone-progression.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/mixins" as nr; 2 | 3 | :host { 4 | display: block; 5 | width: 100%; 6 | > div { 7 | display: flex; 8 | justify-content: center; 9 | align-items: center; 10 | height: 40px; 11 | margin: 10px 0; 12 | time { 13 | cursor: default; 14 | font-family: 'Roboto Mono', monospace; 15 | display: block; 16 | width: 80px; 17 | } 18 | time:first-of-type { 19 | text-align: right; 20 | margin-right: 20px; 21 | } 22 | time:last-of-type { 23 | text-align: left; 24 | margin-left: 20px; 25 | } 26 | } 27 | &.ten-feet { 28 | > div { 29 | border-radius: 12px; 30 | @include nr.ten-feet-blur(); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-progression/zone-progression.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { signal, WritableSignal } from "@angular/core"; 2 | import { ComponentFixture, TestBed } from "@angular/core/testing"; 3 | import { DEFAULT_ZONE_PROGRESSION, ZoneProgression } from "@model/client"; 4 | import { ZoneProgressionComponent } from "./zone-progression.component"; 5 | 6 | describe("ZoneProgressionComponent", () => { 7 | let component: ZoneProgressionComponent; 8 | let fixture: ComponentFixture; 9 | let $zoneProgression: WritableSignal; 10 | 11 | beforeEach(() => { 12 | $zoneProgression = signal(DEFAULT_ZONE_PROGRESSION); 13 | TestBed.configureTestingModule({ 14 | imports: [ZoneProgressionComponent], 15 | }); 16 | fixture = TestBed.createComponent(ZoneProgressionComponent); 17 | fixture.componentRef.setInput("$zoneProgression", $zoneProgression); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it("should create", () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-progression/zone-progression.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, Input, Signal } from "@angular/core"; 2 | import { MatProgressBarModule } from "@angular/material/progress-bar"; 3 | import { ZoneProgression } from "@model/client"; 4 | 5 | @Component({ 6 | selector: "nr-zone-progression", 7 | imports: [MatProgressBarModule], 8 | templateUrl: "./zone-progression.component.html", 9 | styleUrl: "./zone-progression.component.scss", 10 | changeDetection: ChangeDetectionStrategy.OnPush, 11 | }) 12 | export class ZoneProgressionComponent { 13 | @Input({ required: true }) $zoneProgression!: Signal; 14 | } 15 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-queue-dialog/zone-queue-dialog.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-queue-dialog/zone-queue-dialog.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/mixins" as nr; 2 | 3 | :host { 4 | .queue-dialog { 5 | height: 100%; 6 | width: 100%; 7 | display: flex; 8 | flex-direction: column; 9 | justify-content: flex-end; 10 | mat-dialog-content { 11 | flex-grow: 1; 12 | max-height: 100%; 13 | } 14 | mat-dialog-actions { 15 | @include nr.mat-dialog-action-bottom-right(); 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-queue-dialog/zone-queue-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import { Subscription } from "rxjs"; 2 | import { NgTemplateOutlet } from "@angular/common"; 3 | import { ChangeDetectionStrategy, Component, inject, OnDestroy, TemplateRef } from "@angular/core"; 4 | import { MatButton } from "@angular/material/button"; 5 | import { MAT_DIALOG_DATA, MatDialogActions, MatDialogContent, MatDialogRef } from "@angular/material/dialog"; 6 | import { MatIcon } from "@angular/material/icon"; 7 | import { LayoutContext } from "@model/client"; 8 | import { SettingsService } from "@services/settings.service"; 9 | 10 | @Component({ 11 | selector: "nr-zone-queue-dialog", 12 | imports: [MatButton, MatDialogActions, MatDialogContent, MatIcon, NgTemplateOutlet], 13 | templateUrl: "./zone-queue-dialog.component.html", 14 | styleUrl: "./zone-queue-dialog.component.scss", 15 | changeDetection: ChangeDetectionStrategy.OnPush, 16 | }) 17 | export class ZoneQueueDialogComponent implements OnDestroy { 18 | private readonly _dialogRef: MatDialogRef; 19 | private readonly _dialogRefCloseSubscription: Subscription; 20 | private readonly _settingsService: SettingsService; 21 | readonly queueComponentTemplateRef: TemplateRef; 22 | readonly queueComponentTemplateContext: LayoutContext; 23 | 24 | constructor() { 25 | const data = inject(MAT_DIALOG_DATA) as { 26 | queueComponentTemplateRef: TemplateRef; 27 | }; 28 | this._dialogRef = inject>(MatDialogRef); 29 | this._settingsService = inject(SettingsService); 30 | this.queueComponentTemplateRef = data.queueComponentTemplateRef; 31 | this.queueComponentTemplateContext = { 32 | class: `in-dialog ${this._settingsService.displayModeClass()()}`, 33 | }; 34 | this._dialogRefCloseSubscription = this._dialogRef.beforeClosed().subscribe(() => { 35 | this._settingsService.saveDisplayQueueTrack(false); 36 | }); 37 | } 38 | 39 | closeDialog() { 40 | this._dialogRef.close(); 41 | } 42 | 43 | ngOnDestroy() { 44 | this._dialogRefCloseSubscription.unsubscribe(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-queue/zone-queue.component.html: -------------------------------------------------------------------------------- 1 | @if ($queue().length > 0) { 2 | 14 |
    15 |
  1. 16 | 44 |
    45 | @if (!last) { 46 | 47 | } 48 |
  2. 49 |
50 |
51 | 52 | 53 | 54 | 55 | 56 | } @else { 57 |
58 |
59 | Nothing in queue, go add some music or enjoy Roon radio! 60 |
61 |
62 | } 63 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-queue/zone-queue.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/mixins" as nr; 2 | 3 | :host { 4 | width: 100%; 5 | flex-grow: 0; 6 | .queue { 7 | height: 100%; 8 | width: 100%; 9 | box-sizing: border-box; 10 | span { 11 | @include nr.block-overflow-ellipsis(); 12 | } 13 | cdk-virtual-scroll-viewport { 14 | height: 100%; 15 | width: 100%; 16 | } 17 | ol { 18 | list-style: none; 19 | padding: 0 0 0 var(--queue-list-padding); 20 | margin: 0; 21 | li { 22 | padding-right: 20px; 23 | .mat-divider.mat-divider-inset { 24 | margin-left: var(--queue-item-divider-margin); 25 | } 26 | } 27 | } 28 | .track { 29 | @include nr.button-reset(); 30 | width: 100%; 31 | display: flex; 32 | height: var(--queue-item-height); 33 | align-items: center; 34 | padding-left: var(--queue-item-padding); 35 | .track-image { 36 | height: var(--queue-item-image-height); 37 | min-width: var(--queue-item-image-height); 38 | } 39 | .track-info { 40 | display: inline-block; 41 | // TODO: Make this value depending on device size and orientation 42 | margin-left: var(--queue-item-margin); 43 | flex-grow: 1; 44 | min-width: 0; 45 | cursor: pointer; 46 | .track-title { 47 | font-size: 1.2em; 48 | margin-bottom: var(--queue-item-track-title-margin); 49 | } 50 | .track-album { 51 | margin-bottom: var(--queue-item-track-album-margin); 52 | } 53 | } 54 | .no-track { 55 | display: inline-block; 56 | white-space: break-spaces; 57 | } 58 | } 59 | &.is-big-fonts { 60 | --queue-item-height: 160px; 61 | --queue-item-image-height: 120px; 62 | --queue-item-margin: 30px; 63 | --queue-item-track-title-margin: 13px; 64 | --queue-item-track-album-margin: 11px; 65 | --queue-item-padding: 20px; 66 | } 67 | } 68 | } 69 | 70 | :host.open { 71 | flex-grow: 2; 72 | } 73 | 74 | :host.in-dialog { 75 | --queue-list-padding: 0; 76 | --queue-item-divider-margin: 0; 77 | --queue-item-padding: 0; 78 | } 79 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-queue/zone-queue.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { MockProvider } from "ng-mocks"; 2 | import { computed, Signal, signal, WritableSignal } from "@angular/core"; 3 | import { ComponentFixture, TestBed } from "@angular/core/testing"; 4 | import { QueueState } from "@model"; 5 | import { EMPTY_TRACK, TrackDisplay } from "@model/client"; 6 | import { RoonService } from "@services/roon.service"; 7 | import { SettingsService } from "@services/settings.service"; 8 | import { ZoneQueueComponent } from "./zone-queue.component"; 9 | 10 | describe("ZoneQueueComponent", () => { 11 | let $zoneId: WritableSignal; 12 | let $queue: WritableSignal; 13 | let $trackDisplay: WritableSignal; 14 | let $displayQueueTrack: WritableSignal; 15 | let roonService: { 16 | queueState: jest.Mock; 17 | }; 18 | let settingsService: { 19 | displayedZoneId: jest.Mock; 20 | displayQueueTrack: jest.Mock; 21 | }; 22 | let component: ZoneQueueComponent; 23 | let fixture: ComponentFixture; 24 | 25 | beforeEach(() => { 26 | $zoneId = signal("zone_id"); 27 | $queue = signal({ 28 | zone_id: "zone_id", 29 | tracks: [], 30 | }); 31 | $trackDisplay = signal(EMPTY_TRACK); 32 | $displayQueueTrack = signal(true); 33 | roonService = { 34 | queueState: jest.fn().mockImplementation(($zoneId: Signal) => { 35 | return computed(() => { 36 | const qs = $queue(); 37 | return { 38 | ...qs, 39 | zone_id: $zoneId(), 40 | }; 41 | }); 42 | }), 43 | }; 44 | settingsService = { 45 | displayedZoneId: jest.fn().mockImplementation(() => $zoneId), 46 | displayQueueTrack: jest.fn().mockImplementation(() => $displayQueueTrack), 47 | }; 48 | TestBed.configureTestingModule({ 49 | imports: [ZoneQueueComponent], 50 | providers: [MockProvider(RoonService, roonService), MockProvider(SettingsService, settingsService)], 51 | }); 52 | fixture = TestBed.createComponent(ZoneQueueComponent); 53 | fixture.componentRef.setInput("$trackDisplay", $trackDisplay); 54 | component = fixture.componentInstance; 55 | fixture.detectChanges(); 56 | }); 57 | 58 | it("should create", () => { 59 | expect(component).toBeTruthy(); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-selector/zone-selector.component.html: -------------------------------------------------------------------------------- 1 | @if (withoutLabel) { 2 | 9 | } @else { 10 | 17 | } 18 | 19 | 20 | @for (zd of $zones(); track zd.zone_id) { 21 | 22 | } 23 | 24 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-selector/zone-selector.component.scss: -------------------------------------------------------------------------------- 1 | :host.ten-feet { 2 | button { 3 | height: 90px; 4 | width: 100%; 5 | border-radius: 16px; 6 | border: 1px solid var(--mat-text-button-state-layer-color); 7 | font-size: 1.5rem; 8 | } 9 | .mat-mdc-button > .mat-icon { 10 | font-size: 1.5rem; 11 | width: 1.5rem; 12 | height: 1.5rem; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-selector/zone-selector.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { MockProvider } from "ng-mocks"; 2 | import { signal, WritableSignal } from "@angular/core"; 3 | import { ComponentFixture, TestBed } from "@angular/core/testing"; 4 | import { ApiState, RoonState } from "@model"; 5 | import { RoonService } from "@services/roon.service"; 6 | import { ZoneSelectorComponent } from "./zone-selector.component"; 7 | 8 | describe("ZoneSelectorComponent", () => { 9 | let component: ZoneSelectorComponent; 10 | let fixture: ComponentFixture; 11 | let $roonState: WritableSignal; 12 | 13 | beforeEach(async () => { 14 | $roonState = signal({ 15 | state: RoonState.SYNC, 16 | zones: [], 17 | outputs: [], 18 | }); 19 | await TestBed.configureTestingModule({ 20 | imports: [ZoneSelectorComponent], 21 | providers: [ 22 | MockProvider(RoonService, { 23 | roonState: () => $roonState, 24 | }), 25 | ], 26 | }).compileComponents(); 27 | 28 | fixture = TestBed.createComponent(ZoneSelectorComponent); 29 | component = fixture.componentInstance; 30 | fixture.detectChanges(); 31 | }); 32 | 33 | it("should create", () => { 34 | expect(component).toBeTruthy(); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-transfer-dialog/zone-transfer-dialog.component.html: -------------------------------------------------------------------------------- 1 |

Transfer zone

2 | 3 | 4 | {{currentZone}} 5 | 6 |

to zone:

7 | @for (zone of transferableZones; track zone.zone_id; let index = $index;) { 8 |
9 | 16 |
17 | } 18 |
19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-transfer-dialog/zone-transfer-dialog.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | h2 { 3 | padding-bottom: 18px; 4 | } 5 | mat-dialog-actions { 6 | button { 7 | margin: 0 8px 8px 0; 8 | } 9 | } 10 | .zone-transfer { 11 | mat-divider { 12 | min-width: 250px; 13 | 14 | } 15 | .from-zone { 16 | display: flex; 17 | margin-bottom: 20px; 18 | align-items: center; 19 | mat-icon { 20 | margin-right: 10px; 21 | } 22 | } 23 | button { 24 | margin-bottom: 20px; 25 | } 26 | &:not(.is-small-screen) { 27 | min-width: 480px; 28 | } 29 | } 30 | } 31 | 32 | @media screen and (max-width: 300px) { 33 | mat-divider { 34 | min-width: 170px; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-volume-dialog/zone-volume-dialog.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/mixins" as nr; 2 | 3 | .volume-header { 4 | margin: 5px; 5 | padding: 0; 6 | display: flex; 7 | touch-action: manipulation; 8 | > div { 9 | display: flex; 10 | justify-content: space-between; 11 | width: 100%; 12 | } 13 | } 14 | .volume-output-name { 15 | @include nr.block-overflow-ellipsis(); 16 | padding: 12px; 17 | width: 95%; 18 | } 19 | .volume-control { 20 | display: flex; 21 | align-items: center; 22 | justify-content: space-between; 23 | margin: 10px 5px; 24 | touch-action: manipulation; 25 | .volume-value { 26 | display: inline-block; 27 | width: 2rem; 28 | cursor: default; 29 | text-align: center; 30 | } 31 | .volume-slider { 32 | margin-left: 18px; 33 | margin-right: 6px; 34 | display: flex; 35 | align-items: center; 36 | flex-grow: 1; 37 | mat-slider { 38 | flex-grow: 1; 39 | } 40 | sub { 41 | vertical-align: baseline; 42 | font-size: 0.8rem; 43 | } 44 | } 45 | .volume-fixed { 46 | margin-right: 12px; 47 | } 48 | } 49 | .volume-panel { 50 | padding: 0; 51 | } 52 | 53 | @media screen and (max-width: 399px) { 54 | .volume-control { 55 | .mat-icon { 56 | font-size: 18px; 57 | width: 18px; 58 | height: 18px; 59 | } 60 | .mat-mdc-icon-button.mat-mdc-button-base { 61 | --mdc-icon-button-state-layer-size: 36px; 62 | padding: 0; 63 | } 64 | } 65 | } 66 | 67 | @media screen and (max-width: 290px) { 68 | .volume-control { 69 | .volume-buttons { 70 | display: none; 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-volume-dialog/zone-volume-dialog.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { MockProvider } from "ng-mocks"; 2 | import { Signal, signal, WritableSignal } from "@angular/core"; 3 | import { ComponentFixture, TestBed } from "@angular/core/testing"; 4 | import { Output } from "@model"; 5 | import { DialogService } from "@services/dialog.service"; 6 | import { SettingsService } from "@services/settings.service"; 7 | import { VolumeService } from "@services/volume.service"; 8 | import { ZoneVolumeDialogComponent } from "./zone-volume-dialog.component"; 9 | 10 | describe("ZoneVolumeDialogComponent", () => { 11 | let $isSmallScreen: WritableSignal; 12 | let $outputs: Signal; 13 | let $canGroup: Signal; 14 | let $isGroup: Signal; 15 | let $isGroupedZoneMute: Signal; 16 | let dialogService: { 17 | open: jest.Mock; 18 | }; 19 | let settingsService: { 20 | isSmallScreen: jest.Mock; 21 | }; 22 | let volumeService: { 23 | outputs: jest.Mock; 24 | canGroup: jest.Mock; 25 | isGrouped: jest.Mock; 26 | isGroupedZoneMute: jest.Mock; 27 | }; 28 | let component: ZoneVolumeDialogComponent; 29 | let fixture: ComponentFixture; 30 | 31 | beforeEach(() => { 32 | $isSmallScreen = signal(false); 33 | $outputs = signal([]); 34 | $canGroup = signal(false); 35 | $isGroup = signal(false); 36 | $isGroupedZoneMute = signal(false); 37 | dialogService = { 38 | open: jest.fn(), 39 | }; 40 | settingsService = { 41 | isSmallScreen: jest.fn().mockImplementation(() => $isSmallScreen), 42 | }; 43 | volumeService = { 44 | outputs: jest.fn().mockImplementation(() => $outputs), 45 | canGroup: jest.fn().mockImplementation(() => $canGroup), 46 | isGrouped: jest.fn().mockImplementation(() => $isGroup), 47 | isGroupedZoneMute: jest.fn().mockImplementation(() => $isGroupedZoneMute), 48 | }; 49 | TestBed.configureTestingModule({ 50 | imports: [ZoneVolumeDialogComponent], 51 | providers: [ 52 | MockProvider(DialogService, dialogService), 53 | MockProvider(SettingsService, settingsService), 54 | MockProvider(VolumeService, volumeService), 55 | ], 56 | }); 57 | 58 | fixture = TestBed.createComponent(ZoneVolumeDialogComponent); 59 | component = fixture.componentInstance; 60 | fixture.detectChanges(); 61 | }); 62 | 63 | it("should create", () => { 64 | expect(component).toBeTruthy(); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-volume/zone-volume.component.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/mixins" as nr; 2 | 3 | :host { 4 | .zone-volume-large { 5 | display: flex; 6 | flex-direction: column; 7 | align-items: center; 8 | > div { 9 | width: 652px; 10 | display: flex; 11 | align-items: center; 12 | justify-content: space-between; 13 | } 14 | } 15 | &.ten-feet { 16 | .zone-volume-large { 17 | span { 18 | @include nr.ten-feet-button(510px, 40px, 1rem, 0, 8px); 19 | } 20 | > div:not(:last-child) { 21 | margin-bottom: 15px; 22 | > div { 23 | width: 40px; 24 | height: 40px; 25 | border: 1px solid var(--mat-text-button-state-layer-color); 26 | border-radius: 8px; 27 | display: flex; 28 | align-items: center; 29 | justify-content: center; 30 | } 31 | } 32 | .zone-volume-actions { 33 | span { 34 | @include nr.ten-feet-button(310px, 40px, 1rem, 0, 8px); 35 | } 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/components/zone-volume/zone-volume.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { MockProvider } from "ng-mocks"; 2 | import { signal, WritableSignal } from "@angular/core"; 3 | import { ComponentFixture, TestBed } from "@angular/core/testing"; 4 | import { Output } from "@model"; 5 | import { DisplayMode } from "@model/client"; 6 | import { DialogService } from "@services/dialog.service"; 7 | import { SettingsService } from "@services/settings.service"; 8 | import { VolumeService } from "@services/volume.service"; 9 | import { ZoneVolumeComponent } from "./zone-volume.component"; 10 | 11 | describe("ZoneVolumeComponent", () => { 12 | let component: ZoneVolumeComponent; 13 | let fixture: ComponentFixture; 14 | let $displayMode: WritableSignal; 15 | let $isSmallScreen: WritableSignal; 16 | let settingsService: { 17 | isSmallScreen: jest.Mock; 18 | displayMode: jest.Mock; 19 | }; 20 | let $outputs: WritableSignal; 21 | let $isMuted: WritableSignal; 22 | let volumeService: { 23 | outputs: jest.Mock; 24 | isMute: jest.Mock; 25 | }; 26 | let dialogService: { 27 | open: jest.Mock; 28 | }; 29 | 30 | beforeEach(() => { 31 | $outputs = signal([]); 32 | $displayMode = signal(DisplayMode.WIDE); 33 | $isSmallScreen = signal(false); 34 | settingsService = { 35 | displayMode: jest.fn().mockImplementation(() => $displayMode), 36 | isSmallScreen: jest.fn().mockImplementation(() => $isSmallScreen), 37 | }; 38 | $isMuted = signal(false); 39 | $outputs = signal([]); 40 | volumeService = { 41 | outputs: jest.fn().mockImplementation(() => $outputs), 42 | isMute: jest.fn().mockImplementation(() => $isMuted), 43 | }; 44 | dialogService = { 45 | open: jest.fn(), 46 | }; 47 | TestBed.configureTestingModule({ 48 | imports: [ZoneVolumeComponent], 49 | providers: [ 50 | MockProvider(DialogService, dialogService), 51 | MockProvider(SettingsService, settingsService), 52 | MockProvider(VolumeService, volumeService), 53 | ], 54 | }); 55 | fixture = TestBed.createComponent(ZoneVolumeComponent); 56 | component = fixture.componentInstance; 57 | fixture.detectChanges(); 58 | }); 59 | 60 | it("should create", () => { 61 | expect(component).toBeTruthy(); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/model/browse.model.ts: -------------------------------------------------------------------------------- 1 | export interface NavigationEvent { 2 | item_key: string; 3 | input?: string; 4 | scrollIndex: number; 5 | } 6 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/model/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./action.model"; 2 | export * from "./browse.model"; 3 | export * from "./layout.model"; 4 | export * from "./roon-service.model"; 5 | export * from "./settings.model"; 6 | export * from "./track.model"; 7 | export * from "./visibility.model"; 8 | export * from "./worker.model"; 9 | export * from "./zone-command.model"; 10 | export * from "./zone-progression.model"; 11 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/model/layout.model.ts: -------------------------------------------------------------------------------- 1 | import { TemplateRef } from "@angular/core"; 2 | 3 | export enum DisplayMode { 4 | COMPACT = "COMPACT", 5 | ONE_COLUMN = "ONE_COLUMN", 6 | WIDE = "WIDE", 7 | TEN_FEET = "TEN_FEET", 8 | } 9 | 10 | export interface DisplayModeData { 11 | label?: string; 12 | class: string; 13 | } 14 | 15 | export const DisplayModesData: { [key in DisplayMode]: DisplayModeData } = { 16 | COMPACT: { 17 | label: "Compact", 18 | class: "compact", 19 | }, 20 | ONE_COLUMN: { 21 | class: "one-column", 22 | }, 23 | TEN_FEET: { 24 | label: "10 feet", 25 | class: "ten-feet", 26 | }, 27 | WIDE: { 28 | label: "Wide", 29 | class: "wide", 30 | }, 31 | }; 32 | 33 | export interface LayoutContext { 34 | class: string; 35 | } 36 | 37 | export interface LayoutData { 38 | zoneActions: TemplateRef; 39 | zoneCommands: TemplateRef; 40 | zoneCurrentTrack: TemplateRef; 41 | zoneImage: TemplateRef; 42 | zoneProgression: TemplateRef; 43 | zoneQueue: TemplateRef; 44 | zoneVolume: TemplateRef; 45 | context: LayoutContext; 46 | } 47 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/model/roon-service.model.ts: -------------------------------------------------------------------------------- 1 | import { CommandState, OutputDescription } from "@model"; 2 | 3 | export type CommandCallback = (commandState: CommandState) => void; 4 | 5 | export type OutputCallback = (outputs: OutputDescription[]) => void; 6 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/model/settings.model.ts: -------------------------------------------------------------------------------- 1 | import { MatDialogConfig } from "@angular/material/dialog"; 2 | 3 | export enum ChosenTheme { 4 | BROWSER = "BROWSER", 5 | DARK = "DARK", 6 | LIGHT = "LIGHT", 7 | } 8 | 9 | export interface Theme { 10 | id: ChosenTheme; 11 | label: string; 12 | icon: string; 13 | } 14 | 15 | export const Themes: Theme[] = [ 16 | { 17 | id: ChosenTheme.BROWSER, 18 | icon: "contrast", 19 | label: "Browser", 20 | }, 21 | { 22 | id: ChosenTheme.LIGHT, 23 | icon: "light_mode", 24 | label: "Light", 25 | }, 26 | { 27 | id: ChosenTheme.DARK, 28 | icon: "dark_mode", 29 | label: "Dark", 30 | }, 31 | ]; 32 | 33 | export type ClientBreakpoints = { 34 | [key: string]: boolean; 35 | }; 36 | 37 | export const SettingsDialogConfig: MatDialogConfig = { 38 | restoreFocus: false, 39 | width: "500px", 40 | maxWidth: "95svw", 41 | maxHeight: "95svh", 42 | data: { 43 | selectedTab: 0, 44 | }, 45 | position: { 46 | top: "5svh", 47 | }, 48 | }; 49 | 50 | export const SettingsDialogConfigBigFonts: MatDialogConfig = { 51 | ...SettingsDialogConfig, 52 | width: "800px", 53 | }; 54 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/model/track.model.ts: -------------------------------------------------------------------------------- 1 | import { Track } from "@model"; 2 | 3 | export type TrackDisplay = Omit; 4 | 5 | export const EMPTY_TRACK: Track = { 6 | title: "No current track", 7 | }; 8 | 9 | export interface TrackImage { 10 | src: string; 11 | size: number; 12 | isReady: boolean; 13 | } 14 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/model/visibility.model.ts: -------------------------------------------------------------------------------- 1 | export enum VisibilityState { 2 | VISIBLE = "VISIBLE", 3 | HIDDEN = "HIDDEN", 4 | } 5 | 6 | export type VisibilityListener = (state: VisibilityState) => void; 7 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/model/zone-command.model.ts: -------------------------------------------------------------------------------- 1 | export interface ZoneCommands { 2 | zoneId: string; 3 | previousTrack: ZoneCommandState; 4 | loading: ZoneCommandState; 5 | play: ZoneCommandState; 6 | pause: ZoneCommandState; 7 | nextTrack: ZoneCommandState; 8 | } 9 | 10 | export enum ZoneCommandState { 11 | ABSENT = "ABSENT", 12 | ACTIVE = "ACTIVE", 13 | DISABLED = "DISABLED", 14 | } 15 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/model/zone-progression.model.ts: -------------------------------------------------------------------------------- 1 | export interface ZoneProgression { 2 | length: string; 3 | position: string; 4 | percentage: number; 5 | } 6 | 7 | export const DEFAULT_ZONE_PROGRESSION: ZoneProgression = { 8 | length: "-", 9 | position: "-", 10 | percentage: 0, 11 | }; 12 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/nr-root.component.html: -------------------------------------------------------------------------------- 1 |
2 | @switch ($clientState()) { 3 | @case ("STARTING") { 4 | @defer (on timer(1s)) { 5 | 6 | } 7 | } 8 | @case ("SYNCING") { 9 | @defer (on timer(1s)) { 10 |
11 |

syncing with roon server

12 | 13 |
14 | } 15 | } 16 | @case ("SYNC") { 17 | @defer { 18 | @if ($isWithFullScreen()) { 19 | 20 | } 21 | 22 | } @loading (minimum 500ms) { 23 |
24 |

loading application

25 | 26 |
27 | } 28 | } 29 | @case ("GROUPING") { 30 |
31 |

zone grouping in progress

32 | 33 |
34 | } 35 | @case ("NEED_SELECTION") { 36 |
37 |

choose a zone to display

38 | 39 |
40 | } 41 | @case("LOST") { 42 |
43 |

lost roon server, waiting for the server to be available again

44 | 45 |
46 | } 47 | @case("STOPPED") { 48 |
49 |

disconnected from roon server, trying to reconnect

50 | 51 |
52 | } 53 | @default { 54 |

unknown state! something wrong!

55 | } 56 | } 57 |
58 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/nr-root.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 3 | Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", 4 | "Segoe UI Symbol"; 5 | box-sizing: border-box; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | main { 9 | width: 100%; 10 | height: 100%; 11 | display: flex; 12 | justify-content: center; 13 | align-items: stretch; 14 | padding: 1rem; 15 | box-sizing: inherit; 16 | position: relative; 17 | .loading-spinner { 18 | display: flex; 19 | flex-direction: column; 20 | align-items: center; 21 | justify-content: space-evenly; 22 | } 23 | } 24 | } 25 | 26 | 27 | 28 | .content { 29 | display: flex; 30 | justify-content: space-around; 31 | width: 100%; 32 | } 33 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/nr-root.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { MockProvider } from "ng-mocks"; 2 | import { signal, WritableSignal } from "@angular/core"; 3 | import { ComponentFixture, TestBed } from "@angular/core/testing"; 4 | import { ApiState, RoonState } from "@model"; 5 | import { DialogService } from "@services/dialog.service"; 6 | import { RoonService } from "@services/roon.service"; 7 | import { SettingsService } from "@services/settings.service"; 8 | import { NrRootComponent } from "./nr-root.component"; 9 | 10 | describe("NrRootComponent", () => { 11 | let component: NrRootComponent; 12 | let fixture: ComponentFixture; 13 | let $displayedZoneId: WritableSignal; 14 | let $state: WritableSignal; 15 | let $isGrouping: WritableSignal; 16 | let dialogService: { 17 | close: jest.Mock; 18 | }; 19 | let roonService: { 20 | roonState: jest.Mock; 21 | isGrouping: jest.Mock; 22 | }; 23 | let settingsService: { 24 | displayedZonedId: jest.Mock; 25 | }; 26 | 27 | beforeEach(() => { 28 | dialogService = { 29 | close: jest.fn(), 30 | }; 31 | $state = signal({ 32 | state: RoonState.STARTING, 33 | zones: [], 34 | outputs: [], 35 | }); 36 | $isGrouping = signal(false); 37 | roonService = { 38 | roonState: jest.fn().mockImplementation(() => $state), 39 | isGrouping: jest.fn().mockImplementation(() => $isGrouping), 40 | }; 41 | $displayedZoneId = signal(zone_id); 42 | settingsService = { 43 | displayedZonedId: jest.fn().mockImplementation(() => $displayedZoneId), 44 | }; 45 | TestBed.configureTestingModule({ 46 | imports: [NrRootComponent], 47 | providers: [ 48 | MockProvider(DialogService, dialogService), 49 | MockProvider(RoonService, roonService), 50 | MockProvider(SettingsService, settingsService as Partial), 51 | ], 52 | }); 53 | fixture = TestBed.createComponent(NrRootComponent); 54 | component = fixture.componentInstance; 55 | }); 56 | 57 | it("should create the nr-root", () => { 58 | expect(component).toBeTruthy(); 59 | }); 60 | }); 61 | 62 | const zone_id = "zone_id"; 63 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/nr.config.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationConfig, inject, provideAppInitializer } from "@angular/core"; 2 | import { MatIconRegistry } from "@angular/material/icon"; 3 | import { provideAnimationsAsync } from "@angular/platform-browser/animations/async"; 4 | import { RoonService } from "@services/roon.service"; 5 | 6 | const useMaterialSymbol = (iconRegistry: MatIconRegistry) => { 7 | const defaultFontSetClasses = iconRegistry.getDefaultFontSetClass(); 8 | const outlinedFontSetClasses = defaultFontSetClasses 9 | .filter((fontSetClass) => fontSetClass !== "material-icons") 10 | .concat(["material-symbols-outlined"]); 11 | iconRegistry.setDefaultFontSetClass(...outlinedFontSetClasses); 12 | }; 13 | 14 | export const nrConfig: ApplicationConfig = { 15 | providers: [ 16 | provideAppInitializer(() => { 17 | const iconRegistry: MatIconRegistry = inject(MatIconRegistry); 18 | useMaterialSymbol(iconRegistry); 19 | const roonService = inject(RoonService); 20 | return roonService.start(); 21 | }), 22 | provideAnimationsAsync(), 23 | ], 24 | }; 25 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/services/dialog.service.ts: -------------------------------------------------------------------------------- 1 | import { ComponentType } from "@angular/cdk/overlay"; 2 | import { inject, Injectable, Signal } from "@angular/core"; 3 | import { MatDialog, MatDialogConfig, MatDialogRef } from "@angular/material/dialog"; 4 | import { NgxSpatialNavigableService } from "@nihilux/ngx-spatial-navigable"; 5 | import { IdleService } from "@services/idle.service"; 6 | import { SettingsService } from "@services/settings.service"; 7 | 8 | @Injectable({ 9 | providedIn: "root", 10 | }) 11 | export class DialogService { 12 | private readonly _dialog: MatDialog; 13 | private readonly _idleService: IdleService; 14 | private readonly _spatialNavigableService: NgxSpatialNavigableService; 15 | private readonly _$layoutClass: Signal; 16 | private _openedDialog?: MatDialogRef; 17 | private _isIdleWatched: boolean; 18 | 19 | constructor() { 20 | this._dialog = inject(MatDialog); 21 | this._idleService = inject(IdleService); 22 | this._spatialNavigableService = inject(NgxSpatialNavigableService); 23 | this._$layoutClass = inject(SettingsService).displayModeClass(); 24 | this._isIdleWatched = false; 25 | } 26 | 27 | open(component: ComponentType, config?: MatDialogConfig): void { 28 | const panelClass = [...((config?.panelClass ?? []) as string[])]; 29 | panelClass.push("nr-dialog-custom", this._$layoutClass()); 30 | this._isIdleWatched = this._idleService.isWatching(); 31 | this._openedDialog?.close(); 32 | this._openedDialog = this._dialog.open(component, { 33 | ...config, 34 | panelClass, 35 | }); 36 | const element = this._openedDialog.componentRef?.location.nativeElement as unknown as HTMLElement; 37 | const spatialNavigationDialog = element.parentElement ?? element; 38 | this._openedDialog.afterOpened().subscribe(() => { 39 | if (this._isIdleWatched) { 40 | this._idleService.stopWatch(); 41 | } 42 | const autofocus = typeof config?.autoFocus === "string" ? config.autoFocus : false; 43 | this._spatialNavigableService.dialogOpened(spatialNavigationDialog, autofocus); 44 | }); 45 | this._openedDialog.beforeClosed().subscribe(() => { 46 | if (this._isIdleWatched) { 47 | this._idleService.startWatch(); 48 | } 49 | this._spatialNavigableService.dialogClosed(); 50 | delete this._openedDialog; 51 | }); 52 | } 53 | 54 | close() { 55 | this._openedDialog?.close(); 56 | delete this._openedDialog; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/services/fullscreen.service.ts: -------------------------------------------------------------------------------- 1 | import { DOCUMENT } from "@angular/common"; 2 | import { inject, Injectable, OnDestroy, Signal, signal, WritableSignal } from "@angular/core"; 3 | 4 | @Injectable({ 5 | providedIn: "root", 6 | }) 7 | export class FullscreenService implements OnDestroy { 8 | private readonly _document: Document; 9 | 10 | private static readonly ENTER_FULL_SCREEN_ICON = "open_in_full"; 11 | private static readonly EXIT_FULL_SCREEN_ICON = "close_fullscreen"; 12 | private readonly _$icon: WritableSignal; 13 | 14 | constructor() { 15 | this._document = inject(DOCUMENT); 16 | if (this._document.fullscreenElement) { 17 | this._$icon = signal(FullscreenService.EXIT_FULL_SCREEN_ICON); 18 | } else { 19 | this._$icon = signal(FullscreenService.ENTER_FULL_SCREEN_ICON); 20 | } 21 | this._document.addEventListener("fullscreenchange", () => { 22 | this.onFullscreenChange(); 23 | }); 24 | } 25 | 26 | toggleFullScreen() { 27 | if (this.supportsFullScreen()) { 28 | if (this.isFullScreen()) { 29 | void this._document.exitFullscreen().then(() => { 30 | this._$icon.set(FullscreenService.ENTER_FULL_SCREEN_ICON); 31 | }); 32 | } else { 33 | void this._document.documentElement 34 | .requestFullscreen({ 35 | navigationUI: "hide", 36 | }) 37 | .then(() => { 38 | this._$icon.set(FullscreenService.EXIT_FULL_SCREEN_ICON); 39 | }); 40 | } 41 | } 42 | } 43 | 44 | isFullScreen(): boolean { 45 | return this._document.fullscreenElement !== null; 46 | } 47 | 48 | supportsFullScreen() { 49 | return this._document.fullscreenEnabled; 50 | } 51 | 52 | icon(): Signal { 53 | return this._$icon; 54 | } 55 | 56 | ngOnDestroy() { 57 | this._document.removeEventListener("fullscreenchange", () => { 58 | this.onFullscreenChange(); 59 | }); 60 | } 61 | 62 | private onFullscreenChange(): void { 63 | const isFullScreen = 64 | this._document.fullscreenElement != null 65 | ? FullscreenService.EXIT_FULL_SCREEN_ICON 66 | : FullscreenService.ENTER_FULL_SCREEN_ICON; 67 | if (isFullScreen !== this._$icon()) { 68 | this._$icon.set(isFullScreen); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/services/settings.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from "@angular/core/testing"; 2 | import { SettingsService } from "./settings.service"; 3 | 4 | describe("SettingsService", () => { 5 | let service: SettingsService; 6 | 7 | beforeEach(() => { 8 | TestBed.configureTestingModule({}); 9 | service = TestBed.inject(SettingsService); 10 | }); 11 | 12 | it("should be created", () => { 13 | expect(service).toBeTruthy(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/services/visibility.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from "@angular/core/testing"; 2 | import { VisibilityService } from "@services/visibility.service"; 3 | 4 | describe("VisibilityService", () => { 5 | let service: VisibilityService; 6 | 7 | beforeEach(() => { 8 | TestBed.configureTestingModule({}); 9 | service = TestBed.inject(VisibilityService); 10 | }); 11 | 12 | it("should be created", () => { 13 | expect(service).toBeTruthy(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/services/worker.utils.ts: -------------------------------------------------------------------------------- 1 | export const buildRoonWorker = (): Worker => { 2 | if (typeof Worker !== "undefined") { 3 | return new Worker(new URL("./roon.worker", import.meta.url), { 4 | type: "module", 5 | }); 6 | } else { 7 | throw new Error("web worker are not supported in your browser, sorry"); 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/app/styles/variables.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | 3 | $roon-web-ng-client-typography: mat.m2-define-typography-config(); 4 | 5 | $roon-web-ng-client-dark-theme: mat.m2-define-dark-theme(( 6 | color: ( 7 | primary: mat.m2-define-palette(mat.$m2-grey-palette, A200, A100, A400), 8 | accent: mat.m2-define-palette(mat.$m2-indigo-palette, A200, A100, A400), 9 | warn: mat.m2-define-palette(mat.$m2-red-palette), 10 | ), 11 | density: 0, 12 | typography: $roon-web-ng-client-typography 13 | )); 14 | 15 | $roon-web-ng-client-light-theme: mat.m2-define-light-theme(( 16 | color: ( 17 | primary: mat.m2-define-palette(mat.$m2-indigo-palette, A200, A100, A400), 18 | accent: mat.m2-define-palette(mat.$m2-pink-palette, A200, A100, A400), 19 | warn: mat.m2-define-palette(mat.$m2-red-palette), 20 | ) 21 | )); 22 | 23 | 24 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/app/roon-web-ng-client/src/assets/.gitkeep -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/assets/favicons/favicon-114-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/app/roon-web-ng-client/src/assets/favicons/favicon-114-precomposed.png -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/assets/favicons/favicon-120-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/app/roon-web-ng-client/src/assets/favicons/favicon-120-precomposed.png -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/assets/favicons/favicon-144-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/app/roon-web-ng-client/src/assets/favicons/favicon-144-precomposed.png -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/assets/favicons/favicon-152-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/app/roon-web-ng-client/src/assets/favicons/favicon-152-precomposed.png -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/assets/favicons/favicon-180-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/app/roon-web-ng-client/src/assets/favicons/favicon-180-precomposed.png -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/assets/favicons/favicon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/app/roon-web-ng-client/src/assets/favicons/favicon-192.png -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/assets/favicons/favicon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/app/roon-web-ng-client/src/assets/favicons/favicon-32.png -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/assets/favicons/favicon-36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/app/roon-web-ng-client/src/assets/favicons/favicon-36.png -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/assets/favicons/favicon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/app/roon-web-ng-client/src/assets/favicons/favicon-48.png -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/assets/favicons/favicon-57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/app/roon-web-ng-client/src/assets/favicons/favicon-57.png -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/assets/favicons/favicon-60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/app/roon-web-ng-client/src/assets/favicons/favicon-60.png -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/assets/favicons/favicon-72-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/app/roon-web-ng-client/src/assets/favicons/favicon-72-precomposed.png -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/assets/favicons/favicon-72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/app/roon-web-ng-client/src/assets/favicons/favicon-72.png -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/assets/favicons/favicon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/app/roon-web-ng-client/src/assets/favicons/favicon-76.png -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/assets/favicons/favicon-96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/app/roon-web-ng-client/src/assets/favicons/favicon-96.png -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/assets/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/app/roon-web-ng-client/src/assets/favicons/favicon.ico -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/assets/favicons/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "roon-web-client", 3 | "display": "standalone", 4 | "start_url": "/", 5 | "icons": [ 6 | { 7 | "src": "/assets/favicons/favicon-36.png", 8 | "sizes": "36x36", 9 | "type": "image/png", 10 | "density": 0.75 11 | }, 12 | { 13 | "src": "/assets/favicons/favicon-48.png", 14 | "sizes": "48x48", 15 | "type": "image/png", 16 | "density": 1 17 | }, 18 | { 19 | "src": "/assets/favicons/favicon-72.png", 20 | "sizes": "72x72", 21 | "type": "image/png", 22 | "density": 1.5 23 | }, 24 | { 25 | "src": "/assets/favicons/favicon-96.png", 26 | "sizes": "96x96", 27 | "type": "image/png", 28 | "density": 2 29 | }, 30 | { 31 | "src": "/assets/favicons/favicon-144-precomposed.png", 32 | "sizes": "144x144", 33 | "type": "image/png", 34 | "density": 3 35 | }, 36 | { 37 | "src": "/assets/favicons/favicon-192.png", 38 | "sizes": "192x192", 39 | "type": "image/png", 40 | "density": 4 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Roon Web Client 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapApplication } from "@angular/platform-browser"; 2 | import { nrConfig } from "./app/nr.config"; 3 | import { NrRootComponent } from "./app/nr-root.component"; 4 | 5 | bootstrapApplication(NrRootComponent, nrConfig).catch((err: unknown) => { 6 | // eslint-disable-next-line no-console 7 | console.error(err); 8 | }); 9 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/mock/nanoid.mock.ts: -------------------------------------------------------------------------------- 1 | const nanoid = jest.fn(); 2 | 3 | export const nanoidMock = nanoid; 4 | 5 | jest.mock("nanoid", () => ({ 6 | nanoid, 7 | })); 8 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/mock/roon-cqrs-client.mock.ts: -------------------------------------------------------------------------------- 1 | const start = jest.fn(); 2 | const client_stop = jest.fn(); 3 | const onRoonState = jest.fn(); 4 | const offRoonState = jest.fn(); 5 | const onCommandState = jest.fn(); 6 | const offCommandState = jest.fn(); 7 | const onZoneState = jest.fn(); 8 | const offZoneState = jest.fn(); 9 | const onQueueState = jest.fn(); 10 | const offQueueState = jest.fn(); 11 | const command = jest.fn(); 12 | 13 | export const roonCqrsClientMock = { 14 | start, 15 | stop: client_stop, 16 | onRoonState, 17 | offRoonState, 18 | onCommandState, 19 | offCommandState, 20 | onZoneState, 21 | offZoneState, 22 | onQueueState, 23 | offQueueState, 24 | command, 25 | }; 26 | 27 | const build = jest.fn().mockImplementation(() => roonCqrsClientMock); 28 | 29 | export const roonWebClientFactoryMock = { 30 | build, 31 | }; 32 | 33 | jest.mock("@nihilux/roon-web-client", () => ({ 34 | roonWebClientFactory: roonWebClientFactoryMock, 35 | })); 36 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/mock/worker.utils.mock.ts: -------------------------------------------------------------------------------- 1 | import { RawWorkerEvent, WorkerActionMessage } from "@model/client"; 2 | 3 | let onMessageListener: (m: MessageEvent) => void; 4 | let receivedMessages: WorkerActionMessage[] = []; 5 | 6 | export const roonWorkerMock = { 7 | postMessage: jest.fn().mockImplementation((m: WorkerActionMessage) => { 8 | receivedMessages.push(m); 9 | if (m.event === "worker-client" && m.data.action === "start-client") { 10 | onMessageListener({ 11 | data: { 12 | event: "clientState", 13 | data: { 14 | status: "started", 15 | roonClientId: "roon_client_id", 16 | }, 17 | }, 18 | } as MessageEvent); 19 | } else if (m.event === "worker-api" && m.data.type === "version") { 20 | onMessageListener({ 21 | data: { 22 | event: "apiResult", 23 | data: { 24 | id: m.data.id, 25 | type: "version", 26 | data: "version", 27 | }, 28 | }, 29 | } as MessageEvent); 30 | } 31 | }), 32 | set onmessage(listener: (m: MessageEvent) => void) { 33 | onMessageListener = listener; 34 | }, 35 | get onmessage() { 36 | return onMessageListener; 37 | }, 38 | clearMessages: () => { 39 | receivedMessages = []; 40 | }, 41 | get messages() { 42 | return receivedMessages; 43 | }, 44 | dispatchEvent: (event: RawWorkerEvent): void => { 45 | onMessageListener({ 46 | data: event, 47 | } as MessageEvent); 48 | }, 49 | }; 50 | 51 | const buildRoonWorker = () => roonWorkerMock; 52 | 53 | export const mockRoonWorker = () => 54 | jest.mock("@services/worker.utils", () => ({ 55 | buildRoonWorker, 56 | })); 57 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/src/proxy.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "/api": { 3 | "target": "http://localhost:3000", 4 | "secure": false, 5 | "logLevel": "debug" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [ 7 | "node" 8 | ] 9 | }, 10 | "files": [ 11 | "src/main.ts" 12 | ], 13 | "include": [ 14 | "src/**/*.d.ts", 15 | "../../packages/roon-web-client/src/index.ts", 16 | "../../packages/roon-web-model/src/index.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "outDir": "./dist/out-tsc", 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "noImplicitOverride": true, 9 | "noPropertyAccessFromIndexSignature": true, 10 | "noImplicitReturns": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "skipLibCheck": true, 13 | "esModuleInterop": true, 14 | "sourceMap": true, 15 | "declaration": false, 16 | "experimentalDecorators": true, 17 | "moduleResolution": "node", 18 | "importHelpers": true, 19 | "target": "ES2022", 20 | "module": "ES2022", 21 | "useDefineForClassFields": false, 22 | "lib": [ 23 | "ES2022", 24 | "dom" 25 | ], 26 | "paths": { 27 | "@components/*": [ 28 | "./src/app/components/*" 29 | ], 30 | "@mock/*": [ 31 | "./src/mock/*" 32 | ], 33 | "@services/*": [ 34 | "./src/app/services/*" 35 | ], 36 | "@model": [ 37 | "../../packages/roon-web-model/src/index.d.ts" 38 | ], 39 | "@nihilux/ngx-spatial-navigable": [ 40 | "./projects/nihilux/ngx-spatial-navigable/src/public-api.ts" 41 | ], 42 | "@model/client": [ 43 | "./src/app/model" 44 | ] 45 | } 46 | }, 47 | "angularCompilerOptions": { 48 | "enableI18nLegacyMessageIdFormat": false, 49 | "strictInjectionParameters": true, 50 | "strictInputAccessModifiers": true, 51 | "strictTemplates": true, 52 | "strictStandalone": true 53 | }, 54 | "references": [ 55 | { 56 | "path": "../../packages/roon-web-model" 57 | }, 58 | { 59 | "path": "../../packages/roon-web-client" 60 | } 61 | ] 62 | } 63 | -------------------------------------------------------------------------------- /app/roon-web-ng-client/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "node", 8 | "jest" 9 | ] 10 | }, 11 | "include": [ 12 | "**/*.ts", 13 | "**/*.spec.ts", 14 | "**/*.mock.ts", 15 | "**/*.d.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /doc/images/enable-in-roon-extension-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/doc/images/enable-in-roon-extension-settings.png -------------------------------------------------------------------------------- /doc/images/first-launch-without-extension-enabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/doc/images/first-launch-without-extension-enabled.png -------------------------------------------------------------------------------- /doc/images/main-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/doc/images/main-screen.png -------------------------------------------------------------------------------- /doc/images/roon-extension-manager.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/doc/images/roon-extension-manager.png -------------------------------------------------------------------------------- /doc/images/selecting-zone-at-first-launch.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/doc/images/selecting-zone-at-first-launch.gif -------------------------------------------------------------------------------- /doc/images/ug-browse-and-library.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/doc/images/ug-browse-and-library.gif -------------------------------------------------------------------------------- /doc/images/ug-display-mode-and-responsive.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/doc/images/ug-display-mode-and-responsive.gif -------------------------------------------------------------------------------- /doc/images/ug-grouped-volume-drawer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/doc/images/ug-grouped-volume-drawer.gif -------------------------------------------------------------------------------- /doc/images/ug-live-radio-and-play-from-here.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/doc/images/ug-live-radio-and-play-from-here.gif -------------------------------------------------------------------------------- /doc/images/ug-theme-selection.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/doc/images/ug-theme-selection.gif -------------------------------------------------------------------------------- /doc/images/ug-volume-drawer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/doc/images/ug-volume-drawer.gif -------------------------------------------------------------------------------- /doc/images/ug-zone-selection.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/doc/images/ug-zone-selection.gif -------------------------------------------------------------------------------- /doc/images/zone-selection-and-settings.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nihilux-org/roon-web-stack/095623abc65465888198c63d3a84bdd203885811/doc/images/zone-selection-and-settings.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nihilux/roon-web-stack", 3 | "version": "0.0.11", 4 | "description": "a web stack, from api to web client, to use roon ia a browser", 5 | "private": true, 6 | "author": "nihil@nihilux.org", 7 | "license": "MIT", 8 | "workspaces": [ 9 | "packages/*", 10 | "app/*" 11 | ], 12 | "engines": { 13 | "node": ">= 22.14.0" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/nihilux-org/roon-web-stack.git" 18 | }, 19 | "scripts": { 20 | "build": "yarn workspaces foreach -At run build", 21 | "lint": "yarn workspaces foreach -At run lint", 22 | "lint:fix": "yarn workspaces foreach -At run lint:fix", 23 | "test": "yarn workspaces foreach -At run test", 24 | "backend": "yarn workspace @nihilux/roon-web-api start", 25 | "frontend": "yarn workspace @nihilux/roon-web-ng-client start", 26 | "jest": "yarn workspaces foreach -At run jest" 27 | }, 28 | "packageManager": "yarn@4.9.2" 29 | } 30 | -------------------------------------------------------------------------------- /packages/roon-web-client/jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | collectCoverage: true, 4 | collectCoverageFrom: ["src/**/*.ts"], 5 | coverageDirectory: "coverage", 6 | coveragePathIgnorePatterns: [".*\\.(mock)|d\\.ts", ".*index(\\.mock)?\\.ts"], 7 | coverageProvider: "v8", 8 | coverageReporters: ["html", "text", "text-summary", "cobertura"], 9 | // coverageReporters: [ 10 | // "json", 11 | // "text", 12 | // "lcov", 13 | // "clover" 14 | // ], 15 | coverageThreshold: { 16 | global: { 17 | branches: 100, 18 | functions: 100, 19 | lines: 100, 20 | statements: 100, 21 | }, 22 | }, 23 | globals: { 24 | window: {}, 25 | }, 26 | moduleNameMapper: { 27 | "@model": "/../roon-cqrs-model/src/index.ts", 28 | "@mock": "/src/mock/index.ts", 29 | }, 30 | transform: { 31 | "^.+\\.ts?$": ["ts-jest", {}], 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /packages/roon-web-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nihilux/roon-web-client", 3 | "version": "0.0.11", 4 | "main": "bin/roon-web-client.js", 5 | "typings": "bin/src/index.d.ts", 6 | "license": "MIT", 7 | "type": "module", 8 | "private": true, 9 | "exports": { 10 | ".": { 11 | "types": "./bin/src/index.d.ts", 12 | "import": "./bin/roon-web-client.js", 13 | "default": "./bin/roon-web-client.js" 14 | } 15 | }, 16 | "scripts": { 17 | "build": "yarn run clean && yarn webpack", 18 | "clean": "rimraf bin", 19 | "lint": "eslint .", 20 | "lint:fix": "eslint . --fix", 21 | "test": "jest" 22 | }, 23 | "dependencies": { 24 | "tslib": "2.8.1" 25 | }, 26 | "devDependencies": { 27 | "@eslint/compat": "1.2.9", 28 | "@eslint/eslintrc": "3.3.1", 29 | "@eslint/js": "9.28.0", 30 | "@nihilux/roon-web-model": "workspace:*", 31 | "@types/jest": "29.5.14", 32 | "@typescript-eslint/eslint-plugin": "8.33.1", 33 | "@typescript-eslint/parser": "8.33.1", 34 | "eslint": "9.28.0", 35 | "eslint-config-prettier": "10.1.5", 36 | "eslint-config-standard": "17.1.0", 37 | "eslint-import-resolver-typescript": "4.4.3", 38 | "eslint-plugin-import": "2.31.0", 39 | "eslint-plugin-n": "17.19.0", 40 | "eslint-plugin-prettier": "5.4.1", 41 | "eslint-plugin-promise": "7.2.1", 42 | "eslint-plugin-simple-import-sort": "12.1.1", 43 | "eslint-webpack-plugin": "5.0.1", 44 | "globals": "16.2.0", 45 | "jest": "29.7.0", 46 | "jest-fetch-mock": "3.0.3", 47 | "jest-junit": "16.0.0", 48 | "prettier": "3.5.3", 49 | "rimraf": "6.0.1", 50 | "ts-jest": "29.3.4", 51 | "ts-loader": "9.5.2", 52 | "tsconfig-paths-webpack-plugin": "4.2.0", 53 | "typescript": "5.8.3", 54 | "webpack": "5.99.9", 55 | "webpack-cli": "6.0.1" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/roon-web-client/src/client/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./roon-web-client-factory"; 2 | -------------------------------------------------------------------------------- /packages/roon-web-client/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./client"; 2 | -------------------------------------------------------------------------------- /packages/roon-web-client/src/mock/event-source.mock.ts: -------------------------------------------------------------------------------- 1 | export const eventSourceMocks: Map = new Map(); 2 | 3 | export const resetEventSourceMocks: () => void = (): void => { 4 | eventSourceMocks.clear(); 5 | }; 6 | 7 | export class EventSourceMock { 8 | private readonly listeners: Map; 9 | private _state: number; 10 | private _onerror?: () => void; 11 | 12 | constructor() { 13 | this.listeners = new Map(); 14 | this._state = 1; 15 | } 16 | 17 | close = jest.fn().mockImplementation(() => { 18 | this._state = 0; 19 | this.listeners.clear(); 20 | return; 21 | }); 22 | 23 | addEventListener = jest.fn().mockImplementation((type: string, listener: EventListener): void => { 24 | this.listeners.set(type, listener); 25 | }); 26 | 27 | getEventListener = (event: string): EventListener | undefined => { 28 | return this.listeners.get(event); 29 | }; 30 | 31 | dispatchEvent = (event: MessageEvent): void => { 32 | const listener = this.listeners.get(event.type); 33 | if (listener) { 34 | listener(event); 35 | } 36 | }; 37 | 38 | get onerror(): (() => void) | undefined { 39 | return this._onerror; 40 | } 41 | 42 | set onerror(listener: (() => void) | undefined) { 43 | this._onerror = () => { 44 | this._state = 0; 45 | if (listener) { 46 | listener(); 47 | } 48 | }; 49 | } 50 | 51 | get OPEN(): number { 52 | return 1; 53 | } 54 | 55 | get readyState(): number { 56 | return this._state; 57 | } 58 | } 59 | 60 | export const eventSourceMockConstructor = jest.fn().mockImplementation((url: URL): EventSourceMock => { 61 | const eventSourceMock = new EventSourceMock(); 62 | eventSourceMocks.set(url.toString(), eventSourceMock); 63 | return eventSourceMock; 64 | }); 65 | 66 | Object.defineProperty(global, "EventSource", { 67 | writable: true, 68 | value: eventSourceMockConstructor, 69 | }); 70 | -------------------------------------------------------------------------------- /packages/roon-web-client/src/mock/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./event-source.mock"; 2 | -------------------------------------------------------------------------------- /packages/roon-web-client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "baseUrl": "./src", 6 | "lib": [ 7 | "ES2022", 8 | "DOM" 9 | ], 10 | "paths": { 11 | "@model": [ 12 | "../../roon-web-model/src/index.d.ts" 13 | ], 14 | "@mock": [ 15 | "mock/index.ts" 16 | ] 17 | }, 18 | /* Specify a set of entries that re-map imports to additional lookup locations. */ 19 | "outDir": "bin" 20 | /* Specify an output folder for all emitted files. */ 21 | }, 22 | "references": [ 23 | { 24 | "path": "../roon-web-model" 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /packages/roon-web-client/webpack.config.cjs: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin"); 3 | const ESLintPlugin = require("eslint-webpack-plugin"); 4 | 5 | const isProduction = process.env.NODE_ENV !== "development"; 6 | 7 | const config = { 8 | entry: "./src/index.ts", 9 | target: "es2022", 10 | output: { 11 | path: path.resolve(__dirname, "bin"), 12 | filename: "roon-web-client.js", 13 | library: { 14 | type: "module", 15 | }, 16 | module: true, 17 | }, 18 | plugins: [ 19 | new ESLintPlugin({ 20 | context: path.resolve(__dirname, "./src"), 21 | emitError: true, 22 | emitWarning: true, 23 | failOnError: true, 24 | failOnWarning: true, 25 | extensions: ["ts", "json", "d.ts"], 26 | fix: false, 27 | cache: false, 28 | configType: "flat", 29 | }), 30 | ], 31 | module: { 32 | rules: [ 33 | { 34 | test: /\.ts$/, 35 | use: require.resolve("ts-loader"), 36 | exclude: /node_modules/, 37 | }, 38 | ], 39 | }, 40 | resolve: { 41 | extensions: [".ts"], 42 | plugins: [new TsconfigPathsPlugin({})], 43 | }, 44 | experiments: { 45 | outputModule: true, 46 | }, 47 | }; 48 | 49 | module.exports = () => { 50 | if (isProduction) { 51 | config.mode = "production"; 52 | config.devtool = "source-map"; 53 | } else { 54 | config.mode = "development"; 55 | config.devtool = "inline-source-map" 56 | } 57 | return config; 58 | }; 59 | -------------------------------------------------------------------------------- /packages/roon-web-model/README.md: -------------------------------------------------------------------------------- 1 | # roon-web-model 2 | 3 | A simple package to hold the `.d.ts` typing the common part of this repo. 4 | 5 | Nothing more, nothing less. 6 | 7 | Used as a devDependency to ensure `types` coherence between the [API](../../app/roon-web-api/README.md), the [client](../roon-web-client/README.md) and the [web app](../../app/roon-web-ng-client/README.md). 8 | 9 | **This module includes sources copied from [Stevenic/roon-kit](https://github.com/Stevenic/roon-kit), see in the [README](./src/roon-kit/README.md) in the `roon-kit` folder for more info.** 10 | -------------------------------------------------------------------------------- /packages/roon-web-model/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nihilux/roon-web-model", 3 | "version": "0.0.11", 4 | "private": true, 5 | "types": "src/index.d.ts", 6 | "license": "MIT", 7 | "scripts": { 8 | "lint": "eslint .", 9 | "lint:fix": "eslint . --fix" 10 | }, 11 | "dependencies": { 12 | "rxjs": "7.8.2", 13 | "tslib": "2.8.1" 14 | }, 15 | "devDependencies": { 16 | "@eslint/compat": "1.2.9", 17 | "@eslint/eslintrc": "3.3.1", 18 | "@eslint/js": "9.28.0", 19 | "@typescript-eslint/eslint-plugin": "8.33.1", 20 | "@typescript-eslint/parser": "8.33.1", 21 | "eslint": "9.28.0", 22 | "eslint-config-prettier": "10.1.5", 23 | "eslint-config-standard": "17.1.0", 24 | "eslint-import-resolver-typescript": "4.4.3", 25 | "eslint-plugin-import": "2.31.0", 26 | "eslint-plugin-n": "17.19.0", 27 | "eslint-plugin-prettier": "5.4.1", 28 | "eslint-plugin-promise": "7.2.1", 29 | "eslint-plugin-simple-import-sort": "12.1.1", 30 | "globals": "16.2.0", 31 | "prettier": "3.5.3", 32 | "typescript": "5.8.3" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/roon-web-model/src/api-model/client.d.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from "rxjs"; 2 | import { 3 | RoonApiBrowseLoadOptions, 4 | RoonApiBrowseLoadResponse, 5 | RoonApiBrowseOptions, 6 | RoonApiBrowseResponse, 7 | } from "../roon-kit"; 8 | import { Command, RoonSseMessage } from "./index"; 9 | 10 | export interface Client { 11 | events: () => Observable; 12 | close: () => void; 13 | command: (command: Command) => string; 14 | browse: (options: RoonApiBrowseOptions) => Promise; 15 | load: (options: RoonApiBrowseLoadOptions) => Promise; 16 | } 17 | 18 | export interface ClientManager { 19 | register: (previous_client_id?: string) => string; 20 | unregister: (client_id: string) => void; 21 | get: (client_id: string) => Client; 22 | start: () => Promise; 23 | stop: () => void; 24 | } 25 | -------------------------------------------------------------------------------- /packages/roon-web-model/src/api-model/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from "./client"; 2 | export * from "./command"; 3 | export * from "./common"; 4 | export * from "./queue"; 5 | export * from "./zone"; 6 | -------------------------------------------------------------------------------- /packages/roon-web-model/src/api-model/queue.d.ts: -------------------------------------------------------------------------------- 1 | import { Subject } from "rxjs"; 2 | import { QueueItem, RoonApiTransportQueue, RoonSubscriptionResponse, Zone } from "../roon-kit"; 3 | import { Roon, RoonSseMessage, SseMessage, Track } from "./index"; 4 | 5 | export interface Queue { 6 | zone_id: string; 7 | items: QueueItem[]; 8 | } 9 | 10 | export interface QueueManager { 11 | stop: () => void; 12 | start: () => Promise; 13 | queue: () => RoonSseMessage; 14 | isStarted: () => boolean; 15 | } 16 | 17 | export interface QueueManagerFactory { 18 | build: (zone: Zone, eventPublisher: Subject, queueSize: number) => QueueManager; 19 | } 20 | 21 | export interface QueueListener { 22 | (response: RoonSubscriptionResponse, body: RoonApiTransportQueue): void; 23 | } 24 | 25 | export interface QueueTrack extends Omit { 26 | queue_item_id: number; 27 | } 28 | 29 | export interface QueueState { 30 | zone_id: string; 31 | tracks: QueueTrack[]; 32 | } 33 | 34 | export interface QueueSseMessage extends SseMessage { 35 | event: "queue"; 36 | } 37 | 38 | export interface QueueBot { 39 | start: (roon: Roon) => void; 40 | watchQueue: (queue: Queue) => void; 41 | } 42 | -------------------------------------------------------------------------------- /packages/roon-web-model/src/api-model/zone.d.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from "rxjs"; 2 | import { 3 | Output, 4 | RoonApiTransportOutputs, 5 | RoonApiTransportZones, 6 | RoonPlaybackState, 7 | RoonServer, 8 | RoonSubscriptionResponse, 9 | Zone, 10 | } from "../roon-kit"; 11 | import { RoonSseMessage, SseMessage, Track } from "./index"; 12 | 13 | export type ZoneDescription = Pick; 14 | 15 | export type OutputDescription = Pick; 16 | 17 | export interface ZoneListener { 18 | (core: RoonServer, response: RoonSubscriptionResponse, body: RoonApiTransportZones): void; 19 | } 20 | 21 | export interface OutputListener { 22 | (core: RoonServer, response: RoonSubscriptionResponse, body: RoonApiTransportOutputs): void; 23 | } 24 | 25 | export const enum RoonState { 26 | LOST = "LOST", 27 | STARTING = "STARTING", 28 | STOPPED = "STOPPED", 29 | SYNC = "SYNC", 30 | SYNCING = "SYNCING", 31 | } 32 | 33 | export interface ZoneManager { 34 | zones: () => ZoneDescription[]; 35 | events: () => Observable; 36 | start: () => Promise; 37 | stop: () => void; 38 | isStarted: () => boolean; 39 | } 40 | export interface ZoneNicePlaying { 41 | track: Track; 42 | total_queue_remaining_time?: string; 43 | nb_items_in_queue?: number; 44 | state: RoonPlaybackState; 45 | } 46 | 47 | export interface ZoneState extends Omit { 48 | nice_playing?: ZoneNicePlaying; 49 | } 50 | 51 | export interface ZoneSseMessage extends SseMessage { 52 | event: "zone"; 53 | } 54 | -------------------------------------------------------------------------------- /packages/roon-web-model/src/client-model/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from "./client-model"; 2 | -------------------------------------------------------------------------------- /packages/roon-web-model/src/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from "./api-model"; 2 | export * from "./client-model"; 3 | export * from "./roon-kit"; 4 | -------------------------------------------------------------------------------- /packages/roon-web-model/src/roon-kit/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Steven Ickman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/roon-web-model/src/roon-kit/README.md: -------------------------------------------------------------------------------- 1 | **DISCLAIMER:** 2 | 3 | **These typings are mainly extracted from [Stevenic/roon-kit](https://github.com/Stevenic/roon-kit). I've just made some additions and corrections to fix some of them that were not reflecting what was returned by the `roon` API.** 4 | 5 | **Once again, thanks [Stevenic](https://github.com/Stevenic) for [roon-kit](https://github.com/Stevenic/roon-kit)!** 6 | -------------------------------------------------------------------------------- /packages/roon-web-model/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "baseUrl": "src" 6 | }, 7 | "files": [ 8 | "src/index.d.ts" 9 | ], 10 | "include": [ 11 | "src" 12 | ] 13 | } 14 | --------------------------------------------------------------------------------