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