From 79b1e991ef72cbd23d90627984dd8d6017c0164e Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 16 Nov 2025 17:41:50 +0100 Subject: [PATCH] feat(remote-control): add remote control UI library Add a new library libs/ui/remote-control including component, template, Jest setup, and project configuration to provide a remote-control UI component. - Add RemoteControlComponent implemented with inject-based service usage, loading and error state, and async channelUp/channelDown handlers. This centralizes remote control logic and handles errors for the UI. - Add HTML template for the remote-control touchpad, buttons, error display, and basic branding markup. - Add Jest config (jest.config.ts) and tsconfig.spec.json configured for unit tests with jest-preset-angular to enable testing of Angular components. - Add Nx project configuration (project.json) with test and lint targets so the library is discoverable and runnable by the workspace. These changes scaffold the remote-control feature as a reusable UI library and enable testing and linting within the monorepo. --- libs/ui/remote-control/README.md | 7 + libs/ui/remote-control/eslint.config.mjs | 34 ++ libs/ui/remote-control/jest.config.ts | 21 + libs/ui/remote-control/project.json | 21 + libs/ui/remote-control/src/index.ts | 2 + .../remote-control.component.html | 77 ++++ .../remote-control.component.scss | 398 ++++++++++++++++++ .../remote-control.component.ts | 42 ++ .../remote-control/remote-control.service.ts | 34 ++ libs/ui/remote-control/src/test-setup.ts | 6 + libs/ui/remote-control/tsconfig.json | 30 ++ libs/ui/remote-control/tsconfig.lib.json | 17 + libs/ui/remote-control/tsconfig.spec.json | 17 + 13 files changed, 706 insertions(+) create mode 100644 libs/ui/remote-control/README.md create mode 100644 libs/ui/remote-control/eslint.config.mjs create mode 100644 libs/ui/remote-control/jest.config.ts create mode 100644 libs/ui/remote-control/project.json create mode 100644 libs/ui/remote-control/src/index.ts create mode 100644 libs/ui/remote-control/src/lib/remote-control/remote-control.component.html create mode 100644 libs/ui/remote-control/src/lib/remote-control/remote-control.component.scss create mode 100644 libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts create mode 100644 libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts create mode 100644 libs/ui/remote-control/src/test-setup.ts create mode 100644 libs/ui/remote-control/tsconfig.json create mode 100644 libs/ui/remote-control/tsconfig.lib.json create mode 100644 libs/ui/remote-control/tsconfig.spec.json diff --git a/libs/ui/remote-control/README.md b/libs/ui/remote-control/README.md new file mode 100644 index 000000000..0630db4e9 --- /dev/null +++ b/libs/ui/remote-control/README.md @@ -0,0 +1,7 @@ +# remote-control + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test remote-control` to execute the unit tests. diff --git a/libs/ui/remote-control/eslint.config.mjs b/libs/ui/remote-control/eslint.config.mjs new file mode 100644 index 000000000..937c5f406 --- /dev/null +++ b/libs/ui/remote-control/eslint.config.mjs @@ -0,0 +1,34 @@ +import nx from '@nx/eslint-plugin'; +import baseConfig from '../../../eslint.config.mjs'; + +export default [ + ...baseConfig, + ...nx.configs['flat/angular'], + ...nx.configs['flat/angular-template'], + { + files: ['**/*.ts'], + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { + type: 'attribute', + prefix: 'lib', + style: 'camelCase', + }, + ], + '@angular-eslint/component-selector': [ + 'error', + { + type: 'element', + prefix: 'lib', + style: 'kebab-case', + }, + ], + }, + }, + { + files: ['**/*.html'], + // Override or add rules here + rules: {}, + }, +]; diff --git a/libs/ui/remote-control/jest.config.ts b/libs/ui/remote-control/jest.config.ts new file mode 100644 index 000000000..395a66fa9 --- /dev/null +++ b/libs/ui/remote-control/jest.config.ts @@ -0,0 +1,21 @@ +export default { + displayName: 'remote-control', + preset: '../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../coverage/libs/ui/remote-control', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/ui/remote-control/project.json b/libs/ui/remote-control/project.json new file mode 100644 index 000000000..d5257af8e --- /dev/null +++ b/libs/ui/remote-control/project.json @@ -0,0 +1,21 @@ +{ + "name": "remote-control", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/ui/remote-control/src", + "prefix": "lib", + "projectType": "library", + "tags": [], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/ui/remote-control/jest.config.ts", + "tsConfig": "libs/ui/remote-control/tsconfig.spec.json" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libs/ui/remote-control/src/index.ts b/libs/ui/remote-control/src/index.ts new file mode 100644 index 000000000..a2f5fa64c --- /dev/null +++ b/libs/ui/remote-control/src/index.ts @@ -0,0 +1,2 @@ +export * from './lib/remote-control/remote-control.component'; +export * from './lib/remote-control/remote-control.service'; diff --git a/libs/ui/remote-control/src/lib/remote-control/remote-control.component.html b/libs/ui/remote-control/src/lib/remote-control/remote-control.component.html new file mode 100644 index 000000000..bcfc57fb2 --- /dev/null +++ b/libs/ui/remote-control/src/lib/remote-control/remote-control.component.html @@ -0,0 +1,77 @@ +
+
+ +
+ +
+ + + @if (error) { +
+ {{ error }} +
+ } + + +
+
+ + + + + + + +
+
+
+
+ + +
+
+ + +
+
+ {{ + isLoading ? 'Switching' : 'Ready' + }} +
+
+
diff --git a/libs/ui/remote-control/src/lib/remote-control/remote-control.component.scss b/libs/ui/remote-control/src/lib/remote-control/remote-control.component.scss new file mode 100644 index 000000000..4f73c25aa --- /dev/null +++ b/libs/ui/remote-control/src/lib/remote-control/remote-control.component.scss @@ -0,0 +1,398 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; + user-select: none; +} + +// Main container +.remote { + min-height: 100vh; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + background-color: #1a1a1a; + font-family: + -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', + sans-serif; + padding: 1rem; + background-image: + radial-gradient( + circle at 50% 20%, + rgba(10, 132, 255, 0.15) 0%, + transparent 50% + ), + radial-gradient( + circle at 80% 80%, + rgba(120, 120, 255, 0.08) 0%, + transparent 50% + ); +} + +.remote__body { + width: 100%; + max-width: 420px; + display: flex; + flex-direction: column; + gap: 2.5rem; + animation: fadeInUp 0.8s cubic-bezier(0.16, 1, 0.3, 1) backwards; +} + +// Header +.remote__header { + text-align: center; +} + +.remote__logo { + font-size: 1.5rem; + font-weight: 600; + letter-spacing: -0.02em; + color: #ffffff; + opacity: 0; + animation: fadeIn 1s ease-out 0.3s forwards; +} + +// Error message +.remote__error { + background: rgba(255, 69, 58, 0.15); + border: 1px solid rgba(255, 69, 58, 0.4); + border-radius: 1rem; + padding: 1rem 1.25rem; + color: #ffb3ab; + font-size: 0.875rem; + font-weight: 500; + text-align: center; + animation: slideDown 0.4s cubic-bezier(0.16, 1, 0.3, 1); +} + +// Touchpad - Main control +.touchpad { + position: relative; + width: 100%; + aspect-ratio: 1; + max-width: 360px; + margin: 0 auto; + animation: scaleIn 0.9s cubic-bezier(0.16, 1, 0.3, 1) 0.2s backwards; +} + +.touchpad__glass { + position: relative; + width: 100%; + height: 100%; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + backdrop-filter: blur(20px); + box-shadow: + 0 16px 48px rgba(0, 0, 0, 0.6), + 0 0 0 1px rgba(255, 255, 255, 0.15), + inset 0 1px 1px rgba(255, 255, 255, 0.1), + inset 0 -1px 1px rgba(0, 0, 0, 0.2); + border: 1px solid rgba(255, 255, 255, 0.3); + overflow: hidden; + + // Top reflection + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 45%; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.25) 0%, + rgba(255, 255, 255, 0.1) 50%, + transparent 100% + ); + border-radius: 50% 50% 0 0; + pointer-events: none; + } + + // Bottom shadow + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 35%; + background: linear-gradient( + 0deg, + rgba(0, 0, 0, 0.4) 0%, + rgba(0, 0, 0, 0.1) 50%, + transparent 100% + ); + border-radius: 0 0 50% 50%; + pointer-events: none; + } +} + +.touchpad__edge { + position: absolute; + inset: -2px; + border-radius: 50%; + background: radial-gradient( + circle at 50% 0%, + rgba(255, 255, 255, 0.15), + transparent 50% + ); + opacity: 0.6; + pointer-events: none; + filter: blur(1px); +} + +// Touch buttons +.touchpad__button { + position: absolute; + left: 0; + width: 100%; + height: 50%; + background: transparent; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); + + svg { + width: 56px; + height: 56px; + color: #ffffff; + stroke: #ffffff; + opacity: 0.5; + transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1); + filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3)); + } + + &:hover:not(:disabled) svg { + opacity: 0.9; + transform: scale(1.05); + } + + &:active:not(:disabled) { + svg { + opacity: 1; + transform: scale(0.92); + } + + &::after { + opacity: 1; + } + } + + // Ripple effect + &::after { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient( + circle at center, + rgba(10, 132, 255, 0.5) 0%, + transparent 70% + ); + opacity: 0; + transition: opacity 0.2s ease; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.4; + } + + &.is-loading svg { + opacity: 0.5; + animation: pulse 1.5s ease-in-out infinite; + } +} + +.touchpad__button--up { + top: 0; + border-radius: 50% 50% 0 0; + + svg { + margin-bottom: 20%; + } +} + +.touchpad__button--down { + bottom: 0; + border-radius: 0 0 50% 50%; + + svg { + margin-top: 20%; + } +} + +// Center indicator +.touchpad__center { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 48px; + height: 48px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.2); + border: 1px solid rgba(255, 255, 255, 0.08); + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.touchpad__pulse { + width: 16px; + height: 16px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + transition: all 0.4s ease; + + &.is-active { + background: #0a84ff; + box-shadow: + 0 0 12px rgba(10, 132, 255, 0.5), + 0 0 24px rgba(10, 132, 255, 0.5); + animation: breathe 3s ease-in-out infinite; + } +} + +// Status bar +.remote__status { + display: flex; + align-items: center; + justify-content: center; + gap: 0.625rem; + padding: 0.875rem 1.25rem; + background: rgba(255, 255, 255, 0.1); + border-radius: 1.25rem; + border: 1px solid rgba(255, 255, 255, 0.06); + animation: fadeIn 1s ease-out 0.5s backwards; +} + +.status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.25); + transition: all 0.4s ease; + + &.is-active { + background: #30d158; + box-shadow: + 0 0 8px rgba(48, 209, 88, 0.5), + 0 0 16px rgba(48, 209, 88, 0.3); + animation: breathe 2.5s ease-in-out infinite; + } +} + +.status-text { + font-size: 0.8125rem; + font-weight: 500; + color: rgba(255, 255, 255, 0.7); + letter-spacing: 0.01em; +} + +// Animations +@keyframes fadeIn { + to { + opacity: 1; + } +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-8px); + } +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.9); + } +} + +@keyframes breathe { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.7; + transform: scale(0.9); + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 0.5; + } + 50% { + opacity: 0.8; + } +} + +// Responsive +@media (max-width: 480px) { + .remote__logo { + font-size: 1.25rem; + } + + .touchpad { + max-width: 320px; + } + + .touchpad__button svg { + width: 48px; + height: 48px; + } +} + +@media (max-width: 360px) { + .touchpad { + max-width: 280px; + } + + .touchpad__button svg { + width: 42px; + height: 42px; + } + + .touchpad__center { + width: 40px; + height: 40px; + } + + .touchpad__pulse { + width: 14px; + height: 14px; + } +} + +// Accessibility +@media (prefers-reduced-motion: reduce) { + * { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts b/libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts new file mode 100644 index 000000000..eb390caf7 --- /dev/null +++ b/libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts @@ -0,0 +1,42 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { RemoteControlService } from './remote-control.service'; + +@Component({ + selector: 'lib-remote-control', + imports: [CommonModule], + templateUrl: './remote-control.component.html', + styleUrls: ['./remote-control.component.scss'], +}) +export class RemoteControlComponent { + private remoteControlService = inject(RemoteControlService); + + isLoading = false; + error: string | null = null; + + async changeChannelUp(): Promise { + this.isLoading = true; + this.error = null; + try { + await this.remoteControlService.channelUp(); + } catch (err) { + this.error = 'Failed to change channel up'; + console.error(err); + } finally { + this.isLoading = false; + } + } + + async changeChannelDown(): Promise { + this.isLoading = true; + this.error = null; + try { + await this.remoteControlService.channelDown(); + } catch (err) { + this.error = 'Failed to change channel down'; + console.error(err); + } finally { + this.isLoading = false; + } + } +} diff --git a/libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts b/libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts new file mode 100644 index 000000000..8151ae401 --- /dev/null +++ b/libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; + +/** + * Service for remote control operations. + * This service communicates with the Electron backend via HTTP API. + */ +@Injectable({ + providedIn: 'root', +}) +export class RemoteControlService { + private readonly apiBaseUrl = '/api/remote-control'; + + constructor(private http: HttpClient) {} + + /** + * Changes to the next channel + */ + async channelUp(): Promise { + await firstValueFrom( + this.http.post(`${this.apiBaseUrl}/channel/up`, {}) + ); + } + + /** + * Changes to the previous channel + */ + async channelDown(): Promise { + await firstValueFrom( + this.http.post(`${this.apiBaseUrl}/channel/down`, {}) + ); + } +} diff --git a/libs/ui/remote-control/src/test-setup.ts b/libs/ui/remote-control/src/test-setup.ts new file mode 100644 index 000000000..0d88648e3 --- /dev/null +++ b/libs/ui/remote-control/src/test-setup.ts @@ -0,0 +1,6 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true, +}); diff --git a/libs/ui/remote-control/tsconfig.json b/libs/ui/remote-control/tsconfig.json new file mode 100644 index 000000000..e05028a82 --- /dev/null +++ b/libs/ui/remote-control/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "target": "es2022", + "moduleResolution": "bundler", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "typeCheckHostBindings": true, + "strictTemplates": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/ui/remote-control/tsconfig.lib.json b/libs/ui/remote-control/tsconfig.lib.json new file mode 100644 index 000000000..8eb88c679 --- /dev/null +++ b/libs/ui/remote-control/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/ui/remote-control/tsconfig.spec.json b/libs/ui/remote-control/tsconfig.spec.json new file mode 100644 index 000000000..7b3e56bde --- /dev/null +++ b/libs/ui/remote-control/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"], + "moduleResolution": "node10" + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +}