fix(i18n): follow runtime language switches in stored and memoized labels (#1872)

* fix(i18n): follow runtime language switches in stored and memoized labels

A label translated once and kept kept its language after Settings ->
Language until the data reloaded or the app restarted. The Stalker store
baked `translate.instant('PORTALS.ALL_CATEGORIES' | 'PORTALS.ALL_RADIO')`
into its category list, so the every-item genre stayed English in the
rail, the live header, the fullscreen panel title, the catalog title and
the search scope.

Stalker: the every-item genre now carries `labelKey` and an empty
`category_name`; the category views render the key through the translate
pipe. `getSelectedCategoryName` becomes `getSelectedCategoryLabel`
({ name, labelKey }) and every text consumer goes through
`stalkerCategoryLabelText()` inside a computed that reads a language
signal, so no consumer can show the empty name or a stale translation.

Same class elsewhere (computed or stored `instant` text without a language
signal): the Xtream import overlay title and progress (shell-provided),
the Settings About version note (stored in a signal on the page where the
language changes), the remove-all-playlists progress, the context panel's
status/error text and category search, and the movie/series heroes
(`createVodDetailsHeroState`, `createSeriesHeroState`, the Xtream movie
presenter), cast & crew "Director", the M3U sidebar count, the M3U movie
hero and the collection panel title. These read
`toSignal(onLangChange.pipe(startWith(null)))` and also recover when the
translation file lands after the first render.

Documents the rule in the UI guidelines and the Stalker store contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(stalker): record getSelectedCategoryLabel in the store API baseline

getSelectedCategoryName is gone without an alias: a name-only selector is
empty for the every-item genre, whose label is a translation key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(i18n): one translation tick that also follows late dictionaries

Greptile asked for coverage of a dictionary that lands after the first
render. That exposed a gap: every hand-written tick listened to
`onLangChange` only, but a start-up without a saved language never calls
`use()` - the default dictionary landing fires `onDefaultLangChange` alone,
so computeds that ran before it kept raw keys. Dictionary updates
(`onTranslationChange`) were missed the same way.

`injectTranslationTick()` in `@iptvnator/pipes` merges the three events the
translate pipe re-renders on (the embedded MPV player already did). All 37
ticks use it now, including the hero factories' `language` dependency. New
specs cover the delayed default-language load, a late `use()` dictionary
and a dictionary update; the hero specs add the start-up case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(i18n): stub the Xtream selection the merged search scope reads

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(i18n): series view reads the shared translation tick

#1871 added an onLangChange-only tick for the synthetic episode titles; a
start-up dictionary that lands without use() would leave raw keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(i18n): move injectTranslationTick to @iptvnator/services

The tick injects TranslateService and subscribes, so it is injectable
runtime state; nx-workspace-boundaries.md reserves type:util for pure
helpers and contracts. @iptvnator/services is the shared type:data-access
project every consumer domain may depend on, and it imports none of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-10 18:31:38 +02:00
1 parent 17a2b1b3b0
commit e1d4f00d93
87 files changed
+1442 -341

No files matched your search

@@ -0,0 +1,10 @@
---
type: fix
area: i18n
---
Changing the app language now updates labels on screen without a restart.
This covers the "All categories" and "All radio" entries for Stalker portals,
the version note in Settings → About and the Xtream import progress, along with
movie and series details pages that could keep the old language or show raw
keys.
@@ -252,10 +252,10 @@ describe('SettingsAppUpdateFacade', () => {
facade.showVersionInformation(latestVersion);
expect(facade.updateMessage()).toBe('New version available: 1.0.0');
expect(translate.instant).toHaveBeenCalledWith(
'SETTINGS.NEW_VERSION_AVAILABLE'
);
expect(facade.updateMessage()).toBe('New version available: 1.0.0');
});
it('reports the installed version as current when it matches', () => {
@@ -268,5 +268,26 @@ describe('SettingsAppUpdateFacade', () => {
expect(facade.updateMessage()).toBe('Latest version installed');
expect(facade.version()).toBe(latestVersion);
});
it('rewords the version note after a language switch on this page', () => {
(translate.instant as jest.Mock).mockRestore();
translate.setTranslation('en', {
SETTINGS: { LATEST_VERSION: 'Latest version installed' },
});
translate.setTranslation('ru', {
SETTINGS: { LATEST_VERSION: 'Установлена последняя версия' },
});
translate.use('en');
jest.spyOn(dataService, 'getAppVersion').mockReturnValue(
latestVersion
);
facade.showVersionInformation(latestVersion);
expect(facade.updateMessage()).toBe('Latest version installed');
translate.use('ru');
expect(facade.updateMessage()).toBe('Установлена последняя версия');
});
});
});
@@ -1,6 +1,10 @@
import { inject, Injectable, signal } from '@angular/core';
import { computed, inject, Injectable, signal } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
import {
DataService,
RuntimeCapabilitiesService,
injectTranslationTick,
} from '@iptvnator/services';
import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
@@ -33,8 +37,28 @@ export class SettingsAppUpdateFacade {
/** Current version of the app */
readonly version = signal('');
/** Update message to show */
readonly updateMessage = signal('');
/** Outcome of the last version check, kept apart from its wording. */
private readonly versionCheck = signal<{
outdated: boolean;
version: string;
} | null>(null);
/**
* The language switch happens on this very page, so the message is
* worded per language instead of stored in the one it was checked in.
*/
private readonly languageTick = injectTranslationTick();
/** Update message to show, in the current language */
readonly updateMessage = computed(() => {
this.languageTick();
const check = this.versionCheck();
if (!check) {
return '';
}
return check.outdated
? `${this.translate.instant('SETTINGS.NEW_VERSION_AVAILABLE') as string}: ${check.version}`
: (this.translate.instant('SETTINGS.LATEST_VERSION') as string);
});
private unsubscribeStatus: (() => void) | null = null;
@@ -133,21 +157,10 @@ export class SettingsAppUpdateFacade {
* @param currentVersion current version of the application
*/
showVersionInformation(currentVersion: string): void {
const isOutdated = this.isCurrentVersionOutdated(currentVersion);
if (isOutdated) {
this.updateMessage.set(
`${
this.translate.instant(
'SETTINGS.NEW_VERSION_AVAILABLE'
) as string
}: ${currentVersion}`
);
} else {
this.updateMessage.set(
this.translate.instant('SETTINGS.LATEST_VERSION')
);
}
this.versionCheck.set({
outdated: this.isCurrentVersionOutdated(currentVersion),
version: currentVersion,
});
}
/**
@@ -96,6 +96,24 @@ describe('SettingsPlaylistResetFacade', () => {
window.electron = originalElectron;
});
it('rewords the removal progress after a language switch mid-run', () => {
(translate.instant as jest.Mock).mockRestore();
translate.setTranslation('en', {
SETTINGS: { REMOVE_ALL_IN_PROGRESS: 'Removing playlists…' },
});
translate.setTranslation('ru', {
SETTINGS: { REMOVE_ALL_IN_PROGRESS: 'Удаление плейлистов…' },
});
translate.use('en');
facade.isRemovingAllPlaylists.set(true);
expect(facade.removeAllProgressLabel()).toBe('Removing playlists…');
translate.use('ru');
expect(facade.removeAllProgressLabel()).toBe('Удаление плейлистов…');
});
it('refuses the global wipe when there are no playlists', () => {
setPlaylists([]);
@@ -10,6 +10,7 @@ import {
DbOperationEvent,
PlaylistsService,
RuntimeCapabilitiesService,
injectTranslationTick,
} from '@iptvnator/services';
import {
SettingsDeleteAllPlaylistsDialogComponent,
@@ -32,6 +33,8 @@ export class SettingsPlaylistResetFacade {
private readonly settingsSnackbar = inject(SettingsSnackbarService);
private readonly store = inject(Store);
private readonly translate = inject(TranslateService);
/** The language can be switched on this page while a removal runs. */
private readonly languageTick = injectTranslationTick();
readonly isRemovingAllPlaylists = signal(false);
readonly removeAllProgress = signal<DbOperationEvent | null>(null);
@@ -48,13 +51,14 @@ export class SettingsPlaylistResetFacade {
() => !this.isRemovingAllPlaylists() && this.deleteSummary().total > 0
);
readonly removeAllProgressLabel = computed(() =>
buildRemoveAllProgressLabel({
readonly removeAllProgressLabel = computed(() => {
this.languageTick();
return buildRemoveAllProgressLabel({
isRemovingAllPlaylists: this.isRemovingAllPlaylists(),
progress: this.removeAllProgress(),
translate: (key, params) => this.translate.instant(key, params),
})
);
});
});
/**
* Asks for confirmation in a dedicated dialog before wiping every
@@ -11,14 +11,14 @@ import {
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { injectTranslationTick } from '@iptvnator/services';
import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings-context';
import {
SettingsRevealRequest,
SettingsSearchEntry,
SettingsSearchService,
} from '@iptvnator/workspace/shell/util/settings-search';
import { map, startWith } from 'rxjs';
import { map } from 'rxjs';
/** How long a revealed row keeps its highlight. */
export const SETTINGS_REVEAL_HIGHLIGHT_MS = 2400;
@@ -43,15 +43,11 @@ export class SettingsSearchFacade {
private readonly route = inject(ActivatedRoute);
private readonly settingsSearch = inject(SettingsSearchService);
private readonly settingsCtx = inject(SettingsContextService);
private readonly translate = inject(TranslateService);
private readonly host = inject(ElementRef<HTMLElement>);
private readonly injector = inject(Injector);
private highlightTimeoutId: ReturnType<typeof setTimeout> | null = null;
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly languageTick = injectTranslationTick();
readonly query = toSignal(
this.route.queryParamMap.pipe(
@@ -22,6 +22,7 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
EpgSourceReconciliationError,
RuntimeCapabilitiesService,
injectTranslationTick,
} from '@iptvnator/services';
import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access';
import {
@@ -29,7 +30,7 @@ import {
Language,
StreamFormat,
} from '@iptvnator/shared/interfaces';
import { firstValueFrom, map, startWith } from 'rxjs';
import { firstValueFrom, map } from 'rxjs';
import { BUILD_COMMIT } from '../../environments/build-commit';
import { SettingsAboutSectionComponent } from './settings-about-section.component';
import { SettingsAppUpdateFacade } from './settings-app-update.facade';
@@ -199,10 +200,7 @@ export class SettingsComponent
});
/** Re-reads translated text after a language switch */
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly languageTick = injectTranslationTick();
/** Rows whose saved change waits for a restart, as one readable list */
readonly restartPendingLabels = computed(() => {
@@ -964,6 +964,28 @@ vocabulary, so a field reads the same wherever it appears:
outcomes that close the dialog. `add-source-forms.e2e.ts` in `web-e2e`
covers the shared labels, the toggle and the URL errors.
## Translated Text
The UI language can change while views stay mounted: Settings → Language
switches it at runtime, and on a cold start the translation file can arrive
after the first render. Text on screen must follow the current language:
- Keep translation keys, not translated strings, in stores, services and
data models. When the app adds an entry to provider data, give it a
`labelKey` that the view renders with `| translate` and leave its provider
name empty. The Stalker every-item genre is an example.
- Prefer `| translate` in the template. A `computed` that calls
`TranslateService.instant` must also read `injectTranslationTick()` from
`@iptvnator/services`. It ticks on the three events the translate pipe
listens to: `onLangChange`, `onDefaultLangChange` and
`onTranslationChange`. `onLangChange` alone is not enough. A start-up
without a saved language never calls `use()`, so only
`onDefaultLangChange` fires when the dictionary lands, and the computed
would keep showing raw keys. Without any tick, the computed keeps the
language it first ran in. State factories such as `createSeriesHeroState`
and `createVodDetailsHeroState` take the tick as `language`.
- `instant` is fine at event time, for snackbar, dialog and confirm text.
## Source Type Icons
`SOURCE_TYPE_ICONS` in `@iptvnator/shared/interfaces` is the only source of
+13 -2
View File
@@ -405,6 +405,17 @@ would not reload by itself, so the session calls `reloadCategories()` once
it has installed that playlist. This contract applies to collection details as well as routed
catalogs, because both use the root Stalker store.
The store adds an every-item genre (`category_id: '*'`) in front of a
portal's list when the portal sends none. That entry holds no translated
name: its `category_name` is empty and its `labelKey` is
`PORTALS.ALL_CATEGORIES` (`PORTALS.ALL_RADIO` for radio). The category lists
render the key with the translate pipe. `getSelectedCategoryLabel()` returns
`{ name, labelKey }` for the selected genre. Text consumers (live header,
fullscreen panel title, catalog title, search scope) turn it into a string
with `stalkerCategoryLabelText()` inside a computed that reads
`injectTranslationTick()`. The root store outlives a runtime language switch, so a name
translated at load time would keep the old language until the next reload.
Failure-handling rule:
- Failed category or content requests must degrade into empty/error UI state,
@@ -1311,8 +1322,8 @@ The Stalker live route and radio route intentionally share
`create_link` with `type=radio`, skips EPG loading, and renders the same
`AudioPlayerComponent` layout instead of the Stalker VOD detail layout.
- Some Stalker portals do not expose radio categories. Radio category loading
falls back to a synthetic `PORTALS.ALL_RADIO` category with
`category_id: '*'` so the station list can still be loaded.
falls back to a synthetic every-item category (`category_id: '*'`,
`labelKey: 'PORTALS.ALL_RADIO'`) so the station list can still be loaded.
- A category click in the shell context panel only re-filters the channel
sidebar; the selected channel or station keeps playing (Xtream live #936 and
M3U group parity). `onStalkerCategoryClicked` therefore must NOT
@@ -46,7 +46,9 @@ Direct signal properties currently exposed by `signalStore`:
- `getCategoryResource: StalkerCategoryItem[]`
- `isCategoryResourceLoading: boolean`
- `isCategoryResourceFailed: unknown`
- `getSelectedCategoryName: string`
- `getSelectedCategoryLabel: StalkerCategoryLabel` (`{ name, labelKey }`;
render with `stalkerCategoryLabelText()` inside a computed that reads
`injectTranslationTick()`)
## Exposed Resources/Props
@@ -74,6 +76,12 @@ Removed:
page size: the portal decides how large a page is. No facade alias is
provided, because a surviving `limit` would advertise a client-side window
that the append path does not honour.
- `getSelectedCategoryName` — replaced by `getSelectedCategoryLabel`. The
every-item genre the store adds (`category_id: '*'`) has no portal name: it
carries a `labelKey` that the caller translates, so the label follows a
runtime language switch. No alias is provided, because a name-only
selector is empty for that entry, and translating in the root store would
keep the language the list was loaded in.
During refactor:
@@ -2,7 +2,7 @@ import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { Subject } from 'rxjs';
import { EMPTY, Subject } from 'rxjs';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { ChannelActions } from '@iptvnator/m3u-state';
import { SettingsStore } from '@iptvnator/services';
@@ -96,6 +96,8 @@ describe('M3uEpgGuideSourceService', () => {
return translateStub.instant;
},
onLangChange,
onDefaultLangChange: EMPTY,
onTranslationChange: EMPTY,
},
},
],
@@ -1,11 +1,9 @@
import { computed, inject, Injectable, Signal, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
import { SettingsStore } from '@iptvnator/services';
import { SettingsStore, injectTranslationTick } from '@iptvnator/services';
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
import { Channel } from '@iptvnator/shared/interfaces';
import type { EpgProgram } from '@iptvnator/shared/interfaces';
@@ -59,10 +57,7 @@ export class M3uEpgGuideSourceService implements EpgGuideSource {
private readonly inputs = signal<M3uEpgGuideInputs | null>(null);
private readonly scope = signal(SCOPE_ALL);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly languageTick = injectTranslationTick();
readonly scopeId = this.scope.asReadonly();
@@ -170,6 +170,24 @@ describe('M3uVodDetailComponent', () => {
fixture?.destroy();
});
it('re-words the kind label after a runtime language switch', async () => {
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
WORKSPACE: { DASHBOARD: { TYPE_MOVIE: 'Movie' } },
});
translate.setTranslation('ru', {
WORKSPACE: { DASHBOARD: { TYPE_MOVIE: 'Фильм' } },
});
translate.use('en');
await create({ channel: channel(), playback: playback() });
expect(fixture.componentInstance.kindLabel()).toBe('Movie');
translate.use('ru');
expect(fixture.componentInstance.kindLabel()).toBe('Фильм');
});
it('starts in watch state — activation means play in M3U', async () => {
await create({ channel: channel(), playback: playback() });
@@ -16,6 +16,7 @@ import {
tmdbBackdropUrl,
tmdbPosterUrl,
topCast,
injectTranslationTick,
} from '@iptvnator/services';
import {
Channel,
@@ -77,6 +78,8 @@ import {
export class M3uVodDetailComponent {
private readonly metadata = inject(M3uVodMetadataService);
private readonly translate = inject(TranslateService);
/** Read by the worded labels below, so a language switch re-words them. */
private readonly languageTick = injectTranslationTick();
readonly channel = input.required<Channel>();
/** Parent-owned playback payload (headers/DRM already resolved). */
@@ -149,12 +152,14 @@ export class M3uVodDetailComponent {
.join(', ')
);
readonly runtimeLabel = computed(() => {
this.languageTick();
const label = formatDurationLabel((this.tmdb()?.runtime ?? 0) * 60);
return label ? this.translate.instant(label.key, label.params) : '';
});
readonly kindLabel = computed(() =>
this.translate.instant('WORKSPACE.DASHBOARD.TYPE_MOVIE')
);
readonly kindLabel = computed(() => {
this.languageTick();
return this.translate.instant('WORKSPACE.DASHBOARD.TYPE_MOVIE');
});
readonly rating = computed(() => {
const details = this.tmdb();
const average = details?.vote_average ?? 0;
@@ -128,6 +128,8 @@ export const translateServiceProvider = {
useValue: {
instant: (key: string) => key,
onLangChange: EMPTY,
onDefaultLangChange: EMPTY,
onTranslationChange: EMPTY,
},
};
@@ -1,5 +1,8 @@
import { Injector } from '@angular/core';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import {
RuntimeCapabilitiesService,
injectTranslationTick,
} from '@iptvnator/services';
import { SourceHealthService } from '@iptvnator/portal/shared/data-access';
import { foldSearchText, sourceHealthType } from '@iptvnator/shared/interfaces';
import { SourceHealthIndicatorComponent } from '../source-health/source-health-indicator.component';
@@ -16,7 +19,6 @@ import {
signal,
viewChild,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatRippleModule } from '@angular/material/core';
@@ -43,7 +45,6 @@ import {
isPortalAccountPlaylist,
PlaylistMeta,
} from '@iptvnator/shared/interfaces';
import { startWith } from 'rxjs';
import { PlaylistRefreshActionService } from '../playlist-refresh-action.service';
import { PlaylistInfoComponent } from '../recent-playlists/playlist-info/playlist-info.component';
@@ -89,10 +90,7 @@ export class PlaylistSwitcherComponent {
private readonly snackBar = inject(MatSnackBar);
private readonly store = inject(Store);
private focusSearchTimeoutId: ReturnType<typeof setTimeout> | null = null;
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly languageTick = injectTranslationTick();
readonly runtime = inject(RuntimeCapabilitiesService);
private readonly healthInjector = inject(Injector);
@@ -20,7 +20,6 @@ import {
signal,
ChangeDetectionStrategy,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
@@ -28,11 +27,11 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltip } from '@angular/material/tooltip';
import { normalizeDateLocale } from '@iptvnator/pipes';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import {
PortalStatus,
PortalStatusService,
RuntimeCapabilitiesService,
injectTranslationTick,
} from '@iptvnator/services';
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
@@ -111,10 +110,7 @@ export class PlaylistItemComponent implements OnInit {
);
}
private readonly translate = inject(TranslateService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly languageTick = injectTranslationTick();
readonly supportsPlaylistRefresh = this.runtime.supportsPlaylistRefresh;
readonly supportsXtreamSqliteDataSource =
@@ -11,9 +11,10 @@ import {
import { MatTooltip } from '@angular/material/tooltip';
import { TranslateService } from '@ngx-translate/core';
import { normalizeDateLocale } from '@iptvnator/pipes';
import { toSignal } from '@angular/core/rxjs-interop';
import { startWith } from 'rxjs';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import {
RuntimeCapabilitiesService,
injectTranslationTick,
} from '@iptvnator/services';
import { SourceHealthService } from '@iptvnator/portal/shared/data-access';
import { PlaylistMeta, sourceHealthType } from '@iptvnator/shared/interfaces';
@@ -65,9 +66,7 @@ export class SourceHealthIndicatorComponent {
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly injector = inject(Injector);
private readonly translate = inject(TranslateService);
private readonly language = toSignal(
this.translate.onLangChange.pipe(startWith(null))
);
private readonly language = injectTranslationTick();
readonly supported = computed(
() =>
this.runtime.supportsSourceHealth &&
@@ -20,6 +20,7 @@ import {
PlaylistsService,
type RecordingItem,
RecordingsService,
injectTranslationTick,
} from '@iptvnator/services';
import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui';
import {
@@ -136,10 +137,7 @@ export class DownloadsComponent {
this.playlistsService.getAllPlaylists().pipe(startWith(null)),
{ initialValue: null as Playlist[] | null }
);
readonly languageChange = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
readonly languageChange = injectTranslationTick();
readonly playlistItems = computed(() => this.playlists() ?? []);
readonly playlistsLoaded = computed(() => this.playlists() !== null);
readonly hasNoPlaylists = computed(
@@ -12,11 +12,15 @@
(click)="categoryClicked.emit(item)"
>
<span class="nav-item-label">
{{
item.category_name ||
$any(item).name ||
('PORTALS.UNNAMED_CATEGORY' | translate)
}}
@if (item.labelKey) {
{{ item.labelKey | translate }}
} @else {
{{
item.category_name ||
$any(item).name ||
('PORTALS.UNNAMED_CATEGORY' | translate)
}}
}
</span>
@if (showCounts()) {
<div class="item-count app-count-badge">
@@ -17,6 +17,11 @@ interface CategoryViewItem {
readonly category_name?: string;
readonly count?: number;
readonly id?: string | number;
/**
* Translation key of an entry the app adds itself (a provider's "All"
* genre); rendered through the pipe so a language switch re-labels it.
*/
readonly labelKey?: string;
readonly name?: string;
}
@@ -2,7 +2,7 @@ import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { EMPTY, of } from 'rxjs';
import { SettingsStore } from '@iptvnator/services';
import { ContentCardComponent } from './content-card.component';
@@ -20,6 +20,8 @@ describe('ContentCardComponent', () => {
provide: TranslateService,
useValue: {
onLangChange: of(),
onDefaultLangChange: EMPTY,
onTranslationChange: EMPTY,
currentLang: 'en',
defaultLang: 'en',
},
@@ -8,13 +8,12 @@ import {
linkedSignal,
output,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { normalizeDateLocale } from '@iptvnator/pipes';
import { injectTranslationTick } from '@iptvnator/services';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import { CoverTitlesService } from '../../cover-titles/cover-titles.service';
/** Channel-like cards always keep their label: logos rarely identify them. */
@@ -32,10 +31,7 @@ const LABELLED_CONTENT_TYPES: ReadonlySet<string> = new Set(['live', 'radio']);
export class ContentCardComponent {
private readonly translate = inject(TranslateService);
private readonly coverTitles = inject(CoverTitlesService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly languageTick = injectTranslationTick();
/** URL of the poster image */
readonly posterUrl = input<string>();
@@ -3,7 +3,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import { of } from 'rxjs';
import { EMPTY, of } from 'rxjs';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import { SettingsStore } from '@iptvnator/services';
import { UnifiedGridTabComponent } from './unified-grid-tab.component';
@@ -38,6 +38,8 @@ describe('UnifiedGridTabComponent posters-only wall', () => {
provide: TranslateService,
useValue: {
onLangChange: of(),
onDefaultLangChange: EMPTY,
onTranslationChange: EMPTY,
currentLang: 'en',
defaultLang: 'en',
},
@@ -48,7 +48,11 @@ import {
WebPlayerViewComponent,
} from '@iptvnator/ui/playback';
import { ResizableDirective } from '@iptvnator/ui/components';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
RuntimeCapabilitiesService,
SettingsStore,
injectTranslationTick,
} from '@iptvnator/services';
import { RecordingStoppedEvent } from '@iptvnator/shared/interfaces';
import {
createUnifiedLiveCatchup,
@@ -116,6 +120,7 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost {
private readonly settingsStore = inject(SettingsStore);
private readonly destroyRef = inject(DestroyRef);
private readonly translate = inject(TranslateService);
private readonly languageTick = injectTranslationTick();
private readonly router = inject(Router);
private readonly portalPlayer = inject(PORTAL_PLAYER);
@@ -130,13 +135,15 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost {
? null
: (this.fullscreenChannelPanelTemplate() ?? null)
);
readonly panelTitle = computed(() =>
this.translate.instant(
/** Worded per language: the tick re-runs this after a switch. */
readonly panelTitle = computed(() => {
this.languageTick();
return this.translate.instant(
this.mode() === 'favorites'
? 'HOME.PLAYLISTS.GLOBAL_FAVORITES'
: 'PORTALS.SIDEBAR.RECENT'
)
);
);
});
readonly activeDetail = signal<ResolvedLiveCollectionDetail | null>(null);
readonly activeUid = signal<string | null>(null);
@@ -2,7 +2,7 @@ import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateModule } from '@ngx-translate/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
AudioPlayerComponent,
WebPlayerViewComponent,
@@ -175,6 +175,24 @@ describe('UnifiedLiveTabComponent fullscreen channel panel', () => {
}
);
it('re-words the panel title after a runtime language switch', () => {
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
PORTALS: { SIDEBAR: { RECENT: 'Recently viewed' } },
});
translate.setTranslation('ru', {
PORTALS: { SIDEBAR: { RECENT: 'Недавно просмотренные' } },
});
translate.use('en');
fixture.componentRef.setInput('mode', 'recent');
expect(component.panelTitle()).toBe('Recently viewed');
translate.use('ru');
expect(component.panelTitle()).toBe('Недавно просмотренные');
});
it('correlates an inline row with its session key, not its stream URL', async () => {
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
const item = buildM3uLiveItem();
@@ -1,6 +1,7 @@
export * from './lib/models';
export * from './lib/stores';
export * from './lib/stalker-account-info.service';
export * from './lib/stalker-category-label';
export * from './lib/stalker-content-types';
export * from './lib/stalker-portal-error';
export * from './lib/stalker-response-classification';
@@ -1,6 +1,14 @@
export interface StalkerCategoryItem {
category_id: string;
category_name: string;
/**
* Translation key of an entry the store adds itself — the every-item
* genre `'*'` ("All categories" / "All radio"). Such an entry keeps an
* empty `category_name`: a name translated while the list loaded would
* stay in that language after a runtime language switch, so views
* translate this key when they render it.
*/
labelKey?: string;
/**
* Ministra "censored" genre flag (adult categories). Portals typically
* exclude these channels from `get_all_channels`, so censored categories
@@ -0,0 +1,23 @@
/**
* What a Stalker genre is called: the portal's own name, or — for an entry
* the store adds itself, such as the every-item genre `'*'` — a translation
* key. The key stays untranslated in state so that a runtime language switch
* re-labels the entry instead of keeping the language it was loaded in.
*/
export interface StalkerCategoryLabel {
readonly name: string;
readonly labelKey: string | null;
}
/**
* The label's text in the current language. A caller that keeps the result
* in a `computed` must also read `injectTranslationTick()` from
* `@iptvnator/services`, or the text stays in the language the computed
* first ran in.
*/
export function stalkerCategoryLabelText(
label: StalkerCategoryLabel,
translate: (key: string) => string
): string {
return label.labelKey ? translate(label.labelKey) : label.name;
}
@@ -118,7 +118,7 @@ describe('StalkerStore API compatibility smoke', () => {
'getCategoryResource',
'isCategoryResourceLoading',
'isCategoryResourceFailed',
'getSelectedCategoryName',
'getSelectedCategoryLabel',
];
for (const computedName of expectedComputed) {
@@ -1,7 +1,6 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { DataService, ParentalLockService } from '@iptvnator/services';
import {
CONNECTIVITY_GUARD_RESET,
@@ -183,12 +182,6 @@ describe('withStalkerContent failure states', () => {
}),
},
},
{
provide: TranslateService,
useValue: {
instant: jest.fn((key: string) => key),
},
},
],
});
@@ -295,8 +288,44 @@ describe('withStalkerContent failure states', () => {
expect(
store
.getCategoryResource()
.map((category) => category.category_name)
.map((category) => category.labelKey ?? category.category_name)
).toEqual(['PORTALS.ALL_CATEGORIES', 'Zulu', 'Alpha', 'Movies']);
// The added entry is named by its key alone: a name translated at
// load time would keep that language after a runtime switch.
expect(store.getCategoryResource()[0]).toEqual({
category_id: '*',
category_name: '',
labelKey: 'PORTALS.ALL_CATEGORIES',
});
});
it('names the selected genre by the portal name or the added entry key', async () => {
dataService.sendIpcEvent.mockResolvedValue({
js: [{ id: '7', title: 'Drama' }],
});
store.setSelectedContentType('vod');
store.setCurrentPlaylist(PLAYLIST);
void store.isCategoryResourceLoading();
await waitForCondition(() => store.getCategoryResource().length > 0);
store.setSelectedCategory('*');
expect(store.getSelectedCategoryLabel()).toEqual({
name: '',
labelKey: 'PORTALS.ALL_CATEGORIES',
});
store.setSelectedCategory('7');
expect(store.getSelectedCategoryLabel()).toEqual({
name: 'Drama',
labelKey: null,
});
store.setSelectedCategory(null);
expect(store.getSelectedCategoryLabel()).toEqual({
name: '',
labelKey: null,
});
});
it('normalizes content failures into empty collections instead of undefined state', async () => {
@@ -838,18 +867,13 @@ describe('withStalkerContent failure states', () => {
}),
})
);
expect(store.getCategoryResource()).toEqual([
{
category_id: '*',
category_name: 'PORTALS.ALL_RADIO',
},
]);
expect(store.radioCategories()).toEqual([
{
category_id: '*',
category_name: 'PORTALS.ALL_RADIO',
},
]);
const allRadio = {
category_id: '*',
category_name: '',
labelKey: 'PORTALS.ALL_RADIO',
};
expect(store.getCategoryResource()).toEqual([allRadio]);
expect(store.radioCategories()).toEqual([allRadio]);
expect(store.isCategoryResourceFailed()).toBeNull();
});
@@ -996,12 +1020,6 @@ describe('withStalkerContent full ITV channel list cache', () => {
}),
},
},
{
provide: TranslateService,
useValue: {
instant: jest.fn((key: string) => key),
},
},
],
});
@@ -7,7 +7,6 @@ import {
withProps,
withState,
} from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
DataService,
@@ -22,6 +21,7 @@ import {
StalkerItvChannel,
StalkerVodSource,
} from '../../models';
import type { StalkerCategoryLabel } from '../../stalker-category-label';
import { StalkerContentTypes } from '../../stalker-content-types';
import { StalkerItvCacheService } from '../../stalker-itv-cache.service';
import { StalkerPortalRepairService } from '../../stalker-portal-repair.service';
@@ -171,24 +171,27 @@ function buildCategoryPatch(
}
}
/**
* The every-item genre. It carries a translation key, not a translated
* name: the list outlives a runtime language switch, so the views translate
* the key whenever they render it.
*/
function buildAllCategory(
contentType: StalkerContentType,
translateService: TranslateService
contentType: StalkerContentType
): StalkerCategoryItem {
return {
category_name: translateService.instant(
category_name: '',
category_id: '*',
labelKey:
contentType === 'radio'
? 'PORTALS.ALL_RADIO'
: 'PORTALS.ALL_CATEGORIES'
),
category_id: '*',
: 'PORTALS.ALL_CATEGORIES',
};
}
function prependAllCategory(
contentType: StalkerContentType,
categories: StalkerCategoryItem[],
translateService: TranslateService
categories: StalkerCategoryItem[]
): StalkerCategoryItem[] {
const allIndex = categories.findIndex(
(category) => category.category_name.trim().toLowerCase() === 'all'
@@ -204,16 +207,14 @@ function prependAllCategory(
categories.length > 0 &&
!categories.some((category) => String(category.category_id) === '*')
) {
categories.unshift(buildAllCategory(contentType, translateService));
categories.unshift(buildAllCategory(contentType));
}
return categories;
}
function fallbackRadioCategories(
translateService: TranslateService
): StalkerCategoryItem[] {
return [buildAllCategory('radio', translateService)];
function fallbackRadioCategories(): StalkerCategoryItem[] {
return [buildAllCategory('radio')];
}
/** Stable identity of a Stalker portal inside the content resource. */
@@ -282,7 +283,6 @@ export function withStalkerContent() {
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService),
portalRepair = inject(StalkerPortalRepairService),
translateService = inject(TranslateService),
itvCache = inject(StalkerItvCacheService),
parentalLock = inject(ParentalLockService)
) => {
@@ -369,9 +369,7 @@ export function withStalkerContent() {
);
if (params.contentType === 'radio') {
const fallback =
fallbackRadioCategories(
translateService
);
fallbackRadioCategories();
patchState(store, {
radioCategories: fallback,
categoryError: null,
@@ -401,11 +399,8 @@ export function withStalkerContent() {
params.contentType,
params.contentType === 'radio' &&
normalizedCategories.length === 0
? fallbackRadioCategories(
translateService
)
: normalizedCategories,
translateService
? fallbackRadioCategories()
: normalizedCategories
);
patchState(store, {
@@ -424,10 +419,7 @@ export function withStalkerContent() {
error,
});
if (params.contentType === 'radio') {
const fallback =
fallbackRadioCategories(
translateService
);
const fallback = fallbackRadioCategories();
patchState(store, {
radioCategories: fallback,
categoryError: null,
@@ -1013,6 +1005,21 @@ export function withStalkerContent() {
counts.set(Number.NaN, channels.length);
return counts;
});
/** The selected genre as listed, or undefined (none or unknown). */
const selectedListedCategory = computed(() => {
const selectedCategoryId = storeContext.selectedCategoryId();
if (!selectedCategoryId) {
return undefined;
}
return getCategoriesByType(
store,
storeContext.selectedContentType(),
stalkerPlaylistKey(storeContext.currentPlaylist())
).find(
(item) =>
String(item.category_id) === String(selectedCategoryId)
);
});
return {
/** True when the complete ITV channel list is cached, so local search covers all channels. */
@@ -1124,24 +1131,16 @@ export function withStalkerContent() {
}
);
}),
getSelectedCategoryName: computed(() => {
const selectedCategoryId =
storeContext.selectedCategoryId();
if (!selectedCategoryId) {
return '';
}
const category = getCategoriesByType(
store,
storeContext.selectedContentType(),
stalkerPlaylistKey(storeContext.currentPlaylist())
).find(
(item) =>
String(item.category_id) ===
String(selectedCategoryId)
);
return category?.category_name ?? '';
/**
* What the selected genre is called; render it with
* `stalkerCategoryLabelText`. Empty while none is selected.
*/
getSelectedCategoryLabel: computed<StalkerCategoryLabel>(() => {
const category = selectedListedCategory();
return {
name: category?.category_name ?? '',
labelKey: category?.labelKey ?? null,
};
}),
getPaginatedContent: computed(() => store.paginatedContent()),
isPaginatedContentLoading: computed(() =>
@@ -1,6 +1,10 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
type StalkerCategoryLabel,
StalkerStore,
} from '@iptvnator/portal/stalker/data-access';
import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
@@ -84,7 +88,7 @@ describe('StalkerCatalogFacadeService', () => {
hasContentAppendError: signal(false),
isPaginatedContentLoading: signal(false),
currentPlaylist: signal(playlist),
getSelectedCategoryName: jest.fn(() => null),
getSelectedCategoryLabel: signal({ name: '', labelKey: null }),
setSelectedCategory: jest.fn(),
clearSelectedItem: jest.fn(),
setSearchPhrase: jest.fn(),
@@ -100,6 +104,7 @@ describe('StalkerCatalogFacadeService', () => {
};
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
providers: [
StalkerCatalogFacadeService,
{
@@ -122,16 +127,39 @@ describe('StalkerCatalogFacadeService', () => {
});
});
it('re-labels the every-item genre after a runtime language switch', () => {
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
PORTALS: { ALL_CATEGORIES: 'All categories' },
});
translate.setTranslation('ru', {
PORTALS: { ALL_CATEGORIES: 'Все категории' },
});
translate.use('en');
(
stalkerStoreMock['getSelectedCategoryLabel'] as ReturnType<
typeof signal<StalkerCategoryLabel>
>
).set({ name: '', labelKey: 'PORTALS.ALL_CATEGORIES' });
const service = TestBed.inject(StalkerCatalogFacadeService);
expect(service.selectedCategoryTitle()).toBe('All categories');
translate.use('ru');
expect(service.selectedCategoryTitle()).toBe('Все категории');
});
it('leaves the every-item title to the view while no category is selected', () => {
const service = TestBed.inject(StalkerCatalogFacadeService);
const categoryId = stalkerStoreMock['selectedCategoryId'] as ReturnType<
typeof signal<string | null>
>;
(
stalkerStoreMock['getSelectedCategory'] as ReturnType<
typeof signal<Record<string, unknown> | null>
stalkerStoreMock['getSelectedCategoryLabel'] as ReturnType<
typeof signal<StalkerCategoryLabel>
>
).set({ category_id: '5', category_name: 'News' });
).set({ name: 'News', labelKey: null });
expect(service.selectedCategoryTitle()).toBe('News');
@@ -7,14 +7,19 @@ import {
inject,
signal,
} from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import {
buildStalkerSelectedVodItem,
isStalkerSeriesFlag,
StalkerLinkFlagSource,
StalkerStore,
StalkerVodSource,
stalkerCategoryLabelText,
} from '@iptvnator/portal/stalker/data-access';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import {
PlaybackPositionRuntimeBridgeService,
injectTranslationTick,
} from '@iptvnator/services';
import {
PortalCatalogItemProgress,
PortalCatalogPlaylistMeta,
@@ -36,6 +41,8 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade<
StalkerVodSource
> {
private readonly stalkerStore = inject(StalkerStore);
private readonly translate = inject(TranslateService);
private readonly languageTick = injectTranslationTick();
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly playbackPositionBridge = inject(
PlaybackPositionRuntimeBridgeService
@@ -84,19 +91,17 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade<
*/
private readonly savedScrollPositions = new Map<string, number>();
readonly selectedCategoryTitle = computed(() => {
// No category means every item: the view supplies that title.
if (!this.stalkerStore.selectedCategoryId()) {
return null;
}
const category = this.selectedCategory();
const fromCategory = category
? String(category.category_name ?? '')
: '';
if (fromCategory) {
return fromCategory;
}
return this.stalkerStore.getSelectedCategoryName() ?? '';
// The store's every-item genre is named by a key, translated in the
// current language; the tick re-runs this after a switch.
this.languageTick();
return stalkerCategoryLabelText(
this.stalkerStore.getSelectedCategoryLabel(),
(key) => this.translate.instant(key)
);
});
readonly categoryItemCount = computed(() => this.stalkerStore.totalCount());
readonly contentSortMode = computed<PortalCatalogSortMode | null>(
@@ -128,7 +128,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
const isPaginatedContentLoading = signal(false);
const stalkerStore = {
getSelectedCategoryName: signal('News'),
getSelectedCategoryLabel: signal({ name: 'News', labelKey: null }),
itvChannels,
radioChannels,
searchPhrase,
@@ -42,6 +42,7 @@ import {
RecordingsService,
RuntimeCapabilitiesService,
SettingsStore,
injectTranslationTick,
} from '@iptvnator/services';
import { isStalkerPlaybackRequestLockCurrent } from './stalker-live-lock-guard';
import {
@@ -99,6 +100,7 @@ import {
StalkerItvChannel,
StalkerStore,
normalizeStalkerEntityId,
stalkerCategoryLabelText,
} from '@iptvnator/portal/stalker/data-access';
import {
PanelSearchWindow,
@@ -203,7 +205,18 @@ export class StalkerLiveStreamLayoutComponent
LiveLayoutSidebarStateService
);
private readonly logger = createLogger('StalkerLiveStream');
readonly selectedCategoryTitle = this.stalkerStore.getSelectedCategoryName;
private readonly languageTick = injectTranslationTick();
/**
* The portal's genre name, or the store's every-item entry translated
* in the current language: the tick re-runs this after a switch.
*/
readonly selectedCategoryTitle = computed(() => {
this.languageTick();
return stalkerCategoryLabelText(
this.stalkerStore.getSelectedCategoryLabel(),
(key) => this.translate.instant(key)
);
});
/** Channels */
readonly isRadioMode = computed(
@@ -3,7 +3,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { EMPTY, of } from 'rxjs';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
LiveLayoutSidebarStateService,
@@ -62,7 +62,7 @@ describe('StalkerLiveStreamLayoutComponent EPG fallback races', () => {
const fetchChannelEpg = jest.fn();
const hasItvEpgMappingOverride = jest.fn(() => false);
const store = {
getSelectedCategoryName: signal('All'),
getSelectedCategoryLabel: signal({ name: 'All', labelKey: null }),
currentPlaylist: playlist,
selectedContentType,
selectedCategoryId: signal<string | null>('all'),
@@ -177,7 +177,12 @@ describe('StalkerLiveStreamLayoutComponent EPG fallback races', () => {
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
{
provide: TranslateService,
useValue: { instant: (key: string) => key },
useValue: {
instant: (key: string) => key,
onLangChange: EMPTY,
onDefaultLangChange: EMPTY,
onTranslationChange: EMPTY,
},
},
],
})
@@ -2,14 +2,17 @@ import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateModule } from '@ngx-translate/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
LiveLayoutSidebarStateService,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
type StalkerCategoryLabel,
StalkerStore,
} from '@iptvnator/portal/stalker/data-access';
import {
PlaylistsService,
RuntimeCapabilitiesService,
@@ -21,8 +24,9 @@ import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.c
const FILL_CHECK_DELAY_MS = 100;
/**
* Focused spec for the fullscreen panel's search on a paged portal — kept
* separate from the main layout spec, which sits at the max-lines budget.
* Focused spec for the fullscreen panel's title and its search on a paged
* portal — kept separate from the main layout spec, which sits at the
* max-lines budget.
*/
describe('StalkerLiveStreamLayoutComponent fullscreen panel search', () => {
let fixture: ComponentFixture<StalkerLiveStreamLayoutComponent>;
@@ -50,7 +54,10 @@ describe('StalkerLiveStreamLayoutComponent fullscreen panel search', () => {
const itvFullListActive = signal(false);
const itvSelectedCategoryFromCache = signal(false);
const store = {
getSelectedCategoryName: signal('All'),
getSelectedCategoryLabel: signal<StalkerCategoryLabel>({
name: 'All',
labelKey: null,
}),
currentPlaylist: signal({ _id: 'playlist-one', title: 'Portal One' }),
selectedContentType: signal<'itv' | 'radio'>('itv'),
selectedCategoryId: signal<string | null>('all'),
@@ -97,6 +104,7 @@ describe('StalkerLiveStreamLayoutComponent fullscreen panel search', () => {
itvFullListActive.set(false);
itvSelectedCategoryFromCache.set(false);
store.selectedCategoryId.set('all');
store.getSelectedCategoryLabel.set({ name: 'All', labelKey: null });
store.selectedContentType.set('itv');
store.radioChannels.set([]);
store.itvFullChannelList.set([]);
@@ -480,4 +488,36 @@ describe('StalkerLiveStreamLayoutComponent fullscreen panel search', () => {
expect(component.visibleChannels()).toEqual([category[249]]);
expect(store.setPage).not.toHaveBeenCalled();
});
it('re-labels the every-item genre in the header and panel after a language switch', () => {
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
PORTALS: { ALL_CATEGORIES: 'All categories' },
});
translate.setTranslation('ru', {
PORTALS: { ALL_CATEGORIES: 'Все категории' },
});
translate.use('en');
store.selectedCategoryId.set('*');
store.getSelectedCategoryLabel.set({
name: '',
labelKey: 'PORTALS.ALL_CATEGORIES',
});
fixture.detectChanges();
const headerTitle = () =>
(
fixture.nativeElement.querySelector(
'.sidebar-header .category-title'
) as HTMLElement | null
)?.textContent?.trim();
expect(headerTitle()).toBe('All categories');
expect(component.panelTitle()).toBe('All categories');
translate.use('ru');
fixture.detectChanges();
expect(headerTitle()).toBe('Все категории');
expect(component.panelTitle()).toBe('Все категории');
});
});
@@ -3,7 +3,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { EMPTY, of } from 'rxjs';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import {
@@ -57,7 +57,7 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
const resolveItvPlayback = jest.fn();
const snackBar = { open: jest.fn() };
const store = {
getSelectedCategoryName: signal('All'),
getSelectedCategoryLabel: signal({ name: 'All', labelKey: null }),
currentPlaylist: playlist,
selectedContentType,
selectedCategoryId: signal<string | null>('all'),
@@ -150,7 +150,12 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
{ provide: MatSnackBar, useValue: snackBar },
{
provide: TranslateService,
useValue: { instant: (key: string) => key },
useValue: {
instant: (key: string) => key,
onLangChange: EMPTY,
onDefaultLangChange: EMPTY,
onTranslationChange: EMPTY,
},
},
],
})
@@ -77,7 +77,7 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => {
const selectedItvId = signal<string | undefined>(undefined);
const stalkerStore = {
getSelectedCategoryName: signal('News'),
getSelectedCategoryLabel: signal({ name: 'News', labelKey: null }),
itvChannels,
radioChannels,
searchPhrase: signal(''),
@@ -8,6 +8,7 @@ import {
import {
SettingsStore,
type CrossPortalSimilarItem,
injectTranslationTick,
} from '@iptvnator/services';
import { youtubeEmbedUrl } from '@iptvnator/shared/interfaces';
import {
@@ -30,6 +31,7 @@ interface StalkerSeriesHeroBindings {
@Injectable()
export class StalkerSeriesHeroPresenter {
private readonly translate = inject(TranslateService);
private readonly languageTick = injectTranslationTick();
private readonly stalkerStore = inject(StalkerStore);
private readonly trailerDialog = inject(TrailerDialogService);
private readonly settingsStore = inject(SettingsStore);
@@ -84,6 +86,7 @@ export class StalkerSeriesHeroPresenter {
director: computed(() => this.info()?.director),
quickStart: this.quickStart,
translate: this.translate,
language: this.languageTick,
});
readonly similarRailItems = computed<SimilarRailItem[]>(() =>
@@ -12,11 +12,9 @@ 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 {
@@ -98,6 +96,7 @@ import {
CrossPortalSimilarService,
PlaybackPositionRuntimeBridgeService,
TmdbEnrichmentService,
injectTranslationTick,
} from '@iptvnator/services';
import { StalkerSeriesTmdbSeasonsService } from './stalker-series-tmdb-seasons.service';
import { StalkerSeriesHeroPresenter } from './stalker-series-hero.presenter';
@@ -186,10 +185,7 @@ 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 }
);
private readonly languageTick = injectTranslationTick();
readonly backClicked = output<void>();
private readonly logger = createLogger('StalkerSeriesView');
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
@@ -2,7 +2,7 @@ import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { EMPTY, of } from 'rxjs';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { RecentlyAddedComponent } from './recently-added.component';
@@ -82,6 +82,8 @@ describe('RecentlyAddedComponent', () => {
defaultLang: 'en',
instant: jest.fn((key: string) => key),
onLangChange: of(null),
onDefaultLangChange: EMPTY,
onTranslationChange: EMPTY,
},
},
],
@@ -4,10 +4,9 @@ import {
computed,
inject,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import { injectTranslationTick } from '@iptvnator/services';
import {
ContentCardComponent,
ContentRailShellComponent,
@@ -49,10 +48,7 @@ export class RecentlyAddedComponent {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly translate = inject(TranslateService);
// Re-compute translated labels when the active language changes.
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly languageTick = injectTranslationTick();
readonly recentlyAddedLive = computed(() =>
this.getRecentlyAdded(
@@ -5,7 +5,7 @@ import {
type SeriesQuickStartAction,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { SettingsStore } from '@iptvnator/services';
import { SettingsStore, injectTranslationTick } from '@iptvnator/services';
import type { CrossPortalSimilarItem } from '@iptvnator/services';
import { youtubeEmbedUrl } from '@iptvnator/shared/interfaces';
import {
@@ -31,6 +31,7 @@ interface SerialDetailsHeroBindings {
@Injectable()
export class SerialDetailsHeroPresenter {
private readonly translate = inject(TranslateService);
private readonly languageTick = injectTranslationTick();
private readonly xtreamStore = inject(XtreamStore);
private readonly trailerDialog = inject(TrailerDialogService);
private readonly settingsStore = inject(SettingsStore);
@@ -82,6 +83,7 @@ export class SerialDetailsHeroPresenter {
director: computed(() => this.info()?.director),
quickStart: this.quickStart,
translate: this.translate,
language: this.languageTick,
});
readonly similarRailItems = computed<SimilarRailItem[]>(() => [
@@ -0,0 +1,46 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { SettingsStore } from '@iptvnator/services';
import { TrailerDialogService } from '@iptvnator/ui/components';
import { VodDetailsHeroPresenter } from './vod-details-hero.presenter';
describe('VodDetailsHeroPresenter', () => {
it('re-words the hero labels after a runtime language switch', () => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
providers: [
VodDetailsHeroPresenter,
{
provide: XtreamStore,
useValue: { currentPlaylist: signal({ name: 'Portal' }) },
},
{ provide: SettingsStore, useValue: {} },
{
provide: TrailerDialogService,
useValue: { open: jest.fn() },
},
],
});
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
WORKSPACE: { DASHBOARD: { TYPE_MOVIE: 'Movie' } },
XTREAM: { PLAY: 'Play' },
});
translate.setTranslation('ru', {
WORKSPACE: { DASHBOARD: { TYPE_MOVIE: 'Фильм' } },
XTREAM: { PLAY: 'Смотреть' },
});
translate.use('en');
const presenter = TestBed.inject(VodDetailsHeroPresenter);
expect(presenter.kindLabel()).toBe('Movie · Portal');
expect(presenter.primaryAction().label).toBe('Play');
translate.use('ru');
expect(presenter.kindLabel()).toBe('Фильм · Portal');
expect(presenter.primaryAction().label).toBe('Смотреть');
});
});
@@ -19,7 +19,7 @@ import {
type TmdbGenreFacet,
type XtreamVodInfo,
} from '@iptvnator/shared/interfaces';
import { SettingsStore } from '@iptvnator/services';
import { SettingsStore, injectTranslationTick } from '@iptvnator/services';
import type { CrossPortalSimilarItem } from '@iptvnator/services';
import {
castMembersFromNames,
@@ -70,6 +70,11 @@ interface VodDetailsHeroBindings {
@Injectable()
export class VodDetailsHeroPresenter {
private readonly translate = inject(TranslateService);
/**
* Read by every computed that words a label, so a UI language switch
* (or translations landing after the first render) re-words the hero.
*/
private readonly languageTick = injectTranslationTick();
private readonly xtreamStore = inject(XtreamStore);
private readonly settingsStore = inject(SettingsStore);
private readonly trailerDialog = inject(TrailerDialogService);
@@ -82,6 +87,7 @@ export class VodDetailsHeroPresenter {
private readonly info = computed(() => this.bindings()?.info() ?? null);
readonly kindLabel = computed(() => {
this.languageTick();
const kind = this.translate.instant('WORKSPACE.DASHBOARD.TYPE_MOVIE');
const source = this.xtreamStore.currentPlaylist()?.name?.trim();
return source ? `${kind} · ${source}` : kind;
@@ -92,9 +98,10 @@ export class VodDetailsHeroPresenter {
return info?.duration_secs || parseDurationSeconds(info?.duration);
});
readonly durationLabel = computed(() =>
this.label(formatDurationLabel(this.durationSeconds()))
);
readonly durationLabel = computed(() => {
this.languageTick();
return this.label(formatDurationLabel(this.durationSeconds()));
});
/** One chip per TMDB genre facet, else the provider's list split up. */
readonly genreChips = computed<VodHeroGenreChip[]>(() => {
@@ -134,6 +141,7 @@ export class VodDetailsHeroPresenter {
});
readonly primaryAction = computed<VodHeroPrimaryAction>(() => {
this.languageTick();
const bindings = this.bindings();
const state = bindings?.externalState() ?? 'idle';
const icon = bindings?.isOfflinePrimary()
+5
View File
@@ -1,5 +1,10 @@
# services
Shared runtime data access: injectable services, stores and runtime bridges
used across the renderer. It also hosts `injectTranslationTick()`, the signal
every `computed` that calls `TranslateService.instant` reads (see "Translated
Text" in the [UI guidelines](../../docs/architecture/iptvnator-ui-guidelines.md)).
This library was generated with [Nx](https://nx.dev).
## Running unit tests
+1
View File
@@ -30,3 +30,4 @@ export * from './lib/xtream-connection-test-state';
export * from './lib/source-health-evidence.service';
export * from './lib/source-activity.service';
export * from './lib/translation-tick';
@@ -0,0 +1,101 @@
import { computed } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
TranslateLoader,
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import { Observable, of, Subject } from 'rxjs';
import { injectTranslationTick } from './translation-tick';
/** A loader whose dictionaries arrive only when the test sends them. */
class DelayedLoader implements TranslateLoader {
readonly pending = new Map<string, Subject<Record<string, unknown>>>();
getTranslation(lang: string): Observable<Record<string, unknown>> {
const subject = new Subject<Record<string, unknown>>();
this.pending.set(lang, subject);
return subject;
}
deliver(lang: string, dictionary: Record<string, unknown>): void {
const subject = this.pending.get(lang);
subject?.next(dictionary);
subject?.complete();
}
}
function setUp(options: {
defaultLanguage?: string;
loader?: TranslateLoader;
}) {
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot({
defaultLanguage: options.defaultLanguage,
loader: {
provide: TranslateLoader,
useValue: options.loader ?? {
getTranslation: () => of({}),
},
},
}),
],
});
const translate = TestBed.inject(TranslateService);
const tick = TestBed.runInInjectionContext(() => injectTranslationTick());
const label = computed(() => {
tick();
return translate.instant('PLAYER.TITLE');
});
return { translate, label };
}
describe('injectTranslationTick', () => {
it('re-runs a computed when a start-up dictionary lands without use()', () => {
const loader = new DelayedLoader();
const { label } = setUp({ defaultLanguage: 'en', loader });
// Read before the dictionary arrives: instant answers with the key.
expect(label()).toBe('PLAYER.TITLE');
loader.deliver('en', { PLAYER: { TITLE: 'Player' } });
expect(label()).toBe('Player');
});
it('re-runs a computed when the dictionary of use() arrives late', () => {
const loader = new DelayedLoader();
const { translate, label } = setUp({ loader });
translate.use('ru');
expect(label()).toBe('PLAYER.TITLE');
loader.deliver('ru', { PLAYER: { TITLE: 'Плеер' } });
expect(label()).toBe('Плеер');
});
it('re-runs a computed on a runtime language switch', () => {
const { translate, label } = setUp({});
translate.setTranslation('en', { PLAYER: { TITLE: 'Player' } });
translate.setTranslation('ru', { PLAYER: { TITLE: 'Плеер' } });
translate.use('en');
expect(label()).toBe('Player');
translate.use('ru');
expect(label()).toBe('Плеер');
});
it('re-runs a computed when the current dictionary is updated', () => {
const { translate, label } = setUp({});
translate.setTranslation('en', { PLAYER: { TITLE: 'Player' } });
translate.use('en');
expect(label()).toBe('Player');
translate.setTranslation('en', { PLAYER: { TITLE: 'Media player' } });
expect(label()).toBe('Media player');
});
});
+31
View File
@@ -0,0 +1,31 @@
import { inject, type Signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { TranslateService } from '@ngx-translate/core';
import { merge } from 'rxjs';
/**
* A signal that changes whenever `TranslateService.instant` may answer
* differently — on the same three events the translate pipe re-renders on:
*
* - `onLangChange`: a runtime language switch, or the first `use()` once its
* dictionary has loaded;
* - `onDefaultLangChange`: the default language's dictionary landing. A
* start-up that never calls `use()` (no saved language) fires only this;
* - `onTranslationChange`: a dictionary of the current language updated.
*
* Read it inside every `computed` that calls `instant` (or keep keys in state
* and translate in the template). Without it the computed keeps the text of
* the moment it first ran: the previous language, or raw keys when it ran
* before the dictionary arrived. Call it in an injection context.
*/
export function injectTranslationTick(): Signal<unknown> {
const translate = inject(TranslateService);
return toSignal(
merge(
translate.onLangChange,
translate.onDefaultLangChange,
translate.onTranslationChange
),
{ initialValue: null }
);
}
@@ -1,5 +1,5 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import type { TmdbEnrichedCastMember } from '@iptvnator/shared/interfaces';
import { CastCrewRowComponent } from './cast-crew-row.component';
@@ -39,6 +39,28 @@ describe('CastCrewRowComponent', () => {
expect(personSelected).toHaveBeenCalledWith(ACTOR);
});
it('re-words the director role after a runtime language switch', () => {
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', { XTREAM: { DIRECTOR: 'Director' } });
translate.setTranslation('ru', { XTREAM: { DIRECTOR: 'Режиссёр' } });
translate.use('en');
fixture.componentRef.setInput('directors', [
{ name: 'Lee Park' } as TmdbEnrichedCastMember,
]);
fixture.detectChanges();
const role = () =>
(fixture.nativeElement as HTMLElement)
.querySelector('.person__role')
?.textContent?.trim();
expect(role()).toBe('Director');
translate.use('ru');
fixture.detectChanges();
expect(role()).toBe('Режиссёр');
});
it('renders plain entries for a host without an actor page', () => {
const personSelected = jest.fn();
fixture.componentInstance.personSelected.subscribe(personSelected);
@@ -7,6 +7,7 @@ import {
output,
} from '@angular/core';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { injectTranslationTick } from '@iptvnator/services';
import type { TmdbEnrichedCastMember } from '@iptvnator/shared/interfaces';
import { buildCastCrewEntries, type CastCrewEntry } from './cast-crew.util';
import { DetailRailComponent } from './detail-rail.component';
@@ -41,14 +42,17 @@ export class CastCrewRowComponent {
readonly personSelected = output<TmdbEnrichedCastMember>();
private readonly translate = inject(TranslateService);
private readonly languageTick = injectTranslationTick();
readonly entries = computed<CastCrewEntry[]>(() =>
buildCastCrewEntries(
/** The "Director" role is worded per language: the tick re-runs this. */
readonly entries = computed<CastCrewEntry[]>(() => {
this.languageTick();
return buildCastCrewEntries(
this.cast(),
this.directors(),
this.translate.instant('XTREAM.DIRECTOR')
)
);
);
});
isClickable(entry: CastCrewEntry): boolean {
return this.interactive() && !!entry.member.tmdbPersonId;
@@ -1,5 +1,15 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
TranslateLoader,
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import { Observable, of, Subject } from 'rxjs';
import { injectTranslationTick } from '@iptvnator/services';
import {
buildSeriesMenuSections,
createSeriesHeroState,
SERIES_MENU_ACTION,
} from './series-hero.state';
@@ -67,3 +77,93 @@ describe('buildSeriesMenuSections', () => {
expect(row({ startPending: true }, external)?.disabled).toBe(true);
});
});
const EN = {
WORKSPACE: { DASHBOARD: { TYPE_SERIES: 'Series' } },
XTREAM: { PLAY: 'Play' },
};
function createHero(loader: TranslateLoader, defaultLanguage?: string) {
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot({
defaultLanguage,
loader: { provide: TranslateLoader, useValue: loader },
}),
],
});
const translate = TestBed.inject(TranslateService);
const language = TestBed.runInInjectionContext(() =>
injectTranslationTick()
);
const hero = createSeriesHeroState({
title: signal('Show'),
sourceLabel: signal('Portal'),
status: signal(null),
year: signal(null),
tmdbGenres: signal(undefined),
genre: signal(undefined),
tmdbCountries: signal(undefined),
tmdbCast: signal(undefined),
cast: signal(undefined),
tmdbDirectors: signal(undefined),
director: signal(undefined),
quickStart: signal({
labelKey: 'XTREAM.PLAY',
episodeLabel: 'S01E01',
icon: 'play_arrow',
disabled: false,
kind: null,
position: null,
episodeCode: 'S01E01',
}),
translate,
language,
});
return { translate, hero };
}
describe('createSeriesHeroState', () => {
it('re-words its labels after a runtime language switch', () => {
const { translate, hero } = createHero({
getTranslation: () => of({}),
});
translate.setTranslation('en', EN);
translate.setTranslation('ru', {
WORKSPACE: { DASHBOARD: { TYPE_SERIES: 'Сериал' } },
XTREAM: { PLAY: 'Смотреть' },
});
translate.use('en');
expect(hero.kindLabel()).toBe('Series · Portal');
expect(hero.primaryAction()?.label).toBe('Play');
translate.use('ru');
expect(hero.kindLabel()).toBe('Сериал · Portal');
expect(hero.primaryAction()?.label).toBe('Смотреть');
});
it('replaces raw keys once a start-up dictionary lands without use()', () => {
// No saved language: the app never calls use(), so the default
// language's dictionary arriving is the only event.
const dictionary = new Subject<Record<string, unknown>>();
const { hero } = createHero(
{
getTranslation: (): Observable<Record<string, unknown>> =>
dictionary,
},
'en'
);
expect(hero.kindLabel()).toBe(
'WORKSPACE.DASHBOARD.TYPE_SERIES · Portal'
);
dictionary.next(EN);
dictionary.complete();
expect(hero.kindLabel()).toBe('Series · Portal');
expect(hero.primaryAction()?.label).toBe('Play');
});
});
@@ -60,6 +60,13 @@ export interface SeriesHeroStateDeps {
readonly director: Signal<string | undefined>;
readonly quickStart: Signal<SeriesHeroQuickStart | null>;
readonly translate: Pick<TranslateService, 'instant'>;
/**
* The host's `injectTranslationTick()`: ticks on a language switch and
* when a dictionary lands. Every label below is worded through a wrapper
* that reads it, so the computeds re-word instead of keeping the
* language (or the raw keys) they first ran with.
*/
readonly language: Signal<unknown>;
}
/**
@@ -68,8 +75,10 @@ export interface SeriesHeroStateDeps {
* primary button's two lines ("Continue" / "S02E03 · 18m left").
*/
export function createSeriesHeroState(deps: SeriesHeroStateDeps) {
const translate = (key: string, params?: Record<string, unknown>) =>
deps.translate.instant(key, params);
const translate = (key: string, params?: Record<string, unknown>) => {
deps.language();
return deps.translate.instant(key, params);
};
const titleParts = computed(() => splitSeasonSuffix(deps.title()));
const castMembers = computed<TmdbEnrichedCastMember[]>(() =>
deps.tmdbCast()?.length
@@ -20,15 +20,13 @@ import {
untracked,
viewChild,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { normalizeDateLocale } from '@iptvnator/pipes';
import { SettingsStore } from '@iptvnator/services';
import { SettingsStore, injectTranslationTick } from '@iptvnator/services';
import { EpgProgram, epgProviderClockMs } from '@iptvnator/shared/interfaces';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import { EpgDateNavigationDirection } from '../epg-date';
import { EpgProgrammeDialogService } from '../epg-programme-dialog.service';
import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util';
@@ -106,10 +104,7 @@ export class EpgGuideComponent implements OnDestroy {
readonly filter = this.view.filter;
readonly rowHeightPx = this.view.rowHeightPx;
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly languageTick = injectTranslationTick();
readonly currentLocale = computed(() => {
this.languageTick();
return normalizeDateLocale(
@@ -5,15 +5,13 @@ import {
inject,
ChangeDetectionStrategy,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatIcon } from '@angular/material/icon';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { normalizeDateLocale } from '@iptvnator/pipes';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { differenceInMinutes } from 'date-fns';
import { startWith } from 'rxjs';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { SettingsStore } from '@iptvnator/services';
import { SettingsStore, injectTranslationTick } from '@iptvnator/services';
import { getProgramTimeMs } from '../epg-program.utils';
export type EpgItemDialogAction =
@@ -45,10 +43,7 @@ export class EpgItemDescriptionComponent {
dialogData = inject<EpgItemDialogData>(MAT_DIALOG_DATA);
private readonly translate = inject(TranslateService);
private readonly settingsStore = inject(SettingsStore);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly languageTick = injectTranslationTick();
epgProgram: EpgProgram;
channelName: string | null = null;
@@ -13,13 +13,13 @@ import {
signal,
viewChild,
} from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { normalizeDateLocale } from '@iptvnator/pipes';
import { injectTranslationTick } from '@iptvnator/services';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import {
EpgDateNavigationDirection,
getTodayEpgDateKey,
@@ -123,10 +123,7 @@ export class EpgListViewComponent {
return key ? key : getTodayEpgDateKey();
});
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly languageTick = injectTranslationTick();
readonly currentLocale = computed(() => {
this.languageTick();
return normalizeDateLocale(
@@ -15,13 +15,13 @@ import {
untracked,
viewChild,
} from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { normalizeDateLocale } from '@iptvnator/pipes';
import { injectTranslationTick } from '@iptvnator/services';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import {
EpgDateNavigationDirection,
getTodayEpgDateKey,
@@ -165,10 +165,7 @@ export class EpgTimelineComponent {
),
});
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly languageTick = injectTranslationTick();
readonly currentLocale = computed(() => {
this.languageTick();
return normalizeDateLocale(
@@ -8,7 +8,6 @@ import {
signal,
untracked,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import {
EmbeddedMpvSession,
EmbeddedMpvSupport,
@@ -17,7 +16,7 @@ import {
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { TranslateService } from '@ngx-translate/core';
import { merge } from 'rxjs';
import { injectTranslationTick } from '@iptvnator/services';
import {
DEFAULT_ASPECT_PRESETS,
DEFAULT_PLAYER_CAPABILITIES,
@@ -62,14 +61,7 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
private readonly controller = inject(EmbeddedMpvSessionController);
private readonly translate = inject(TranslateService);
private readonly destroyRef = inject(DestroyRef);
private readonly translationsTick = toSignal(
merge(
this.translate.onLangChange,
this.translate.onTranslationChange,
this.translate.onDefaultLangChange
),
{ initialValue: null }
);
private readonly translationsTick = injectTranslationTick();
private readonly configuredContext =
signal<EmbeddedMpvControlsContext | null>(null);
@@ -16,9 +16,8 @@ import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { toSignal } from '@angular/core/rxjs-interop';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { merge } from 'rxjs';
import { injectTranslationTick } from '@iptvnator/services';
import {
EmbeddedMpvAudioTrack,
RecordingStartMetadata,
@@ -115,14 +114,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
* otherwise labels keep the previous language (or the raw key when the
* component mounts before the translation file finishes loading).
*/
private readonly translationsTick = toSignal(
merge(
this.translate.onLangChange,
this.translate.onTranslationChange,
this.translate.onDefaultLangChange
),
{ initialValue: null }
);
private readonly translationsTick = injectTranslationTick();
readonly controller = inject(EmbeddedMpvSessionController);
readonly sharedControls = inject(EmbeddedMpvControlsAdapter);
private readonly shortcuts = new EmbeddedMpvShortcuts();
@@ -0,0 +1,37 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { SidebarComponent } from './sidebar.component';
describe('SidebarComponent', () => {
it('re-words the channel count after a runtime language switch', () => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), SidebarComponent],
providers: [
{
provide: PlaylistContextFacade,
useValue: { activePlaylist: signal(null) },
},
],
}).overrideComponent(SidebarComponent, {
set: { template: '', imports: [] },
});
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
HOME: { PLAYLISTS: { CHANNELS: 'channels' } },
});
translate.setTranslation('ru', {
HOME: { PLAYLISTS: { CHANNELS: 'каналов' } },
});
translate.use('en');
const fixture = TestBed.createComponent(SidebarComponent);
fixture.componentRef.setInput('channels', [{}, {}]);
expect(fixture.componentInstance.subtitle()).toBe('2 channels');
translate.use('ru');
expect(fixture.componentInstance.subtitle()).toBe('2 каналов');
});
});
@@ -11,6 +11,7 @@ import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { RouterLink } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { injectTranslationTick } from '@iptvnator/services';
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { Channel } from '@iptvnator/shared/interfaces';
@@ -45,6 +46,7 @@ export class SidebarComponent {
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly translate = inject(TranslateService);
private readonly languageTick = injectTranslationTick();
readonly activePlaylist = this.playlistContext.activePlaylist;
readonly playlistTitle = computed(() => {
@@ -59,7 +61,9 @@ export class SidebarComponent {
);
});
/** Worded per language: the tick re-runs this after a switch. */
readonly subtitle = computed(() => {
this.languageTick();
if (this.channelsLoading()) {
return this.translate.instant('CHANNELS.LOADING');
}
@@ -0,0 +1,96 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
TranslateLoader,
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import { Observable, of, Subject } from 'rxjs';
import { injectTranslationTick } from '@iptvnator/services';
import type { NormalizedVodMeta } from '@iptvnator/shared/interfaces';
import { createVodDetailsHeroState } from './vod-details-hero.state';
const EN = {
WORKSPACE: { DASHBOARD: { TYPE_MOVIE: 'Movie' } },
XTREAM: { PLAY: 'Play' },
};
function createHero(loader: TranslateLoader, defaultLanguage?: string) {
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot({
defaultLanguage,
loader: { provide: TranslateLoader, useValue: loader },
}),
],
});
const translate = TestBed.inject(TranslateService);
const language = TestBed.runInInjectionContext(() =>
injectTranslationTick()
);
const hero = createVodDetailsHeroState({
meta: signal({} as NormalizedVodMeta),
sourceLabel: signal('Portal'),
playbackPosition: signal(null),
playbackDurationSeconds: signal(null),
hasPlaybackPosition: signal(false),
isWatched: signal(false),
supportsExternalPlayers: () => false,
playbackStartPending: signal(false),
isOfflinePrimary: signal(false),
externalLabel: signal(null),
externalIcon: signal('play_arrow'),
externalState: signal('idle'),
similarInPortals: signal([]),
configuredPlayer: signal(null),
translate,
language,
});
return { translate, hero };
}
describe('createVodDetailsHeroState', () => {
it('re-words its labels after a runtime language switch', () => {
const { translate, hero } = createHero({
getTranslation: () => of({}),
});
translate.setTranslation('en', EN);
translate.setTranslation('ru', {
WORKSPACE: { DASHBOARD: { TYPE_MOVIE: 'Фильм' } },
XTREAM: { PLAY: 'Смотреть' },
});
translate.use('en');
expect(hero.kindLabel()).toBe('Movie · Portal');
expect(hero.primaryAction().label).toBe('Play');
translate.use('ru');
expect(hero.kindLabel()).toBe('Фильм · Portal');
expect(hero.primaryAction().label).toBe('Смотреть');
});
it('replaces raw keys once a start-up dictionary lands without use()', () => {
// No saved language: the app never calls use(), so the default
// language's dictionary arriving is the only event.
const dictionary = new Subject<Record<string, unknown>>();
const { hero } = createHero(
{
getTranslation: (): Observable<Record<string, unknown>> =>
dictionary,
},
'en'
);
expect(hero.kindLabel()).toBe(
'WORKSPACE.DASHBOARD.TYPE_MOVIE · Portal'
);
expect(hero.primaryAction().label).toBe('XTREAM.PLAY');
dictionary.next(EN);
dictionary.complete();
expect(hero.kindLabel()).toBe('Movie · Portal');
expect(hero.primaryAction().label).toBe('Play');
});
});
@@ -43,6 +43,13 @@ export interface VodDetailsHeroStateDeps {
readonly similarInPortals: Signal<readonly CrossPortalSimilarItem[]>;
readonly configuredPlayer: Signal<VideoPlayer | null | undefined>;
readonly translate: Pick<TranslateService, 'instant'>;
/**
* The host's `injectTranslationTick()`: ticks on a language switch and
* when a dictionary lands. Every label below is worded through a wrapper
* that reads it, so the computeds re-word instead of keeping the
* language (or the raw keys) they first ran with.
*/
readonly language: Signal<unknown>;
}
/**
@@ -51,8 +58,10 @@ export interface VodDetailsHeroStateDeps {
* Similar rail and the rows of the "…" menu.
*/
export function createVodDetailsHeroState(deps: VodDetailsHeroStateDeps) {
const translate = (key: string, params?: Record<string, unknown>) =>
deps.translate.instant(key, params);
const translate = (key: string, params?: Record<string, unknown>) => {
deps.language();
return deps.translate.instant(key, params);
};
const kindLabel = computed(() => {
const kind = translate('WORKSPACE.DASHBOARD.TYPE_MOVIE');
const source = deps.sourceLabel()?.trim();
@@ -47,6 +47,7 @@ import {
RuntimeCapabilitiesService,
SettingsStore,
TmdbEnrichmentService,
injectTranslationTick,
} from '@iptvnator/services';
import { VOD_DETAILS_MENU_ACTION } from './vod-details-presentation';
import { createVodDetailsHeroState } from './vod-details-hero.state';
@@ -204,6 +205,7 @@ export class VodDetailsComponent {
private readonly router = inject(Router);
private readonly settingsStore = inject(SettingsStore);
private readonly translate = inject(TranslateService);
private readonly languageTick = injectTranslationTick();
private readonly trailerDialog = inject(TrailerDialogService);
// ============ Computed State ============
@@ -296,6 +298,7 @@ export class VodDetailsComponent {
similarInPortals: this.similarInPortals,
configuredPlayer: this.settingsStore.player,
translate: this.translate,
language: this.languageTick,
});
runMenuAction(actionId: string): void {
@@ -6,7 +6,7 @@ import {
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { of, Subject } from 'rxjs';
import { EMPTY, of, Subject } from 'rxjs';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import {
PlaybackPositionData,
@@ -223,6 +223,8 @@ describe('DashboardDataService', () => {
useValue: {
instant: (key: string) => key,
onLangChange: of(null),
onDefaultLangChange: EMPTY,
onTranslationChange: EMPTY,
currentLang: 'en',
defaultLang: 'en',
},
@@ -6,7 +6,6 @@ import {
inject,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import {
@@ -14,12 +13,13 @@ import {
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { firstValueFrom, startWith } from 'rxjs';
import { firstValueFrom } from 'rxjs';
import {
DatabaseService,
GlobalRecentlyAddedKind,
PlaylistsService,
RuntimeCapabilitiesService,
injectTranslationTick,
} from '@iptvnator/services';
import {
XTREAM_DATA_SOURCE,
@@ -134,10 +134,7 @@ export class DashboardDataService {
private readonly translate = inject(TranslateService);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly favoritesAutoRefreshEnabled = signal(false);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly languageTick = injectTranslationTick();
private readonly xtreamGlobalRecentItems = signal<GlobalRecentItem[]>([]);
private readonly xtreamRecentlyAddedItemsState = signal<
@@ -1,7 +1,7 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { TranslateService } from '@ngx-translate/core';
import { Subject } from 'rxjs';
import { EMPTY, Subject } from 'rxjs';
import { isPortalPlaybackWatched } from '@iptvnator/portal/shared/util';
import type {
PlaybackPositionData,
@@ -200,6 +200,8 @@ describe('DashboardHeroSlidesPresenter', () => {
provide: TranslateService,
useValue: {
onLangChange: new Subject(),
onDefaultLangChange: EMPTY,
onTranslationChange: EMPTY,
instant: (key: string, params?: object) =>
params ? `${key} ${JSON.stringify(params)}` : key,
},
@@ -8,9 +8,8 @@ import {
signal,
untracked,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import { injectTranslationTick } from '@iptvnator/services';
import { splitSeasonSuffix } from '@iptvnator/portal/shared/util';
import {
playlistDisplayLabel,
@@ -78,10 +77,7 @@ export class DashboardHeroSlidesPresenter {
private readonly liveEpg = inject(DashboardLiveEpgPresenter);
private readonly heroTmdb = inject(DashboardHeroTmdbService);
private readonly translate = inject(TranslateService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly languageTick = injectTranslationTick();
private readonly failedImages = signal<Record<string, true>>({});
private readonly tmdbExtras = signal<
@@ -6,8 +6,6 @@ import {
inject,
untracked,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { startWith } from 'rxjs';
import {
getPlaylistSourceIcon,
isStalkerAccountPlaylist,
@@ -36,6 +34,7 @@ import {
PlaylistDeleteActionService,
RuntimeCapabilitiesService,
SettingsStore,
injectTranslationTick,
} from '@iptvnator/services';
import {
DashboardDataService,
@@ -121,10 +120,7 @@ export class WorkspaceDashboardRailsComponent {
private readonly snackBar = inject(MatSnackBar);
private readonly store = inject(Store);
private readonly translate = inject(TranslateService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly languageTick = injectTranslationTick();
private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
@@ -26,11 +26,15 @@
(contextmenu)="onCategoryContextMenu(item, $event)"
>
<span class="nav-item-label">
{{
item.category_name ||
$any(item).name ||
('PORTALS.UNNAMED_CATEGORY' | translate)
}}
@if (item.labelKey) {
{{ item.labelKey | translate }}
} @else {
{{
item.category_name ||
$any(item).name ||
('PORTALS.UNNAMED_CATEGORY' | translate)
}}
}
</span>
@if (showCounts()) {
@if (countDisplayMode() === 'loading') {
@@ -17,6 +17,11 @@ export interface WorkspaceCategoryViewItem {
readonly category_name?: string;
readonly count?: number;
readonly id?: string | number;
/**
* Translation key of an entry the app adds itself (a provider's "All"
* genre); rendered through the pipe so a language switch re-labels it.
*/
readonly labelKey?: string;
readonly name?: string;
}
@@ -0,0 +1,165 @@
import { Component, inject, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
StalkerItvCacheService,
StalkerSessionService,
withStalkerContent,
} from '@iptvnator/portal/stalker/data-access';
import { DataService, ParentalLockService } from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { WorkspaceContextCategoryViewComponent } from './workspace-context-category-view.component';
type ContentType = 'vod' | 'series' | 'itv' | 'radio';
const PLAYLIST = {
_id: 'playlist-1',
title: 'Demo Stalker',
count: 0,
autoRefresh: false,
importDate: '2026-04-14T00:00:00.000Z',
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
macAddress: '00:1A:79:00:00:01',
isFullStalkerPortal: false,
} as PlaylistMeta;
/** The real category loader, minus the selection features it reads. */
const CategoryStore = signalStore(
{ protectedState: false },
withState({
currentPlaylist: undefined as PlaylistMeta | undefined,
selectedContentType: 'itv' as ContentType,
selectedCategoryId: undefined as string | null | undefined,
searchPhrase: '',
page: 0,
}),
withMethods((store) => ({
load(playlist: PlaylistMeta, contentType: ContentType) {
patchState(store, {
currentPlaylist: playlist,
selectedContentType: contentType,
});
},
})),
withStalkerContent()
);
@Component({
imports: [WorkspaceContextCategoryViewComponent],
template: `<app-workspace-context-category-view
[items]="store.getCategoryResource()"
/>`,
})
class CategoryRailHostComponent {
readonly store = inject(CategoryStore);
}
async function waitFor(predicate: () => boolean): Promise<void> {
for (let attempt = 0; attempt < 20; attempt += 1) {
if (predicate()) {
return;
}
TestBed.flushEffects();
await new Promise((resolve) => setTimeout(resolve));
}
throw new Error('Timed out waiting for the categories to load');
}
/**
* Regression: the Stalker store used to bake `translate.instant` of its
* every-item genre into the loaded list, so a runtime language switch left
* that row in the language the list loaded in until a reload or restart.
*/
describe('Stalker category rail across a runtime language switch', () => {
let translate: TranslateService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), CategoryRailHostComponent],
providers: [
CategoryStore,
{
provide: DataService,
useValue: {
sendIpcEvent: jest.fn().mockResolvedValue({
js: [{ id: '7', title: 'Drama' }],
}),
},
},
{
provide: ParentalLockService,
useValue: {
active: () => false,
version: signal(0),
lockedStalkerIds: () => [],
},
},
{
provide: StalkerItvCacheService,
useValue: {
versionFor: () => 0,
getChannels: () => null,
isReady: () => false,
isLoading: () => false,
isUnsupported: () => false,
progressOf: () => null,
ensureLoaded: jest.fn().mockResolvedValue(undefined),
},
},
{
provide: StalkerSessionService,
useValue: {
makeAuthenticatedRequest: jest.fn(),
ensureToken: jest
.fn()
.mockResolvedValue({ token: null }),
},
},
],
});
translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
PORTALS: {
ALL_CATEGORIES: 'All categories',
ALL_RADIO: 'All radio',
},
});
translate.setTranslation('ru', {
PORTALS: {
ALL_CATEGORIES: 'Все категории',
ALL_RADIO: 'Всё радио',
},
});
translate.use('en');
});
it.each([
['itv', 'All categories', 'Все категории'],
['radio', 'All radio', 'Всё радио'],
] as const)(
're-labels the %s every-item genre loaded before the switch',
async (contentType, english, russian) => {
const fixture = TestBed.createComponent(CategoryRailHostComponent);
const store = fixture.componentInstance.store;
store.load(PLAYLIST, contentType);
await waitFor(() => store.getCategoryResource().length === 2);
fixture.detectChanges();
const labels = () =>
Array.from(
fixture.nativeElement.querySelectorAll(
'.nav-item-label'
) as NodeListOf<HTMLElement>,
(label) => label.textContent?.trim()
);
expect(labels()).toEqual([english, 'Drama']);
translate.use('ru');
fixture.detectChanges();
// The provider's own genre name is never translated.
expect(labels()).toEqual([russian, 'Drama']);
}
);
});
@@ -31,6 +31,7 @@ const translations: Record<string, string> = {
'WORKSPACE.SORT_SERVER': 'Server sorting',
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING':
'Fetching playlist data from source...',
'PORTALS.ALL_CATEGORIES': 'All Categories',
};
interface RouteSnapshotStub {
@@ -103,7 +104,11 @@ describe('WorkspaceContextPanelComponent', () => {
{ category_id: '1', category_name: 'News' },
]),
getCategoryResource: signal<
Array<{ category_id: string; category_name: string }>
Array<{
category_id: string;
category_name: string;
labelKey?: string;
}>
>([]),
selectedCategoryId: signal<string | null>(null),
isCategoryResourceLoading: signal(false),
@@ -442,8 +447,13 @@ describe('WorkspaceContextPanelComponent', () => {
playlistId: 'stalker-1',
});
fixture.componentRef.setInput('section', 'series');
// The store names its every-item entry by a key, not a name.
stalkerStore.getCategoryResource.set([
{ category_id: '*', category_name: 'All Categories' },
{
category_id: '*',
category_name: '',
labelKey: 'PORTALS.ALL_CATEGORIES',
},
{ category_id: 'z', category_name: 'Zulu' },
{ category_id: 'a', category_name: 'Alpha' },
{ category_id: 'm', category_name: 'Movies' },
@@ -457,6 +467,13 @@ describe('WorkspaceContextPanelComponent', () => {
'Movies',
]);
// The filter matches the label on screen, translated key included.
fixture.componentInstance.categorySearchTerm.set('all c');
fixture.detectChanges();
expect(getCategoryLabels(fixture)).toEqual(['All Categories']);
fixture.componentInstance.categorySearchTerm.set('');
fixture.detectChanges();
fixture.componentInstance.setCategorySortMode('name-asc');
fixture.detectChanges();
@@ -31,7 +31,10 @@ import {
sortPortalCategoryItems,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { ParentalLockService } from '@iptvnator/services';
import {
ParentalLockService,
injectTranslationTick,
} from '@iptvnator/services';
import {
toParentalLockStalkerCategoryType,
toParentalLockXtreamCategoryType,
@@ -66,6 +69,7 @@ interface WorkspaceCategoryLike {
readonly category_id?: string | number;
readonly category_name?: string;
readonly id?: string | number;
readonly labelKey?: string;
readonly name?: string;
}
@@ -92,6 +96,12 @@ export class WorkspaceContextPanelComponent {
private readonly dialog = inject(MatDialog);
private readonly destroyRef = inject(DestroyRef);
private readonly translate = inject(TranslateService);
/**
* Computeds below that call `translate.instant` (or filter by a
* translated label) read this, so a runtime language switch re-runs them
* instead of keeping the text of the language they first ran in.
*/
private readonly languageTick = injectTranslationTick();
// Root-provided; optional keeps standalone unit tests light. Only relevant
// when the panel renders as the phone drawer. Some selections here (e.g.
// Stalker ITV/radio) update the store without navigating, so the drawer's
@@ -244,6 +254,7 @@ export class WorkspaceContextPanelComponent {
this.parentalLock.withholdsEverything()
);
readonly xtreamStatusText = computed(() => {
this.languageTick();
if (
!this.isXtreamCategories() ||
this.isXtreamCategoryLoading() ||
@@ -277,6 +288,7 @@ export class WorkspaceContextPanelComponent {
* refusal gets its own actionable text.
*/
readonly stalkerCategoryErrorDescription = computed(() => {
this.languageTick();
const portalError = asStalkerPortalError(
this.isStalkerCategoryFailed()
);
@@ -368,6 +380,7 @@ export class WorkspaceContextPanelComponent {
);
readonly filteredXtreamCategories = computed(() => {
this.languageTick();
const cats = this.xtreamCategories();
const term = foldSearchText(this.categorySearchTerm().trim());
const filtered = term
@@ -385,6 +398,7 @@ export class WorkspaceContextPanelComponent {
});
readonly filteredStalkerCategories = computed(() => {
this.languageTick();
const cats = this.stalkerCategories();
const term = foldSearchText(this.categorySearchTerm().trim());
const filtered = term
@@ -778,7 +792,11 @@ export class WorkspaceContextPanelComponent {
}
}
/** The label the rail shows, so the search filters what is on screen. */
private getCategoryLabel(category: WorkspaceCategoryLike): string {
if (category.labelKey) {
return this.translate.instant(category.labelKey);
}
return category.category_name ?? category.name ?? '';
}
@@ -1,9 +1,8 @@
import { computed, inject, Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import { injectTranslationTick } from '@iptvnator/services';
import {
PlaylistInfoComponent,
PlaylistRefreshActionService,
@@ -41,10 +40,7 @@ export class WorkspaceShellHeaderService {
);
private readonly liveSidebar = inject(LiveLayoutSidebarStateService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly languageTick = injectTranslationTick();
readonly playlistTitle = computed(() => {
const playlist = this.routeState.activePlaylist();
@@ -5,18 +5,21 @@ import {
Injectable,
signal,
} from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { filter, startWith } from 'rxjs';
import { filter } from 'rxjs';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
buildPortalRailLinks,
PortalRailLink,
} from '@iptvnator/portal/shared/util';
import { selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import {
injectTranslationTick,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import {
parseWorkspaceShellRoute,
WorkspacePortalContext,
@@ -37,10 +40,7 @@ export class WorkspaceShellRouteStateService {
private readonly destroyRef = inject(DestroyRef);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly languageTick = injectTranslationTick();
readonly activePlaylist = this.playlistContext.activePlaylist;
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
@@ -3,7 +3,7 @@ import { TestBed } from '@angular/core/testing';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { Subject, of } from 'rxjs';
import { EMPTY, of, Subject } from 'rxjs';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
@@ -96,6 +96,8 @@ describe('WorkspaceShellSearchSyncService', () => {
useValue: {
instant: jest.fn((key: string) => key),
onLangChange: of(null),
onDefaultLangChange: EMPTY,
onTranslationChange: EMPTY,
},
},
{
@@ -119,7 +121,10 @@ describe('WorkspaceShellSearchSyncService', () => {
provide: StalkerStore,
useValue: {
setSearchPhrase: jest.fn(),
getSelectedCategoryName: signal(''),
getSelectedCategoryLabel: signal({
name: '',
labelKey: null,
}),
itvFullListActive: signal(false),
},
},
@@ -1,11 +1,15 @@
import { computed, inject, Injectable } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
StalkerStore,
stalkerCategoryLabelText,
} from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import {
RuntimeCapabilitiesService,
injectTranslationTick,
} from '@iptvnator/services';
import { WorkspaceSearchCapability } from '@iptvnator/workspace/shell/util';
import { SettingsSearchService } from '@iptvnator/workspace/shell/util/settings-search';
import {
@@ -32,10 +36,7 @@ export class WorkspaceShellSearchService {
private readonly searchSync = inject(WorkspaceShellSearchSyncService);
private readonly settingsSearch = inject(SettingsSearchService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly languageTick = injectTranslationTick();
readonly searchQuery = this.searchSync.searchQuery;
readonly appliedSearchQuery = this.searchSync.appliedSearchQuery;
@@ -160,8 +161,12 @@ export class WorkspaceShellSearchService {
xtreamCategory: this.xtreamStore.selectedCategoryId()
? this.xtreamStore.getSelectedCategory()
: { name: this.translateText('PORTALS.ALL_ITEMS') },
stalkerCategoryName:
this.stalkerStore.getSelectedCategoryName(),
// This computed reads the language tick, so the every-item
// genre is translated in the current language.
stalkerCategoryName: stalkerCategoryLabelText(
this.stalkerStore.getSelectedCategoryLabel(),
(key) => this.translateText(key)
),
}),
statusLabel: isDegradedStalkerLive
? this.translateText(SEARCH_LOADED_ONLY_STATUS)
@@ -2,7 +2,7 @@ import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { EMPTY, of } from 'rxjs';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
@@ -63,13 +63,20 @@ describe('WorkspaceShellSearchService on settings routes', () => {
},
{
provide: StalkerStore,
useValue: { getSelectedCategoryName: () => '' },
useValue: {
getSelectedCategoryLabel: () => ({
name: '',
labelKey: null,
}),
},
},
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
onLangChange: of(null),
onDefaultLangChange: EMPTY,
onTranslationChange: EMPTY,
},
},
{
@@ -0,0 +1,99 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
type StalkerCategoryLabel,
StalkerStore,
} from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { parseWorkspaceShellRoute } from '@iptvnator/workspace/shell/util';
import { SettingsSearchService } from '@iptvnator/workspace/shell/util/settings-search';
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
import { WorkspaceShellSearchSyncService } from './workspace-shell-search-sync.service';
import { WorkspaceShellSearchService } from './workspace-shell-search.service';
describe('WorkspaceShellSearchService Stalker search scope', () => {
let service: WorkspaceShellSearchService;
let translate: TranslateService;
const categoryLabel = signal<StalkerCategoryLabel>({
name: '',
labelKey: null,
});
beforeEach(() => {
const route = parseWorkspaceShellRoute('/workspace/stalker/pl-1/vod');
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
providers: [
WorkspaceShellSearchService,
{ provide: Router, useValue: { navigate: jest.fn() } },
{
provide: XtreamStore,
useValue: {
getSelectedCategory: () => null,
selectedCategoryId: () => null,
},
},
{
provide: StalkerStore,
useValue: {
getSelectedCategoryLabel: categoryLabel,
itvFullListActive: signal(false),
},
},
{
provide: RuntimeCapabilitiesService,
useValue: { isElectron: true },
},
{
provide: WorkspaceShellRouteStateService,
useValue: {
currentRoute: signal(route),
playlists: signal([]),
dashboardXtreamContext: signal(null),
currentContext: signal(route.context),
},
},
{
provide: WorkspaceShellSearchSyncService,
useValue: {
searchQuery: signal(''),
appliedSearchQuery: signal(''),
},
},
{ provide: SettingsSearchService, useValue: {} },
],
});
translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
PORTALS: { ALL_CATEGORIES: 'All categories' },
WORKSPACE: { SHELL: { RAIL_MOVIES: 'Movies' } },
});
translate.setTranslation('ru', {
PORTALS: { ALL_CATEGORIES: 'Все категории' },
WORKSPACE: { SHELL: { RAIL_MOVIES: 'Фильмы' } },
});
translate.use('en');
service = TestBed.inject(WorkspaceShellSearchService);
});
it('re-words the every-item genre in the scope after a language switch', () => {
categoryLabel.set({ name: '', labelKey: 'PORTALS.ALL_CATEGORIES' });
expect(service.searchScopeLabel()).toBe('Movies / All categories');
translate.use('ru');
expect(service.searchScopeLabel()).toBe('Фильмы / Все категории');
});
it('keeps a portal genre name as the portal sent it', () => {
categoryLabel.set({ name: 'Drama', labelKey: null });
translate.use('ru');
expect(service.searchScopeLabel()).toBe('Фильмы / Drama');
});
});
@@ -0,0 +1,54 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PlaylistRefreshActionService } from '@iptvnator/playlist/shared/ui';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
import { WorkspaceShellXtreamImportService } from './workspace-shell-xtream-import.service';
describe('WorkspaceShellXtreamImportService', () => {
it('re-words the import overlay after a runtime language switch', () => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
providers: [
WorkspaceShellXtreamImportService,
{
provide: XtreamStore,
useValue: {
getImportCount: signal(0),
itemsToImport: signal(0),
activeImportContentType: signal(null),
activeImportCurrentCount: signal(0),
activeImportTotalCount: signal(0),
currentImportPhase: signal(null),
isCancellingImport: signal(false),
},
},
{
provide: PlaylistRefreshActionService,
useValue: { refreshPreparation: signal(null) },
},
{ provide: RuntimeCapabilitiesService, useValue: {} },
{ provide: WorkspaceShellRouteStateService, useValue: {} },
],
});
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
WORKSPACE: { SHELL: { XTREAM_IMPORT_TITLE: 'Importing' } },
});
translate.setTranslation('ru', {
WORKSPACE: { SHELL: { XTREAM_IMPORT_TITLE: 'Импорт' } },
});
translate.use('en');
// The shell provides this service, so it outlives the Settings
// visit where the language is switched.
const service = TestBed.inject(WorkspaceShellXtreamImportService);
expect(service.xtreamImportTitleLabel()).toBe('Importing');
translate.use('ru');
expect(service.xtreamImportTitleLabel()).toBe('Импорт');
});
});
@@ -1,10 +1,11 @@
import { computed, inject, Injectable } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import { PlaylistRefreshActionService } from '@iptvnator/playlist/shared/ui';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import {
RuntimeCapabilitiesService,
injectTranslationTick,
} from '@iptvnator/services';
import type { XtreamImportPhaseTone } from './helpers/workspace-shell-constants';
import {
buildXtreamImportDetailLabel,
@@ -29,10 +30,7 @@ export class WorkspaceShellXtreamImportService {
private readonly translate = inject(TranslateService);
private readonly routeState = inject(WorkspaceShellRouteStateService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly languageTick = injectTranslationTick();
isSourceBusy(playlistId: string): boolean {
return (
@@ -106,6 +104,7 @@ export class WorkspaceShellXtreamImportService {
});
readonly xtreamImportTitleLabel = computed(() => {
this.languageTick();
if (this.activeRefreshPreparation()) {
return this.translateText('WORKSPACE.SHELL.XTREAM_REFRESH_TITLE');
}
@@ -122,6 +121,7 @@ export class WorkspaceShellXtreamImportService {
});
readonly xtreamImportProgressLabel = computed(() => {
this.languageTick();
const formatNumber = (value: number): string =>
formatLocalizedNumber(
value,
@@ -72,7 +72,10 @@ class MockXtreamStore {
class MockStalkerStore {
readonly searchPhrase = signal('');
readonly getSelectedCategoryName = signal('All Items');
readonly getSelectedCategoryLabel = signal({
name: 'All Items',
labelKey: null,
});
readonly itvFullListActive = signal(false);
setSearchPhrase = jest.fn((term: string) => this.searchPhrase.set(term));
@@ -1,8 +1,6 @@
import { computed, DestroyRef, inject, Injectable } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
import {
PORTAL_EXTERNAL_PLAYBACK,
@@ -14,6 +12,7 @@ import {
ParentalLockService,
RuntimeCapabilitiesService,
SettingsStore,
injectTranslationTick,
} from '@iptvnator/services';
import {
CommandBuilderActions,
@@ -48,10 +47,7 @@ export class WorkspaceShellFacade {
readonly headerContext = inject(WorkspaceHeaderContextService);
private readonly backNavigation = inject(WorkspaceBackNavigationService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly languageTick = injectTranslationTick();
// Root-provided; optional keeps standalone unit tests light. While the phone context drawer is modal, opening the command
// palette over it would stack two competing focus-trapped surfaces.
private readonly contextDrawer = inject(
@@ -8,6 +8,7 @@ import {
import {
RuntimeCapabilitiesService,
SourceActivityService,
injectTranslationTick,
} from '@iptvnator/services';
import {
PORTAL_EXTERNAL_PLAYBACK,
@@ -37,7 +38,7 @@ import {
selectActiveTypeFilters,
selectAllPlaylistsMeta,
} from '@iptvnator/m3u-state';
import { map, startWith } from 'rxjs';
import { map } from 'rxjs';
import { SortBy, SortOrder, SortService } from '@iptvnator/services';
import {
WORKSPACE_SHELL_ACTIONS,
@@ -126,10 +127,7 @@ export class WorkspaceSourcesComponent {
private readonly playlists = this.store.selectSignal(
selectAllPlaylistsMeta
);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly languageTick = injectTranslationTick();
readonly sortOptions: SortOption[] = [
{