refactor(portals): split the Stalker collection detail component (#1433)

StalkerCollectionDetailComponent was 529 non-blank lines, well over the
400-line production maximum, and passed lint only because it sat in
tools/eslint/max-lines-baseline.mjs — a list CLAUDE.md says must only
shrink.

Extract four cohesive units into siblings in the same folder:

- stalker-collection-playback.controller.ts — playback ownership, inline
  playback and the external-player fallback
- stalker-collection-detail-mode.ts — pure item / detail-mode / category
  resolution (movie vs regular series vs embedded VOD series vs lazy
  is_series)
- stalker-collection-favorites.controller.ts — favorites resource, sync
  and toggle
- stalker-collection-store-snapshot.ts — store snapshot capture/restore

Both controllers follow the existing StalkerVodPlaybackController model —
plain classes taking a config object, constructed in a component field
initializer — rather than DI services. The playback controller needs the
component's `item` input signal and the favorites controller's rxResource
needs an injection context; a field initializer supplies both for free,
where a DI service would have needed an extra effect to feed the input
across.

Public template bindings and the component's public API are unchanged:
the signals are re-exported by reference, so neither the template nor the
spec sees the split. The spec is untouched and its 11 tests, plus the
rest of the project suite (272), pass exactly as before.

Regenerate the max-lines baseline so the file drops off it, and update
the three doc references that named resolveDetailMode() and
resolveSelectedCategory() as methods on the component.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 authored and GitHub committed 2026-08-13 17:44:33 +02:00
1 parent 365cf35317
commit 0f7d529b40
8 files changed
+487 -288

No files matched your search

+5 -3
View File
@@ -1247,15 +1247,17 @@ engine` (restart required) or
section-only route: an Xtream item without a resolvable category and positive
item id keeps the action hidden rather than promising a jump to the title and
landing in a list.
- Stalker section resolution mirrors
`StalkerCollectionDetailComponent.resolveDetailMode()` and must not be
- Stalker section resolution mirrors `resolveStalkerCollectionDetailMode()`
(`libs/portal/stalker/feature/src/lib/stalker-collection-detail-mode.ts`) and
must not be
simplified to `item.contentType`: `extractStalkerItemType()` reports `series`
for embedded `series[]` snapshots and lazy Ministra VOD `is_series` items, but
both belong in the VOD catalog — the lazy season/episode fetch in
`StalkerCatalogFacadeService.selectItem()` is gated on the VOD content type, so
a `/series` route leaves the detail unable to load episodes. The virtual
`series` category is normalized to `vod` the same way
`resolveSelectedCategory()` does. Stalker also carries `stalkerReturnTo`.
`resolveStalkerCollectionSelectedCategory()` does. Stalker also carries
`stalkerReturnTo`.
- Unlike the download handoff this bridge does NOT pass
`detailPresentation: 'provider-only'` — the item exists in the provider
catalog, so the full normal detail (downloads included) is wanted.
@@ -71,8 +71,9 @@ export function getUnifiedCollectionDetailNavigation(
}
/**
* Mirrors `StalkerCollectionDetailComponent.resolveDetailMode()`: only a
* regular `/series` item belongs in the series catalog. Embedded `series[]`
* Mirrors `resolveStalkerCollectionDetailMode()` in
* `libs/portal/stalker/feature/src/lib/stalker-collection-detail-mode.ts`:
* only a regular `/series` item belongs in the series catalog. Embedded `series[]`
* snapshots and lazy Ministra VOD `is_series` items normalize to `series` in
* `extractStalkerItemType()` but must stay in the VOD catalog — the lazy
* season/episode fetch in `StalkerCatalogFacadeService.selectItem()` is gated
@@ -97,7 +98,8 @@ function resolveStalkerDetailType(
}
/**
* Mirrors `StalkerCollectionDetailComponent.resolveSelectedCategory()`: a
* Mirrors `resolveStalkerCollectionSelectedCategory()` in
* `libs/portal/stalker/feature/src/lib/stalker-collection-detail-mode.ts`: a
* VOD-catalog item persisted from the series view carries the virtual
* `series` category, which would otherwise form a `/vod/series` route.
*/
@@ -0,0 +1,93 @@
import {
buildStalkerStateItem,
toStalkerCategoryId,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import { isStalkerSeriesFlag } from '@iptvnator/portal/stalker/data-access';
import { StalkerPortalItem } from '@iptvnator/shared/interfaces';
/** Catalog section a collection item is rendered as. */
export type StalkerDetailCategory = 'vod' | 'series';
export interface StalkerCollectionDetailMode {
category: StalkerDetailCategory;
selectedContentType: StalkerDetailCategory;
hasEmbeddedSeries: boolean;
needsSeriesFetch: boolean;
}
/**
* Rebuilds the portal row a collection entry points at. A favorite/recent
* snapshot may be sparse, so the unified item fills in whatever it can.
*/
export function resolveStalkerCollectionItem(
item: UnifiedCollectionItem
): StalkerPortalItem {
const uidParts = item.uid.split('::');
return buildStalkerStateItem(
item.stalkerItem as StalkerPortalItem | undefined,
{
id: item.stalkerId ?? uidParts[uidParts.length - 1] ?? item.uid,
title: item.name,
type: item.contentType,
category_id: item.categoryId,
poster_url: item.posterUrl ?? item.logo ?? undefined,
}
) as StalkerPortalItem;
}
/**
* Picks the detail flow a collection item belongs to. Embedded `series[]`
* snapshots and lazy Ministra VOD `is_series` rows report as series but live
* in the VOD catalog, so only a portal `/series` row is a regular series.
*
* `getUnifiedCollectionDetailNavigation()` in `@iptvnator/portal/shared/util`
* mirrors this rule — keep the two in sync.
*/
export function resolveStalkerCollectionDetailMode(
item: UnifiedCollectionItem,
stalkerItem: StalkerPortalItem
): StalkerCollectionDetailMode {
const series = (stalkerItem as { series?: unknown[] }).series;
const hasEmbeddedSeries = Array.isArray(series) && series.length > 0;
const isVodSeries = isStalkerSeriesFlag(
(stalkerItem as { is_series?: unknown }).is_series
);
const isRegularSeries =
item.contentType === 'series' && !hasEmbeddedSeries && !isVodSeries;
const selectedContentType: StalkerDetailCategory = isRegularSeries
? 'series'
: 'vod';
return {
category: selectedContentType,
selectedContentType,
hasEmbeddedSeries,
needsSeriesFetch:
selectedContentType === 'vod' && !hasEmbeddedSeries && isVodSeries,
};
}
/**
* Normalizes the virtual `series` category to `vod` for items that render as
* VOD, so the lazy season/episode fetch gated on the VOD content type can run.
*/
export function resolveStalkerCollectionSelectedCategory(
item: UnifiedCollectionItem,
stalkerItem: StalkerPortalItem,
detailMode: StalkerCollectionDetailMode
): string | number {
const categoryId =
item.categoryId ??
(stalkerItem as { category_id?: string | number }).category_id;
if (
detailMode.selectedContentType === 'vod' &&
String(categoryId ?? '').toLowerCase() === 'series'
) {
return 'vod';
}
return categoryId ?? toStalkerCategoryId(detailMode.selectedContentType);
}
@@ -19,65 +19,39 @@ import {
ViewInPortalHandoff,
} from '@iptvnator/ui/components';
import {
buildStalkerStateItem,
createLogger,
getUnifiedCollectionDetailNavigation,
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
toStalkerCategoryId,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import {
buildStalkerSelectedVodItem,
clearStalkerDetailViewState,
createPortalFavoritesResource,
createRefreshTrigger,
createStalkerDetailViewState,
createStalkerInlineDetailState,
isSelectedStalkerVodFavorite,
isStalkerSeriesFlag,
normalizeStalkerEntityId,
StalkerContentType,
StalkerSelectedVodItem,
StalkerStore,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
import { PlaylistsService } from '@iptvnator/services';
import {
PlaybackPositionData,
Playlist,
ResolvedPortalPlayback,
StalkerPortalItem,
VodDetailsItem,
} from '@iptvnator/shared/interfaces';
import { Playlist, VodDetailsItem } from '@iptvnator/shared/interfaces';
import { firstValueFrom } from 'rxjs';
import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component';
import { StalkerVodPlaybackController } from './stalker-vod-playback-controller';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
interface StalkerCollectionStateSnapshot {
currentPlaylist: Playlist | undefined;
selectedContentType: StalkerContentType;
selectedCategoryId: string | null | undefined;
selectedItem: unknown;
}
type StalkerDetailCategory = 'vod' | 'series';
interface StalkerCollectionDetailMode {
category: StalkerDetailCategory;
selectedContentType: StalkerDetailCategory;
hasEmbeddedSeries: boolean;
needsSeriesFetch: boolean;
}
interface StalkerCollectionPlaybackOwner {
readonly sourceId: string;
readonly contentId: string;
readonly sessionKey: string;
}
import {
resolveStalkerCollectionDetailMode,
resolveStalkerCollectionItem,
resolveStalkerCollectionSelectedCategory,
StalkerDetailCategory,
} from './stalker-collection-detail-mode';
import { StalkerCollectionFavoritesController } from './stalker-collection-favorites.controller';
import { StalkerCollectionPlaybackController } from './stalker-collection-playback.controller';
import {
captureStalkerCollectionStoreState,
restoreStalkerCollectionStoreState,
} from './stalker-collection-store-snapshot';
@Component({
selector: 'app-stalker-collection-detail',
@@ -140,44 +114,35 @@ export class StalkerCollectionDetailComponent implements ViewInPortalHandoff {
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly logger = createLogger('StalkerCollectionDetail');
private readonly originalState = this.captureStoreState();
private readonly favoritesRefresh = createRefreshTrigger();
readonly viewInPortalAvailable = computed(() => {
const item = this.item();
return !!item && getUnifiedCollectionDetailNavigation(item) !== null;
});
readonly viewInPortalPlaylistName = computed(
() => this.item()?.playlistName ?? null
private readonly originalState = captureStalkerCollectionStoreState(
this.stalkerStore
);
openInPortal(): void {
const item = this.item();
const navigation = item
? getUnifiedCollectionDetailNavigation(item, {
returnTo: this.router.url,
})
: null;
if (navigation) {
void this.router.navigate(navigation.link, {
state: navigation.state,
});
}
}
private readonly favorites = new StalkerCollectionFavoritesController({
playlistsService: this.playlistsService,
stalkerStore: this.stalkerStore,
vodDetailsItem: () => this.vodDetailsItem(),
});
private readonly playback = new StalkerCollectionPlaybackController({
item: () => this.item(),
stalkerStore: this.stalkerStore,
playbackPositions: this.playbackPositions,
portalPlayer: this.portalPlayer,
snackBar: this.snackBar,
translateService: this.translateService,
logger: this.logger,
});
readonly inlinePlayback = this.playback.inlinePlayback;
readonly playbackSessionKey = this.playback.playbackSessionKey;
readonly selectedVodPlaybackPosition =
this.playback.selectedVodPlaybackPosition;
readonly isSelectedVodFavorite = this.favorites.isFavorite;
readonly portalFavorites = this.favorites.resource;
readonly itemDetails = signal<StalkerSelectedVodItem | null>(null);
readonly vodDetailsItem = signal<VodDetailsItem | null>(null);
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
private readonly playbackOwner = computed(() =>
captureStalkerCollectionPlaybackOwner(this.item())
);
readonly playbackSessionKey = computed(
() => this.playbackOwner()?.sessionKey ?? ''
);
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
null
);
readonly isSelectedVodFavorite = signal(false);
readonly detailCategoryOverride = signal<StalkerDetailCategory | null>(
null
);
@@ -189,33 +154,21 @@ export class StalkerCollectionDetailComponent implements ViewInPortalHandoff {
)
);
readonly portalFavorites = createPortalFavoritesResource(
this.playlistsService,
() => this.stalkerStore.currentPlaylist()?._id,
() => this.favoritesRefresh.refreshVersion()
);
readonly selectedVodPlaybackPosition = computed<number | null>(
() => this.selectedVodPosition()?.positionSeconds ?? null
readonly viewInPortalAvailable = computed(() => {
const item = this.item();
return !!item && getUnifiedCollectionDetailNavigation(item) !== null;
});
readonly viewInPortalPlaylistName = computed(
() => this.item()?.playlistName ?? null
);
private initRequestId = 0;
private currentPlaybackOwnerKey = '';
private readonly vodPlayback = new StalkerVodPlaybackController({
inlinePlayback: this.inlinePlayback,
selectedVodPosition: this.selectedVodPosition,
playbackPositions: this.playbackPositions,
portalPlayer: this.portalPlayer,
snackBar: this.snackBar,
translateService: this.translateService,
logger: this.logger,
playbackErrorLogMessage: 'Failed to start collection VOD playback',
playbackOwnerKey: () => this.playbackSessionKey(),
});
constructor() {
effect(() => {
this.portalFavorites.value();
this.syncSelectedVodFavorite();
this.favorites.sync();
});
effect(() => {
@@ -263,81 +216,62 @@ export class StalkerCollectionDetailComponent implements ViewInPortalHandoff {
}
ngOnDestroy(): void {
void this.stalkerStore.setCurrentPlaylist(
this.originalState.currentPlaylist
);
this.stalkerStore.setSelectedContentType(
this.originalState.selectedContentType
);
this.stalkerStore.setSelectedCategory(
this.originalState.selectedCategoryId ?? null
);
this.stalkerStore.setSelectedItem(
this.originalState.selectedItem as never
restoreStalkerCollectionStoreState(
this.stalkerStore,
this.originalState
);
this.closeInlinePlayer();
}
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
void this.startStalkerVodPlayback(
item.cmd,
item.data.info?.name,
item.data.info?.movie_image
);
openInPortal(): void {
const item = this.item();
const navigation = item
? getUnifiedCollectionDetailNavigation(item, {
returnTo: this.router.url,
})
: null;
if (navigation) {
void this.router.navigate(navigation.link, {
state: navigation.state,
});
}
}
onVodPlay(item: VodDetailsItem): void {
this.playback.onVodPlay(item);
}
onVodResume(event: {
item: VodDetailsItem;
positionSeconds: number;
}): void {
if (event.item.type === 'stalker') {
void this.startStalkerVodPlayback(
event.item.cmd,
event.item.data.info?.name,
event.item.data.info?.movie_image,
event.positionSeconds
);
}
this.playback.onVodResume(event);
}
onVodFavoriteToggled(event: {
item: VodDetailsItem;
isFavorite: boolean;
}): void {
toggleStalkerVodFavorite(event, {
addToFavorites: (item, onDone) =>
this.stalkerStore.addToFavorites(
item as StalkerPortalItem,
onDone
),
removeFromFavorites: (favoriteId, onDone) =>
this.stalkerStore.removeFromFavorites(favoriteId, onDone),
onComplete: () => {
this.favoritesRefresh.refresh();
this.syncSelectedVodFavorite();
},
});
this.favorites.toggle(event);
}
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
this.vodPlayback.handleInlineTimeUpdate(event);
this.playback.handleInlineTimeUpdate(event);
}
closeInlinePlayer(): void {
this.vodPlayback.closeInlinePlayer();
this.playback.closeInlinePlayer();
}
showCopyNotification(): void {
this.vodPlayback.showCopyNotification();
this.playback.showCopyNotification();
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
this.vodPlayback.handleExternalFallbackRequest(request);
this.playback.handleExternalFallbackRequest(request);
}
private async prepareDetail(
@@ -366,8 +300,11 @@ export class StalkerCollectionDetailComponent implements ViewInPortalHandoff {
return;
}
const stalkerItem = this.resolveStalkerItem(item);
const detailMode = this.resolveDetailMode(item, stalkerItem);
const stalkerItem = resolveStalkerCollectionItem(item);
const detailMode = resolveStalkerCollectionDetailMode(
item,
stalkerItem
);
const itemDetails = buildStalkerSelectedVodItem(
stalkerItem as never,
detailMode.needsSeriesFetch
@@ -378,7 +315,11 @@ export class StalkerCollectionDetailComponent implements ViewInPortalHandoff {
detailMode.selectedContentType
);
this.stalkerStore.setSelectedCategory(
this.resolveSelectedCategory(item, stalkerItem, detailMode)
resolveStalkerCollectionSelectedCategory(
item,
stalkerItem,
detailMode
)
);
this.stalkerStore.setSelectedItem(itemDetails);
this.itemDetails.set(itemDetails);
@@ -402,8 +343,8 @@ export class StalkerCollectionDetailComponent implements ViewInPortalHandoff {
);
this.itemDetails.set(detailViewState.itemDetails);
this.vodDetailsItem.set(detailViewState.vodDetailsItem);
this.syncSelectedVodFavorite();
void this.loadSelectedVodPosition(
this.favorites.sync();
void this.playback.loadSelectedVodPosition(
playlist._id,
Number(detailViewState.itemDetails?.id)
);
@@ -412,19 +353,8 @@ export class StalkerCollectionDetailComponent implements ViewInPortalHandoff {
const cleared = clearStalkerDetailViewState();
this.vodDetailsItem.set(cleared.vodDetailsItem);
this.isSelectedVodFavorite.set(false);
this.selectedVodPosition.set(null);
}
private captureStoreState(): StalkerCollectionStateSnapshot {
return {
currentPlaylist:
(this.stalkerStore.currentPlaylist() as Playlist | undefined) ??
undefined,
selectedContentType: this.stalkerStore.selectedContentType(),
selectedCategoryId: this.stalkerStore.selectedCategoryId(),
selectedItem: this.stalkerStore.selectedItem(),
};
this.favorites.reset();
this.playback.clearSelectedVodPosition();
}
private async loadPlaylist(playlistId: string): Promise<Playlist | null> {
@@ -439,139 +369,12 @@ export class StalkerCollectionDetailComponent implements ViewInPortalHandoff {
}
}
private resolveStalkerItem(item: UnifiedCollectionItem): StalkerPortalItem {
return buildStalkerStateItem(
item.stalkerItem as StalkerPortalItem | undefined,
{
id:
item.stalkerId ??
item.uid.split('::')[item.uid.split('::').length - 1] ??
item.uid,
title: item.name,
type: item.contentType,
category_id: item.categoryId,
poster_url: item.posterUrl ?? item.logo ?? undefined,
}
) as StalkerPortalItem;
}
private resolveDetailMode(
item: UnifiedCollectionItem,
stalkerItem: StalkerPortalItem
): StalkerCollectionDetailMode {
const series = (stalkerItem as { series?: unknown[] }).series;
const hasEmbeddedSeries = Array.isArray(series) && series.length > 0;
const isVodSeries = isStalkerSeriesFlag(
(stalkerItem as { is_series?: unknown }).is_series
);
const isRegularSeries =
item.contentType === 'series' && !hasEmbeddedSeries && !isVodSeries;
const selectedContentType: StalkerDetailCategory = isRegularSeries
? 'series'
: 'vod';
return {
category: selectedContentType,
selectedContentType,
hasEmbeddedSeries,
needsSeriesFetch:
selectedContentType === 'vod' &&
!hasEmbeddedSeries &&
isVodSeries,
};
}
private resolveSelectedCategory(
item: UnifiedCollectionItem,
stalkerItem: StalkerPortalItem,
detailMode: StalkerCollectionDetailMode
): string | number {
const categoryId =
item.categoryId ??
(stalkerItem as { category_id?: string | number }).category_id;
if (
detailMode.selectedContentType === 'vod' &&
String(categoryId ?? '').toLowerCase() === 'series'
) {
return 'vod';
}
return (
categoryId ?? toStalkerCategoryId(detailMode.selectedContentType)
);
}
private syncSelectedVodFavorite(): void {
this.isSelectedVodFavorite.set(
isSelectedStalkerVodFavorite(
this.vodDetailsItem(),
this.portalFavorites.value() ?? []
)
);
}
private clearLocalDetailState(): void {
const cleared = clearStalkerDetailViewState();
this.itemDetails.set(cleared.itemDetails);
this.vodDetailsItem.set(cleared.vodDetailsItem);
this.detailCategoryOverride.set(null);
this.isSelectedVodFavorite.set(false);
this.selectedVodPosition.set(null);
}
private async startStalkerVodPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
startTime?: number
): Promise<void> {
await this.vodPlayback.startVodPlayback(() =>
startTime === undefined
? this.stalkerStore.resolveVodPlayback(cmd, title, thumbnail)
: this.stalkerStore.resolveVodPlayback(
cmd,
title,
thumbnail,
undefined,
undefined,
startTime
)
);
}
private async loadSelectedVodPosition(
playlistId: string,
vodId: number
): Promise<void> {
await this.vodPlayback.loadSelectedVodPosition(playlistId, vodId);
this.favorites.reset();
this.playback.clearSelectedVodPosition();
}
}
function captureStalkerCollectionPlaybackOwner(
item: UnifiedCollectionItem | null
): StalkerCollectionPlaybackOwner | null {
if (!item) return null;
const sourceId = item.playlistId.trim();
const providerItem = item.stalkerItem as
{ id?: unknown; stream_id?: unknown } | undefined;
const uidParts = item.uid.split('::');
const contentId = normalizeStalkerEntityId(
providerItem?.id ??
providerItem?.stream_id ??
item.stalkerId ??
uidParts[uidParts.length - 1]
);
if (!sourceId || !contentId) return null;
return Object.freeze({
sourceId,
contentId,
sessionKey: createPlaybackSessionKey({
kind: 'vod',
sourceId,
contentId,
}),
});
}
@@ -0,0 +1,74 @@
import { signal } from '@angular/core';
import {
createPortalFavoritesResource,
createRefreshTrigger,
isSelectedStalkerVodFavorite,
StalkerStore,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
import type { PlaylistsService } from '@iptvnator/services';
import {
StalkerPortalItem,
VodDetailsItem,
} from '@iptvnator/shared/interfaces';
interface StalkerCollectionFavoritesControllerConfig {
playlistsService: PlaylistsService;
stalkerStore: InstanceType<typeof StalkerStore>;
vodDetailsItem: () => VodDetailsItem | null;
}
/**
* Favorite state for the movie currently rendered by a collection detail.
*
* Must be constructed from an injection context — the underlying favorites
* resource is an `rxResource`.
*/
export class StalkerCollectionFavoritesController {
private readonly refreshTrigger = createRefreshTrigger();
readonly isFavorite = signal(false);
readonly resource: ReturnType<typeof createPortalFavoritesResource>;
constructor(
private readonly config: StalkerCollectionFavoritesControllerConfig
) {
this.resource = createPortalFavoritesResource(
config.playlistsService,
() => config.stalkerStore.currentPlaylist()?._id,
() => this.refreshTrigger.refreshVersion()
);
}
sync(): void {
this.isFavorite.set(
isSelectedStalkerVodFavorite(
this.config.vodDetailsItem(),
this.resource.value() ?? []
)
);
}
reset(): void {
this.isFavorite.set(false);
}
toggle(event: { item: VodDetailsItem; isFavorite: boolean }): void {
toggleStalkerVodFavorite(event, {
addToFavorites: (item, onDone) =>
this.config.stalkerStore.addToFavorites(
item as StalkerPortalItem,
onDone
),
removeFromFavorites: (favoriteId, onDone) =>
this.config.stalkerStore.removeFromFavorites(
favoriteId,
onDone
),
onComplete: () => {
this.refreshTrigger.refresh();
this.sync();
},
});
}
}
@@ -0,0 +1,184 @@
import { computed, signal } from '@angular/core';
import type { MatSnackBar } from '@angular/material/snack-bar';
import type { TranslateService } from '@ngx-translate/core';
import type {
Logger,
PortalPlaybackPositions,
PortalPlayer,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import {
normalizeStalkerEntityId,
StalkerStore,
} from '@iptvnator/portal/stalker/data-access';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
import {
PlaybackPositionData,
ResolvedPortalPlayback,
VodDetailsItem,
} from '@iptvnator/shared/interfaces';
import { StalkerVodPlaybackController } from './stalker-vod-playback-controller';
interface StalkerCollectionPlaybackOwner {
readonly sourceId: string;
readonly contentId: string;
readonly sessionKey: string;
}
interface StalkerCollectionPlaybackControllerConfig {
item: () => UnifiedCollectionItem | null;
stalkerStore: InstanceType<typeof StalkerStore>;
playbackPositions: PortalPlaybackPositions;
portalPlayer: PortalPlayer;
snackBar: MatSnackBar;
translateService: TranslateService;
logger: Logger;
}
/**
* Owns inline playback for a collection detail: which item the current player
* session belongs to, the resume position shown next to the play action, and
* the handoff to an external player.
*/
export class StalkerCollectionPlaybackController {
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
null
);
readonly selectedVodPlaybackPosition = computed<number | null>(
() => this.selectedVodPosition()?.positionSeconds ?? null
);
private readonly playbackOwner = computed(() =>
captureStalkerCollectionPlaybackOwner(this.config.item())
);
readonly playbackSessionKey = computed(
() => this.playbackOwner()?.sessionKey ?? ''
);
private readonly vodPlayback: StalkerVodPlaybackController;
constructor(
private readonly config: StalkerCollectionPlaybackControllerConfig
) {
this.vodPlayback = new StalkerVodPlaybackController({
inlinePlayback: this.inlinePlayback,
selectedVodPosition: this.selectedVodPosition,
playbackPositions: config.playbackPositions,
portalPlayer: config.portalPlayer,
snackBar: config.snackBar,
translateService: config.translateService,
logger: config.logger,
playbackErrorLogMessage: 'Failed to start collection VOD playback',
playbackOwnerKey: () => this.playbackSessionKey(),
});
}
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
void this.startVodPlayback(
item.cmd,
item.data.info?.name,
item.data.info?.movie_image
);
}
}
onVodResume(event: {
item: VodDetailsItem;
positionSeconds: number;
}): void {
if (event.item.type === 'stalker') {
void this.startVodPlayback(
event.item.cmd,
event.item.data.info?.name,
event.item.data.info?.movie_image,
event.positionSeconds
);
}
}
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
this.vodPlayback.handleInlineTimeUpdate(event);
}
closeInlinePlayer(): void {
this.vodPlayback.closeInlinePlayer();
}
showCopyNotification(): void {
this.vodPlayback.showCopyNotification();
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
this.vodPlayback.handleExternalFallbackRequest(request);
}
async loadSelectedVodPosition(
playlistId: string,
vodId: number
): Promise<void> {
await this.vodPlayback.loadSelectedVodPosition(playlistId, vodId);
}
clearSelectedVodPosition(): void {
this.selectedVodPosition.set(null);
}
private async startVodPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
startTime?: number
): Promise<void> {
await this.vodPlayback.startVodPlayback(() =>
startTime === undefined
? this.config.stalkerStore.resolveVodPlayback(
cmd,
title,
thumbnail
)
: this.config.stalkerStore.resolveVodPlayback(
cmd,
title,
thumbnail,
undefined,
undefined,
startTime
)
);
}
}
function captureStalkerCollectionPlaybackOwner(
item: UnifiedCollectionItem | null
): StalkerCollectionPlaybackOwner | null {
if (!item) return null;
const sourceId = item.playlistId.trim();
const providerItem = item.stalkerItem as
{ id?: unknown; stream_id?: unknown } | undefined;
const uidParts = item.uid.split('::');
const contentId = normalizeStalkerEntityId(
providerItem?.id ??
providerItem?.stream_id ??
item.stalkerId ??
uidParts[uidParts.length - 1]
);
if (!sourceId || !contentId) return null;
return Object.freeze({
sourceId,
contentId,
sessionKey: createPlaybackSessionKey({
kind: 'vod',
sourceId,
contentId,
}),
});
}
@@ -0,0 +1,42 @@
import {
StalkerContentType,
StalkerStore,
} from '@iptvnator/portal/stalker/data-access';
import { Playlist } from '@iptvnator/shared/interfaces';
type StalkerStoreInstance = InstanceType<typeof StalkerStore>;
export interface StalkerCollectionStateSnapshot {
currentPlaylist: Playlist | undefined;
selectedContentType: StalkerContentType;
selectedCategoryId: string | null | undefined;
selectedItem: unknown;
}
/**
* A collection detail renders an item that belongs to some other portal, so it
* has to repoint the shared store at that playlist. Capture the caller's
* selection on mount and hand it back on teardown, or returning to the list
* leaves the store pointing at the foreign portal.
*/
export function captureStalkerCollectionStoreState(
store: StalkerStoreInstance
): StalkerCollectionStateSnapshot {
return {
currentPlaylist:
(store.currentPlaylist() as Playlist | undefined) ?? undefined,
selectedContentType: store.selectedContentType(),
selectedCategoryId: store.selectedCategoryId(),
selectedItem: store.selectedItem(),
};
}
export function restoreStalkerCollectionStoreState(
store: StalkerStoreInstance,
snapshot: StalkerCollectionStateSnapshot
): void {
void store.setCurrentPlaylist(snapshot.currentPlaylist);
store.setSelectedContentType(snapshot.selectedContentType);
store.setSelectedCategory(snapshot.selectedCategoryId ?? null);
store.setSelectedItem(snapshot.selectedItem as never);
}
-1
View File
@@ -41,7 +41,6 @@ export const maxLinesBaseline = [
'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',
'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts',
'libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts',
'libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts',
'libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts',
'libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts',