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
+ }}
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) }}
}