mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
fix(i18n): translate the remaining hard-coded labels (#1871)
* fix(i18n): translate hard-coded labels, snackbars and missing keys Catalog screens, snackbars and external player messages showed English in every locale, and seven keys used in code were missing from en.json, so ngx-translate rendered them raw. - Catalog: "All items", item and channel counts, channel sort menus and tooltips, unnamed-category and empty-category labels, LIVE/PAUSED badges and the Xtream global search summary are translated. The Xtream and Stalker stores no longer bake an English name into the every-item sentinel; the facades return a null title and the view translates it. - Snackbars: Xtream/Stalker request failures, the 413 upload error, backup export/import results and the category visibility failure use keys; every "Close" action uses CLOSE. - External player: the main process sends an error code instead of an English sentence (player-error event, session errorCode, and a tag in rejected launch errors); the renderer, dock and VOD primary button translate it. The external player info dialog is translated. - Adds the seven missing keys and 43 new ones, translated in all 18 locales; seven legitimately identical values are baselined. - tools/i18n/check-usage.mjs fails on keys used in code but missing from en.json; it runs in i18n:check (and so in CI through i18n:validate). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(i18n): translate catalog counts in the template and skip inline template comments - The category subtitle hands a key to the translate pipe instead of caching translate.instant(), so a cold start re-renders it once the language file loads. - The Xtream live root count uses the singular/plural item keys, so one result no longer reads "1 channels". - check-usage.mjs strips HTML comments inside inline templates of TypeScript files, so a commented-out key no longer fails the check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(i18n): check keys in parenthesised translate pipe operands `(expanded() ? 'SHOW_LESS' : 'SHOW_MORE') | translate` yields keys that neither precede the pipe directly nor contain a dot, so the usage check missed them. It now reads the ternary and fallback branches of a parenthesised operand; a literal compared in the condition is not read as a key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): expect the translated external player failure in the dock A launch failure without an error code now shows the translated generic status in the playback dock, with the raw main-process detail as its tooltip. The ClearKey DASH flow asserted the raw English text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): keep the IPC error tag out of the stored session detail A tagged launch failure reached ExternalPlayerSession.error unchanged, so the dock tooltip showed "[iptvnator:external-player:start-failed]". The registry now strips the tag when it stores the detail; the rejected IPC error keeps it for the renderer to read the code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(web): expect translated portal request failure toasts #1861's new resolved-failure specs asserted the English toast text; the toasts now go through PORTALS.REQUEST_ERRORS keys, so the specs check the key and its message/status params. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(i18n): read only returned branches of a grouped translate operand A literal at the start of a condition, as in `('ERROR' === status() ? 'CLOSE' : 'CLOSE') | translate`, was taken for a translation key, so a valid template could fail the usage check. A grouped literal now counts only when it ends its operand (end of group, `:`, `||` or `??`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(i18n): translate the remaining hard-coded labels Follow-up to the UI-26 pass. The remaining English UI literals now come from translation keys, translated in all 18 locales: - Windows/Linux window controls, playlist switcher title and actions menu, the portal status tooltip, dashboard carousel roles and rail scroll buttons, the search placeholder, the card remove tooltip, the EPG offset unit, the REC chip, the Stalker EPG source label and the export file type. - Embedded MPV failures raised in the renderer and the release-notes dialog without a bridge. Settings never showed its English reasons, so they are dropped. - Unnamed Stalker episodes: data access leaves the title empty and the series view labels it after the TMDB overlay. Synthetic season names stay, because they key persisted episode progress. - The phone remote-control page, which follows the first browser language with a translation and sets <html lang>. Removes the dead getStatusMessage(), the unused favorites layout and the translateWithFallback() helpers whose keys now exist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(remote-control): keep the remote build off shared-interfaces The language resolver imported the Language enum, which made remote-control-web depend on shared-interfaces. Its build-performance chain then hit Nx's recursive-invocation guard through the existing shared-interfaces/shared-logging build loop, failing the Electron E2E and performance journey builds. The resolver now keeps its own list of translation codes, and a spec checks it against the locale files the page loads. Also drops the deleted favorites layout from the zoneless checklist, whose guard spec requires ticked entries to exist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(journeys): raise the launch DOM mutation baseline for translated attributes Launch now sets 569 DOM mutations before the first dashboard card instead of 557 (identical in all three CI iterations). The extra twelve come from attributes this PR moved from static English to translated bindings on the launch path: the window-control labels and tooltips on Linux, the hero carousel and slide roles, and the rail scroll-button labels. A translated attribute is a binding set after the element is attached, so each costs a mutation. Accepted as a deliberate trade-off; it needs the perf-baseline-increase label. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a820194ae5
commit
2b400cb81d
85 files changed
+1822
-585
No files matched your search
@@ -1,2 +1,3 @@
|
||||
export * from './lib/remote-control/remote-control.component';
|
||||
export * from './lib/remote-control/remote-control.service';
|
||||
export * from './lib/remote-control/remote-control-language';
|
||||
@@ -0,0 +1,59 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import {
|
||||
REMOTE_CONTROL_TRANSLATIONS,
|
||||
resolveRemoteControlLanguage,
|
||||
} from './remote-control-language';
|
||||
|
||||
describe('resolveRemoteControlLanguage', () => {
|
||||
it.each([
|
||||
[['de-DE', 'en'], 'de', 'de-DE'],
|
||||
[['fr'], 'fr', 'fr'],
|
||||
[['zh-TW'], 'zhtw', 'zh-TW'],
|
||||
[['zh-Hant-HK'], 'zhtw', 'zh-Hant-HK'],
|
||||
[['zh-CN'], 'zh', 'zh-CN'],
|
||||
[['be-BY'], 'by', 'be-BY'],
|
||||
[['ar-MA'], 'ary', 'ar-MA'],
|
||||
[['ar-EG'], 'ar', 'ar-EG'],
|
||||
[['pt_BR'], 'pt', 'pt_BR'],
|
||||
])(
|
||||
'maps %j to the %s translation',
|
||||
(languages, translation, documentLanguage) => {
|
||||
expect(resolveRemoteControlLanguage(languages)).toEqual({
|
||||
translation,
|
||||
documentLanguage,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('skips languages without a translation', () => {
|
||||
expect(resolveRemoteControlLanguage(['sv-SE', 'ru-RU'])).toEqual({
|
||||
translation: 'ru',
|
||||
documentLanguage: 'ru-RU',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to English', () => {
|
||||
expect(resolveRemoteControlLanguage(['sv-SE'])).toEqual({
|
||||
translation: 'en',
|
||||
documentLanguage: 'en',
|
||||
});
|
||||
expect(resolveRemoteControlLanguage([])).toEqual({
|
||||
translation: 'en',
|
||||
documentLanguage: 'en',
|
||||
});
|
||||
});
|
||||
|
||||
it('lists exactly the translation files the page loads', () => {
|
||||
const directory = resolve(
|
||||
__dirname,
|
||||
'../../../../../../apps/web/src/assets/i18n'
|
||||
);
|
||||
const files = readdirSync(directory)
|
||||
.filter((file) => file.endsWith('.json'))
|
||||
.map((file) => file.replace(/\.json$/, ''))
|
||||
.sort();
|
||||
|
||||
expect([...REMOTE_CONTROL_TRANSLATIONS].sort()).toEqual(files);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Translation files the page loads from `apps/web/src/assets/i18n` (copied
|
||||
* by the build). Kept here instead of importing the app's `Language` enum:
|
||||
* a dependency on shared-interfaces pulls its build cycle into this app's
|
||||
* build. The spec checks the list against the files.
|
||||
*/
|
||||
export const REMOTE_CONTROL_TRANSLATIONS: readonly string[] = [
|
||||
'ar',
|
||||
'ary',
|
||||
'by',
|
||||
'de',
|
||||
'el',
|
||||
'en',
|
||||
'es',
|
||||
'fr',
|
||||
'hu',
|
||||
'it',
|
||||
'ja',
|
||||
'ko',
|
||||
'nl',
|
||||
'pl',
|
||||
'pt',
|
||||
'ru',
|
||||
'tr',
|
||||
'zh',
|
||||
'zhtw',
|
||||
];
|
||||
|
||||
const TRANSLATED_LANGUAGES: ReadonlySet<string> = new Set(
|
||||
REMOTE_CONTROL_TRANSLATIONS
|
||||
);
|
||||
const TRADITIONAL_CHINESE_SUBTAGS = new Set(['hant', 'tw', 'hk', 'mo']);
|
||||
|
||||
export interface RemoteControlLanguage {
|
||||
/** Translation file to load, an app language code such as `zhtw`. */
|
||||
translation: string;
|
||||
/** BCP 47 tag for `<html lang>`. */
|
||||
documentLanguage: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The remote page opens in the phone's browser, which never sees the desktop
|
||||
* app's language setting. It follows the browser's preferred languages
|
||||
* instead: the first one with a translation wins, otherwise English.
|
||||
*/
|
||||
export function resolveRemoteControlLanguage(
|
||||
browserLanguages: readonly string[]
|
||||
): RemoteControlLanguage {
|
||||
for (const tag of browserLanguages) {
|
||||
const translation = toTranslationCode(tag);
|
||||
if (translation) {
|
||||
return { translation, documentLanguage: tag };
|
||||
}
|
||||
}
|
||||
return { translation: 'en', documentLanguage: 'en' };
|
||||
}
|
||||
|
||||
function toTranslationCode(tag: string): string | null {
|
||||
const [language, ...subtags] = tag.trim().toLowerCase().split(/[-_]/);
|
||||
if (language === 'zh') {
|
||||
return subtags.some((subtag) => TRADITIONAL_CHINESE_SUBTAGS.has(subtag))
|
||||
? 'zhtw'
|
||||
: 'zh';
|
||||
}
|
||||
if (language === 'ar' && subtags.includes('ma')) {
|
||||
return 'ary';
|
||||
}
|
||||
if (language === 'be') {
|
||||
return 'by';
|
||||
}
|
||||
return TRANSLATED_LANGUAGES.has(language) ? language : null;
|
||||
}
|
||||
@@ -2,56 +2,78 @@
|
||||
<div class="remote__body">
|
||||
<div class="now-card">
|
||||
<div class="now-card__top">
|
||||
<div class="now-card__label">Now Playing</div>
|
||||
<div class="now-card__label">
|
||||
{{ 'REMOTE_CONTROL.NOW_PLAYING' | translate }}
|
||||
</div>
|
||||
<div class="now-card__statusline">
|
||||
<span class="status-dot" [class.is-active]="isReady"></span>
|
||||
<span>{{ isLoading ? 'Executing' : portalLabel }}</span>
|
||||
<span>{{
|
||||
(isLoading
|
||||
? 'REMOTE_CONTROL.EXECUTING'
|
||||
: portalLabelKey
|
||||
) | translate
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="now-card__channel">
|
||||
{{ status?.channelName || 'No live stream selected' }}
|
||||
{{
|
||||
status?.channelName ||
|
||||
('REMOTE_CONTROL.NO_STREAM' | translate)
|
||||
}}
|
||||
</div>
|
||||
<div class="now-card__meta">
|
||||
<span>
|
||||
@if (status?.channelNumber) {
|
||||
CH {{ status?.channelNumber }}
|
||||
} @else {
|
||||
CH -
|
||||
}
|
||||
{{ 'REMOTE_CONTROL.CHANNEL_ABBR' | translate }}
|
||||
{{ status?.channelNumber || '-' }}
|
||||
</span>
|
||||
<span>{{ status?.portal || 'unknown' }}</span>
|
||||
<span>{{
|
||||
hasKnownPortal
|
||||
? status?.portal
|
||||
: ('REMOTE_CONTROL.UNKNOWN_SOURCE' | translate)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="now-card__program">
|
||||
{{ status?.epgTitle || 'No current EPG program' }}
|
||||
{{
|
||||
status?.epgTitle ||
|
||||
('REMOTE_CONTROL.NO_PROGRAM' | translate)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error) {
|
||||
<div class="remote__error">
|
||||
{{ error }}
|
||||
{{ error | translate }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="cluster">
|
||||
<div class="cluster__title">Channel Navigation</div>
|
||||
<div class="cluster__title">
|
||||
{{ 'REMOTE_CONTROL.CHANNEL_NAVIGATION' | translate }}
|
||||
</div>
|
||||
<div class="touchpad__glass">
|
||||
<button
|
||||
class="touchpad__button touchpad__button--up"
|
||||
(click)="changeChannelUp()"
|
||||
[disabled]="isLoading"
|
||||
[class.is-loading]="isLoading"
|
||||
aria-label="Channel Up"
|
||||
[attr.aria-label]="'REMOTE_CONTROL.UP_CHANNEL' | translate"
|
||||
>
|
||||
<span>CH +</span>
|
||||
<span
|
||||
>{{ 'REMOTE_CONTROL.CHANNEL_ABBR' | translate }} +</span
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
class="touchpad__button touchpad__button--down"
|
||||
(click)="changeChannelDown()"
|
||||
[disabled]="isLoading"
|
||||
[class.is-loading]="isLoading"
|
||||
aria-label="Channel Down"
|
||||
[attr.aria-label]="
|
||||
'REMOTE_CONTROL.DOWN_CHANNEL' | translate
|
||||
"
|
||||
>
|
||||
<span>CH -</span>
|
||||
<span
|
||||
>{{ 'REMOTE_CONTROL.CHANNEL_ABBR' | translate }} -</span
|
||||
>
|
||||
</button>
|
||||
<div class="touchpad__center">
|
||||
<span>{{ status?.channelNumber || '-' }}</span>
|
||||
@@ -60,7 +82,9 @@
|
||||
</div>
|
||||
|
||||
<div class="cluster">
|
||||
<div class="cluster__title">Channel Number</div>
|
||||
<div class="cluster__title">
|
||||
{{ 'REMOTE_CONTROL.CHANNEL_NUMBER' | translate }}
|
||||
</div>
|
||||
<div class="digit-display">{{ numericInput || '----' }}</div>
|
||||
<div class="keypad">
|
||||
@for (digit of digits; track digit) {
|
||||
@@ -77,14 +101,14 @@
|
||||
(click)="backspaceDigit()"
|
||||
[disabled]="isLoading"
|
||||
>
|
||||
DEL
|
||||
{{ 'REMOTE_CONTROL.KEYPAD_DELETE' | translate }}
|
||||
</button>
|
||||
<button
|
||||
class="keypad__key keypad__key--alt"
|
||||
(click)="clearDigits()"
|
||||
[disabled]="isLoading"
|
||||
>
|
||||
CLR
|
||||
{{ 'REMOTE_CONTROL.KEYPAD_CLEAR' | translate }}
|
||||
</button>
|
||||
<button
|
||||
class="keypad__key keypad__key--go"
|
||||
@@ -97,32 +121,35 @@
|
||||
</div>
|
||||
|
||||
<div class="cluster">
|
||||
<div class="cluster__title">Volume</div>
|
||||
<div class="cluster__title">
|
||||
{{ 'REMOTE_CONTROL.VOLUME' | translate }}
|
||||
</div>
|
||||
<div class="volume-row">
|
||||
<button
|
||||
class="volume-row__button"
|
||||
(click)="volumeDown()"
|
||||
[disabled]="isLoading || !status?.supportsVolume"
|
||||
>
|
||||
VOL -
|
||||
{{ 'REMOTE_CONTROL.VOLUME_ABBR' | translate }} -
|
||||
</button>
|
||||
<button
|
||||
class="volume-row__button volume-row__button--mute"
|
||||
(click)="toggleMute()"
|
||||
[disabled]="isLoading || !status?.supportsVolume"
|
||||
>
|
||||
@if (status?.muted) {
|
||||
UNMUTE
|
||||
} @else {
|
||||
MUTE
|
||||
}
|
||||
{{
|
||||
(status?.muted
|
||||
? 'REMOTE_CONTROL.UNMUTE'
|
||||
: 'REMOTE_CONTROL.MUTE'
|
||||
) | translate
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
class="volume-row__button"
|
||||
(click)="volumeUp()"
|
||||
[disabled]="isLoading || !status?.supportsVolume"
|
||||
>
|
||||
VOL +
|
||||
{{ 'REMOTE_CONTROL.VOLUME_ABBR' | translate }} +
|
||||
</button>
|
||||
</div>
|
||||
<div class="volume-meter">
|
||||
@@ -135,22 +162,27 @@
|
||||
@if (status?.supportsVolume) {
|
||||
{{ volumePercent ?? 0 }}%
|
||||
} @else {
|
||||
Volume control unavailable for this player mode
|
||||
{{ 'REMOTE_CONTROL.VOLUME_UNAVAILABLE' | translate }}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="remote__footer">
|
||||
@if (isStatusLoading) {
|
||||
Syncing status...
|
||||
{{ 'REMOTE_CONTROL.SYNCING_STATUS' | translate }}
|
||||
} @else if (status?.updatedAt) {
|
||||
Updated
|
||||
{{
|
||||
$safeNavigationMigration(status?.updatedAt)
|
||||
| date: 'HH:mm:ss'
|
||||
'REMOTE_CONTROL.UPDATED_AT'
|
||||
| translate
|
||||
: {
|
||||
time:
|
||||
($safeNavigationMigration(
|
||||
status?.updatedAt
|
||||
) | date: 'HH:mm:ss'),
|
||||
}
|
||||
}}
|
||||
} @else {
|
||||
Waiting for status...
|
||||
{{ 'REMOTE_CONTROL.WAITING_FOR_STATUS' | translate }}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { ChangeDetectorRef } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { RemoteControlComponent } from './remote-control.component';
|
||||
import {
|
||||
RemoteControlService,
|
||||
RemoteControlStatus,
|
||||
} from './remote-control.service';
|
||||
|
||||
const GERMAN = {
|
||||
REMOTE_CONTROL: {
|
||||
NOW_PLAYING: 'Läuft gerade',
|
||||
SOURCE_XTREAM: 'Xtream live',
|
||||
WAITING_FOR_PLAYBACK: 'Warte auf Wiedergabe',
|
||||
UNKNOWN_SOURCE: 'unbekannt',
|
||||
NO_STREAM: 'Kein Livestream ausgewählt',
|
||||
NO_PROGRAM: 'Keine aktuelle Sendung',
|
||||
CHANNEL_ABBR: 'K',
|
||||
UP_CHANNEL: 'Kanal hoch',
|
||||
MUTE: 'STUMM',
|
||||
VOLUME_UNAVAILABLE: 'Lautstärke nicht verfügbar',
|
||||
WAITING_FOR_STATUS: 'Warte auf Status…',
|
||||
ERRORS: { CHANNEL_UP: 'Kanalwechsel fehlgeschlagen' },
|
||||
},
|
||||
};
|
||||
|
||||
describe('RemoteControlComponent', () => {
|
||||
let fixture: ComponentFixture<RemoteControlComponent>;
|
||||
let service: {
|
||||
getStatus: jest.Mock<Promise<RemoteControlStatus>>;
|
||||
channelUp: jest.Mock<Promise<void>>;
|
||||
};
|
||||
|
||||
const text = (selector: string): string =>
|
||||
(
|
||||
fixture.nativeElement.querySelector(selector) as HTMLElement | null
|
||||
)?.textContent?.trim() ?? '';
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers();
|
||||
service = {
|
||||
getStatus: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ portal: 'unknown', isLiveView: false }),
|
||||
channelUp: jest.fn().mockRejectedValue(new Error('offline')),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [RemoteControlComponent, TranslateModule.forRoot()],
|
||||
providers: [{ provide: RemoteControlService, useValue: service }],
|
||||
}).compileComponents();
|
||||
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('de', GERMAN);
|
||||
translate.use('de');
|
||||
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
// The first status answer stays pending through the first render.
|
||||
let answerFirstStatus!: (status: RemoteControlStatus) => void;
|
||||
service.getStatus.mockReturnValueOnce(
|
||||
new Promise((resolve) => (answerFirstStatus = resolve))
|
||||
);
|
||||
fixture = TestBed.createComponent(RemoteControlComponent);
|
||||
fixture.detectChanges();
|
||||
answerFirstStatus({ portal: 'unknown', isLiveView: false });
|
||||
await settle();
|
||||
});
|
||||
|
||||
/**
|
||||
* The component keeps plain fields, so in this zoneless test bed a
|
||||
* resolved status request neither schedules nor dirties a render (the
|
||||
* app itself runs Zone.js): flush it on the fake clock, mark the view,
|
||||
* then render.
|
||||
*/
|
||||
async function settle(milliseconds = 0): Promise<void> {
|
||||
await jest.advanceTimersByTimeAsync(milliseconds);
|
||||
fixture.debugElement.injector.get(ChangeDetectorRef).markForCheck();
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders its labels in the active language', () => {
|
||||
expect(text('.now-card__label')).toBe('Läuft gerade');
|
||||
expect(text('.now-card__statusline')).toBe('Warte auf Wiedergabe');
|
||||
expect(text('.now-card__channel')).toBe('Kein Livestream ausgewählt');
|
||||
expect(text('.now-card__meta')).toContain('unbekannt');
|
||||
expect(text('.now-card__program')).toBe('Keine aktuelle Sendung');
|
||||
expect(text('.volume-row__button--mute')).toBe('STUMM');
|
||||
expect(text('.volume-caption')).toBe('Lautstärke nicht verfügbar');
|
||||
expect(text('.remote__footer')).toBe('Warte auf Status…');
|
||||
expect(
|
||||
fixture.nativeElement
|
||||
.querySelector('.touchpad__button--up')
|
||||
.getAttribute('aria-label')
|
||||
).toBe('Kanal hoch');
|
||||
});
|
||||
|
||||
it('names the source of the current playback', async () => {
|
||||
service.getStatus.mockResolvedValue({
|
||||
portal: 'xtream',
|
||||
isLiveView: true,
|
||||
channelNumber: 7,
|
||||
});
|
||||
await settle(2000);
|
||||
|
||||
expect(text('.now-card__statusline')).toBe('Xtream live');
|
||||
expect(text('.now-card__meta')).toContain('K 7');
|
||||
expect(text('.now-card__meta')).toContain('xtream');
|
||||
});
|
||||
|
||||
it('shows a failed action in the active language', async () => {
|
||||
await fixture.componentInstance.changeChannelUp();
|
||||
await settle();
|
||||
|
||||
expect(text('.remote__error')).toBe('Kanalwechsel fehlgeschlagen');
|
||||
});
|
||||
});
|
||||
@@ -6,14 +6,21 @@ import {
|
||||
inject,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
RemoteControlService,
|
||||
RemoteControlStatus,
|
||||
} from './remote-control.service';
|
||||
|
||||
const PORTAL_LABEL_KEYS: Readonly<Record<string, string>> = {
|
||||
m3u: 'REMOTE_CONTROL.SOURCE_M3U',
|
||||
xtream: 'REMOTE_CONTROL.SOURCE_XTREAM',
|
||||
stalker: 'REMOTE_CONTROL.SOURCE_STALKER',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'lib-remote-control',
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, TranslatePipe],
|
||||
templateUrl: './remote-control.component.html',
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
@@ -25,6 +32,7 @@ export class RemoteControlComponent implements OnInit, OnDestroy {
|
||||
|
||||
isLoading = false;
|
||||
isStatusLoading = false;
|
||||
/** Translation key of the last failed action. */
|
||||
error: string | null = null;
|
||||
status: RemoteControlStatus | null = null;
|
||||
numericInput = '';
|
||||
@@ -47,14 +55,14 @@ export class RemoteControlComponent implements OnInit, OnDestroy {
|
||||
async changeChannelUp(): Promise<void> {
|
||||
await this.executeAction(
|
||||
() => this.remoteControlService.channelUp(),
|
||||
'Failed to change channel up'
|
||||
'REMOTE_CONTROL.ERRORS.CHANNEL_UP'
|
||||
);
|
||||
}
|
||||
|
||||
async changeChannelDown(): Promise<void> {
|
||||
await this.executeAction(
|
||||
() => this.remoteControlService.channelDown(),
|
||||
'Failed to change channel down'
|
||||
'REMOTE_CONTROL.ERRORS.CHANNEL_DOWN'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,7 +90,7 @@ export class RemoteControlComponent implements OnInit, OnDestroy {
|
||||
await this.executeAction(
|
||||
() =>
|
||||
this.remoteControlService.selectChannelByNumber(channelNumber),
|
||||
'Failed to switch by channel number'
|
||||
'REMOTE_CONTROL.ERRORS.SELECT_NUMBER'
|
||||
);
|
||||
this.clearDigits();
|
||||
}
|
||||
@@ -90,30 +98,33 @@ export class RemoteControlComponent implements OnInit, OnDestroy {
|
||||
async volumeUp(): Promise<void> {
|
||||
await this.executeAction(
|
||||
() => this.remoteControlService.volumeUp(),
|
||||
'Failed to increase volume'
|
||||
'REMOTE_CONTROL.ERRORS.VOLUME_UP'
|
||||
);
|
||||
}
|
||||
|
||||
async volumeDown(): Promise<void> {
|
||||
await this.executeAction(
|
||||
() => this.remoteControlService.volumeDown(),
|
||||
'Failed to decrease volume'
|
||||
'REMOTE_CONTROL.ERRORS.VOLUME_DOWN'
|
||||
);
|
||||
}
|
||||
|
||||
async toggleMute(): Promise<void> {
|
||||
await this.executeAction(
|
||||
() => this.remoteControlService.toggleMute(),
|
||||
'Failed to toggle mute'
|
||||
'REMOTE_CONTROL.ERRORS.TOGGLE_MUTE'
|
||||
);
|
||||
}
|
||||
|
||||
get portalLabel(): string {
|
||||
const portal = this.status?.portal ?? 'unknown';
|
||||
if (portal === 'm3u') return 'M3U Live';
|
||||
if (portal === 'xtream') return 'Xtream Live';
|
||||
if (portal === 'stalker') return 'Stalker ITV';
|
||||
return 'Waiting For Playback';
|
||||
get portalLabelKey(): string {
|
||||
return (
|
||||
PORTAL_LABEL_KEYS[this.status?.portal ?? ''] ??
|
||||
'REMOTE_CONTROL.WAITING_FOR_PLAYBACK'
|
||||
);
|
||||
}
|
||||
|
||||
get hasKnownPortal(): boolean {
|
||||
return !!this.status?.portal && this.status.portal !== 'unknown';
|
||||
}
|
||||
|
||||
get isReady(): boolean {
|
||||
@@ -136,7 +147,7 @@ export class RemoteControlComponent implements OnInit, OnDestroy {
|
||||
this.status = await this.remoteControlService.getStatus();
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
this.error = 'Failed to fetch remote status';
|
||||
this.error = 'REMOTE_CONTROL.ERRORS.STATUS';
|
||||
console.error(err);
|
||||
}
|
||||
} finally {
|
||||
@@ -148,7 +159,7 @@ export class RemoteControlComponent implements OnInit, OnDestroy {
|
||||
|
||||
private async executeAction(
|
||||
action: () => Promise<void>,
|
||||
errorMessage: string
|
||||
errorKey: string
|
||||
): Promise<void> {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
@@ -156,7 +167,7 @@ export class RemoteControlComponent implements OnInit, OnDestroy {
|
||||
await action();
|
||||
await this.refreshStatus(true);
|
||||
} catch (err) {
|
||||
this.error = errorMessage;
|
||||
this.error = errorKey;
|
||||
console.error(err);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
|
||||
Reference in new issue
Block a user