diff --git a/.changes/i18n-language-switch-stale-labels.md b/.changes/i18n-language-switch-stale-labels.md new file mode 100644 index 000000000..6c178245d --- /dev/null +++ b/.changes/i18n-language-switch-stale-labels.md @@ -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. diff --git a/apps/web/src/app/settings/settings-app-update.facade.spec.ts b/apps/web/src/app/settings/settings-app-update.facade.spec.ts index 3feba5a49..c874bcdf2 100644 --- a/apps/web/src/app/settings/settings-app-update.facade.spec.ts +++ b/apps/web/src/app/settings/settings-app-update.facade.spec.ts @@ -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('Установлена последняя версия'); + }); }); }); diff --git a/apps/web/src/app/settings/settings-app-update.facade.ts b/apps/web/src/app/settings/settings-app-update.facade.ts index 9c1410759..463c4aa0a 100644 --- a/apps/web/src/app/settings/settings-app-update.facade.ts +++ b/apps/web/src/app/settings/settings-app-update.facade.ts @@ -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, + }); } /** diff --git a/apps/web/src/app/settings/settings-playlist-reset.facade.spec.ts b/apps/web/src/app/settings/settings-playlist-reset.facade.spec.ts index 6d52202d4..66bf74712 100644 --- a/apps/web/src/app/settings/settings-playlist-reset.facade.spec.ts +++ b/apps/web/src/app/settings/settings-playlist-reset.facade.spec.ts @@ -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([]); diff --git a/apps/web/src/app/settings/settings-playlist-reset.facade.ts b/apps/web/src/app/settings/settings-playlist-reset.facade.ts index d91fd47b4..e77bea756 100644 --- a/apps/web/src/app/settings/settings-playlist-reset.facade.ts +++ b/apps/web/src/app/settings/settings-playlist-reset.facade.ts @@ -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(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 diff --git a/apps/web/src/app/settings/settings-search.facade.ts b/apps/web/src/app/settings/settings-search.facade.ts index 5893c85e0..354dc0be2 100644 --- a/apps/web/src/app/settings/settings-search.facade.ts +++ b/apps/web/src/app/settings/settings-search.facade.ts @@ -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); private readonly injector = inject(Injector); private highlightTimeoutId: ReturnType | 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( diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index a8b695543..db039148c 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -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(() => { diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index ed0a9446d..ec77f0782 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -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 diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index ec723c8da..b5ca8d146 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -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 diff --git a/docs/architecture/stalker-store-api-baseline.md b/docs/architecture/stalker-store-api-baseline.md index e2041a2f4..78145c066 100644 --- a/docs/architecture/stalker-store-api-baseline.md +++ b/docs/architecture/stalker-store-api-baseline.md @@ -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: diff --git a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts index 1617a1c44..7eec09157 100644 --- a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts @@ -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, }, }, ], diff --git a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts index eeab453c2..277698d61 100644 --- a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts +++ b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts @@ -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(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(); diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.spec.ts index 90794de39..a3883e92c 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.spec.ts @@ -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() }); diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts index c5d7fcb18..b57dfa5ec 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts @@ -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(); /** 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; diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-harness.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-harness.ts index 6e0bc3069..e5fa43de3 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-harness.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-harness.ts @@ -128,6 +128,8 @@ export const translateServiceProvider = { useValue: { instant: (key: string) => key, onLangChange: EMPTY, + onDefaultLangChange: EMPTY, + onTranslationChange: EMPTY, }, }; diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts index 69c5a7233..7b0f32b5a 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts @@ -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 | 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); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts index cc1cfbe13..5193eefee 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts @@ -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 = diff --git a/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts b/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts index 4573a2b26..09baa69b0 100644 --- a/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts +++ b/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts @@ -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 && diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.ts b/libs/portal/downloads/feature/src/lib/downloads.component.ts index 5642aae2a..7c527989c 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.ts +++ b/libs/portal/downloads/feature/src/lib/downloads.component.ts @@ -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( diff --git a/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.html b/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.html index da30ba425..f530aec9f 100644 --- a/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.html +++ b/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.html @@ -12,11 +12,15 @@ (click)="categoryClicked.emit(item)" > - {{ - 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) + }} + } @if (showCounts()) {
diff --git a/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.ts b/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.ts index 9cfdec6b3..8fe4ef599 100644 --- a/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.ts +++ b/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.ts @@ -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; } diff --git a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.spec.ts b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.spec.ts index 7609b010c..9b22ff22e 100644 --- a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.spec.ts @@ -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', }, diff --git a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts index 6e8d7c7cb..ed43e9ec9 100644 --- a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts +++ b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts @@ -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 = 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(); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.spec.ts index 382b894cc..8ec96de9b 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.spec.ts @@ -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', }, diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index a85adb88a..0a8e8429a 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -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(null); readonly activeUid = signal(null); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.fullscreen-panel.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.fullscreen-panel.spec.ts index 4e8d81360..d65cb1fd7 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.fullscreen-panel.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.fullscreen-panel.spec.ts @@ -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(); diff --git a/libs/portal/stalker/data-access/src/index.ts b/libs/portal/stalker/data-access/src/index.ts index 38b488f26..4c26b655c 100644 --- a/libs/portal/stalker/data-access/src/index.ts +++ b/libs/portal/stalker/data-access/src/index.ts @@ -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'; diff --git a/libs/portal/stalker/data-access/src/lib/models/stalker-category-item.interface.ts b/libs/portal/stalker/data-access/src/lib/models/stalker-category-item.interface.ts index 98103990f..660e64be6 100644 --- a/libs/portal/stalker/data-access/src/lib/models/stalker-category-item.interface.ts +++ b/libs/portal/stalker/data-access/src/lib/models/stalker-category-item.interface.ts @@ -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 diff --git a/libs/portal/stalker/data-access/src/lib/stalker-category-label.ts b/libs/portal/stalker/data-access/src/lib/stalker-category-label.ts new file mode 100644 index 000000000..d5b1a70af --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-category-label.ts @@ -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; +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts index 2dd134352..00a7d29b7 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts @@ -118,7 +118,7 @@ describe('StalkerStore API compatibility smoke', () => { 'getCategoryResource', 'isCategoryResourceLoading', 'isCategoryResourceFailed', - 'getSelectedCategoryName', + 'getSelectedCategoryLabel', ]; for (const computedName of expectedComputed) { diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts index 9b1c85f17..9d51dff82 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts @@ -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), - }, - }, ], }); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts index 90c8f5df2..1db8684a6 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts @@ -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(() => { + const category = selectedListedCategory(); + return { + name: category?.category_name ?? '', + labelKey: category?.labelKey ?? null, + }; }), getPaginatedContent: computed(() => store.paginatedContent()), isPaginatedContentLoading: computed(() => diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts index aa01c458c..1c0c45c65 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts @@ -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 + > + ).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 >; ( - stalkerStoreMock['getSelectedCategory'] as ReturnType< - typeof signal | null> + stalkerStoreMock['getSelectedCategoryLabel'] as ReturnType< + typeof signal > - ).set({ category_id: '5', category_name: 'News' }); + ).set({ name: 'News', labelKey: null }); expect(service.selectedCategoryTitle()).toBe('News'); diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts index 9bc7da421..f511149b7 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts @@ -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(); 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( diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index 6c7e6509c..3a1bea7c7 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -128,7 +128,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { const isPaginatedContentLoading = signal(false); const stalkerStore = { - getSelectedCategoryName: signal('News'), + getSelectedCategoryLabel: signal({ name: 'News', labelKey: null }), itvChannels, radioChannels, searchPhrase, diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 5005aa1c1..c1ff4e779 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -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( diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.epg-races.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.epg-races.spec.ts index fc28b862c..504fb3b45 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.epg-races.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.epg-races.spec.ts @@ -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('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, + }, }, ], }) diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.panel-search.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.panel-search.spec.ts index 164d0c157..586db0bfb 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.panel-search.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.panel-search.spec.ts @@ -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; @@ -50,7 +54,10 @@ describe('StalkerLiveStreamLayoutComponent fullscreen panel search', () => { const itvFullListActive = signal(false); const itvSelectedCategoryFromCache = signal(false); const store = { - getSelectedCategoryName: signal('All'), + getSelectedCategoryLabel: signal({ + name: 'All', + labelKey: null, + }), currentPlaylist: signal({ _id: 'playlist-one', title: 'Portal One' }), selectedContentType: signal<'itv' | 'radio'>('itv'), selectedCategoryId: signal('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('Все категории'); + }); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.playback-session-key.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.playback-session-key.spec.ts index ec7844f4a..9c6e448ff 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.playback-session-key.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.playback-session-key.spec.ts @@ -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('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, + }, }, ], }) diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.remote-status.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.remote-status.spec.ts index ea8028e87..3953a48e4 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.remote-status.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.remote-status.spec.ts @@ -77,7 +77,7 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => { const selectedItvId = signal(undefined); const stalkerStore = { - getSelectedCategoryName: signal('News'), + getSelectedCategoryLabel: signal({ name: 'News', labelKey: null }), itvChannels, radioChannels, searchPhrase: signal(''), diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-hero.presenter.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-hero.presenter.ts index ee8cd3dd1..5578de0e9 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-hero.presenter.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-hero.presenter.ts @@ -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(() => diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index a84fb33e4..03649b8b6 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -12,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(); private readonly logger = createLogger('StalkerSeriesView'); readonly inlinePlayback = signal(null); diff --git a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.spec.ts b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.spec.ts index f40d19231..0d96ccc59 100644 --- a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.spec.ts @@ -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, }, }, ], diff --git a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts index 8032de338..de67ded6b 100644 --- a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts +++ b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts @@ -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( diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-hero.presenter.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-hero.presenter.ts index 6527d994b..6a2f003f8 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-hero.presenter.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-hero.presenter.ts @@ -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(() => [ diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-hero.presenter.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-hero.presenter.spec.ts new file mode 100644 index 000000000..d51eb152e --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-hero.presenter.spec.ts @@ -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('Смотреть'); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-hero.presenter.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-hero.presenter.ts index 7fd681a21..22f420c5a 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-hero.presenter.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-hero.presenter.ts @@ -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(() => { @@ -134,6 +141,7 @@ export class VodDetailsHeroPresenter { }); readonly primaryAction = computed(() => { + this.languageTick(); const bindings = this.bindings(); const state = bindings?.externalState() ?? 'idle'; const icon = bindings?.isOfflinePrimary() diff --git a/libs/services/README.md b/libs/services/README.md index 8a106f0f6..85cc311c0 100644 --- a/libs/services/README.md +++ b/libs/services/README.md @@ -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 diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index 7eaa9165c..44bf12399 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -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'; diff --git a/libs/services/src/lib/translation-tick.spec.ts b/libs/services/src/lib/translation-tick.spec.ts new file mode 100644 index 000000000..0ebae1596 --- /dev/null +++ b/libs/services/src/lib/translation-tick.spec.ts @@ -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>>(); + + getTranslation(lang: string): Observable> { + const subject = new Subject>(); + this.pending.set(lang, subject); + return subject; + } + + deliver(lang: string, dictionary: Record): 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'); + }); +}); diff --git a/libs/services/src/lib/translation-tick.ts b/libs/services/src/lib/translation-tick.ts new file mode 100644 index 000000000..a5ba6e542 --- /dev/null +++ b/libs/services/src/lib/translation-tick.ts @@ -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 { + const translate = inject(TranslateService); + return toSignal( + merge( + translate.onLangChange, + translate.onDefaultLangChange, + translate.onTranslationChange + ), + { initialValue: null } + ); +} diff --git a/libs/ui/components/src/lib/detail-ui/cast-crew-row.component.spec.ts b/libs/ui/components/src/lib/detail-ui/cast-crew-row.component.spec.ts index 1006ed35d..def39a47d 100644 --- a/libs/ui/components/src/lib/detail-ui/cast-crew-row.component.spec.ts +++ b/libs/ui/components/src/lib/detail-ui/cast-crew-row.component.spec.ts @@ -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); diff --git a/libs/ui/components/src/lib/detail-ui/cast-crew-row.component.ts b/libs/ui/components/src/lib/detail-ui/cast-crew-row.component.ts index 6adba7851..5c3f374b9 100644 --- a/libs/ui/components/src/lib/detail-ui/cast-crew-row.component.ts +++ b/libs/ui/components/src/lib/detail-ui/cast-crew-row.component.ts @@ -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(); private readonly translate = inject(TranslateService); + private readonly languageTick = injectTranslationTick(); - readonly entries = computed(() => - buildCastCrewEntries( + /** The "Director" role is worded per language: the tick re-runs this. */ + readonly entries = computed(() => { + this.languageTick(); + return buildCastCrewEntries( this.cast(), this.directors(), this.translate.instant('XTREAM.DIRECTOR') - ) - ); + ); + }); isClickable(entry: CastCrewEntry): boolean { return this.interactive() && !!entry.member.tmdbPersonId; diff --git a/libs/ui/components/src/lib/detail-ui/series-hero.state.spec.ts b/libs/ui/components/src/lib/detail-ui/series-hero.state.spec.ts index eea5bed29..38423c669 100644 --- a/libs/ui/components/src/lib/detail-ui/series-hero.state.spec.ts +++ b/libs/ui/components/src/lib/detail-ui/series-hero.state.spec.ts @@ -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>(); + const { hero } = createHero( + { + getTranslation: (): Observable> => + 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'); + }); +}); diff --git a/libs/ui/components/src/lib/detail-ui/series-hero.state.ts b/libs/ui/components/src/lib/detail-ui/series-hero.state.ts index 1005439a9..1d9aa50bb 100644 --- a/libs/ui/components/src/lib/detail-ui/series-hero.state.ts +++ b/libs/ui/components/src/lib/detail-ui/series-hero.state.ts @@ -60,6 +60,13 @@ export interface SeriesHeroStateDeps { readonly director: Signal; readonly quickStart: Signal; readonly translate: Pick; + /** + * 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; } /** @@ -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) => - deps.translate.instant(key, params); + const translate = (key: string, params?: Record) => { + deps.language(); + return deps.translate.instant(key, params); + }; const titleParts = computed(() => splitSeasonSuffix(deps.title())); const castMembers = computed(() => deps.tmdbCast()?.length diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts index c6ba666d3..32b51a83b 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts @@ -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( diff --git a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts index 98ad0ff77..7b300a378 100644 --- a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts +++ b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts @@ -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(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; diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts index dd22e9f10..fd5b0e49f 100644 --- a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts +++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts @@ -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( diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts index 26297d043..d832ac7b5 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts @@ -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( diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts index b74174879..a3696e349 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts @@ -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(null); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts index 1bd09d280..d22c51931 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts @@ -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(); diff --git a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.spec.ts b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.spec.ts new file mode 100644 index 000000000..d655343cd --- /dev/null +++ b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.spec.ts @@ -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 каналов'); + }); +}); diff --git a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts index 3f97a4dbe..a1f0eaa2e 100644 --- a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts +++ b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts @@ -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'); } diff --git a/libs/ui/playback/src/lib/vod-details/vod-details-hero.state.spec.ts b/libs/ui/playback/src/lib/vod-details/vod-details-hero.state.spec.ts new file mode 100644 index 000000000..fa678f516 --- /dev/null +++ b/libs/ui/playback/src/lib/vod-details/vod-details-hero.state.spec.ts @@ -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>(); + const { hero } = createHero( + { + getTranslation: (): Observable> => + 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'); + }); +}); diff --git a/libs/ui/playback/src/lib/vod-details/vod-details-hero.state.ts b/libs/ui/playback/src/lib/vod-details/vod-details-hero.state.ts index d2a8b4d7e..91eb4d5a4 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details-hero.state.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details-hero.state.ts @@ -43,6 +43,13 @@ export interface VodDetailsHeroStateDeps { readonly similarInPortals: Signal; readonly configuredPlayer: Signal; readonly translate: Pick; + /** + * 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; } /** @@ -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) => - deps.translate.instant(key, params); + const translate = (key: string, params?: Record) => { + deps.language(); + return deps.translate.instant(key, params); + }; const kindLabel = computed(() => { const kind = translate('WORKSPACE.DASHBOARD.TYPE_MOVIE'); const source = deps.sourceLabel()?.trim(); diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts index 9e4e4f49f..6c8632476 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts @@ -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 { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index 3ff8cc9b4..692f720f9 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -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', }, diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index 3be7f5b81..d00d483e9 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -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([]); private readonly xtreamRecentlyAddedItemsState = signal< diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts index 88b25cb9f..ee7bcac7c 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts @@ -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, }, diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts index eb747b5a8..b58748195 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts @@ -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>({}); private readonly tmdbExtras = signal< diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index f2d6f267b..ab628f50e 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -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); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html index 60bcce79c..d0d45d8aa 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html @@ -26,11 +26,15 @@ (contextmenu)="onCategoryContextMenu(item, $event)" > - {{ - 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) + }} + } @if (showCounts()) { @if (countDisplayMode() === 'loading') { diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts index 87571b9fe..410f89eab 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts @@ -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; } diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.language-switch.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.language-switch.spec.ts new file mode 100644 index 000000000..92ac61a0e --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.language-switch.spec.ts @@ -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: ``, +}) +class CategoryRailHostComponent { + readonly store = inject(CategoryStore); +} + +async function waitFor(predicate: () => boolean): Promise { + 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, + (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']); + } + ); +}); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts index 3b072efef..7c86f59d9 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts @@ -31,6 +31,7 @@ const translations: Record = { '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(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(); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts index ff449972a..5baffd7ea 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts @@ -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 ?? ''; } diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-header.service.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-header.service.ts index 1de73dcb6..2eee3d36d 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-header.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-header.service.ts @@ -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(); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-route-state.service.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-route-state.service.ts index 018c85c17..afdce13ce 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-route-state.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-route-state.service.ts @@ -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); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.spec.ts index 2245f02f0..3cb28f431 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.spec.ts @@ -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), }, }, diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts index 20f39ef30..c8a910937 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts @@ -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) diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.settings.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.settings.spec.ts index 112159332..ea7f27b82 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.settings.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.settings.spec.ts @@ -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, }, }, { diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.stalker-scope.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.stalker-scope.spec.ts new file mode 100644 index 000000000..726284c35 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.stalker-scope.spec.ts @@ -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({ + 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'); + }); +}); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.spec.ts new file mode 100644 index 000000000..3802adb96 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.spec.ts @@ -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('Импорт'); + }); +}); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.ts index 951de74b8..8ada0d20d 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.ts @@ -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, diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts index 3db8490b5..7d76bd215 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts @@ -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)); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts index 276abcf58..71d39bf38 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts @@ -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( diff --git a/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts index 168d3924e..9db0b07cf 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts @@ -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[] = [ {