From 2b400cb81d2794a160e653afed2fb0760ca73cc1 Mon Sep 17 00:00:00 2001
From: 4gray <4gray@users.noreply.github.com>
Date: Sat, 10 Oct 2026 16:22:31 +0200
Subject: [PATCH] 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
* 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
* 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
* 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
* 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
* 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
* 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
* 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 .
Removes the dead getStatusMessage(), the unused favorites layout and the
translateWithFallback() helpers whose keys now exist.
Co-Authored-By: Claude Opus 5.5
* 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
* 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
---------
Co-authored-by: Claude Opus 5.5
---
.../i18n-remaining-labels-remote-control.md | 9 ++
apps/remote-control-web/src/app/app.ts | 17 ++-
.../src/app/services/electron.service.spec.ts | 4 +-
apps/web/src/app/services/electron.service.ts | 45 ++-----
...ate-release-notes-dialog.component.spec.ts | 19 +++
...p-update-release-notes-dialog.component.ts | 20 ++-
.../settings/settings-embedded-mpv.facade.ts | 9 +-
.../settings-epg-section.component.html | 4 +-
apps/web/src/assets/i18n/ar.json | 53 ++++++++
apps/web/src/assets/i18n/ary.json | 53 ++++++++
apps/web/src/assets/i18n/by.json | 53 ++++++++
apps/web/src/assets/i18n/de.json | 53 ++++++++
apps/web/src/assets/i18n/el.json | 53 ++++++++
apps/web/src/assets/i18n/en.json | 53 ++++++++
apps/web/src/assets/i18n/es.json | 53 ++++++++
apps/web/src/assets/i18n/fr.json | 53 ++++++++
apps/web/src/assets/i18n/hu.json | 53 ++++++++
apps/web/src/assets/i18n/it.json | 53 ++++++++
apps/web/src/assets/i18n/ja.json | 53 ++++++++
apps/web/src/assets/i18n/ko.json | 53 ++++++++
apps/web/src/assets/i18n/nl.json | 53 ++++++++
apps/web/src/assets/i18n/pl.json | 53 ++++++++
apps/web/src/assets/i18n/pt.json | 53 ++++++++
apps/web/src/assets/i18n/ru.json | 53 ++++++++
apps/web/src/assets/i18n/tr.json | 53 ++++++++
apps/web/src/assets/i18n/zh.json | 53 ++++++++
apps/web/src/assets/i18n/zhtw.json | 53 ++++++++
docs/architecture/portal-detail-navigation.md | 2 +-
docs/architecture/remote-control.md | 5 +
docs/architecture/validation-map.md | 4 +-
docs/architecture/zoneless-migration.md | 5 +-
.../add-playlist-dialog.component.ts | 10 +-
.../lib/file-upload/file-upload.component.ts | 3 +-
.../playlist-refresh-action.service.spec.ts | 4 +-
.../lib/playlist-refresh-action.service.ts | 35 ++---
.../playlist-switcher.component.html | 4 +-
.../playlist-switcher.component.spec.ts | 7 +-
.../playlist-switcher.component.ts | 5 +-
.../playlist-info.component.spec.ts | 4 +-
.../playlist-info/playlist-info.component.ts | 4 +-
.../playlist-item.component.html | 8 +-
.../playlist-item.component.spec.ts | 15 ++-
.../playlist-item/playlist-item.component.ts | 12 ++
.../recent-playlists.component.ts | 35 ++---
.../src/lib/recording-queue.component.html | 4 +-
.../src/lib/recording-queue.component.spec.ts | 1 +
libs/portal/shared/ui/src/index.ts | 1 -
.../content-card/content-card.component.html | 4 +-
.../content-card/content-card.component.ts | 8 +-
.../favorites-layout.component.html | 77 -----------
.../favorites-layout.component.scss | 120 -----------------
.../favorites-layout.component.ts | 110 ----------------
.../search-form/search-form.component.html | 2 +-
.../search-form/search-form.component.ts | 3 +-
.../search-layout.component.spec.ts | 5 +-
.../src/lib/stalker-series.adapters.ts | 7 +-
.../stalker-live-stream-layout.component.html | 4 +-
.../stalker-series-episode-titles.spec.ts | 43 ++++++
.../stalker-series-episode-titles.ts | 26 ++++
.../stalker-series-view.component.spec.ts | 6 +-
.../stalker-series-view.component.ts | 24 +++-
.../services/src/lib/portal-status.service.ts | 23 ----
.../window-controls.component.html | 12 +-
.../window-controls.component.spec.ts | 29 ++++-
.../window-controls.component.ts | 8 ++
.../epg-progress-panel.component.ts | 42 ++----
.../embedded-mpv-controls.adapter.ts | 4 +-
.../embedded-mpv-player.component.ts | 4 +-
...mbedded-mpv-session-controller.dpr.spec.ts | 2 +
...d-mpv-session-controller.lifecycle.spec.ts | 62 ++++++++-
...ed-mpv-session-controller.position.spec.ts | 2 +
.../embedded-mpv-session-controller.spec.ts | 2 +
.../embedded-mpv-session-controller.ts | 28 ++--
libs/ui/remote-control/src/index.ts | 1 +
.../remote-control-language.spec.ts | 59 +++++++++
.../remote-control/remote-control-language.ts | 72 +++++++++++
.../remote-control.component.html | 98 +++++++++-----
.../remote-control.component.spec.ts | 122 ++++++++++++++++++
.../remote-control.component.ts | 43 +++---
.../lib/rails/dashboard-hero.component.html | 8 +-
.../lib/rails/dashboard-rail.component.html | 4 +-
.../rails/dashboard-rail.component.spec.ts | 2 +-
tools/coverage/coverage-policy.json | 2 +-
tools/i18n/identical-en-baseline.json | 29 +++++
tools/performance/journey-baselines.json | 8 +-
85 files changed, 1822 insertions(+), 585 deletions(-)
create mode 100644 .changes/i18n-remaining-labels-remote-control.md
delete mode 100644 libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.html
delete mode 100644 libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.scss
delete mode 100644 libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts
create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-episode-titles.spec.ts
create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-episode-titles.ts
create mode 100644 libs/ui/remote-control/src/lib/remote-control/remote-control-language.spec.ts
create mode 100644 libs/ui/remote-control/src/lib/remote-control/remote-control-language.ts
create mode 100644 libs/ui/remote-control/src/lib/remote-control/remote-control.component.spec.ts
diff --git a/.changes/i18n-remaining-labels-remote-control.md b/.changes/i18n-remaining-labels-remote-control.md
new file mode 100644
index 000000000..cc3ac7f58
--- /dev/null
+++ b/.changes/i18n-remaining-labels-remote-control.md
@@ -0,0 +1,9 @@
+---
+type: fix
+area: i18n
+---
+
+The phone remote control page now follows the phone's language. More labels
+appear in the app's language too: the Windows and Linux window buttons, the
+playlist switcher, unnamed Stalker episodes, embedded MPV errors, the dashboard
+carousel and the recording chip.
diff --git a/apps/remote-control-web/src/app/app.ts b/apps/remote-control-web/src/app/app.ts
index 0e688e292..75ac5343b 100644
--- a/apps/remote-control-web/src/app/app.ts
+++ b/apps/remote-control-web/src/app/app.ts
@@ -4,7 +4,11 @@ import {
inject,
ChangeDetectionStrategy,
} from '@angular/core';
-import { RemoteControlComponent } from '@iptvnator/ui/remote-control';
+import { DOCUMENT } from '@angular/common';
+import {
+ RemoteControlComponent,
+ resolveRemoteControlLanguage,
+} from '@iptvnator/ui/remote-control';
import { TranslateService } from '@ngx-translate/core';
@Component({
@@ -17,10 +21,17 @@ import { TranslateService } from '@ngx-translate/core';
})
export class App implements OnInit {
private translate = inject(TranslateService);
+ private document = inject(DOCUMENT);
ngOnInit() {
- // Set default language
+ const navigator = this.document.defaultView?.navigator;
+ const language = resolveRemoteControlLanguage(
+ navigator?.languages?.length
+ ? navigator.languages
+ : [navigator?.language ?? 'en']
+ );
+ this.document.documentElement.lang = language.documentLanguage;
this.translate.setDefaultLang('en');
- this.translate.use('en');
+ this.translate.use(language.translation);
}
}
diff --git a/apps/web/src/app/services/electron.service.spec.ts b/apps/web/src/app/services/electron.service.spec.ts
index 2025c58c4..93bc14def 100644
--- a/apps/web/src/app/services/electron.service.spec.ts
+++ b/apps/web/src/app/services/electron.service.spec.ts
@@ -501,8 +501,8 @@ describe('ElectronService', () => {
await Promise.resolve();
expect(snackBar.open).toHaveBeenCalledWith(
- 'Certificate for this playlist host is invalid.',
- 'Trust host',
+ 'HOME.URL_UPLOAD.ERROR_INVALID_TLS',
+ 'HOME.URL_UPLOAD.TRUST_TLS_HOST',
{ duration: 10000 }
);
});
diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts
index e7fe05417..c3652150c 100644
--- a/apps/web/src/app/services/electron.service.ts
+++ b/apps/web/src/app/services/electron.service.ts
@@ -509,16 +509,14 @@ export class ElectronService extends DataService {
errorMessage
)
) {
- return this.translateWithFallback(
- 'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_NOT_FOUND',
- 'Playlist refresh failed. The local file is no longer available. Check the file path or re-import the playlist.'
+ return this.translateService.instant(
+ 'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_NOT_FOUND'
);
}
if (/(EACCES|EPERM|permission denied)/i.test(errorMessage)) {
- return this.translateWithFallback(
- 'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_ACCESS_ERROR',
- 'Playlist refresh failed. The app can no longer access the local file.'
+ return this.translateService.instant(
+ 'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_ACCESS_ERROR'
);
}
@@ -542,11 +540,6 @@ export class ElectronService extends DataService {
);
}
- private translateWithFallback(key: string, fallback: string): string {
- const translated = this.translateService.instant(key);
- return translated === key ? fallback : translated;
- }
-
private handlePlaylistSecurityError(
error: unknown,
retry: () => void
@@ -560,14 +553,8 @@ export class ElectronService extends DataService {
}
const ref = this.snackBar.open(
- this.translateWithFallback(
- 'HOME.URL_UPLOAD.ERROR_INVALID_TLS',
- 'Certificate for this playlist host is invalid.'
- ),
- this.translateWithFallback(
- 'HOME.URL_UPLOAD.TRUST_TLS_HOST',
- 'Trust host'
- ),
+ this.translateService.instant('HOME.URL_UPLOAD.ERROR_INVALID_TLS'),
+ this.translateService.instant('HOME.URL_UPLOAD.TRUST_TLS_HOST'),
{ duration: 10000 }
);
@@ -583,9 +570,8 @@ export class ElectronService extends DataService {
): void {
if (!host) {
this.snackBar.open(
- this.translateWithFallback(
- 'HOME.URL_UPLOAD.ERROR_TLS_HOST_UNKNOWN',
- 'Could not determine the playlist host. Please retry manually.'
+ this.translateService.instant(
+ 'HOME.URL_UPLOAD.ERROR_TLS_HOST_UNKNOWN'
),
this.translateService.instant('CLOSE'),
{ duration: 5000 }
@@ -594,17 +580,14 @@ export class ElectronService extends DataService {
}
this.dialogService.openConfirmDialog({
- title: this.translateWithFallback(
- 'HOME.URL_UPLOAD.TRUST_TLS_HOST_TITLE',
- 'Trust invalid certificate?'
+ title: this.translateService.instant(
+ 'HOME.URL_UPLOAD.TRUST_TLS_HOST_TITLE'
),
- message: this.translateWithFallback(
- 'HOME.URL_UPLOAD.TRUST_TLS_HOST_WARNING',
- 'Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.'
+ message: this.translateService.instant(
+ 'HOME.URL_UPLOAD.TRUST_TLS_HOST_WARNING'
),
- confirmLabel: this.translateWithFallback(
- 'HOME.URL_UPLOAD.TRUST_TLS_HOST',
- 'Trust host'
+ confirmLabel: this.translateService.instant(
+ 'HOME.URL_UPLOAD.TRUST_TLS_HOST'
),
width: '420px',
onConfirm: () => {
diff --git a/apps/web/src/app/settings/app-update-release-notes-dialog.component.spec.ts b/apps/web/src/app/settings/app-update-release-notes-dialog.component.spec.ts
index 7b19aa44d..6b6760200 100644
--- a/apps/web/src/app/settings/app-update-release-notes-dialog.component.spec.ts
+++ b/apps/web/src/app/settings/app-update-release-notes-dialog.component.spec.ts
@@ -170,6 +170,25 @@ describe('AppUpdateReleaseNotesDialogComponent', () => {
).not.toBeNull();
});
+ it('explains in the active language that the build has no release notes', async () => {
+ window.electron = {} as unknown as typeof window.electron;
+
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ const error = fixture.nativeElement.querySelector(
+ '[data-test-id="release-notes-error"]'
+ ) as HTMLElement;
+ expect(error.textContent).toContain(
+ 'SETTINGS.APP_UPDATE_RELEASE_NOTES_UNAVAILABLE'
+ );
+ expect(error.textContent).not.toContain('not available in this build');
+ expect(error.classList).not.toContain(
+ 'release-notes-dialog__error--failed'
+ );
+ });
+
it('offers the release list, not the earlier release, when paging fails', async () => {
fixture.detectChanges();
await fixture.whenStable();
diff --git a/apps/web/src/app/settings/app-update-release-notes-dialog.component.ts b/apps/web/src/app/settings/app-update-release-notes-dialog.component.ts
index afacf4eff..20e172624 100644
--- a/apps/web/src/app/settings/app-update-release-notes-dialog.component.ts
+++ b/apps/web/src/app/settings/app-update-release-notes-dialog.component.ts
@@ -36,8 +36,12 @@ const IPC_REJECTION_PREFIX =
/^Error invoking remote method '[^']*': (?:Error: )?/;
interface ReleaseNotesLoadError {
- /** `not-found`: GitHub has no release for that version; `failed`: anything else. */
- kind: 'not-found' | 'failed';
+ /**
+ * `not-found`: GitHub has no release for that version; `unavailable`: this
+ * build has no release-notes bridge; `failed`: anything else.
+ */
+ kind: 'not-found' | 'unavailable' | 'failed';
+ /** Raw detail shown under the generic failure; empty for the other kinds. */
message: string;
version?: string;
}
@@ -129,6 +133,13 @@ function decorateReleaseNotesHtml(html: string): string {
| translate: { version: loadError.version }
}}
+ } @else if (loadError.kind === 'unavailable') {
+
+ {{
+ 'SETTINGS.APP_UPDATE_RELEASE_NOTES_UNAVAILABLE'
+ | translate
+ }}
+
} @else {
{{
@@ -322,10 +333,7 @@ export class AppUpdateReleaseNotesDialogComponent implements OnInit {
direction?: ElectronBridgeAppUpdateReleaseNotesDirection
): Promise {
if (!window.electron?.getAppUpdateReleaseNotes) {
- this.error.set({
- kind: 'failed',
- message: 'Release notes are not available in this build.',
- });
+ this.error.set({ kind: 'unavailable', message: '' });
return;
}
diff --git a/apps/web/src/app/settings/settings-embedded-mpv.facade.ts b/apps/web/src/app/settings/settings-embedded-mpv.facade.ts
index 59c2cb632..e9182bb6a 100644
--- a/apps/web/src/app/settings/settings-embedded-mpv.facade.ts
+++ b/apps/web/src/app/settings/settings-embedded-mpv.facade.ts
@@ -43,12 +43,10 @@ export class SettingsEmbeddedMpvFacade {
* for while the page is open, so the option appears without reopening it.
*/
async load(): Promise {
+ // Settings only reads `supported`; an unsupported answer carries no
+ // reason because nothing on this page displays one.
if (!this.runtime.isElectron) {
- this.support.set({
- supported: false,
- platform: 'web',
- reason: 'Embedded MPV requires the Electron desktop build.',
- });
+ this.support.set({ supported: false, platform: 'web' });
return;
}
@@ -56,7 +54,6 @@ export class SettingsEmbeddedMpvFacade {
this.support.set({
supported: false,
platform: window.electron.platform,
- reason: 'Embedded MPV support is not available in this build.',
});
return;
}
diff --git a/apps/web/src/app/settings/settings-epg-section.component.html b/apps/web/src/app/settings/settings-epg-section.component.html
index 9b73ca1c9..2de86cc28 100644
--- a/apps/web/src/app/settings/settings-epg-section.component.html
+++ b/apps/web/src/app/settings/settings-epg-section.component.html
@@ -79,7 +79,9 @@
aria-labelledby="epg-offset-label"
data-test-id="epg-offset-minutes"
/>
- min
+ {{
+ 'SETTINGS.EPG_OFFSET_UNIT' | translate
+ }}
`
+ to it.
- Polls `/status` every 2s
- Uses action wrapper to refresh status after command execution
diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md
index 3c49fd812..9c7ab34ab 100644
--- a/docs/architecture/validation-map.md
+++ b/docs/architecture/validation-map.md
@@ -184,8 +184,8 @@ coverage do not decrease.
`apps/website` is an Astro marketing site. Its useful signal is a successful
static build plus targeted output checks, not a merged code coverage percentage.
-Projects with a test target but no specs, such as `remote-control-web` and
-`remote-control` today, should not be in Tier A until focused specs exist.
+Projects with a test target but no specs, such as `remote-control-web`
+today, should not be in Tier A until focused specs exist.
For local coverage inspection:
diff --git a/docs/architecture/zoneless-migration.md b/docs/architecture/zoneless-migration.md
index 674840f43..7af3e090c 100644
--- a/docs/architecture/zoneless-migration.md
+++ b/docs/architecture/zoneless-migration.md
@@ -60,7 +60,7 @@ must be ticked here.
## Eager components
-66 production files, 67 components (`epg-progress-panel.component.ts` holds
+65 production files, 66 components (`epg-progress-panel.component.ts` holds
two). Tick an entry by deleting `changeDetection: ChangeDetectionStrategy.Eager`
(or setting OnPush) once its template state is signals, signal inputs or
explicitly marked. The guard spec compares the unticked entries with the
@@ -146,9 +146,8 @@ picker), so each marks itself on the form's `events` through
`libs/playlist/m3u/feature-player` (2) goes with the playback PR.
-### libs/portal (9)
+### libs/portal (8)
-- [x] `libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts`
- [x] `libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts`
- [x] `libs/portal/shared/ui/src/lib/components/search-form/search-form.component.ts`
- [x] `libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts`
diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts
index 88ded3ec6..6c71550ba 100644
--- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts
+++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts
@@ -260,9 +260,13 @@ export class AddPlaylistDialogComponent {
rejectFile(filename: string): void {
this.snackBar.open(
- this.translateService.instant('HOME.FILE_UPLOAD.REJECTED', {
- filename,
- })
+ filename
+ ? this.translateService.instant('HOME.FILE_UPLOAD.REJECTED', {
+ filename,
+ })
+ : this.translateService.instant(
+ 'HOME.FILE_UPLOAD.REJECTED_UNNAMED'
+ )
);
}
diff --git a/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts b/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts
index d621b9410..c9b089ed7 100644
--- a/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts
+++ b/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts
@@ -118,7 +118,8 @@ export class FileUploadComponent {
}
if (result.reason !== 'cancelled') {
- this.fileRejected.emit('selected playlist');
+ // The native dialog does not report a file name.
+ this.fileRejected.emit('');
}
}
}
diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts
index d74708295..81f84ec5f 100644
--- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts
+++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts
@@ -414,8 +414,8 @@ describe('PlaylistRefreshActionService', () => {
await Promise.resolve();
expect(snackBar.open).toHaveBeenCalledWith(
- 'Certificate for this playlist host is invalid.',
- 'Trust host',
+ 'HOME.URL_UPLOAD.ERROR_INVALID_TLS',
+ 'HOME.URL_UPLOAD.TRUST_TLS_HOST',
{ duration: 10000 }
);
expect(store.dispatch).toHaveBeenCalledWith(
diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts
index 016f22630..26e298bb9 100644
--- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts
+++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts
@@ -244,14 +244,8 @@ export class PlaylistRefreshActionService {
}
const ref = this.snackBar.open(
- this.translateWithFallback(
- 'HOME.URL_UPLOAD.ERROR_INVALID_TLS',
- 'Certificate for this playlist host is invalid.'
- ),
- this.translateWithFallback(
- 'HOME.URL_UPLOAD.TRUST_TLS_HOST',
- 'Trust host'
- ),
+ this.translate.instant('HOME.URL_UPLOAD.ERROR_INVALID_TLS'),
+ this.translate.instant('HOME.URL_UPLOAD.TRUST_TLS_HOST'),
{ duration: 10000 }
);
ref.onAction().subscribe(() => {
@@ -266,9 +260,8 @@ export class PlaylistRefreshActionService {
): void {
if (!host) {
this.snackBar.open(
- this.translateWithFallback(
- 'HOME.URL_UPLOAD.ERROR_TLS_HOST_UNKNOWN',
- 'Could not determine the playlist host. Please retry manually.'
+ this.translate.instant(
+ 'HOME.URL_UPLOAD.ERROR_TLS_HOST_UNKNOWN'
),
this.translate.instant('CLOSE'),
{ duration: 5000 }
@@ -277,17 +270,14 @@ export class PlaylistRefreshActionService {
}
this.dialogService.openConfirmDialog({
- title: this.translateWithFallback(
- 'HOME.URL_UPLOAD.TRUST_TLS_HOST_TITLE',
- 'Trust invalid certificate?'
+ title: this.translate.instant(
+ 'HOME.URL_UPLOAD.TRUST_TLS_HOST_TITLE'
),
- message: this.translateWithFallback(
- 'HOME.URL_UPLOAD.TRUST_TLS_HOST_WARNING',
- 'Only continue if you trust this playlist host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.'
+ message: this.translate.instant(
+ 'HOME.URL_UPLOAD.TRUST_TLS_HOST_WARNING'
),
- confirmLabel: this.translateWithFallback(
- 'HOME.URL_UPLOAD.TRUST_TLS_HOST',
- 'Trust host'
+ confirmLabel: this.translate.instant(
+ 'HOME.URL_UPLOAD.TRUST_TLS_HOST'
),
width: '420px',
onConfirm: () => {
@@ -310,11 +300,6 @@ export class PlaylistRefreshActionService {
});
}
- private translateWithFallback(key: string, fallback: string): string {
- const translated = this.translate.instant(key);
- return translated === key ? fallback : translated;
- }
-
private updateRefreshPreparationFromEvent(
playlistId: string,
operationId: string,
diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html
index 737cbaa19..5d073931a 100644
--- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html
+++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html
@@ -192,7 +192,9 @@
[matMenuTriggerFor]="itemMenu"
[matMenuTriggerData]="{ playlist: playlist }"
(click)="$event.stopPropagation()"
- aria-label="Playlist actions"
+ [attr.aria-label]="
+ 'HOME.PLAYLISTS.PLAYLIST_ACTIONS' | translate
+ "
>
more_vert
diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts
index 5da4cc381..dcf69c4f4 100644
--- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts
+++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts
@@ -418,11 +418,16 @@ describe('PlaylistSwitcherComponent', () => {
expect(component.displayTitle()).toBe('Cinema Xtream');
+ const translate = TestBed.inject(TranslateService);
+ translate.setTranslation('en', {
+ HOME: { PLAYLISTS: { SELECT_PLAYLIST: 'Pick a source' } },
+ });
+ translate.use('en');
resolvedPlaylistIdSignal.set(null);
activePlaylistSignal.set(null);
fixture.detectChanges();
- expect(component.displayTitle()).toBe('Select playlist');
+ expect(component.displayTitle()).toBe('Pick a source');
});
it('delegates playlist deletion and removes the source after confirmation', async () => {
diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts
index c016a87c0..69c5a7233 100644
--- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts
+++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts
@@ -161,8 +161,9 @@ export class PlaylistSwitcherComponent {
);
});
readonly displayTitle = computed(() => {
+ this.languageTick();
if (!this.activePlaylistId()) {
- return 'Select playlist';
+ return this.translate.instant('HOME.PLAYLISTS.SELECT_PLAYLIST');
}
return (
@@ -171,7 +172,7 @@ export class PlaylistSwitcherComponent {
this.activePlaylist()?.filename ||
this.activePlaylist()?.url ||
this.activePlaylist()?.portalUrl ||
- 'Untitled playlist'
+ this.translate.instant('HOME.PLAYLISTS.UNTITLED_PLAYLIST')
);
});
diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts
index d713b589b..a9c8a71af 100644
--- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts
+++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts
@@ -19,6 +19,8 @@ import {
STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS,
} from './stalker-playlist-connection-editor.token';
+const EXPORT_FILE_TYPE = 'HOME.PLAYLISTS.INFO_DIALOG.EXPORT_FILE_TYPE';
+
describe('PlaylistInfoComponent', () => {
let component: PlaylistInfoComponent;
let fixture: ComponentFixture;
@@ -326,7 +328,7 @@ describe('PlaylistInfoComponent', () => {
expect(window.electron.saveFileDialog).toHaveBeenCalledWith(
'My Playlist.m3u8',
- [{ name: 'Playlist', extensions: ['m3u8', 'm3u'] }]
+ [{ name: EXPORT_FILE_TYPE, extensions: ['m3u8', 'm3u'] }]
);
expect(window.electron.writeFile).toHaveBeenCalledWith(
'/tmp/export.m3u8',
diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts
index 35a010ded..0f68e88ba 100644
--- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts
+++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts
@@ -820,7 +820,9 @@ export class PlaylistInfoComponent {
`${this.playlist.title || 'exported'}.m3u8`,
[
{
- name: 'Playlist',
+ name: this.translate.instant(
+ 'HOME.PLAYLISTS.INFO_DIALOG.EXPORT_FILE_TYPE'
+ ),
extensions: ['m3u8', 'm3u'],
},
]
diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html
index f97919a39..2ad7b14bc 100644
--- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html
+++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html
@@ -61,11 +61,15 @@
}
}
@if (showsPortalStatusDot) {
+ @let statusLabel =
+ 'HOME.PLAYLISTS.PORTAL_STATUS'
+ | translate
+ : { status: (portalStatusLabelKey() | translate) };
} @else if (showsAutoRefresh) {
{
username: 'demo',
password: 'secret',
};
+ const translate = TestBed.inject(TranslateService);
+ // Not English text, so the label must come from the keys.
+ translate.setTranslation('en', {
+ HOME: {
+ PLAYLISTS: {
+ PORTAL_STATUS: 'Portalstatus: {{status}}',
+ PORTAL_STATUS_ACTIVE: 'aktiv',
+ },
+ },
+ });
+ translate.use('en');
// The fixture renders on its own: a forced detectChanges() after the
// await would hide a status that does not schedule a render.
fixture.autoDetectChanges();
@@ -219,7 +230,7 @@ describe('PlaylistItemComponent', () => {
'.status-dot'
);
expect(statusDot?.getAttribute('aria-label')).toBe(
- 'Portal status: active'
+ 'Portalstatus: aktiv'
);
});
diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts
index 8cbe29077..cc1cfbe13 100644
--- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts
+++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts
@@ -36,6 +36,15 @@ import {
} from '@iptvnator/services';
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
+/** Translation keys for the status word in the portal status tooltip. */
+const PORTAL_STATUS_LABEL_KEYS: Record = {
+ active: 'HOME.PLAYLISTS.PORTAL_STATUS_ACTIVE',
+ inactive: 'HOME.PLAYLISTS.PORTAL_STATUS_INACTIVE',
+ expired: 'HOME.PLAYLISTS.PORTAL_STATUS_EXPIRED',
+ unavailable: 'HOME.PLAYLISTS.PORTAL_STATUS_UNAVAILABLE',
+ checking: 'HOME.PLAYLISTS.PORTAL_STATUS_CHECKING',
+};
+
@Component({
selector: 'app-playlist-item',
templateUrl: './playlist-item.component.html',
@@ -72,6 +81,9 @@ export class PlaylistItemComponent implements OnInit {
readonly cancelBusyActionClicked = output();
readonly portalStatus = signal('unavailable');
+ readonly portalStatusLabelKey = computed(
+ () => PORTAL_STATUS_LABEL_KEYS[this.portalStatus()]
+ );
private readonly portalStatusService = inject(PortalStatusService);
readonly runtime = inject(RuntimeCapabilitiesService);
readonly sourceIcons = SOURCE_TYPE_ICONS;
diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts
index 3d7af684f..674a3b366 100644
--- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts
+++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts
@@ -652,25 +652,17 @@ export class RecentPlaylistsComponent {
private translatePlaylistRefreshPhase(phase?: string): string {
switch (phase) {
case 'fetching':
- return this.translateWithFallback(
- 'HOME.PLAYLISTS.REFRESH_FETCHING',
- 'Fetching playlist...'
+ return this.translate.instant(
+ 'HOME.PLAYLISTS.REFRESH_FETCHING'
);
case 'reading-file':
- return this.translateWithFallback(
- 'HOME.PLAYLISTS.REFRESH_READING_FILE',
- 'Reading playlist file...'
+ return this.translate.instant(
+ 'HOME.PLAYLISTS.REFRESH_READING_FILE'
);
case 'parsing':
- return this.translateWithFallback(
- 'HOME.PLAYLISTS.REFRESH_PARSING',
- 'Parsing playlist...'
- );
+ return this.translate.instant('HOME.PLAYLISTS.REFRESH_PARSING');
case 'saving':
- return this.translateWithFallback(
- 'HOME.PLAYLISTS.REFRESH_SAVING',
- 'Saving playlist...'
- );
+ return this.translate.instant('HOME.PLAYLISTS.REFRESH_SAVING');
default:
return this.translate.instant('HOME.PLAYLISTS.REFRESH');
}
@@ -686,25 +678,18 @@ export class RecentPlaylistsComponent {
);
if (/(ENOENT|no such file or directory|not found)/i.test(message)) {
- return this.translateWithFallback(
- 'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_NOT_FOUND',
- 'Playlist refresh failed. The local file is no longer available. Check the file path or re-import the playlist.'
+ return this.translate.instant(
+ 'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_NOT_FOUND'
);
}
if (/(EACCES|EPERM|permission denied)/i.test(message)) {
- return this.translateWithFallback(
- 'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_ACCESS_ERROR',
- 'Playlist refresh failed. The app can no longer access the local file.'
+ return this.translate.instant(
+ 'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_ACCESS_ERROR'
);
}
}
return this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR');
}
-
- private translateWithFallback(key: string, fallback: string): string {
- const translated = this.translate.instant(key);
- return translated === key ? fallback : translated;
- }
}
diff --git a/libs/portal/downloads/feature/src/lib/recording-queue.component.html b/libs/portal/downloads/feature/src/lib/recording-queue.component.html
index 69903c10e..275850944 100644
--- a/libs/portal/downloads/feature/src/lib/recording-queue.component.html
+++ b/libs/portal/downloads/feature/src/lib/recording-queue.component.html
@@ -72,7 +72,9 @@
class="recording-queue__rec-dot"
aria-hidden="true"
>
- REC {{ elapsedLabel(row.item) }}
+ {{
+ 'DOWNLOADS.RECORDING_CHIP' | translate
+ }} {{ elapsedLabel(row.item) }}
}
close
diff --git a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts
index 3e8316ae9..6e8d7c7cb 100644
--- a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts
+++ b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts
@@ -13,7 +13,7 @@ import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { normalizeDateLocale } from '@iptvnator/pipes';
-import { TranslateService } from '@ngx-translate/core';
+import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import { CoverTitlesService } from '../../cover-titles/cover-titles.service';
@@ -23,7 +23,7 @@ const LABELLED_CONTENT_TYPES: ReadonlySet = new Set(['live', 'radio']);
@Component({
selector: 'app-content-card',
standalone: true,
- imports: [DatePipe, MatIcon, MatIconButton, MatTooltip],
+ imports: [DatePipe, MatIcon, MatIconButton, MatTooltip, TranslatePipe],
templateUrl: './content-card.component.html',
styleUrl: './content-card.component.scss',
host: { '[class.content-card--posters-only]': 'postersOnly()' },
@@ -52,8 +52,8 @@ export class ContentCardComponent {
/** Whether to show the remove button */
readonly showRemoveButton = input(false);
- /** Tooltip text for the remove button */
- readonly removeTooltip = input('Remove');
+ /** Tooltip text for the remove button; empty means the generic "Remove" */
+ readonly removeTooltip = input('');
/** Whether to show placeholder when no poster */
readonly showPlaceholder = input(true);
diff --git a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.html b/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.html
deleted file mode 100644
index 3abdc71c3..000000000
--- a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.html
+++ /dev/null
@@ -1,77 +0,0 @@
-@if (!isWorkspaceLayout) {
-
-}
-
-
-
-
- @if (favorites().length) {
-
- @for (item of favorites(); track $index) {
-
- }
-
- } @else {
-
-
{{ emptyIcon() }}
-
No items in the selected category
-
- }
-
-
diff --git a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.scss b/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.scss
deleted file mode 100644
index 232c9e901..000000000
--- a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.scss
+++ /dev/null
@@ -1,120 +0,0 @@
-@use '@angular/material' as mat;
-@use '../../styles/content-grid' as grid;
-
-:host {
- display: flex;
- flex-direction: row;
- overflow: hidden;
- height: 100%;
- width: 100%;
-}
-
-.main-content {
- flex: 1;
- display: flex;
- flex-direction: column;
- min-height: 0; // Important for proper flex behavior
-}
-
-.content-header {
- padding: 8px 12px;
- min-height: 48px;
- background: var(--app-widget-header-bg, var(--mat-sys-surface-container-high));
- border-bottom: 1px solid var(--app-separator);
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
-}
-
-.category-meta {
- min-width: 0;
- flex: 1;
- display: flex;
- align-items: baseline;
- gap: 8px;
-}
-
-.category-title {
- margin: 0;
- font-size: 0.94rem;
- font-weight: 600;
- letter-spacing: -0.01em;
- color: var(--mat-sys-on-surface);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-// Informational badge — primary-tinted pill with definition ring
-.category-subtitle {
- white-space: nowrap;
- flex-shrink: 0;
- font-size: 0.70rem;
- font-weight: 700;
- letter-spacing: 0.05em;
- text-transform: capitalize;
- line-height: 1;
- color: var(--mat-sys-primary);
- background: color-mix(in srgb, var(--mat-sys-primary) 10%, var(--mat-sys-surface-container-high));
- padding: 4px 10px;
- border-radius: 999px;
- box-shadow:
- inset 0 0 0 1px color-mix(in srgb, var(--mat-sys-primary) 30%, transparent),
- 0 1px 3px rgba(0, 0, 0, 0.18);
-}
-
-.scrollable-content {
- flex: 1;
- overflow-y: auto;
-}
-
-.favorites-container {
- padding: 20px;
-}
-
-.header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 20px;
-}
-
-.items-grid {
- @include grid.content-grid;
- padding: 16px;
- width: 100%;
- box-sizing: border-box;
- margin: 0;
-}
-
-.no-items {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: 2rem;
-
- mat-icon {
- font-size: 48px;
- width: 48px;
- height: 48px;
- margin-bottom: 1rem;
- }
-
- p {
- font-size: 0.85rem;
- margin: 0;
- opacity: 0.65;
- }
-}
-
-.sidebar-header {
- display: flex;
- flex-direction: row;
- align-items: center;
-
- button {
- @include mat.form-field-density(-5);
- }
-}
diff --git a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts b/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts
deleted file mode 100644
index ea7a5b251..000000000
--- a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import {
- Component,
- inject,
- input,
- output,
- ChangeDetectionStrategy,
-} from '@angular/core';
-import { MatIconButton } from '@angular/material/button';
-import { MatIcon } from '@angular/material/icon';
-import { MatTooltip } from '@angular/material/tooltip';
-import { ActivatedRoute } from '@angular/router';
-import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
-import { isWorkspaceLayoutRoute } from '@iptvnator/portal/shared/util';
-import { TranslatePipe } from '@ngx-translate/core';
-import { ResizableDirective } from '@iptvnator/ui/components';
-import {
- StalkerPortalItem,
- XtreamCategory,
-} from '@iptvnator/shared/interfaces';
-import { CategoryViewComponent } from '../category-view/category-view.component';
-import { ContentCardComponent } from '../content-card/content-card.component';
-
-interface FavoriteLayoutSelection {
- readonly category_id?: string | number;
-}
-
-export interface FavoriteLayoutItem {
- readonly added_at?: string | number;
- readonly content_id?: number;
- readonly category_id?: string | number;
- readonly cover?: string;
- readonly details?: {
- readonly info?: {
- readonly name?: string;
- };
- };
- readonly id?: string | number;
- readonly name?: string;
- readonly o_name?: string;
- readonly playlist_id?: string;
- readonly playlist_name?: string;
- readonly poster_url?: string;
- readonly source?: 'xtream' | 'stalker' | 'm3u';
- readonly stalker_item?: StalkerPortalItem;
- readonly stream_icon?: string;
- readonly title?: string;
- readonly type?: string;
- readonly viewed_at?: string;
- readonly xtream_id?: string | number;
-}
-
-@Component({
- selector: 'app-favorites-layout',
- templateUrl: './favorites-layout.component.html',
- styleUrls: [
- './favorites-layout.component.scss',
- '../../styles/portal-sidebar.scss',
- ],
- changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [
- CategoryViewComponent,
- ContentCardComponent,
- MatIcon,
- MatIconButton,
- PlaylistSwitcherComponent,
- ResizableDirective,
- TranslatePipe,
- MatTooltip,
- ],
-})
-export class FavoritesLayoutComponent {
- readonly isWorkspaceLayout = isWorkspaceLayoutRoute(inject(ActivatedRoute));
-
- readonly categories = input([]);
- readonly favorites = input([]);
- readonly playlistSubtitle = input('');
- readonly playlistTitle = input('Playlist');
- readonly selectedCategoryId = input('movie');
- readonly titleTranslationString = input('CHANNELS.FAVORITES');
- readonly removeTooltip = input('');
- readonly emptyIcon = input('favorite_border');
- readonly showHeaderAction = input(false);
- readonly headerActionIcon = input('delete_sweep');
- readonly headerActionTooltip = input('');
-
- readonly categoryClicked = output();
- readonly removeFavorite = output();
- readonly openItem = output();
- readonly headerActionClicked = output();
-
- setCategoryId(categoryId: string | number | undefined) {
- if (categoryId === undefined) {
- return;
- }
-
- this.categoryClicked.emit({ category_id: categoryId });
- }
-
- removeFromFavorites(item: FavoriteLayoutItem) {
- this.removeFavorite.emit(item);
- }
-
- openFavorite(item: FavoriteLayoutItem) {
- this.openItem.emit(item);
- }
-
- onHeaderActionClick() {
- this.headerActionClicked.emit();
- }
-}
diff --git a/libs/portal/shared/ui/src/lib/components/search-form/search-form.component.html b/libs/portal/shared/ui/src/lib/components/search-form/search-form.component.html
index 2ebb8f1dd..d869383b9 100644
--- a/libs/portal/shared/ui/src/lib/components/search-form/search-form.component.html
+++ b/libs/portal/shared/ui/src/lib/components/search-form/search-form.component.html
@@ -6,7 +6,7 @@
type="search"
spellcheck="false"
autocomplete="off"
- [placeholder]="placeholder"
+ [placeholder]="placeholder || ('PORTALS.SEARCH' | translate)"
[ngModel]="searchTerm()"
(ngModelChange)="onSearchTermChange()"
/>
diff --git a/libs/portal/shared/ui/src/lib/components/search-form/search-form.component.ts b/libs/portal/shared/ui/src/lib/components/search-form/search-form.component.ts
index 30bffdd1f..d9c9f114f 100644
--- a/libs/portal/shared/ui/src/lib/components/search-form/search-form.component.ts
+++ b/libs/portal/shared/ui/src/lib/components/search-form/search-form.component.ts
@@ -89,7 +89,8 @@ export interface SearchFilter {
})
export class SearchFormComponent {
@ViewChild('searchInput') searchInput!: ElementRef;
- @Input() placeholder = 'Search';
+ /** Empty means the generic "Search" label */
+ @Input() placeholder = '';
@Input() filters: SearchFilters = {};
@Input() filterConfig: SearchFilter[] = [];
@Input() singleSelection = false; // Add this new input
diff --git a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.spec.ts b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.spec.ts
index 7e8a44d51..fd85abf23 100644
--- a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.spec.ts
+++ b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.spec.ts
@@ -1,6 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
-import { TranslatePipe } from '@ngx-translate/core';
+import { TranslateModule, TranslatePipe } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
import { SearchLayoutComponent } from './search-layout.component';
@@ -10,7 +10,8 @@ describe('SearchLayoutComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
- imports: [SearchLayoutComponent],
+ // The search form inside renders the real pipe.
+ imports: [SearchLayoutComponent, TranslateModule.forRoot()],
})
.overrideComponent(SearchLayoutComponent, {
remove: { imports: [TranslatePipe] },
diff --git a/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts b/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts
index d77aac580..681f71706 100644
--- a/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts
+++ b/libs/portal/stalker/data-access/src/lib/stalker-series.adapters.ts
@@ -237,7 +237,8 @@ export function mapVodSeriesEpisodes(
...createBaseEpisode(
trackingId,
episodeNum,
- episode.name || `Episode ${episodeNum}`,
+ // No English stand-in: the view labels unnamed episodes.
+ episode.name || '',
'mpg',
'vod-series',
seasonNum,
@@ -286,7 +287,9 @@ export function mapRegularSeriesEpisodes(
...createBaseEpisode(
trackingId,
episodeNum,
- `Episode ${episodeNum}`,
+ // Regular series episodes carry no names; the view
+ // labels them.
+ '',
'',
'regular-series',
Number(seasonKey),
diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html
index aad851fa8..cf7dedeca 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html
+++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html
@@ -202,7 +202,7 @@
controlledChannel()?.name ?? activePlaybackTitle()
"
[channelLogo]="activePlaybackArtwork()"
- [sourceLabel]="'Stalker Portal'"
+ [sourceLabel]="'WORKSPACE.SHELL.STALKER_PORTAL' | translate"
[archivePlaybackAvailable]="false"
[archiveDays]="0"
[loading]="isLoadingEpg()"
@@ -225,7 +225,7 @@
controlledChannel()?.name ?? activePlaybackTitle()
"
[channelLogo]="activePlaybackArtwork()"
- [sourceLabel]="'Stalker Portal'"
+ [sourceLabel]="'WORKSPACE.SHELL.STALKER_PORTAL' | translate"
[archivePlaybackAvailable]="false"
[archiveDays]="0"
[loading]="isLoadingEpg()"
diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-episode-titles.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-episode-titles.spec.ts
new file mode 100644
index 000000000..5dc9233b8
--- /dev/null
+++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-episode-titles.spec.ts
@@ -0,0 +1,43 @@
+import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
+import { withEpisodeTitleFallback } from './stalker-series-episode-titles';
+
+const episode = (
+ episodeNum: number,
+ title: string
+): XtreamSerieEpisode & { originalCmd: string } => ({
+ id: String(episodeNum),
+ episode_num: episodeNum,
+ title,
+ container_extension: '',
+ info: {},
+ custom_sid: 'regular-series',
+ added: '',
+ season: 1,
+ direct_source: '',
+ originalCmd: `cmd-${episodeNum}`,
+});
+
+describe('withEpisodeTitleFallback', () => {
+ const label = (episodeNumber: number) => `Folge ${episodeNumber}`;
+
+ it('labels unnamed episodes and keeps the provider fields', () => {
+ const result = withEpisodeTitleFallback(
+ { '1': [episode(1, ''), episode(2, ' ')] },
+ label
+ );
+
+ expect(result['1'].map((item) => item.title)).toEqual([
+ 'Folge 1',
+ 'Folge 2',
+ ]);
+ expect(result['1'][0]).toMatchObject({ originalCmd: 'cmd-1' });
+ });
+
+ it('leaves named episodes untouched', () => {
+ const named = episode(3, 'A real name');
+
+ const result = withEpisodeTitleFallback({ '2': [named] }, label);
+
+ expect(result['2'][0]).toBe(named);
+ });
+});
diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-episode-titles.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-episode-titles.ts
new file mode 100644
index 000000000..6b5917a7a
--- /dev/null
+++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-episode-titles.ts
@@ -0,0 +1,26 @@
+import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
+
+/**
+ * Portals often send episodes without a name. Data access leaves those titles
+ * empty because it cannot translate, TMDB fills the ones it knows, and the
+ * rest get the label for "Episode N" here. Run it after the TMDB overlay: the
+ * overlay only replaces empty or generic English titles, so a translated
+ * label would keep the real TMDB name out.
+ */
+export function withEpisodeTitleFallback(
+ seasons: Record,
+ episodeLabel: (episodeNumber: number) => string
+): Record {
+ const labelled: Record = {};
+ for (const [seasonKey, episodes] of Object.entries(seasons)) {
+ labelled[seasonKey] = episodes.map((episode) =>
+ episode.title?.trim()
+ ? episode
+ : {
+ ...episode,
+ title: episodeLabel(Number(episode.episode_num)),
+ }
+ );
+ }
+ return labelled;
+}
diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts
index e27488ffc..080cfc78b 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts
+++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts
@@ -57,7 +57,11 @@ describe('StalkerSeriesViewComponent', () => {
expect(quickStartButton).not.toBeNull();
expect(quickStartButton?.textContent).toContain('XTREAM.PLAY');
- expect(quickStartButton?.textContent).toContain('S01E01 · Episode 1');
+ // Regular series episodes have no names: the view labels them
+ // (the harness translates a key to itself).
+ expect(quickStartButton?.textContent).toContain(
+ 'S01E01 · PORTALS.DETAIL.EPISODE_NUMBER'
+ );
quickStartButton?.click();
await fixture.whenStable();
diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts
index 737afa05c..a84fb33e4 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts
+++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts
@@ -12,9 +12,12 @@ import {
viewChild,
} from '@angular/core';
import { Location } from '@angular/common';
+import { toSignal } from '@angular/core/rxjs-interop';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
+import { startWith } from 'rxjs';
+import { withEpisodeTitleFallback } from './stalker-series-episode-titles';
import { FavoritesButtonComponent } from '../stalker-favorites-button/stalker-favorites-button.component';
import {
findStalkerResumeLazySeason,
@@ -183,6 +186,10 @@ export class StalkerSeriesViewComponent implements OnDestroy {
);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
+ private readonly languageTick = toSignal(
+ this.translateService.onLangChange.pipe(startWith(null)),
+ { initialValue: null }
+ );
readonly backClicked = output();
private readonly logger = createLogger('StalkerSeriesView');
readonly inlinePlayback = signal(null);
@@ -719,7 +726,14 @@ export class StalkerSeriesViewComponent implements OnDestroy {
// Overlay lazily fetched TMDB episode data (real names,
// overviews, stills) — a no-op while nothing is fetched
- return this.tmdbSeasons.overlay(base, displayItem?.info?.tmdb_id);
+ const overlaid = this.tmdbSeasons.overlay(
+ base,
+ displayItem?.info?.tmdb_id
+ );
+ this.languageTick();
+ return withEpisodeTitleFallback(overlaid, (episode) =>
+ this.episodeLabel(episode)
+ );
}
);
@@ -961,7 +975,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
? `/media/file_${mappedEpisode.originalId ?? ''}.mpg`
: mappedEpisode.originalCmd;
const title = isLazyVod
- ? `${item.info.name} - ${mappedEpisode.title || `Episode ${episodeState.episodeNumber}`}`
+ ? `${item.info.name} - ${mappedEpisode.title || this.episodeLabel(episodeState.episodeNumber)}`
: item.info.name;
const trackingId = Number(mappedEpisode.id);
const startTime = fromStart
@@ -1072,6 +1086,12 @@ export class StalkerSeriesViewComponent implements OnDestroy {
return toStalkerSeriesId(id);
}
+ private episodeLabel(episode: number): string {
+ return this.translateService.instant('PORTALS.DETAIL.EPISODE_NUMBER', {
+ episode,
+ });
+ }
+
closeInlinePlayer(): void {
this.seriesPlaybackRequestGeneration += 1;
this.inlinePlayback.set(null);
diff --git a/libs/services/src/lib/portal-status.service.ts b/libs/services/src/lib/portal-status.service.ts
index ca6b58128..6eb62a68d 100644
--- a/libs/services/src/lib/portal-status.service.ts
+++ b/libs/services/src/lib/portal-status.service.ts
@@ -342,29 +342,6 @@ export class PortalStatusService {
return { status: 'unavailable', expiresAtSeconds: null };
}
- /**
- * Gets a user-friendly message based on the portal status
- *
- * @param status The portal status
- * @returns A message describing the status
- */
- getStatusMessage(status: PortalStatus | null): string {
- switch (status) {
- case 'active':
- return 'Connection successful! Portal is active.';
- case 'inactive':
- return 'Portal is inactive.';
- case 'expired':
- return 'Portal subscription has expired.';
- case 'unavailable':
- return 'Could not connect to the portal.';
- case 'checking':
- return 'Checking portal status…';
- default:
- return '';
- }
- }
-
/**
* Gets a CSS class name based on the portal status
*
diff --git a/libs/ui/components/src/lib/window-controls/window-controls.component.html b/libs/ui/components/src/lib/window-controls/window-controls.component.html
index a98c9279d..3a6ec15ae 100644
--- a/libs/ui/components/src/lib/window-controls/window-controls.component.html
+++ b/libs/ui/components/src/lib/window-controls/window-controls.component.html
@@ -2,8 +2,8 @@
type="button"
class="window-control"
data-test-id="window-minimize"
- aria-label="Minimize"
- title="Minimize"
+ [attr.aria-label]="'WORKSPACE.SHELL.WINDOW_MINIMIZE' | translate"
+ [attr.title]="'WORKSPACE.SHELL.WINDOW_MINIMIZE' | translate"
(click)="onMinimize()"
>
@@ -15,8 +15,8 @@
type="button"
class="window-control"
data-test-id="window-maximize"
- [attr.aria-label]="isMaximized() ? 'Restore' : 'Maximize'"
- [attr.title]="isMaximized() ? 'Restore' : 'Maximize'"
+ [attr.aria-label]="maximizeLabelKey() | translate"
+ [attr.title]="maximizeLabelKey() | translate"
(click)="onToggleMaximize()"
>
@if (isMaximized()) {
@@ -45,8 +45,8 @@
type="button"
class="window-control close"
data-test-id="window-close"
- aria-label="Close"
- title="Close"
+ [attr.aria-label]="'WORKSPACE.SHELL.WINDOW_CLOSE' | translate"
+ [attr.title]="'WORKSPACE.SHELL.WINDOW_CLOSE' | translate"
(click)="onClose()"
>
diff --git a/libs/ui/components/src/lib/window-controls/window-controls.component.spec.ts b/libs/ui/components/src/lib/window-controls/window-controls.component.spec.ts
index c9f152259..af4c5bf98 100644
--- a/libs/ui/components/src/lib/window-controls/window-controls.component.spec.ts
+++ b/libs/ui/components/src/lib/window-controls/window-controls.component.spec.ts
@@ -1,5 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ElectronBridgeWindowState } from '@iptvnator/shared/interfaces';
+import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { WindowControlsComponent } from './window-controls.component';
describe('WindowControlsComponent', () => {
@@ -42,9 +43,22 @@ describe('WindowControlsComponent', () => {
(window as { electron?: unknown }).electron = electronMock;
await TestBed.configureTestingModule({
- imports: [WindowControlsComponent],
+ imports: [WindowControlsComponent, TranslateModule.forRoot()],
}).compileComponents();
+ const translate = TestBed.inject(TranslateService);
+ translate.setTranslation('de', {
+ WORKSPACE: {
+ SHELL: {
+ WINDOW_MINIMIZE: 'Minimieren',
+ WINDOW_MAXIMIZE: 'Maximieren',
+ WINDOW_RESTORE: 'Wiederherstellen',
+ WINDOW_CLOSE: 'Schließen',
+ },
+ },
+ });
+ translate.use('de');
+
fixture = TestBed.createComponent(WindowControlsComponent);
fixture.detectChanges();
});
@@ -62,6 +76,17 @@ describe('WindowControlsComponent', () => {
expect(electronMock.getWindowState).toHaveBeenCalled();
});
+ it('labels every button in the active language', () => {
+ for (const [testId, label] of [
+ ['window-minimize', 'Minimieren'],
+ ['window-maximize', 'Maximieren'],
+ ['window-close', 'Schließen'],
+ ]) {
+ expect(query(testId)?.getAttribute('aria-label')).toBe(label);
+ expect(query(testId)?.getAttribute('title')).toBe(label);
+ }
+ });
+
it('calls the bridge methods when the buttons are clicked', () => {
query('window-minimize')?.click();
expect(electronMock.minimizeWindow).toHaveBeenCalledTimes(1);
@@ -80,7 +105,7 @@ describe('WindowControlsComponent', () => {
expect(query('window-restore-glyph')).not.toBeNull();
expect(query('window-maximize-glyph')).toBeNull();
expect(query('window-maximize')?.getAttribute('aria-label')).toBe(
- 'Restore'
+ 'Wiederherstellen'
);
});
diff --git a/libs/ui/components/src/lib/window-controls/window-controls.component.ts b/libs/ui/components/src/lib/window-controls/window-controls.component.ts
index dee3e0d77..c0708db1b 100644
--- a/libs/ui/components/src/lib/window-controls/window-controls.component.ts
+++ b/libs/ui/components/src/lib/window-controls/window-controls.component.ts
@@ -2,12 +2,14 @@ import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
+ computed,
DestroyRef,
ElementRef,
inject,
signal,
} from '@angular/core';
import { ElectronBridgeWindowState } from '@iptvnator/shared/interfaces';
+import { TranslatePipe } from '@ngx-translate/core';
/**
* Renderer-drawn window-management buttons (minimize / maximize-restore /
@@ -25,6 +27,7 @@ import { ElectronBridgeWindowState } from '@iptvnator/shared/interfaces';
*/
@Component({
selector: 'app-window-controls',
+ imports: [TranslatePipe],
templateUrl: './window-controls.component.html',
styleUrl: './window-controls.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -36,6 +39,11 @@ import { ElectronBridgeWindowState } from '@iptvnator/shared/interfaces';
export class WindowControlsComponent implements AfterViewInit {
readonly isMaximized = signal(false);
readonly isFullScreen = signal(false);
+ readonly maximizeLabelKey = computed(() =>
+ this.isMaximized()
+ ? 'WORKSPACE.SHELL.WINDOW_RESTORE'
+ : 'WORKSPACE.SHELL.WINDOW_MAXIMIZE'
+ );
private readonly host =
inject>(ElementRef).nativeElement;
diff --git a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts
index a1011039b..ae0751ff4 100644
--- a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts
+++ b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts
@@ -158,13 +158,10 @@ export class EpgProgressPanelComponent {
item.errorCode ===
ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked
) {
- return this.translateWithFallback(
- 'EPG.ALLOW_PRIVATE_SOURCE',
- 'Allow source'
- );
+ return this.translate.instant('EPG.ALLOW_PRIVATE_SOURCE');
}
- return this.translateWithFallback('EPG.TRUST_TLS_HOST', 'Trust host');
+ return this.translate.instant('EPG.TRUST_TLS_HOST');
}
confirmTrust(item: EpgImportProgress): void {
@@ -174,17 +171,14 @@ export class EpgProgressPanelComponent {
) {
this.openTrustDialog(
{
- title: this.translateWithFallback(
- 'EPG.ALLOW_PRIVATE_SOURCE_TITLE',
- 'Allow private-network EPG source?'
+ title: this.translate.instant(
+ 'EPG.ALLOW_PRIVATE_SOURCE_TITLE'
),
- message: this.translateWithFallback(
- 'EPG.ALLOW_PRIVATE_SOURCE_WARNING',
- 'Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.'
+ message: this.translate.instant(
+ 'EPG.ALLOW_PRIVATE_SOURCE_WARNING'
),
- confirmLabel: this.translateWithFallback(
- 'EPG.ALLOW_PRIVATE_SOURCE',
- 'Allow source'
+ confirmLabel: this.translate.instant(
+ 'EPG.ALLOW_PRIVATE_SOURCE'
),
},
() => {
@@ -198,18 +192,9 @@ export class EpgProgressPanelComponent {
this.openTrustDialog(
{
- title: this.translateWithFallback(
- 'EPG.TRUST_TLS_HOST_TITLE',
- 'Trust invalid certificate?'
- ),
- message: this.translateWithFallback(
- 'EPG.TRUST_TLS_HOST_WARNING',
- 'Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.'
- ),
- confirmLabel: this.translateWithFallback(
- 'EPG.TRUST_TLS_HOST',
- 'Trust host'
- ),
+ title: this.translate.instant('EPG.TRUST_TLS_HOST_TITLE'),
+ message: this.translate.instant('EPG.TRUST_TLS_HOST_WARNING'),
+ confirmLabel: this.translate.instant('EPG.TRUST_TLS_HOST'),
},
() => {
void this.epgProgress.trustInsecureTlsHostAndRetry(
@@ -236,9 +221,4 @@ export class EpgProgressPanelComponent {
}
});
}
-
- private translateWithFallback(key: string, fallback: string): string {
- const translated = this.translate.instant(key);
- return translated === key ? fallback : translated;
- }
}
diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts
index 2d3c2294f..b74174879 100644
--- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts
+++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts
@@ -315,7 +315,7 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
return {
status: 'idle',
statusMessage:
- support.reason ??
+ support.reason ||
this.translate.instant('EMBEDDED_MPV.PLAYER.NOT_AVAILABLE'),
};
}
@@ -372,7 +372,7 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
return {
status: 'error',
statusMessage:
- session.error ??
+ session.error ||
this.translate.instant(
'EMBEDDED_MPV.PLAYER.PLAYBACK_FAILED'
),
diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts
index 17e6b7452..ff75c3a0f 100644
--- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts
+++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts
@@ -224,7 +224,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
}
if (session?.status === 'error') {
return (
- session.error ??
+ session.error ||
this.translate.instant('EMBEDDED_MPV.PLAYER.PLAYBACK_FAILED')
);
}
@@ -238,7 +238,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
}
if (!this.isSupported()) {
return (
- this.support()?.reason ??
+ this.support()?.reason ||
this.translate.instant('EMBEDDED_MPV.PLAYER.NOT_AVAILABLE')
);
}
diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.dpr.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.dpr.spec.ts
index 8a4058089..285e2fd6c 100644
--- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.dpr.spec.ts
+++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.dpr.spec.ts
@@ -3,6 +3,7 @@ import {
EmbeddedMpvSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
+import { TranslateModule } from '@ngx-translate/core';
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
/**
@@ -105,6 +106,7 @@ describe('EmbeddedMpvSessionController devicePixelRatio watch', () => {
});
TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot()],
providers: [EmbeddedMpvSessionController],
});
});
diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.lifecycle.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.lifecycle.spec.ts
index c9cb22bf6..0ac36fea9 100644
--- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.lifecycle.spec.ts
+++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.lifecycle.spec.ts
@@ -5,6 +5,7 @@ import {
EmbeddedMpvSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
+import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => {
@@ -68,6 +69,7 @@ describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => {
});
TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot()],
providers: [EmbeddedMpvSessionController],
});
});
@@ -83,10 +85,10 @@ describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => {
setBridge(undefined);
const controller = TestBed.inject(EmbeddedMpvSessionController);
+ // No English reason: the player falls back to its translated text.
expect(controller.support()).toEqual({
supported: false,
platform: 'unknown',
- reason: 'Embedded MPV requires the Electron desktop build.',
});
});
@@ -208,6 +210,64 @@ describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => {
expect(electron.createEmbeddedMpvSession).not.toHaveBeenCalled();
});
+ describe('its own failure messages', () => {
+ beforeEach(() => {
+ const translate = TestBed.inject(TranslateService);
+ translate.setTranslation('de', {
+ EMBEDDED_MPV: {
+ PLAYER: {
+ NOT_AVAILABLE: 'Eingebettetes MPV ist nicht verfügbar.',
+ FRAME_VIEW_FAILED:
+ 'Die MPV-Videoansicht konnte nicht starten.',
+ },
+ },
+ });
+ translate.use('de');
+ });
+
+ it('are translated when prepare reports unsupported without a reason', async () => {
+ electron.prepareEmbeddedMpv.mockResolvedValueOnce({
+ supported: false,
+ platform: 'darwin',
+ });
+ const controller = TestBed.inject(EmbeddedMpvSessionController);
+
+ controller.startSession(createHost(), createPlayback(), 0.5);
+ await waitFor(
+ () => controller.session()?.status === 'error',
+ 'error session to be set'
+ );
+
+ expect(controller.session()?.error).toBe(
+ 'Eingebettetes MPV ist nicht verfügbar.'
+ );
+ });
+
+ it('are translated when the frame view fails to attach', async () => {
+ const frameCopySupport = createSupport('frame-copy');
+ electron.getEmbeddedMpvSupport.mockResolvedValueOnce(
+ frameCopySupport
+ );
+ electron.prepareEmbeddedMpv.mockResolvedValueOnce(frameCopySupport);
+ electron.attachEmbeddedMpvFrameView.mockResolvedValueOnce(false);
+ const controller = TestBed.inject(EmbeddedMpvSessionController);
+
+ await waitFor(
+ () => controller.support() === frameCopySupport,
+ 'frame-copy support probe to resolve'
+ );
+ controller.startSession(createHost(), createPlayback(), 0.5);
+ await waitFor(
+ () => controller.session()?.status === 'error',
+ 'error session to be set'
+ );
+
+ expect(controller.session()?.error).toBe(
+ 'Die MPV-Videoansicht konnte nicht starten.'
+ );
+ });
+ });
+
it('retry clears session state, stall flag, and bumps the retry token', () => {
const controller = TestBed.inject(EmbeddedMpvSessionController);
controller.sessionId.set('mpv-1');
diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.position.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.position.spec.ts
index cc875c1c1..9b5ac2e09 100644
--- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.position.spec.ts
+++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.position.spec.ts
@@ -4,6 +4,7 @@ import {
EmbeddedMpvSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
+import { TranslateModule } from '@ngx-translate/core';
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
/**
@@ -71,6 +72,7 @@ describe('EmbeddedMpvSessionController position drift poll', () => {
});
TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot()],
providers: [EmbeddedMpvSessionController],
});
});
diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts
index 7ef5883c7..c1e2dfda4 100644
--- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts
+++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.spec.ts
@@ -3,6 +3,7 @@ import {
EmbeddedMpvSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
+import { TranslateModule } from '@ngx-translate/core';
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
describe('EmbeddedMpvSessionController', () => {
@@ -100,6 +101,7 @@ describe('EmbeddedMpvSessionController', () => {
});
TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot()],
providers: [EmbeddedMpvSessionController],
});
});
diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts
index f7ea0c166..8986bd828 100644
--- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts
+++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts
@@ -17,6 +17,7 @@ import {
ResolvedPortalPlayback,
watchEmbeddedMpvSupport,
} from '@iptvnator/shared/interfaces';
+import { TranslateService } from '@ngx-translate/core';
import { EmbeddedMpvCommandRunner } from './embedded-mpv-command-runner';
import { measureBounds } from './embedded-mpv-format.utils';
import {
@@ -80,6 +81,7 @@ export class EmbeddedMpvSessionController {
private readonly destroyRef = inject(DestroyRef);
private readonly zone = inject(NgZone);
+ private readonly translate = inject(TranslateService);
private readonly unsubscribeSessionUpdate?: () => void;
private boundsProvider: EmbeddedMpvBoundsProvider = (host) =>
@@ -100,10 +102,10 @@ export class EmbeddedMpvSessionController {
if (typeof window.electron?.getEmbeddedMpvSupport === 'function') {
stopSupportWatch = this.watchSupport();
} else {
+ // No reason: the player shows its translated "not available".
this.support.set({
supported: false,
platform: typeof window === 'undefined' ? 'web' : 'unknown',
- reason: 'Embedded MPV requires the Electron desktop build.',
});
}
@@ -247,9 +249,7 @@ export class EmbeddedMpvSessionController {
const electron = this.getElectronBridge();
if (!electron) {
- throw new Error(
- 'Embedded MPV requires the Electron desktop build.'
- );
+ throw new Error(this.notAvailableMessage());
}
const prepared = await electron.prepareEmbeddedMpv?.();
@@ -257,10 +257,7 @@ export class EmbeddedMpvSessionController {
return;
}
if (prepared && !prepared.supported) {
- throw new Error(
- prepared.reason ??
- 'Embedded MPV is not available in this environment.'
- );
+ throw new Error(prepared.reason || this.notAvailableMessage());
}
const created = await electron.createEmbeddedMpvSession(
@@ -298,7 +295,9 @@ export class EmbeddedMpvSessionController {
.disposeEmbeddedMpvSession(created.id)
.catch(() => undefined);
throw new Error(
- 'The embedded MPV frame view failed to initialize.'
+ this.translate.instant(
+ 'EMBEDDED_MPV.PLAYER.FRAME_VIEW_FAILED'
+ )
);
}
}
@@ -394,9 +393,10 @@ export class EmbeddedMpvSessionController {
() => {
const electron = this.getElectronBridge();
if (!electron?.getEmbeddedMpvSupport) {
- throw new Error(
- 'Embedded MPV requires the Electron desktop build.'
- );
+ return Promise.resolve({
+ supported: false,
+ platform: electron?.platform ?? 'unknown',
+ });
}
return electron.getEmbeddedMpvSupport();
},
@@ -414,4 +414,8 @@ export class EmbeddedMpvSessionController {
private getElectronBridge(): ElectronBridge | undefined {
return window.electron;
}
+
+ private notAvailableMessage(): string {
+ return this.translate.instant('EMBEDDED_MPV.PLAYER.NOT_AVAILABLE');
+ }
}
diff --git a/libs/ui/remote-control/src/index.ts b/libs/ui/remote-control/src/index.ts
index a2f5fa64c..ed27e5c00 100644
--- a/libs/ui/remote-control/src/index.ts
+++ b/libs/ui/remote-control/src/index.ts
@@ -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';
diff --git a/libs/ui/remote-control/src/lib/remote-control/remote-control-language.spec.ts b/libs/ui/remote-control/src/lib/remote-control/remote-control-language.spec.ts
new file mode 100644
index 000000000..0a72b4483
--- /dev/null
+++ b/libs/ui/remote-control/src/lib/remote-control/remote-control-language.spec.ts
@@ -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);
+ });
+});
diff --git a/libs/ui/remote-control/src/lib/remote-control/remote-control-language.ts b/libs/ui/remote-control/src/lib/remote-control/remote-control-language.ts
new file mode 100644
index 000000000..2637cb288
--- /dev/null
+++ b/libs/ui/remote-control/src/lib/remote-control/remote-control-language.ts
@@ -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 = 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 ``. */
+ 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;
+}
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
index 341d11d3a..42689b55e 100644
--- 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
@@ -2,56 +2,78 @@
-
Now Playing
+
+ {{ 'REMOTE_CONTROL.NOW_PLAYING' | translate }}
+
- {{ isLoading ? 'Executing' : portalLabel }}
+ {{
+ (isLoading
+ ? 'REMOTE_CONTROL.EXECUTING'
+ : portalLabelKey
+ ) | translate
+ }}
- {{ status?.channelName || 'No live stream selected' }}
+ {{
+ status?.channelName ||
+ ('REMOTE_CONTROL.NO_STREAM' | translate)
+ }}
- @if (status?.channelNumber) {
- CH {{ status?.channelNumber }}
- } @else {
- CH -
- }
+ {{ 'REMOTE_CONTROL.CHANNEL_ABBR' | translate }}
+ {{ status?.channelNumber || '-' }}
- {{ status?.portal || 'unknown' }}
+ {{
+ hasKnownPortal
+ ? status?.portal
+ : ('REMOTE_CONTROL.UNKNOWN_SOURCE' | translate)
+ }}
- {{ status?.epgTitle || 'No current EPG program' }}
+ {{
+ status?.epgTitle ||
+ ('REMOTE_CONTROL.NO_PROGRAM' | translate)
+ }}
@if (error) {
- {{ error }}
+ {{ error | translate }}
}
-
Channel Navigation
+
+ {{ 'REMOTE_CONTROL.CHANNEL_NAVIGATION' | translate }}
+
- CH +
+ {{ 'REMOTE_CONTROL.CHANNEL_ABBR' | translate }} +
- CH -
+ {{ 'REMOTE_CONTROL.CHANNEL_ABBR' | translate }} -
{{ status?.channelNumber || '-' }}
@@ -60,7 +82,9 @@
-
Channel Number
+
+ {{ 'REMOTE_CONTROL.CHANNEL_NUMBER' | translate }}
+
{{ numericInput || '----' }}
@for (digit of digits; track digit) {
@@ -77,14 +101,14 @@
(click)="backspaceDigit()"
[disabled]="isLoading"
>
- DEL
+ {{ 'REMOTE_CONTROL.KEYPAD_DELETE' | translate }}
- CLR
+ {{ 'REMOTE_CONTROL.KEYPAD_CLEAR' | translate }}
-
Volume
+
+ {{ 'REMOTE_CONTROL.VOLUME' | translate }}
+
- VOL -
+ {{ 'REMOTE_CONTROL.VOLUME_ABBR' | translate }} -
- @if (status?.muted) {
- UNMUTE
- } @else {
- MUTE
- }
+ {{
+ (status?.muted
+ ? 'REMOTE_CONTROL.UNMUTE'
+ : 'REMOTE_CONTROL.MUTE'
+ ) | translate
+ }}
- VOL +
+ {{ 'REMOTE_CONTROL.VOLUME_ABBR' | translate }} +
@@ -135,22 +162,27 @@
@if (status?.supportsVolume) {
{{ volumePercent ?? 0 }}%
} @else {
- Volume control unavailable for this player mode
+ {{ 'REMOTE_CONTROL.VOLUME_UNAVAILABLE' | translate }}
}
diff --git a/libs/ui/remote-control/src/lib/remote-control/remote-control.component.spec.ts b/libs/ui/remote-control/src/lib/remote-control/remote-control.component.spec.ts
new file mode 100644
index 000000000..33f1b4199
--- /dev/null
+++ b/libs/ui/remote-control/src/lib/remote-control/remote-control.component.spec.ts
@@ -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
;
+ let service: {
+ getStatus: jest.Mock>;
+ channelUp: jest.Mock>;
+ };
+
+ 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 {
+ 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');
+ });
+});
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
index a9a5560d7..4baa276a1 100644
--- 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
@@ -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> = {
+ 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 {
await this.executeAction(
() => this.remoteControlService.channelUp(),
- 'Failed to change channel up'
+ 'REMOTE_CONTROL.ERRORS.CHANNEL_UP'
);
}
async changeChannelDown(): Promise {
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 {
await this.executeAction(
() => this.remoteControlService.volumeUp(),
- 'Failed to increase volume'
+ 'REMOTE_CONTROL.ERRORS.VOLUME_UP'
);
}
async volumeDown(): Promise {
await this.executeAction(
() => this.remoteControlService.volumeDown(),
- 'Failed to decrease volume'
+ 'REMOTE_CONTROL.ERRORS.VOLUME_DOWN'
);
}
async toggleMute(): Promise {
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,
- errorMessage: string
+ errorKey: string
): Promise {
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;
diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html
index 3a5358d4f..221c43394 100644
--- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html
+++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html
@@ -2,7 +2,9 @@
chevron_left
@@ -312,7 +312,7 @@
class="rail__chev rail__chev--right"
[class.rail__chev--visible]="canScrollRight()"
(click)="scrollBy(1)"
- aria-label="Scroll right"
+ [attr.aria-label]="'PORTALS.SCROLL_RIGHT' | translate"
tabindex="-1"
>
chevron_right
diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts
index 4c6fcc43e..f21d965f9 100644
--- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts
+++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts
@@ -11,7 +11,7 @@ import {
describe('DashboardRailComponent', () => {
const createComponent = async (stripCountryPrefix: boolean) => {
await TestBed.configureTestingModule({
- imports: [DashboardRailComponent],
+ imports: [DashboardRailComponent, TranslateModule.forRoot()],
providers: [
{
provide: SettingsStore,
diff --git a/tools/coverage/coverage-policy.json b/tools/coverage/coverage-policy.json
index ef1d82de8..c237d495e 100644
--- a/tools/coverage/coverage-policy.json
+++ b/tools/coverage/coverage-policy.json
@@ -374,7 +374,7 @@
{
"name": "remote-control",
"root": "libs/ui/remote-control",
- "reason": "No unit specs yet; validate through remote-control flows or add focused tests when behavior changes directly."
+ "reason": "Small phone UI: focused specs cover its labels and language choice; validate the rest through remote-control flows."
},
{
"name": "shared-portals",
diff --git a/tools/i18n/identical-en-baseline.json b/tools/i18n/identical-en-baseline.json
index b5b9ca8b6..f9b01b15a 100644
--- a/tools/i18n/identical-en-baseline.json
+++ b/tools/i18n/identical-en-baseline.json
@@ -327,6 +327,7 @@
"CHANNELS.DETAILS_DIALOG.HTTP_USER_AGENT": "User-Agent",
"CHANNELS.DETAILS_DIALOG.NAME": "Name",
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
+ "DOWNLOADS.RECORDING_CHIP": "REC",
"DOWNLOADS.TITLE": "Downloads",
"EMBEDDED_MPV.PLAYER.AUDIO_TRACK_FALLBACK": "Audio {{index}}",
"EMBEDDED_MPV.PLAYER.LIVE_BADGE": "LIVE",
@@ -574,6 +575,7 @@
"CHANNELS.DETAILS_DIALOG.TVG_ID": "TVG ID",
"CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec",
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
+ "DOWNLOADS.RECORDING_CHIP": "REC",
"EMBEDDED_MPV.PLAYER.AUDIO_TRACK_FALLBACK": "Audio {{index}}",
"EMBEDDED_MPV.PLAYER.STATS_AUDIO": "Audio",
"EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
@@ -621,11 +623,13 @@
"PORTALS.MULTI_SOURCE.FILTER_HD": "HD+",
"PORTALS.SERIES": "Series",
"PORTALS.SIDEBAR.SERIES": "Series",
+ "REMOTE_CONTROL.VOLUME_ABBR": "VOL",
"SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"SETTINGS.APP_UPDATE_CHANNEL_STABLE": "Stable",
"SETTINGS.BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"SETTINGS.CARD_XTREAM": "Xtream",
"SETTINGS.CHIP_EXPERIMENTAL": "Experimental",
+ "SETTINGS.EPG_OFFSET_UNIT": "min",
"SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"SETTINGS.EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"SETTINGS.EPG_SOURCE_LIST_TITLE": "Source list",
@@ -705,6 +709,7 @@
"CHANNELS.DETAILS_DIALOG.TVG_ID": "TVG ID",
"CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec",
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
+ "DOWNLOADS.RECORDING_CHIP": "REC",
"EMBEDDED_MPV.PLAYER.AUDIO_TRACK_FALLBACK": "Audio {{index}}",
"EMBEDDED_MPV.PLAYER.PAUSE": "Pause",
"EMBEDDED_MPV.PLAYER.QUALITY_AUTO": "Auto",
@@ -759,6 +764,9 @@
"PORTALS.MULTI_SOURCE.SOURCE": "Source",
"PORTALS.MULTI_SOURCE.SOURCES": "Sources",
"PORTALS.PAGE": "Page",
+ "REMOTE_CONTROL.CHANNEL_ABBR": "CH",
+ "REMOTE_CONTROL.VOLUME": "Volume",
+ "REMOTE_CONTROL.VOLUME_ABBR": "VOL",
"SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"SETTINGS.APP_UPDATE_CHANNEL_STABLE": "Stable",
"SETTINGS.BROWSE_EPG_FILE": "Choose a local XMLTV file…",
@@ -766,6 +774,7 @@
"SETTINGS.CARD_GUIDE": "Guide",
"SETTINGS.CARD_SOURCES": "Sources",
"SETTINGS.CARD_XTREAM": "Xtream",
+ "SETTINGS.EPG_OFFSET_UNIT": "min",
"SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"SETTINGS.EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"SETTINGS.EPG_SOURCE_LIST_TITLE": "Source list",
@@ -844,6 +853,7 @@
"DOWNLOADS.ACTION_FAILED": "Action failed",
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
"DOWNLOADS.PAUSE": "Pause",
+ "DOWNLOADS.RECORDING_CHIP": "REC",
"DOWNLOADS.RESUME": "Resume",
"DOWNLOADS.STATUS.PAUSED": "Paused",
"EMBEDDED_MPV.PLAYER.BACK": "Back",
@@ -978,6 +988,7 @@
"CHANNELS.DETAILS_DIALOG.TVG_ID": "TVG ID",
"CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec",
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
+ "DOWNLOADS.RECORDING_CHIP": "REC",
"EMBEDDED_MPV.PLAYER.AUDIO_TRACK_FALLBACK": "Audio {{index}}",
"EMBEDDED_MPV.PLAYER.STATS_AUDIO": "Audio",
"EMBEDDED_MPV.PLAYER.STATS_BUFFER": "Buffer",
@@ -1031,12 +1042,15 @@
"PORTALS.DETAIL.TRAILER_OF": "{{title}} · Trailer",
"PORTALS.ERROR_VIEW.HOME": "Home",
"PORTALS.MULTI_SOURCE.FILTER_HD": "HD+",
+ "REMOTE_CONTROL.VOLUME": "Volume",
+ "REMOTE_CONTROL.VOLUME_ABBR": "VOL",
"SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"SETTINGS.APP_UPDATE_CHANNEL_STABLE": "Stable",
"SETTINGS.BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"SETTINGS.CARD_CACHE": "Cache",
"SETTINGS.CARD_DESKTOP": "Desktop",
"SETTINGS.CARD_XTREAM": "Xtream",
+ "SETTINGS.EPG_OFFSET_UNIT": "min",
"SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"SETTINGS.EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"SETTINGS.EPG_SOURCE_LIST_TITLE": "Source list",
@@ -1217,6 +1231,7 @@
"CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec",
"CHANNELS.DETAILS_DIALOG.TVG_URL": "TVG URL",
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
+ "DOWNLOADS.RECORDING_CHIP": "REC",
"EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
"EPG.DOWNLOAD_SUCCESS": "EPG was successfully downloaded.",
"EPG.ERROR": "Ooops, EPG could not be loaded.",
@@ -1338,6 +1353,7 @@
"CHANNELS.DETAILS_DIALOG.HTTP_REFERRER": "Referrer",
"CHANNELS.DETAILS_DIALOG.HTTP_USER_AGENT": "User-Agent",
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
+ "DOWNLOADS.RECORDING_CHIP": "REC",
"DOWNLOADS.TITLE": "Downloads",
"EMBEDDED_MPV.PLAYER.AUDIO_TRACK_FALLBACK": "Audio {{index}}",
"EMBEDDED_MPV.PLAYER.LIVE_BADGE": "LIVE",
@@ -1404,6 +1420,8 @@
"PORTALS.SERIES": "Series",
"PORTALS.SIDEBAR.RECENT": "Recent",
"PORTALS.SIDEBAR.SERIES": "Series",
+ "REMOTE_CONTROL.VOLUME": "Volume",
+ "REMOTE_CONTROL.VOLUME_ABBR": "VOL",
"SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"SETTINGS.APP_UPDATE_CHANNEL_STABLE": "Stable",
"SETTINGS.BROWSE_EPG_FILE": "Choose a local XMLTV file…",
@@ -1411,6 +1429,7 @@
"SETTINGS.CARD_DESKTOP": "Desktop",
"SETTINGS.CARD_XTREAM": "Xtream",
"SETTINGS.COMMUNITY": "Project & community",
+ "SETTINGS.EPG_OFFSET_UNIT": "min",
"SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"SETTINGS.EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"SETTINGS.EPG_SOURCE_LIST_TITLE": "Source list",
@@ -1506,6 +1525,7 @@
"CHANNELS.DETAILS_DIALOG.TVG_ID": "TVG ID",
"CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec",
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
+ "DOWNLOADS.RECORDING_CHIP": "REC",
"EMBEDDED_MPV.PLAYER.AUDIO_TRACK_FALLBACK": "Audio {{index}}",
"EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
"EPG.TIMELINE.MINUTES_SHORT": "min",
@@ -1545,11 +1565,14 @@
"PLAYBACK_DIAGNOSTICS.EXTERNAL_STARTED": "Player started",
"PORTALS.DETAIL.DURATION_MINUTES": "{{minutes}} min",
"PORTALS.MULTI_SOURCE.FILTER_HD": "HD+",
+ "REMOTE_CONTROL.CHANNEL_ABBR": "CH",
+ "REMOTE_CONTROL.VOLUME_ABBR": "VOL",
"SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"SETTINGS.APP_UPDATE_CHANNEL_STABLE": "Stable",
"SETTINGS.BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"SETTINGS.CARD_XTREAM": "Xtream",
"SETTINGS.CHIP_RESTART": "Restart",
+ "SETTINGS.EPG_OFFSET_UNIT": "min",
"SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"SETTINGS.EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"SETTINGS.EPG_SOURCE_LIST_TITLE": "Source list",
@@ -1620,6 +1643,7 @@
"CHANNELS.DETAILS_DIALOG.TVG_ID": "TVG ID",
"CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec",
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
+ "DOWNLOADS.RECORDING_CHIP": "REC",
"DOWNLOADS.TITLE": "Downloads",
"EMBEDDED_MPV.PLAYER.STATS_BUFFER": "Buffer",
"EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
@@ -1670,12 +1694,16 @@
"PORTALS.DETAIL.TRAILER_OF": "{{title}} · Trailer",
"PORTALS.ITEM": "item",
"PORTALS.MULTI_SOURCE.FILTER_HD": "HD+",
+ "REMOTE_CONTROL.CHANNEL_ABBR": "CH",
+ "REMOTE_CONTROL.VOLUME": "Volume",
+ "REMOTE_CONTROL.VOLUME_ABBR": "VOL",
"SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"SETTINGS.APP_UPDATE_CHANNEL_STABLE": "Stable",
"SETTINGS.BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"SETTINGS.CARD_CACHE": "Cache",
"SETTINGS.CARD_XTREAM": "Xtream",
"SETTINGS.CHIP_EXPERIMENTAL": "Experimental",
+ "SETTINGS.EPG_OFFSET_UNIT": "min",
"SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"SETTINGS.EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"SETTINGS.EPG_SOURCE_LIST_TITLE": "Source list",
@@ -1720,6 +1748,7 @@
"SOURCE_HEALTH.RECHECK": "Check again",
"STALKER.ACCOUNT_INFO.PORTAL_INFO": "Portal",
"WORKSPACE.CONTEXT.ITEM_COUNT_ONE": "1 item",
+ "WORKSPACE.DASHBOARD.HERO_ROLE_SLIDE": "slide",
"WORKSPACE.DASHBOARD.M3U": "M3U",
"WORKSPACE.DASHBOARD.STALKER": "Stalker",
"WORKSPACE.DASHBOARD.XTREAM": "Xtream",
diff --git a/tools/performance/journey-baselines.json b/tools/performance/journey-baselines.json
index 9728f8097..118d8ea81 100644
--- a/tools/performance/journey-baselines.json
+++ b/tools/performance/journey-baselines.json
@@ -20,12 +20,12 @@
"measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in 3 runs"
},
"renderer.domMutationsToFirstCard": {
- "value": 558,
+ "value": 569,
"unit": "mutations",
"slack": 0,
- "updatedAt": "2026-10-04",
- "evidencePr": 1828,
- "evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/37192092882",
+ "updatedAt": "2026-10-10",
+ "evidencePr": 1871,
+ "evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/38045444230",
"measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in 3 runs"
},
"main.modulesRegisteredBeforeWindow": {