diff --git a/CLAUDE.md b/CLAUDE.md index d54b5572d..43788a913 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.ts b/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.ts index 4eb39f8b9..040bd8178 100644 --- a/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.ts +++ b/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.ts @@ -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. */ diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail-mode.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail-mode.ts new file mode 100644 index 000000000..c0ffe772d --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail-mode.ts @@ -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); +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts index 3868e46d9..fa8d500b4 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts @@ -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(null); readonly vodDetailsItem = signal(null); - readonly inlinePlayback = signal(null); - private readonly playbackOwner = computed(() => - captureStalkerCollectionPlaybackOwner(this.item()) - ); - readonly playbackSessionKey = computed( - () => this.playbackOwner()?.sessionKey ?? '' - ); - private readonly selectedVodPosition = signal( - null - ); - readonly isSelectedVodFavorite = signal(false); readonly detailCategoryOverride = signal( 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( - () => 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 { @@ -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 { - 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 { - 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, - }), - }); -} diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-favorites.controller.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-favorites.controller.ts new file mode 100644 index 000000000..5e763f44a --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-favorites.controller.ts @@ -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; + 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; + + 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(); + }, + }); + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-playback.controller.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-playback.controller.ts new file mode 100644 index 000000000..8520c6cdc --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-playback.controller.ts @@ -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; + 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(null); + + private readonly selectedVodPosition = signal( + null + ); + readonly selectedVodPlaybackPosition = computed( + () => 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 { + await this.vodPlayback.loadSelectedVodPosition(playlistId, vodId); + } + + clearSelectedVodPosition(): void { + this.selectedVodPosition.set(null); + } + + private async startVodPlayback( + cmd?: string, + title?: string, + thumbnail?: string, + startTime?: number + ): Promise { + 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, + }), + }); +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-store-snapshot.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-store-snapshot.ts new file mode 100644 index 000000000..91e427a64 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-store-snapshot.ts @@ -0,0 +1,42 @@ +import { + StalkerContentType, + StalkerStore, +} from '@iptvnator/portal/stalker/data-access'; +import { Playlist } from '@iptvnator/shared/interfaces'; + +type StalkerStoreInstance = InstanceType; + +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); +} diff --git a/tools/eslint/max-lines-baseline.mjs b/tools/eslint/max-lines-baseline.mjs index 752a99ecd..5e3c4a64b 100644 --- a/tools/eslint/max-lines-baseline.mjs +++ b/tools/eslint/max-lines-baseline.mjs @@ -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',