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

@@ -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[] = [
{