refactor(collections): split the unified collection page and live tab (#1642)

* refactor(collections): split the unified collection page and live tab

Both files sat far above the workspace's 400-line hard maximum and were
only green because `tools/eslint/max-lines-baseline.mjs` exempted them.
No behavior change: every template binding, public signal and handler the
components exposed still resolves the same way, and the two baseline
entries are gone.

`unified-collection-page.component.ts` (966 → 398 lines) keeps the view
surface and delegates:

- `unified-collection-data.service.ts` — component-provided; owns the
  rows, the favorite uid set, the loading/reload indicators and every
  mutation, plus `loadedRequest` (the PR #1636 invariant, now read through
  one `mutationRequest` computed instead of two ad-hoc fallbacks)
- `unified-collection-load.ts` — the reload key and the load effect
- `unified-collection-scope.ts` — the This-playlist/All-playlists toggle
- `unified-collection-content-type.ts` — the Live/Movies/Series tab
- `unified-collection-history.ts` — the `window.history.state` view entry
- `unified-collection-detail-state.ts` / `-detail-navigation.ts` — the
  inline detail and where an item this route cannot render goes instead
- `unified-collection-clear-action.ts`, `-labels.ts`, `-favorites-sort.ts`

`unified-live-tab.component.ts` (1051 → 426 lines) continues the pattern
its siblings already established:

- `unified-live-selection.ts` (+ `-selection-generation.ts`) — activating
  a row while the mounted player stays alive
- `unified-live-selection-view.ts` — what the selection implies about the
  source and the player surface
- `unified-live-epg-view.ts`, `unified-live-epg-map.ts` — the EPG panel
  and the rail's now-playing map
- `unified-live-catchup.ts` — the timeshift override
- `unified-live-recording-metadata.ts`, `unified-live-channel-rows.ts`

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

* refactor(collections): move the collection data service into data-access

Codex review on PR #1642: `UnifiedCollectionDataService` loads and persists
favorites/recent rows, which CLAUDE.md places in
`@iptvnator/portal/shared/data-access`, not in the `type:ui` project that
renders them — and the dialog-scoped `SourceCleanupService` already sets that
precedent for a component-provided stateful collection service. It was also
the only non-root `@Injectable()` in `portal-shared-ui`.

`collection-reload-indicator.ts` moves with it: `type:data-access` may not
depend on `type:ui`, and the indicator is the service's own loading state
machine rather than a view. Both are exported from the data-access collection
barrel, so the boundary is now lint-enforced instead of conventional.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 authored and GitHub committed 2026-09-20 19:51:34 +02:00
1 parent 1471e7af36
commit 975b1f7f74
27 files changed
+2552 -1541

No files matched your search

+3 -2
View File
@@ -564,14 +564,15 @@ shows nothing. `aria-busy` is set immediately, since it does not paint. A
superseded reload keeps the earliest deadline and never clears the indicator;
only the latest request's completion does. Reference implementation:
`createCollectionReloadIndicator` in
`libs/portal/shared/ui/src/lib/components/unified-collection/collection-reload-indicator.ts`.
`libs/portal/shared/data-access/src/lib/collection/collection-reload-indicator.ts`.
Controls that triggered the reload stay enabled and reflect the requested
value at once (`scope.set()` runs synchronously before the load starts).
Because the rows on screen then belong to the PREVIOUS request, actions on
them (Clear, drag reorder) must bind to the request that loaded those rows,
never to the toggle's current value — "This playlist" applied to still-mounted
global rows would delete other playlists' favorites or write foreign URLs
into this playlist (`loadedRequest` on the collection page).
into this playlist (`loadedRequest` on `UnifiedCollectionDataService`, which
the collection page reads through its own `mutationRequest`).
## Navigation Lists
+5 -3
View File
@@ -1227,9 +1227,11 @@ class EpgService {
- The unified favorites/recent live tab
(`libs/portal/shared/ui/.../unified-collection/unified-live-tab.component.ts`)
hosts the same timeline but does not use the NgRx playlist state; it keeps
its own `activeTimeshift` signal, resolves the replay URL with
`resolveM3uCatchupUrl`, and swaps the inline player's playback target (or
hands the URL to the configured external player). Selecting another channel,
its own `activeTimeshift` signal and hands it to
`createUnifiedLiveCatchup` (`unified-live-catchup.ts`), which resolves the
replay URL with `resolveM3uCatchupUrl` and swaps the inline player's
playback target (or hands the URL to the configured external player).
Selecting another channel,
closing the player, or "Return to live" clears the override.
- Catch-up activation is never silent: if the replay URL cannot be resolved
for a programme the user clicked, both hosts surface a
@@ -1,3 +1,5 @@
export * from './collection-reload-indicator';
export * from './unified-collection-data.service';
export * from './unified-favorites-data.service';
export * from './unified-recent-data.service';
export * from './stream-resolver.service';
@@ -0,0 +1,204 @@
import { DestroyRef, inject, Injectable, Signal, signal } from '@angular/core';
import {
CollectionContentType,
CollectionScope,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import { createCollectionReloadIndicator } from './collection-reload-indicator';
import { UnifiedFavoritesDataService } from './unified-favorites-data.service';
import { UnifiedRecentDataService } from './unified-recent-data.service';
export type CollectionMode = 'favorites' | 'recent';
export interface CollectionLoadRequest {
scope: CollectionScope;
playlistId?: string;
portalType?: string;
}
/**
* Reads and writes the rows of one unified collection (a favorites or a
* recently-viewed list) and owns their loading state. Not `providedIn:
* 'root'`: `UnifiedCollectionPageComponent` provides it, so every mounted
* page has its own; the page itself keeps only the view concerns on top of
* this.
*/
@Injectable()
export class UnifiedCollectionDataService {
private readonly favoritesData = inject(UnifiedFavoritesDataService);
private readonly recentData = inject(UnifiedRecentDataService);
private readonly reloadIndicator = createCollectionReloadIndicator(
inject(DestroyRef)
);
/** First load with nothing on screen: the skeleton replaces the content. */
readonly isLoading = signal(true);
/**
* A reload (scope switch, favorites reload) is in flight while the
* previous items stay mounted. Never swaps to the skeleton, so a playing
* channel and the focused toggle survive.
*/
readonly isReloading = this.reloadIndicator.active;
/** `isReloading` past its grace period: progress bar + dimming render. */
readonly showReloadIndicator = this.reloadIndicator.visible;
readonly allItems = signal<UnifiedCollectionItem[]>([]);
readonly favoriteUidSet = signal<ReadonlySet<string>>(new Set<string>());
private readonly request = signal<CollectionLoadRequest | null>(null);
/**
* The request that produced `allItems`. Actions on displayed rows (Clear,
* drag reorder) must use it, not the scope toggle's current value: during
* a reload the toggle already names the requested scope while the
* previous rows are still on screen, and "This playlist" against global
* rows would delete other playlists' favorites or write foreign URLs into
* this playlist.
*/
readonly loadedRequest: Signal<CollectionLoadRequest | null> =
this.request.asReadonly();
private requestId = 0;
/**
* Replace the collection with the rows the request resolves to. Returns
* the loaded rows, or `null` when the load was superseded by a newer one
* or failed — in both cases the caller must not post-process it.
*/
async load(
params: CollectionLoadRequest & { mode: CollectionMode }
): Promise<UnifiedCollectionItem[] | null> {
const requestId = ++this.requestId;
if (this.allItems().length === 0) {
this.isLoading.set(true);
} else {
this.reloadIndicator.begin();
}
try {
const items =
params.mode === 'favorites'
? await this.favoritesData.getFavorites(
params.scope,
params.playlistId,
params.portalType
)
: await this.recentData.getRecentItems(
params.scope,
params.playlistId,
params.portalType
);
const favoriteUids =
params.mode === 'favorites'
? new Set(items.map((item) => item.uid))
: await this.loadFavoriteUidSet(params);
if (requestId !== this.requestId) {
return null;
}
this.allItems.set(items);
this.request.set({
scope: params.scope,
playlistId: params.playlistId,
portalType: params.portalType,
});
this.favoriteUidSet.set(favoriteUids);
return items;
} catch {
if (requestId !== this.requestId) {
return null;
}
this.allItems.set([]);
return null;
} finally {
if (requestId === this.requestId) {
this.isLoading.set(false);
this.reloadIndicator.settle();
}
}
}
async removeItem(
mode: CollectionMode,
item: UnifiedCollectionItem
): Promise<void> {
if (mode === 'favorites') {
await this.favoritesData.removeFavorite(item);
this.favoriteUidSet.update((favoriteUids) => {
const nextFavoriteUids = new Set(favoriteUids);
nextFavoriteUids.delete(item.uid);
return nextFavoriteUids;
});
} else {
await this.recentData.removeRecentItem(item);
}
this.allItems.update((items) =>
items.filter((i) => i.uid !== item.uid)
);
}
async toggleFavorite(item: UnifiedCollectionItem): Promise<void> {
const nextFavoriteUids = new Set(this.favoriteUidSet());
if (nextFavoriteUids.has(item.uid)) {
await this.favoritesData.removeFavorite(item);
nextFavoriteUids.delete(item.uid);
} else {
await this.favoritesData.addFavorite(item);
nextFavoriteUids.add(item.uid);
}
this.favoriteUidSet.set(nextFavoriteUids);
}
async reorder(
items: UnifiedCollectionItem[],
request: CollectionLoadRequest
): Promise<void> {
const nonLive = this.allItems().filter((i) => i.contentType !== 'live');
this.allItems.set([...items, ...nonLive]);
await this.favoritesData.reorder(items, request);
}
/** Drop every row of one type from the list, without persisting. */
dropContentType(contentType: CollectionContentType): void {
this.allItems.update((items) =>
items.filter((item) => item.contentType !== contentType)
);
}
clearFavorites(items: UnifiedCollectionItem[]): Promise<void> {
return this.favoritesData.clearFavorites(items);
}
removeRecentItemsBatch(items: UnifiedCollectionItem[]): void {
void this.recentData.removeRecentItemsBatch(items);
}
/** Move a just-played item back to the head of the recent list. */
promoteRecentItem(item: UnifiedCollectionItem): void {
this.allItems.update((items) => {
const nextItems = [
item,
...items.filter((candidate) => candidate.uid !== item.uid),
];
return nextItems.sort(
(a, b) =>
new Date(b.viewedAt ?? 0).getTime() -
new Date(a.viewedAt ?? 0).getTime()
);
});
}
private async loadFavoriteUidSet(
params: CollectionLoadRequest
): Promise<ReadonlySet<string>> {
try {
const favorites = await this.favoritesData.getFavorites(
params.scope,
params.playlistId,
params.portalType
);
return new Set(favorites.map((item) => item.uid));
} catch {
return new Set<string>();
}
}
}
@@ -0,0 +1,144 @@
import { effect, inject, Signal } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import {
CollectionContentType,
UnifiedCollectionItem,
WorkspaceViewCommandService,
} from '@iptvnator/portal/shared/util';
import {
CollectionMode,
UnifiedCollectionDataService,
} from '@iptvnator/portal/shared/data-access';
import { DialogService } from '@iptvnator/ui/components';
import { resolveClearCollectionDialogKeys } from './unified-collection-labels';
export interface ClearCollectionAction {
/** Ask for confirmation, then clear every row of the tab on screen. */
run(): void;
}
/**
* "Clear this tab" for a unified collection. The rows leave the screen as
* soon as the user confirms; a favorites write that then fails puts the
* collection back the way storage has it. Must run in an injection context.
*/
export function createClearCollectionAction(options: {
mode: Signal<CollectionMode>;
/** Rows of the tab on screen — the ones this clears. */
items: Signal<UnifiedCollectionItem[]>;
typeLabelKey: Signal<string>;
/** Whether those rows came from a single playlist; names the wording. */
isPlaylistScope: () => boolean;
data: UnifiedCollectionDataService;
/** Drop the cleared tab and move to one that still has rows. */
dropCurrentType: () => void;
/** Restore the collection from storage after a failed write. */
reload: () => Promise<void>;
}): ClearCollectionAction {
const dialogService = inject(DialogService);
const translate = inject(TranslateService);
const clearFavorites = async (
itemsToRemove: UnifiedCollectionItem[]
): Promise<void> => {
options.dropCurrentType();
try {
await options.data.clearFavorites(itemsToRemove);
} catch {
await options.reload();
}
};
return {
run(): void {
const itemsToRemove = options.items();
if (itemsToRemove.length === 0) {
return;
}
const isFavorites = options.mode() === 'favorites';
const type = translate.instant(options.typeLabelKey());
const { titleKey, messageKey } = resolveClearCollectionDialogKeys(
options.mode(),
options.isPlaylistScope()
);
dialogService.openConfirmDialog({
title: translate.instant(titleKey, { type }),
message: translate.instant(messageKey, { type }),
onConfirm: async () => {
if (isFavorites) {
await clearFavorites(itemsToRemove);
return;
}
options.dropCurrentType();
options.data.removeRecentItemsBatch(itemsToRemove);
},
});
},
};
}
export interface ClearCollectionViewCommandHost {
/** Only the workspace shell renders a command palette to register into. */
isWorkspaceLayout: boolean;
mode: Signal<CollectionMode>;
selectedContentType: Signal<CollectionContentType>;
/** Rows the command would clear; with none there is nothing to offer. */
currentTypeItems: Signal<UnifiedCollectionItem[]>;
labelKey: Signal<string>;
typeLabelKey: Signal<string>;
run: () => void;
}
/**
* Publish "clear the current tab" to the workspace command palette while the
* tab has rows. Must run in an injection context (the hosting component's
* field initializer or constructor).
*/
export function setupClearCollectionViewCommand(
host: ClearCollectionViewCommandHost
): void {
const workspaceViewCommands = inject(WorkspaceViewCommandService);
const translate = inject(TranslateService);
const typeParams = () => ({
type: translate.instant(host.typeLabelKey()),
});
effect((onCleanup) => {
if (!host.isWorkspaceLayout || host.currentTypeItems().length === 0) {
return;
}
onCleanup(
workspaceViewCommands.registerCommand({
id: `unified-collection-clear-current-${host.mode()}`,
group: 'view',
icon: 'delete_sweep',
labelKey: host.labelKey(),
labelParams: typeParams,
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.CLEAR_CURRENT_VIEW_DESCRIPTION',
descriptionParams: typeParams,
keywords: () =>
host.mode() === 'favorites'
? [
'clear',
'favorites',
'remove',
host.selectedContentType(),
]
: [
'clear',
'recent',
'history',
host.selectedContentType(),
],
priority: 10,
run: host.run,
})
);
});
}
@@ -0,0 +1,96 @@
import { computed, Signal, signal, WritableSignal } from '@angular/core';
import {
CollectionContentType,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
/**
* The Live / Movies / Series tab of a unified collection, plus the item
* partition it selects from. A tab exists only while the collection holds
* items of that type, so the state also owns the correction that runs after
* every load and every bulk removal.
*/
export interface CollectionContentTypeState {
readonly selected: WritableSignal<CollectionContentType>;
readonly liveItems: Signal<UnifiedCollectionItem[]>;
readonly movieItems: Signal<UnifiedCollectionItem[]>;
readonly seriesItems: Signal<UnifiedCollectionItem[]>;
readonly hasLive: Signal<boolean>;
readonly hasMovies: Signal<boolean>;
readonly hasSeries: Signal<boolean>;
readonly availableTypes: Signal<CollectionContentType[]>;
readonly showToggle: Signal<boolean>;
readonly currentTypeItems: Signal<UnifiedCollectionItem[]>;
readonly currentTypeLabelKey: Signal<string>;
/** Move off a tab the collection no longer has items for. */
autoSelect(): void;
}
export function createCollectionContentTypeState(
allItems: Signal<readonly UnifiedCollectionItem[]>,
initialType: CollectionContentType
): CollectionContentTypeState {
const selected = signal<CollectionContentType>(initialType);
const itemsOfType = (contentType: CollectionContentType) =>
computed(() =>
allItems().filter((item) => item.contentType === contentType)
);
const liveItems = itemsOfType('live');
const movieItems = itemsOfType('movie');
const seriesItems = itemsOfType('series');
const hasLive = computed(() => liveItems().length > 0);
const hasMovies = computed(() => movieItems().length > 0);
const hasSeries = computed(() => seriesItems().length > 0);
const availableTypes = computed(() => {
const types: CollectionContentType[] = [];
if (hasLive()) types.push('live');
if (hasMovies()) types.push('movie');
if (hasSeries()) types.push('series');
return types;
});
const currentTypeItems = computed(() => {
switch (selected()) {
case 'live':
return liveItems();
case 'movie':
return movieItems();
case 'series':
return seriesItems();
}
});
const currentTypeLabelKey = computed(() => {
switch (selected()) {
case 'live':
return 'PORTALS.LIVE_TV';
case 'movie':
return 'PORTALS.MOVIES';
case 'series':
return 'PORTALS.SERIES';
}
});
return {
selected,
liveItems,
movieItems,
seriesItems,
hasLive,
hasMovies,
hasSeries,
availableTypes,
showToggle: computed(() => availableTypes().length > 1),
currentTypeItems,
currentTypeLabelKey,
autoSelect(): void {
const types = availableTypes();
if (types.length > 0 && !types.includes(selected())) {
selected.set(types[0]);
}
},
};
}
@@ -0,0 +1,88 @@
import {
buildGlobalCollectionDetailNavigationTarget,
getUnifiedCollectionNavigation,
SeriesResumeTarget,
STALKER_RETURN_TO_STATE_KEY,
UnifiedCollectionItem,
WorkspaceNavigationTarget,
} from '@iptvnator/portal/shared/util';
/**
* Whether the route on screen may host this item's detail inline. A portal's
* own favorites/recent tabs render only their own provider's items, while the
* global collections host both portal providers; anything else falls back to
* the portal type the page was configured with.
*/
export function canRenderCollectionDetailOnRoute(
routerUrl: string,
portalType: string | undefined,
item: UnifiedCollectionItem
): boolean {
if (routerUrl.includes('/workspace/xtreams/')) {
return item.sourceType === 'xtream';
}
if (routerUrl.includes('/workspace/stalker/')) {
return item.sourceType === 'stalker';
}
if (
routerUrl.includes('/workspace/global-favorites') ||
routerUrl.includes('/workspace/global-recent')
) {
return item.sourceType === 'xtream' || item.sourceType === 'stalker';
}
return !portalType || portalType === item.sourceType;
}
/**
* Route to the item's detail inside the matching global collection. Live
* channels and M3U items have no such detail, so they keep the ordinary
* in-portal navigation below.
*/
export function buildCollectionDetailNavigation(
mode: 'favorites' | 'recent',
item: UnifiedCollectionItem,
seriesResume?: SeriesResumeTarget | null
): WorkspaceNavigationTarget | null {
if (
item.contentType === 'live' ||
(item.sourceType !== 'xtream' && item.sourceType !== 'stalker')
) {
return null;
}
return buildGlobalCollectionDetailNavigationTarget(
mode,
item,
seriesResume
);
}
/**
* Ordinary navigation into the item's own portal. A Stalker detail carries
* the current URL so its back affordance returns to this collection instead
* of the portal's catalog root.
*/
export function buildCollectionPortalNavigation(
item: UnifiedCollectionItem,
routerUrl: string
): WorkspaceNavigationTarget | null {
const navigation = getUnifiedCollectionNavigation(item);
if (!navigation) {
return null;
}
if (item.sourceType !== 'stalker' || item.contentType === 'live') {
return navigation;
}
return {
...navigation,
state: {
...(navigation.state ?? {}),
[STALKER_RETURN_TO_STATE_KEY]: routerUrl,
},
};
}
@@ -0,0 +1,166 @@
import {
computed,
effect,
inject,
signal,
Signal,
untracked,
WritableSignal,
} from '@angular/core';
import { Router } from '@angular/router';
import {
clearNavigationStateKeys,
CollectionContentType,
getOpenCollectionDetailItemState,
OPEN_COLLECTION_DETAIL_STATE_KEY,
SeriesResumeTarget,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import { CollectionMode } from '@iptvnator/portal/shared/data-access';
import {
buildCollectionDetailNavigation,
canRenderCollectionDetailOnRoute,
} from './unified-collection-detail-navigation';
import { UnifiedCollectionDetailContext } from './unified-collection-detail.directive';
export interface CollectionDetailState {
readonly item: WritableSignal<UnifiedCollectionItem | null>;
readonly seriesResume: WritableSignal<SeriesResumeTarget | null>;
/** Template context for the host's projected detail template. */
readonly context: Signal<UnifiedCollectionDetailContext | null>;
/** Whether this route and host can render the item's detail in place. */
canOpen(item: UnifiedCollectionItem): boolean;
open(
item: UnifiedCollectionItem,
seriesResume?: SeriesResumeTarget | null
): void;
clear(): void;
/** Open, keep, or close the inline detail per the history entry. */
syncFromHistory(): void;
}
/**
* The detail a collection renders in place of its list. Only the portal
* providers have one, and only where the host projected a detail template;
* an item belonging to a provider this route cannot render is handed over to
* the matching global collection instead of being dropped.
*
* Must run in an injection context (the hosting component's field
* initializer or constructor).
*/
export function createCollectionDetailState(options: {
mode: Signal<CollectionMode>;
portalType: Signal<string | undefined>;
/** Absent while the host projects no detail template. */
detailTemplate: Signal<unknown>;
/** Show the tab the opened item belongs to. */
onOpen: (contentType: CollectionContentType) => void;
/** The detail closed: fall back to a tab that still has rows. */
onClear: () => void;
}): CollectionDetailState {
const router = inject(Router);
const item = signal<UnifiedCollectionItem | null>(null);
const seriesResume = signal<SeriesResumeTarget | null>(null);
const canRenderOnCurrentRoute = (
candidate: UnifiedCollectionItem
): boolean =>
canRenderCollectionDetailOnRoute(
router.url,
options.portalType(),
candidate
);
const canOpen = (candidate: UnifiedCollectionItem): boolean =>
Boolean(options.detailTemplate()) &&
candidate.contentType !== 'live' &&
(candidate.sourceType === 'xtream' ||
candidate.sourceType === 'stalker') &&
canRenderOnCurrentRoute(candidate);
const open = (
next: UnifiedCollectionItem,
nextSeriesResume?: SeriesResumeTarget | null
): void => {
options.onOpen(next.contentType);
item.set(next);
seriesResume.set(nextSeriesResume ?? null);
};
const clear = (): void => {
item.set(null);
seriesResume.set(null);
options.onClear();
clearNavigationStateKeys([OPEN_COLLECTION_DETAIL_STATE_KEY]);
};
const requestClose = (): void => {
if (getOpenCollectionDetailItemState(window.history.state)) {
window.history.back();
return;
}
clear();
};
// A detail this route cannot render (a Stalker item inside an Xtream
// portal's own tabs, say) belongs to the matching global collection.
effect(() => {
const current = item();
if (!current || canRenderOnCurrentRoute(current)) {
return;
}
const navigation = buildCollectionDetailNavigation(
options.mode(),
current,
seriesResume()
);
if (!navigation) {
return;
}
untracked(() => {
item.set(null);
void router.navigate(navigation.link, {
state: navigation.state,
});
});
});
return {
item,
seriesResume,
context: computed<UnifiedCollectionDetailContext | null>(() => {
const current = item();
if (!current) {
return null;
}
return {
$implicit: current,
item: current,
seriesResume: seriesResume(),
close: requestClose,
};
}),
canOpen,
open,
clear,
syncFromHistory(): void {
const detailState = getOpenCollectionDetailItemState(
window.history.state
);
const detailItem = detailState?.item;
if (detailItem && canOpen(detailItem)) {
open(detailItem, detailState.seriesResume);
return;
}
if (item()) {
clear();
}
},
};
}
@@ -0,0 +1,66 @@
import { computed, Signal, signal } from '@angular/core';
import {
FavoritesChannelSortMode,
getFavoritesChannelSortModeTranslationKey,
persistFavoritesChannelSortMode,
restoreFavoritesChannelSortMode,
} from '@iptvnator/portal/shared/util';
export interface FavoritesSortOption {
readonly mode: FavoritesChannelSortMode;
readonly translationKey: string;
readonly icon: string;
}
export const FAVORITES_SORT_OPTIONS: readonly FavoritesSortOption[] = [
{
mode: 'custom',
translationKey: 'WORKSPACE.SORT_CUSTOM',
icon: 'drag_indicator',
},
{
mode: 'name-asc',
translationKey: 'WORKSPACE.SORT_NAME_ASC',
icon: 'sort_by_alpha',
},
{
mode: 'name-desc',
translationKey: 'WORKSPACE.SORT_NAME_DESC',
icon: 'sort_by_alpha',
},
{
mode: 'date-desc',
translationKey: 'WORKSPACE.SORT_DATE_DESC',
icon: 'schedule',
},
];
export interface FavoritesSortState {
readonly mode: Signal<FavoritesChannelSortMode>;
readonly labelKey: Signal<string>;
readonly options: readonly FavoritesSortOption[];
setMode(mode: FavoritesChannelSortMode): void;
}
/**
* Sort order of the favorites live rail. The choice is global rather than
* per collection, so it is restored from — and written straight back to —
* the shared persisted value.
*/
export function createFavoritesSortState(): FavoritesSortState {
const mode = signal<FavoritesChannelSortMode>(
restoreFavoritesChannelSortMode()
);
return {
mode: mode.asReadonly(),
labelKey: computed(() =>
getFavoritesChannelSortModeTranslationKey(mode())
),
options: FAVORITES_SORT_OPTIONS,
setMode(next: FavoritesChannelSortMode): void {
mode.set(next);
persistFavoritesChannelSortMode(next);
},
};
}
@@ -0,0 +1,141 @@
import {
effect,
Signal,
signal,
untracked,
WritableSignal,
} from '@angular/core';
import {
buildCollectionViewState,
buildOpenCollectionDetailItemState,
COLLECTION_VIEW_STATE_KEY,
CollectionContentType,
CollectionScope,
CollectionViewState,
getCollectionViewState,
OPEN_COLLECTION_DETAIL_STATE_KEY,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
/**
* The collection's selected tab and scope live in `window.history.state`, so
* the browser's own Back/Forward restores the view the user left instead of
* the route defaults. This owns that one key of the current entry: reading
* it back after a popstate, and replacing it in place while the user works.
*/
export class CollectionViewStateHistory {
private readonly state = signal<CollectionViewState | null>(
getCollectionViewState(window.history.state)
);
/** View state of the history entry currently on screen. */
readonly current: Signal<CollectionViewState | null> =
this.state.asReadonly();
/** Re-read the entry — after a popstate, or on first content init. */
refresh(): CollectionViewState | null {
const collectionViewState = getCollectionViewState(
window.history.state
);
this.state.set(collectionViewState);
return collectionViewState;
}
/**
* Replace the current entry's view state. A no-op while nothing changed,
* so a repeated read never spends a `replaceState` on an identical value.
*/
commit(next: CollectionViewState): void {
const nextCollectionViewState = buildCollectionViewState(next);
if (isSameCollectionViewState(this.state(), nextCollectionViewState)) {
return;
}
const nextState = toHistoryStateRecord(window.history.state);
if (nextCollectionViewState) {
nextState[COLLECTION_VIEW_STATE_KEY] = nextCollectionViewState;
} else {
delete nextState[COLLECTION_VIEW_STATE_KEY];
}
window.history.replaceState(nextState, document.title);
this.state.set(nextCollectionViewState);
}
}
export interface CollectionViewStateSync {
/** Apply the entry's view state to the page (popstate, first init). */
restore(): void;
/** Write the page's current view state before it navigates away. */
commitCurrent(): void;
}
/**
* Keep the history entry and the page's own view state in step: the page
* writes every change back as it happens, and reads the entry again after
* the browser moved to another one. Must run in an injection context.
*/
export function setupCollectionViewStateSync(options: {
history: CollectionViewStateHistory;
selectedContentType: WritableSignal<CollectionContentType>;
/** `undefined` wherever the scope toggle is not offered. */
scope: Signal<CollectionScope | undefined>;
}): CollectionViewStateSync {
const currentViewState = (): CollectionViewState => ({
selectedContentType: options.selectedContentType(),
scope: options.scope(),
});
effect(() => {
const state = currentViewState();
untracked(() => options.history.commit(state));
});
return {
restore(): void {
const collectionViewState = options.history.refresh();
if (collectionViewState?.selectedContentType) {
options.selectedContentType.set(
collectionViewState.selectedContentType
);
}
},
commitCurrent(): void {
options.history.commit(currentViewState());
},
};
}
/**
* Push a history entry for an inline detail, so every way back out of it
* (Escape, the shell's Back arrow, the browser's own Back) returns to the
* list rather than leaving the route.
*/
export function pushOpenCollectionDetailState(
item: UnifiedCollectionItem
): void {
window.history.pushState(
{
...toHistoryStateRecord(window.history.state),
[OPEN_COLLECTION_DETAIL_STATE_KEY]:
buildOpenCollectionDetailItemState(item),
},
document.title
);
}
function isSameCollectionViewState(
left: CollectionViewState | null,
right: CollectionViewState | null
): boolean {
return (
left?.selectedContentType === right?.selectedContentType &&
left?.scope === right?.scope
);
}
function toHistoryStateRecord(state: unknown): Record<string, unknown> {
return state && typeof state === 'object'
? { ...(state as Record<string, unknown>) }
: {};
}
@@ -0,0 +1,64 @@
import { computed, Signal } from '@angular/core';
import { CollectionMode } from '@iptvnator/portal/shared/data-access';
export interface CollectionModeLabels {
readonly title: Signal<string>;
readonly clearTooltipKey: Signal<string>;
readonly emptyStateIcon: Signal<string>;
readonly emptyStateTitleKey: Signal<string>;
readonly emptyStateBodyKey: Signal<string>;
}
/**
* The copy a unified collection page shows for its mode. Favorites and
* recently viewed share every control, so only these strings differ.
*/
export function createCollectionModeLabels(
mode: Signal<CollectionMode>
): CollectionModeLabels {
const byMode = <T>(favorites: T, recent: T): Signal<T> =>
computed(() => (mode() === 'favorites' ? favorites : recent));
return {
title: byMode('PORTALS.FAVORITES', 'PORTALS.RECENTLY_VIEWED'),
clearTooltipKey: byMode(
'WORKSPACE.SHELL.CLEAR_FAVORITES_TYPE',
'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_TYPE'
),
emptyStateIcon: byMode('favorite_border', 'history_toggle_off'),
emptyStateTitleKey: byMode(
'WORKSPACE.GLOBAL_FAVORITES.NO_ITEMS_TITLE',
'WORKSPACE.GLOBAL_RECENT.NO_ITEMS_TITLE'
),
emptyStateBodyKey: byMode(
'WORKSPACE.GLOBAL_FAVORITES.NO_ITEMS_BODY',
'WORKSPACE.GLOBAL_RECENT.NO_ITEMS_BODY'
),
};
}
/**
* Confirmation copy for "clear the current tab". The message names what the
* action reaches: the playlist scope removes this playlist's rows, the
* global one every playlist's.
*/
export function resolveClearCollectionDialogKeys(
mode: CollectionMode,
isPlaylistScope: boolean
): { titleKey: string; messageKey: string } {
if (mode === 'favorites') {
return {
titleKey: 'WORKSPACE.SHELL.CLEAR_FAVORITES_DIALOG_TITLE',
messageKey: isPlaylistScope
? 'WORKSPACE.SHELL.CLEAR_FAVORITES_DIALOG_MESSAGE_PLAYLIST'
: 'WORKSPACE.SHELL.CLEAR_FAVORITES_DIALOG_MESSAGE_ALL',
};
}
return {
titleKey: 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_TITLE',
messageKey: isPlaylistScope
? 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST'
: 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_ALL',
};
}
@@ -0,0 +1,78 @@
import {
computed,
effect,
EffectRef,
inject,
Signal,
untracked,
} from '@angular/core';
import { Store } from '@ngrx/store';
import {
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import {
CollectionLoadRequest,
CollectionMode,
} from '@iptvnator/portal/shared/data-access';
import { CollectionScope } from '@iptvnator/portal/shared/util';
/**
* Re-load the collection whenever what it should contain changes: its mode,
* the playlist or scope it is bound to, and — for favorites — the playlist
* rows themselves, since a heart toggled anywhere else in the app lands
* there rather than in this page's state.
*
* Must run in an injection context (the hosting component's field
* initializer or constructor).
*/
export function setupCollectionLoad(options: {
mode: Signal<CollectionMode>;
portalType: Signal<string | undefined>;
playlistId: Signal<string | undefined>;
scope: Signal<CollectionScope>;
load: (request: CollectionLoadRequest & { mode: CollectionMode }) => void;
}): EffectRef {
const store = inject(Store);
const playlists = store.selectSignal(selectAllPlaylistsMeta);
const playlistsLoaded = store.selectSignal(selectPlaylistsLoadingFlag);
const favoritesReloadKey = computed(() => {
if (options.mode() !== 'favorites') {
return 'recent';
}
if (!playlistsLoaded()) {
return null;
}
return playlists()
.map((playlist) =>
[
playlist._id,
playlist.serverUrl
? 'xtream'
: playlist.macAddress
? 'stalker'
: 'm3u',
JSON.stringify(playlist.favorites ?? []),
].join('::')
)
.join('|');
});
const loadRequest = computed(() => ({
mode: options.mode(),
portalType: options.portalType(),
playlistId: options.playlistId(),
scope: options.scope(),
reloadKey: favoritesReloadKey(),
}));
return effect(() => {
const { mode, portalType, playlistId, scope } = loadRequest();
untracked(() => {
options.load({ mode, portalType, playlistId, scope });
});
});
}
@@ -35,7 +35,7 @@ import {
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { BehaviorSubject } from 'rxjs';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { COLLECTION_RELOAD_INDICATOR_DELAY_MS } from './collection-reload-indicator';
import { COLLECTION_RELOAD_INDICATOR_DELAY_MS } from '@iptvnator/portal/shared/data-access';
import { UnifiedCollectionPageComponent } from './unified-collection-page.component';
import { UnifiedCollectionDetailDirective } from './unified-collection-detail.directive';
import { UnifiedGridTabComponent } from './unified-grid-tab.component';
@@ -0,0 +1,78 @@
import {
computed,
inject,
linkedSignal,
Signal,
WritableSignal,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import {
CollectionScope,
queryParamSignal,
ScopeToggleService,
} from '@iptvnator/portal/shared/util';
export interface CollectionScopeState {
/** The toggle's value, even where the toggle itself is not offered. */
readonly scope: WritableSignal<CollectionScope>;
/** Only a playlist-bound collection can narrow to one playlist. */
readonly showToggle: Signal<boolean>;
/** The scope to load with: `all` wherever the toggle is absent. */
readonly effective: Signal<CollectionScope>;
/** The user picked a scope; remember it for the next visit. */
select(value: CollectionScope): void;
}
/**
* "This playlist" vs "All playlists" for a collection. The value is taken
* from the first source that states one — the `scope` query param, the
* history entry the user came back to, the route's own default — and falls
* back to what this collection was last left on. Must run in an injection
* context (the hosting component's field initializer or constructor).
*/
export function createCollectionScopeState(options: {
/** Keys the persisted scope: favorites and recent are remembered apart. */
scopeKey: Signal<string>;
playlistId: Signal<string | undefined>;
defaultScope: Signal<CollectionScope | undefined>;
historyScope: Signal<CollectionScope | undefined>;
}): CollectionScopeState {
const route = inject(ActivatedRoute);
const scopeService = inject(ScopeToggleService);
const queryScope = queryParamSignal<CollectionScope | null>(
route,
'scope',
(value) => (value === 'all' || value === 'playlist' ? value : null)
);
const persistedScope = computed(() =>
scopeService.getScope(options.scopeKey())()
);
const showToggle = computed(() => Boolean(options.playlistId()));
const scope = linkedSignal<CollectionScope>(() => {
if (!showToggle()) {
return 'all';
}
return (
queryScope() ??
options.historyScope() ??
options.defaultScope() ??
persistedScope()
);
});
return {
scope,
showToggle,
effective: computed(() => (showToggle() ? scope() : 'all')),
select(value: CollectionScope): void {
if (!showToggle()) {
return;
}
scope.set(value);
scopeService.setScope(options.scopeKey(), value);
},
};
}
@@ -0,0 +1,183 @@
import { computed, inject, Signal, WritableSignal } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { resolveM3uCatchupUrl } from '@iptvnator/shared/m3u-utils';
import {
ResolvedLiveCollectionDetail,
StreamResolverService,
} from '@iptvnator/portal/shared/data-access';
import {
PORTAL_PLAYER,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import {
EpgArchiveCopyService,
EpgProgramActivationEvent,
} from '@iptvnator/ui/epg';
import { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
import { toEpochSeconds } from './unified-live-epg-summary.util';
import { UnifiedLiveSelectionGeneration } from './unified-live-selection-generation';
/** Catch-up override for the active channel; `null` = live playback. */
export interface UnifiedLiveTimeshift {
url: string;
program: EpgProgram;
}
export interface UnifiedLiveCatchup {
readonly activeProgram: Signal<EpgProgram | null>;
/** Act on a programme picked in the timeline or the list view. */
handleProgramActivation(event: EpgProgramActivationEvent): void;
returnToLive(): void;
}
/**
* Catch-up playback for the live tab: turning a programme from the EPG
* panel into a timeshift URL, and getting back to the live edge. The
* override itself lives on the host, because the selection session has to
* swap it together with the detail it belongs to.
*
* Must run in an injection context (the hosting component's field
* initializer or constructor).
*/
export function createUnifiedLiveCatchup(options: {
activeTimeshift: WritableSignal<UnifiedLiveTimeshift | null>;
activeItem: Signal<UnifiedCollectionItem | null>;
activeDetail: Signal<ResolvedLiveCollectionDetail | null>;
isM3uSelection: Signal<boolean>;
currentM3uChannel: Signal<Channel | null>;
shouldUseInlinePlayer: Signal<boolean>;
/** Drops a portal resolution that landed after a channel switch. */
generation: UnifiedLiveSelectionGeneration;
}): UnifiedLiveCatchup {
const archiveCopy = inject(EpgArchiveCopyService);
const streamResolver = inject(StreamResolverService);
const portalPlayer = inject(PORTAL_PLAYER);
const snackBar = inject(MatSnackBar);
const translate = inject(TranslateService);
const reportFailure = (): void => {
snackBar.open(
translate.instant('EPG.TIMELINE.CATCHUP_FAILED'),
undefined,
{ duration: 4000 }
);
};
/** Hand the resolved archive URL to whichever surface is playing. */
const playArchive = (url: string, program: EpgProgram): void => {
options.activeTimeshift.set({ url, program });
const playback = options.activeDetail()?.playback;
if (!options.shouldUseInlinePlayer() && playback) {
void portalPlayer.openResolvedPlayback({
...playback,
streamUrl: url,
isLive: false,
});
}
};
/** M3U catch-up: resolve via the M3U timeshift URL resolver. */
const activateM3uCatchup = (program: EpgProgram): void => {
const playbackUrl = resolveM3uCatchupUrl(
options.currentM3uChannel(),
program
);
if (!playbackUrl) {
reportFailure();
return;
}
playArchive(playbackUrl, program);
};
/**
* Portal (Xtream) catch-up: compute start/stop as epoch seconds and
* resolve via the provider's timeshift endpoint.
*/
const activatePortalCatchup = async (
program: EpgProgram
): Promise<void> => {
const generation = options.generation.current();
const item = options.activeItem();
const startEpoch = toEpochSeconds(
program.startTimestamp,
program.start
);
const stopEpoch = toEpochSeconds(program.stopTimestamp, program.stop);
if (!item?.xtreamId || startEpoch == null || stopEpoch == null) {
reportFailure();
return;
}
const playbackUrl = await streamResolver.resolveXtreamCatchupUrl(
item,
startEpoch,
stopEpoch
);
if (generation !== options.generation.current()) {
return; // switched channel — discard silently
}
if (!playbackUrl) {
reportFailure();
return;
}
playArchive(playbackUrl, program);
};
const copyArchiveUrl = (program: EpgProgram): void => {
const channel = options.currentM3uChannel();
const item = options.activeItem();
const isM3u = options.isM3uSelection();
void archiveCopy.copy(() => {
if (isM3u) return resolveM3uCatchupUrl(channel, program);
const start = toEpochSeconds(program.startTimestamp, program.start);
const stop = toEpochSeconds(program.stopTimestamp, program.stop);
return item?.xtreamId &&
start != null &&
stop != null &&
stop > start
? streamResolver.resolveXtreamCatchupUrl(item, start, stop)
: null;
});
};
const returnToLive = (): void => {
options.activeTimeshift.set(null);
// Inline player is already (back) on the live stream once the
// timeshift override is cleared. With an external player configured,
// "Watch live" must open it even when no archive was active — e.g.
// openStreamOnDoubleClick shows the guide without launching playback.
const playback = options.activeDetail()?.playback;
if (!options.shouldUseInlinePlayer() && playback) {
void portalPlayer.openResolvedPlayback(playback);
}
};
return {
activeProgram: computed(
() => options.activeTimeshift()?.program ?? null
),
handleProgramActivation(event: EpgProgramActivationEvent): void {
if (event.type === 'copy-catchup-url') {
copyArchiveUrl(event.program);
return;
}
if (event.type === 'live') {
returnToLive();
return;
}
if (options.isM3uSelection()) {
activateM3uCatchup(event.program);
} else {
void activatePortalCatchup(event.program);
}
},
returnToLive,
};
}
@@ -0,0 +1,84 @@
import { computed, Signal } from '@angular/core';
import {
deriveVisibleFavoriteChannels,
FavoritesChannelSortMode,
UnifiedCollectionItem,
UnifiedFavoriteChannel,
} from '@iptvnator/portal/shared/util';
export interface UnifiedLiveChannelRows {
/** Every collection row, as the shared list renders them. */
readonly all: Signal<UnifiedFavoriteChannel[]>;
/**
* The channel list in exactly the order the sidebar renders it
* (search-filtered; sorted in favorites mode) — remote-control
* navigation and channel numbers must follow what is on screen.
*/
readonly visible: Signal<readonly UnifiedFavoriteChannel[]>;
/**
* Rows for the fullscreen channel panel. Radio is withheld: it renders
* through `app-audio-player` instead of `app-web-player-view`, so
* selecting a station destroys the element that owns fullscreen and
* drops the user out of it — the opposite of what the panel exists for.
* The page's own list keeps every row.
*/
readonly fullscreenPanel: Signal<UnifiedFavoriteChannel[]>;
}
export function createUnifiedLiveChannelRows(options: {
items: Signal<UnifiedCollectionItem[]>;
searchTerm: Signal<string>;
mode: Signal<'favorites' | 'recent'>;
sortMode: Signal<FavoritesChannelSortMode>;
}): UnifiedLiveChannelRows {
const all = computed((): UnifiedFavoriteChannel[] =>
options.items().map(toUnifiedFavoriteChannel)
);
return {
all,
visible: computed(() =>
deriveVisibleFavoriteChannels(all(), {
searchTerm: options.searchTerm(),
sortMode:
options.mode() === 'favorites' ? options.sortMode() : null,
getName: (channel) => channel.name,
getAddedAt: (channel) => channel.addedAt,
})
),
fullscreenPanel: computed(() =>
all().filter((channel) => channel.radio !== 'true')
),
};
}
/**
* Project a collection row onto the channel shape the shared favorites list
* renders. The two differ because a collection row also carries VOD fields
* the live rail has no use for.
*/
export function toUnifiedFavoriteChannel(
item: UnifiedCollectionItem
): UnifiedFavoriteChannel {
return {
uid: item.uid,
name: item.name,
logo: item.logo ?? null,
sourceType: item.sourceType,
playlistId: item.playlistId,
playlistName: item.playlistName,
streamUrl: item.streamUrl,
m3uChannel: item.m3uChannel,
radio: item.radio,
xtreamId: item.xtreamId,
tvArchive: item.tvArchive ?? null,
tvArchiveDuration: item.tvArchiveDuration ?? null,
tvgId: item.tvgId,
stalkerCmd: item.stalkerCmd,
stalkerPortalUrl: item.stalkerPortalUrl,
stalkerMacAddress: item.stalkerMacAddress,
addedAt: item.addedAt ?? new Date(0).toISOString(),
position: item.position ?? 0,
contentId: item.contentId,
};
}
@@ -0,0 +1,63 @@
import { effect, inject, Signal, signal, untracked } from '@angular/core';
import { StreamResolverService } from '@iptvnator/portal/shared/data-access';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import { EpgProgram } from '@iptvnator/shared/interfaces';
export interface UnifiedLiveEpgMap {
/** What is on now, per collection row, for the channel rail. */
readonly programs: Signal<Map<string, EpgProgram | null>>;
load(items: UnifiedCollectionItem[]): void;
clear(): void;
}
/**
* The "now playing" programme of every row in the live rail. Must run in an
* injection context (the hosting component's field initializer).
*/
export function createUnifiedLiveEpgMap(options: {
supportsEpg: boolean;
items: Signal<UnifiedCollectionItem[]>;
/** EPG display offset; the map answers "what is on at that clock". */
offsetMinutes: Signal<number>;
}): UnifiedLiveEpgMap {
const streamResolver = inject(StreamResolverService);
const programs = signal<Map<string, EpgProgram | null>>(new Map());
let requestId = 0;
const load = (items: UnifiedCollectionItem[]): void => {
// Only the latest load may install its map: an older one — started
// under a previous display offset or item set — must not overwrite
// the refreshed result when it completes last.
const currentRequestId = ++requestId;
void streamResolver.loadEpgForItems(items).then((epgMap) => {
if (currentRequestId === requestId) {
programs.set(epgMap);
}
});
};
// A changed display offset reloads the map; the request id above drops
// the older load. The first run only records the initial value.
let appliedOffsetMinutes: number | null = null;
effect(() => {
const offsetMinutes = options.offsetMinutes();
if (appliedOffsetMinutes === offsetMinutes) {
return;
}
const isInitial = appliedOffsetMinutes === null;
appliedOffsetMinutes = offsetMinutes;
if (isInitial || !options.supportsEpg) {
return;
}
load(untracked(() => options.items()));
});
return {
programs: programs.asReadonly(),
load,
clear(): void {
requestId += 1;
programs.set(new Map());
},
};
}
@@ -0,0 +1,177 @@
import { computed, inject, Signal, signal } from '@angular/core';
import {
applyChannelNameStrip,
getM3uArchiveDays,
isM3uCatchupPlaybackSupported,
} from '@iptvnator/shared/m3u-utils';
import { ResolvedLiveCollectionDetail } from '@iptvnator/portal/shared/data-access';
import {
LiveEpgPanelState,
persistLiveEpgPanelState,
restoreLiveEpgPanelState,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import {
EpgDateNavigationDirection,
getTodayEpgDateKey,
shiftEpgDateKey,
} from '@iptvnator/ui/epg';
import { SettingsStore } from '@iptvnator/services';
import {
Channel,
EpgItem,
EpgProgram,
EpgViewMode,
} from '@iptvnator/shared/interfaces';
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
import {
getLiveEpgPanelSummary,
toEpgProgram,
toLiveEpgPanelSummary,
} from './unified-live-epg-summary.util';
import { UnifiedLiveTimeshift } from './unified-live-catchup';
/**
* The EPG panel under the live player: its collapsed/expanded state, the
* day it is showing, and the programme data the timeline and list views
* render for whichever channel is selected.
*/
export interface UnifiedLiveEpgView {
readonly panelState: Signal<LiveEpgPanelState>;
readonly isPanelCollapsed: Signal<boolean>;
readonly selectedDate: Signal<string>;
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
readonly viewMode: Signal<EpgViewMode>;
readonly offsetMinutes: Signal<number>;
readonly timelinePrograms: Signal<EpgProgram[]>;
readonly timelineChannelName: Signal<string>;
readonly timelineChannelLogo: Signal<string>;
readonly timelineArchiveAvailable: Signal<boolean>;
readonly timelineArchiveDays: Signal<number>;
readonly panelSummary: Signal<LiveEpgPanelSummary | null>;
readonly panelSummaryLabelKey: Signal<string>;
setPanelCollapsed(collapsed: boolean): void;
navigateDate(direction: EpgDateNavigationDirection): void;
selectDate(selectedDate: string): void;
}
/**
* Must run in an injection context (the hosting component's field
* initializer or constructor).
*/
export function createUnifiedLiveEpgView(options: {
activeDetail: Signal<ResolvedLiveCollectionDetail | null>;
activeItem: Signal<UnifiedCollectionItem | null>;
activeTimeshift: Signal<UnifiedLiveTimeshift | null>;
isM3uSelection: Signal<boolean>;
currentM3uChannel: Signal<Channel | null>;
currentM3uPrograms: Signal<EpgProgram[]>;
currentPortalEpgItems: Signal<EpgItem[]>;
/** 30 s tick that re-evaluates "what is on now". */
progressTick: Signal<number>;
}): UnifiedLiveEpgView {
const settingsStore = inject(SettingsStore);
const panelState = signal<LiveEpgPanelState>(restoreLiveEpgPanelState());
const selectedDate = signal(getTodayEpgDateKey());
const offsetMinutes = settingsStore.resolvedEpgOffsetMinutes;
const currentM3uArchivePlaybackAvailable = computed(() =>
isM3uCatchupPlaybackSupported(options.currentM3uChannel())
);
const timelinePrograms = computed<EpgProgram[]>(() =>
options.isM3uSelection()
? options.currentM3uPrograms()
: // Portal EPG items normalised to the timeline programme shape.
options.currentPortalEpgItems().map((item) => toEpgProgram(item))
);
const timelineArchiveAvailable = computed(() => {
if (options.isM3uSelection()) {
return currentM3uArchivePlaybackAvailable();
}
// Portal archive: tvArchive === 1 means the provider has
// timeshift / archive enabled for this channel.
const item = options.activeItem();
return (
Number(item?.tvArchive ?? 0) === 1 &&
Number(item?.tvArchiveDuration ?? 0) > 0
);
});
return {
panelState: panelState.asReadonly(),
isPanelCollapsed: computed(() => panelState() === 'collapsed'),
selectedDate: selectedDate.asReadonly(),
viewMode: settingsStore.resolvedEpgViewMode,
offsetMinutes,
timelinePrograms,
timelineChannelName: computed(() =>
applyChannelNameStrip(
options.currentM3uChannel()?.name ??
options.activeDetail()?.playback?.title,
settingsStore.stripCountryPrefix?.()
)
),
timelineChannelLogo: computed(
() =>
options.currentM3uChannel()?.tvg?.logo ??
options.activeDetail()?.playback?.thumbnail ??
''
),
timelineArchiveAvailable,
/**
* Catch-up window (days) for the active channel, so the timeline can
* gate "Watch" to programmes inside it. Without this the timeline
* defaults `archiveDays` to 0 (treated as unlimited) and offers
* catch-up on programmes older than the real archive window.
*
* M3U: reads catchup-days / timeshift / tvg-rec from the channel
* attrs. Portal: tvArchiveDuration is already in days — pass it
* through, matching `live-stream-layout.controlledArchiveDays`.
*/
timelineArchiveDays: computed(() => {
if (!timelineArchiveAvailable()) return 0;
if (options.isM3uSelection()) {
return getM3uArchiveDays(options.currentM3uChannel());
}
return Math.max(
0,
Number(options.activeItem()?.tvArchiveDuration ?? 0) || 0
);
}),
panelSummary: computed(() => {
const timeshift = options.activeTimeshift();
if (timeshift) {
// Archive summary is frozen — don't track the 30s tick.
return toLiveEpgPanelSummary(timeshift.program);
}
options.progressTick();
return getLiveEpgPanelSummary(
options.activeDetail(),
offsetMinutes()
);
}),
panelSummaryLabelKey: computed(() =>
options.activeTimeshift()
? 'EPG.ARCHIVE_PLAYBACK'
: 'EPG.CURRENT_PROGRAM'
),
setPanelCollapsed(collapsed: boolean): void {
const state: LiveEpgPanelState = collapsed
? 'collapsed'
: 'expanded';
panelState.set(state);
persistLiveEpgPanelState(state);
},
navigateDate(direction: EpgDateNavigationDirection): void {
selectedDate.set(shiftEpgDateKey(selectedDate(), direction));
},
selectDate(nextDate: string): void {
selectedDate.set(nextDate);
},
};
}
@@ -0,0 +1,132 @@
import { computed, inject, Signal } from '@angular/core';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import { RecordingsService } from '@iptvnator/services';
import {
buildStalkerEpgMappingKey,
buildXtreamEpgMappingKey,
EpgProgram,
epgProviderClockMs,
filterRecordingProgramsOverlap,
playlistDisplayLabel,
RecordingStartMetadata,
RecordingStoppedEvent,
toRecordingProgramSnapshot,
} from '@iptvnator/shared/interfaces';
export interface UnifiedLiveRecording {
/** Channel/EPG snapshot for the embedded-MPV recording tracker. */
readonly metadata: Signal<RecordingStartMetadata | null>;
/** Stop enrichment: programs overlapping the recorded window. */
handleRecordingStopped(event: RecordingStoppedEvent): void;
}
/**
* What a live recording started from this tab records about its channel.
* Provider EPG never reaches SQLite, so the schedule can only be captured
* while the stream is on screen. Must run in an injection context.
*/
export function createUnifiedLiveRecording(options: {
activeItem: Signal<UnifiedCollectionItem | null>;
timelinePrograms: Signal<EpgProgram[]>;
timelineChannelLogo: Signal<string>;
epgOffsetMinutes: Signal<number>;
/** 30 s tick; see `metadata`. */
progressTick: Signal<number>;
}): UnifiedLiveRecording {
const recordingsService = inject(RecordingsService);
const metadata = computed<RecordingStartMetadata | null>(() => {
const item = options.activeItem();
if (!item) {
return null;
}
// Track the 30 s tick: without it this computed caches its
// Date.now() verdict, and a recording started after an EPG boundary
// would snapshot the previous show.
options.progressTick();
// Raw programme times vs. now in the provider's EPG clock, like the
// panel summary — the start snapshot is authoritative for the
// recording's title, so it must name the same programme.
const now = epgProviderClockMs(Date.now(), options.epgOffsetMinutes());
const program =
options.timelinePrograms().find((candidate) => {
const start = Date.parse(candidate.start);
const stop = Date.parse(candidate.stop);
return (
Number.isFinite(start) &&
Number.isFinite(stop) &&
start <= now &&
now < stop
);
}) ?? null;
return {
channelName: item.name?.trim() || 'Live TV',
channelLogoUrl:
options.timelineChannelLogo() || item.logo || undefined,
playlistId: item.playlistId,
playlistName: playlistDisplayLabel(item.playlistName) || undefined,
sourceType: item.sourceType,
epgChannelId: resolveRecordingEpgChannelId(item),
// The EPG key is not unique for M3U items (shared tvgId, or the
// display-name fallback); the uid names the exact selection.
sourceItemKey: item.uid,
currentProgram: program
? toRecordingProgramSnapshot(program)
: undefined,
};
});
return {
metadata,
handleRecordingStopped(event: RecordingStoppedEvent): void {
// A channel switch auto-stops the recording, and by now this host
// already describes the new channel — enriching then would attach
// the wrong schedule (and could promote an unrelated program to
// the recording's title).
if (
event.epgChannelId &&
event.epgChannelId !== metadata()?.epgChannelId
) {
return;
}
// The EPG key alone cannot tell two same-keyed M3U items apart —
// the uid must also match the exact recorded selection.
if (
event.sourceItemKey &&
event.sourceItemKey !== metadata()?.sourceItemKey
) {
return;
}
const programs = filterRecordingProgramsOverlap(
options.timelinePrograms().map(toRecordingProgramSnapshot),
event.startedAt,
event.endedAt,
options.epgOffsetMinutes()
);
if (programs.length === 0) {
return;
}
void recordingsService.updatePrograms(event.targetPath, programs);
},
};
}
function resolveRecordingEpgChannelId(
item: UnifiedCollectionItem
): string | undefined {
switch (item.sourceType) {
case 'm3u':
return item.tvgId?.trim() || item.name?.trim() || undefined;
case 'xtream':
return item.xtreamId !== undefined
? buildXtreamEpgMappingKey(item.playlistId, item.xtreamId)
: undefined;
case 'stalker':
return item.stalkerId !== undefined
? buildStalkerEpgMappingKey(
item.playlistId,
String(item.stalkerId)
)
: undefined;
}
}
@@ -0,0 +1,20 @@
/**
* Monotonic counter shared by everything the live tab resolves for the
* selection on screen — the detail itself, its M3U programmes, a catch-up
* URL. Anything that started under an older generation landed too late and
* must be discarded instead of replacing what the user is now watching.
*/
export interface UnifiedLiveSelectionGeneration {
current(): number;
/** Invalidate everything in flight and take the next generation. */
next(): number;
}
export function createUnifiedLiveSelectionGeneration(): UnifiedLiveSelectionGeneration {
let generation = 0;
return {
current: () => generation,
next: () => ++generation,
};
}
@@ -0,0 +1,184 @@
import { computed, inject, Signal } from '@angular/core';
import { isDashChannel, isDashStreamUrl } from '@iptvnator/shared/m3u-utils';
import { ResolvedLiveCollectionDetail } from '@iptvnator/portal/shared/data-access';
import {
getLiveCollectionPlaylistNavigation,
PORTAL_PLAYER,
UnifiedCollectionItem,
WorkspaceNavigationTarget,
} from '@iptvnator/portal/shared/util';
import { SettingsStore } from '@iptvnator/services';
import {
Channel,
EpgItem,
EpgProgram,
playlistDisplayLabel,
ResolvedPortalPlayback,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key';
import { UnifiedLiveTimeshift } from './unified-live-catchup';
/**
* Everything the live tab derives from "what is selected right now": the
* source shape behind it, and which player surface it belongs on.
*/
export interface UnifiedLiveSelectionView {
readonly player: Signal<VideoPlayer>;
readonly isEmbeddedPlayer: Signal<boolean>;
/** Playback target for the inline player, honouring a catch-up override. */
readonly inlinePlayback: Signal<ResolvedPortalPlayback | null>;
readonly currentStreamUrl: Signal<string>;
readonly isM3uSelection: Signal<boolean>;
readonly currentPortalEpgItems: Signal<EpgItem[]>;
readonly currentM3uPrograms: Signal<EpgProgram[]>;
readonly currentM3uChannel: Signal<Channel | null>;
readonly activeRadioChannel: Signal<Channel | null>;
readonly isRadioSelection: Signal<boolean>;
readonly isM3uDashSelection: Signal<boolean>;
/** Player the inline surface uses, after the DASH correction. */
readonly inlinePlayer: Signal<VideoPlayer>;
readonly shouldUseInlinePlayer: Signal<boolean>;
/** Identity of the selection an EPG archive action would act on. */
readonly archiveContextKey: Signal<string>;
/**
* "Open in playlist" for the channel on screen: the EPG panel chip
* renders only while this resolves (Xtream/M3U; Stalker has no
* open-on-arrival contract yet). Same verdict the row menu uses.
*/
readonly openInPlaylistTarget: Signal<WorkspaceNavigationTarget | null>;
readonly openInPlaylistName: Signal<string>;
readonly playbackSessionKey: Signal<string>;
isRadioDetail(
detail: ResolvedLiveCollectionDetail | null | undefined
): boolean;
/** Whether activating this detail should launch the external player. */
shouldOpenExternalPlayback(
detail: ResolvedLiveCollectionDetail,
startPlayback?: boolean
): boolean;
}
/**
* Must run in an injection context (the hosting component's field
* initializer or constructor).
*/
export function createUnifiedLiveSelectionView(options: {
activeItem: Signal<UnifiedCollectionItem | null>;
activeDetail: Signal<ResolvedLiveCollectionDetail | null>;
activeTimeshift: Signal<UnifiedLiveTimeshift | null>;
}): UnifiedLiveSelectionView {
const settingsStore = inject(SettingsStore);
const portalPlayer = inject(PORTAL_PLAYER);
const player = settingsStore.player;
const isEmbeddedPlayer = computed(() => portalPlayer.isEmbeddedPlayer());
const inlinePlayback = computed(() => {
const playback = options.activeDetail()?.playback ?? null;
const timeshift = options.activeTimeshift();
if (!playback || !timeshift) {
return playback;
}
return {
...playback,
streamUrl: timeshift.url,
isLive: false,
};
});
const isM3uSelection = computed(
() => options.activeDetail()?.epgMode === 'm3u'
);
const currentM3uChannel = computed(() => {
const detail = options.activeDetail();
return detail?.epgMode === 'm3u' ? (detail.channel ?? null) : null;
});
const currentStreamUrl = computed(() => inlinePlayback()?.streamUrl ?? '');
const activeRadioChannel = computed(() => {
const channel = options.activeDetail()?.channel ?? null;
return channel?.radio === 'true' ? channel : null;
});
const isRadioSelection = computed(() => activeRadioChannel() !== null);
// Match the M3U player: DASH needs Shaka even with Video.js or MPV/VLC
// selected.
const isM3uDashSelection = computed(
() =>
isM3uSelection() &&
(isDashStreamUrl(currentStreamUrl()) ||
isDashChannel(currentM3uChannel()))
);
const isRadioDetail = (
detail: ResolvedLiveCollectionDetail | null | undefined
): boolean => detail?.channel?.radio === 'true';
return {
player,
isEmbeddedPlayer,
inlinePlayback,
currentStreamUrl,
isM3uSelection,
currentPortalEpgItems: computed(
() => options.activeDetail()?.epgItems ?? []
),
currentM3uPrograms: computed(() => {
const detail = options.activeDetail();
if (detail?.epgMode !== 'm3u' || detail.channel?.radio === 'true') {
return [];
}
return detail.epgPrograms ?? [];
}),
currentM3uChannel,
activeRadioChannel,
isRadioSelection,
isM3uDashSelection,
archiveContextKey: computed(() =>
JSON.stringify([
options.activeItem()?.uid,
options.activeItem()?.playlistId,
currentM3uChannel()?.url,
])
),
openInPlaylistTarget: computed(() => {
const item = options.activeItem();
return item ? getLiveCollectionPlaylistNavigation(item) : null;
}),
openInPlaylistName: computed(() =>
playlistDisplayLabel(options.activeItem()?.playlistName)
),
playbackSessionKey: computed(() =>
createUnifiedLivePlaybackSessionKey(options.activeItem())
),
inlinePlayer: computed(() =>
isM3uDashSelection() && player() !== VideoPlayer.ArtPlayer
? VideoPlayer.Html5Player
: player()
),
shouldUseInlinePlayer: computed(
() =>
isRadioSelection() || isM3uDashSelection() || isEmbeddedPlayer()
),
isRadioDetail,
shouldOpenExternalPlayback(
detail: ResolvedLiveCollectionDetail,
startPlayback = false
): boolean {
if (
isRadioDetail(detail) ||
isM3uDashSelection() ||
portalPlayer.isEmbeddedPlayer()
) {
return false;
}
return !settingsStore.openStreamOnDoubleClick() || startPlayback;
},
};
}
@@ -0,0 +1,273 @@
import { inject, WritableSignal } from '@angular/core';
import {
ResolvedLiveCollectionDetail,
StreamResolverService,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/data-access';
import {
PORTAL_PLAYER,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import { ElectronStreamHeadersService } from '@iptvnator/ui/playback';
import { UnifiedLiveTimeshift } from './unified-live-catchup';
import { UnifiedLiveSelectionGeneration } from './unified-live-selection-generation';
export interface UnifiedLiveSelection {
/**
* Activate a collection row. `startPlayback` forces the external player
* in double-click-to-play mode; `isAutoOpen` reports back to the host
* once the row is really on screen.
*/
activate(
item: UnifiedCollectionItem,
isAutoOpen?: boolean,
startPlayback?: boolean
): Promise<void>;
/** Tear the selection down and release anything scoped to it. */
close(): void;
/** Release the radio header override; for the host's destroy hook. */
dispose(): void;
}
/**
* Resolving the selected live row into something playable, and keeping the
* player that is already mounted alive while the next one resolves.
*
* `activeUid` is the pending selection (row highlight). `activeItem` stays
* paired with `activeDetail`: the previous detail stays mounted while the
* next one resolves, because the player it renders owns DOM fullscreen and a
* selection from the fullscreen channel panel must not unmount that element
* (which would end fullscreen) for the resolution round-trip — and
* everything derived from the item (`playbackSessionKey`, recording/archive
* metadata) must keep describing the stream that player is still showing.
* The catch-up override belongs to that detail too: clearing it early would
* drop the mounted player back to the old channel's live URL for the gap.
* All of it swaps together when the new detail is in; a failed replacement
* keeps a previously mounted video and restores its row.
*
* Must run in an injection context (the hosting component's field
* initializer or constructor).
*/
export function createUnifiedLiveSelection(options: {
activeUid: WritableSignal<string | null>;
activeItem: WritableSignal<UnifiedCollectionItem | null>;
activeDetail: WritableSignal<ResolvedLiveCollectionDetail | null>;
activeTimeshift: WritableSignal<UnifiedLiveTimeshift | null>;
isSelecting: WritableSignal<boolean>;
generation: UnifiedLiveSelectionGeneration;
supportsEpg: boolean;
isRadioDetail(
detail: ResolvedLiveCollectionDetail | null | undefined
): boolean;
shouldOpenExternalPlayback(
detail: ResolvedLiveCollectionDetail,
startPlayback: boolean
): boolean;
/** The row became the latest watched item; the host refreshes its list. */
onItemPlayed(item: UnifiedCollectionItem): void;
onAutoOpenHandled(): void;
}): UnifiedLiveSelection {
const streamResolver = inject(StreamResolverService);
const recentData = inject(UnifiedRecentDataService);
const streamHeaders = inject(ElectronStreamHeadersService);
const portalPlayer = inject(PORTAL_PLAYER);
/** Stream URL of the radio playback whose header override this configured. */
let radioHeaderScopeUrl: string | null = null;
/**
* The selection still resolving. `activeUid` alone cannot tell a pending
* highlight from the stream on screen — `activeItem`/`activeDetail` stay
* paired with the retained player until the replacement is in — so a
* second activation of the same pending row (a double-click's second
* click, an auto-open) folds its intent in here rather than restarting
* the request or, worse, launching the retained stream in its place.
*/
let pendingActivation: {
readonly uid: string;
startPlayback: boolean;
isAutoOpen: boolean;
} | null = null;
const releaseRadioHeaders = (): void => {
streamHeaders.clear(radioHeaderScopeUrl);
radioHeaderScopeUrl = null;
};
const hydrateSelectedM3uPrograms = async (
item: UnifiedCollectionItem,
detail: ResolvedLiveCollectionDetail,
generation: number
): Promise<void> => {
if (detail.epgMode !== 'm3u' || detail.channel?.radio === 'true') {
return;
}
const epgPrograms = await streamResolver.loadM3uProgramsForItem(
item,
detail.channel
);
if (generation !== options.generation.current()) {
return;
}
options.activeDetail.update((currentDetail) => {
if (!currentDetail || currentDetail.epgMode !== 'm3u') {
return currentDetail;
}
return { ...currentDetail, epgPrograms };
});
};
const close = (): void => {
options.generation.next();
pendingActivation = null;
options.isSelecting.set(false);
options.activeDetail.set(null);
options.activeUid.set(null);
options.activeItem.set(null);
options.activeTimeshift.set(null);
// Radio credentials must not outlive the closed player; the service
// no-ops when a newer playback already owns the override slot.
releaseRadioHeaders();
};
async function activate(
item: UnifiedCollectionItem,
isAutoOpen = false,
startPlayback = false
): Promise<void> {
const activeDetail = options.activeDetail();
// The row is on screen only when it is both the highlight and the
// resolved item: while a replacement resolves, `activeUid` already
// points at it but `activeDetail` still belongs to the retained
// stream, and launching that here would open the wrong channel.
if (
options.activeUid() === item.uid &&
options.activeItem()?.uid === item.uid &&
activeDetail
) {
if (
startPlayback &&
options.shouldOpenExternalPlayback(activeDetail, true)
) {
void portalPlayer.openResolvedPlayback(activeDetail.playback);
}
if (isAutoOpen) {
options.onAutoOpenHandled();
}
return;
}
if (pendingActivation?.uid === item.uid && options.isSelecting()) {
// The same row is still resolving: fold this activation's intent
// into that request so its detail launches (or reports the
// auto-open handled) when it lands, without a second round-trip.
pendingActivation.startPlayback ||= startPlayback;
pendingActivation.isAutoOpen ||= isAutoOpen;
return;
}
const generation = options.generation.next();
const activation = { uid: item.uid, startPlayback, isAutoOpen };
pendingActivation = activation;
options.activeUid.set(item.uid);
options.isSelecting.set(true);
// A previously owned radio override must not survive into a
// selection that never mounts a player surface of its own — external
// video playback and failed resolutions would otherwise keep the old
// radio credentials installed for that origin.
releaseRadioHeaders();
try {
const detail =
item.sourceType === 'm3u'
? await streamResolver.resolveM3uPlaybackDetail(item)
: await streamResolver.resolveLiveDetail(item);
if (generation !== options.generation.current()) {
return;
}
if (item.radio === 'true') {
// Radio renders the dedicated audio player, never
// WebPlayerViewComponent, so the scoped Electron header
// override (portal cookie/token for auth-gated streams) is
// configured here BEFORE the audio element gets the URL.
// Ownership is claimed synchronously so a close/destroy
// during the pending IPC can still clear the credentials.
const headerSync = streamHeaders.apply(detail.playback);
radioHeaderScopeUrl = detail.playback.streamUrl;
const stillCurrent = headerSync ? await headerSync : true;
if (
!stillCurrent ||
generation !== options.generation.current()
) {
return;
}
}
options.activeTimeshift.set(null);
options.activeItem.set(item);
options.activeDetail.set(detail);
if (options.supportsEpg && detail.epgMode === 'm3u') {
void hydrateSelectedM3uPrograms(item, detail, generation);
}
if (
options.shouldOpenExternalPlayback(
detail,
activation.startPlayback
)
) {
void portalPlayer.openResolvedPlayback(detail.playback);
}
try {
const updatedItem = await recentData.recordLivePlayback(item);
if (generation === options.generation.current()) {
options.onItemPlayed(updatedItem);
}
} catch {
// Keep playback/EPG visible even if history persistence fails.
}
if (
generation === options.generation.current() &&
activation.isAutoOpen
) {
options.onAutoOpenHandled();
}
} catch {
if (generation === options.generation.current()) {
// The fullscreen panel only offers video rows. Its previous
// stream is still valid when resolution of a replacement
// fails, so retain the player and its catch-up/session state.
// Radio's scoped headers were released above; that path keeps
// its existing reset behavior instead of reviving that scope.
if (!activeDetail || options.isRadioDetail(activeDetail)) {
options.activeTimeshift.set(null);
options.activeDetail.set(null);
options.activeItem.set(null);
}
options.activeUid.set(options.activeItem()?.uid ?? null);
}
} finally {
if (generation === options.generation.current()) {
pendingActivation = null;
options.isSelecting.set(false);
}
}
}
return {
activate,
close,
dispose(): void {
// Invalidate a playback continuation still awaiting its header
// IPC and drop any radio credentials owned by this tab.
options.generation.next();
streamHeaders.clear(radioHeaderScopeUrl);
},
};
}
-2
View File
@@ -33,8 +33,6 @@ export const maxLinesBaseline = [
'libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts',
'libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts',
'libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts',
'libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts',
'libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts',
'libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts',
'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts',
'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts',