mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
365cf35317
commit
0f7d529b40
8 files changed
+487
-288
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user