diff --git a/libs/portal/shared/util/src/lib/series-quick-start.spec.ts b/libs/portal/shared/util/src/lib/series-quick-start.spec.ts index 8a70a30ca..4fa794a61 100644 --- a/libs/portal/shared/util/src/lib/series-quick-start.spec.ts +++ b/libs/portal/shared/util/src/lib/series-quick-start.spec.ts @@ -2,6 +2,8 @@ import { PlaybackPositionData, XtreamSerieEpisode } from 'shared-interfaces'; import { SERIES_QUICK_START_ACTION_KIND, getSeriesQuickStartAction, + getPositiveInteger, + padEpisodePart, } from './series-quick-start'; function episode( @@ -243,3 +245,17 @@ describe('getSeriesQuickStartAction', () => { expect(action).toBeNull(); }); }); + +describe('episode label helpers', () => { + it('normalizes only positive integers', () => { + expect(getPositiveInteger(1)).toBe(1); + expect(getPositiveInteger(0)).toBeNull(); + expect(getPositiveInteger(1.5)).toBeNull(); + expect(getPositiveInteger(Number.NaN)).toBeNull(); + }); + + it('pads single digit episode label parts', () => { + expect(padEpisodePart(1)).toBe('01'); + expect(padEpisodePart(10)).toBe('10'); + }); +}); diff --git a/libs/portal/shared/util/src/lib/series-quick-start.ts b/libs/portal/shared/util/src/lib/series-quick-start.ts index 167af2035..50998f5d6 100644 --- a/libs/portal/shared/util/src/lib/series-quick-start.ts +++ b/libs/portal/shared/util/src/lib/series-quick-start.ts @@ -116,7 +116,9 @@ function createQuickStartAction( }; } -function getOrderedEpisodes(request: SeriesQuickStartRequest): OrderedEpisode[] { +function getOrderedEpisodes( + request: SeriesQuickStartRequest +): OrderedEpisode[] { const orderedEpisodes: OrderedEpisode[] = []; Object.entries(request.seasons) @@ -186,10 +188,10 @@ function getEpisodeLabel(episode: XtreamSerieEpisode): string { return title ? `${episodeCode} ยท ${title}` : episodeCode; } -function getPositiveInteger(value: number): number | null { +export function getPositiveInteger(value: number): number | null { return Number.isInteger(value) && value > 0 ? value : null; } -function padEpisodePart(value: number): string { +export function padEpisodePart(value: number): string { return value < 10 ? `0${value}` : String(value); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts index 60db51560..da8218ae5 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts @@ -191,7 +191,10 @@ describe('StalkerCollectionDetailComponent', () => { }) .overrideComponent(StalkerCollectionDetailComponent, { remove: { - imports: [ContentHeroComponent, StalkerInlineDetailComponent], + imports: [ + ContentHeroComponent, + StalkerInlineDetailComponent, + ], }, add: { imports: [ @@ -230,7 +233,9 @@ describe('StalkerCollectionDetailComponent', () => { expect(stalkerStore.setSelectedContentType).toHaveBeenLastCalledWith( 'vod' ); - expect(stalkerStore.setSelectedCategory).toHaveBeenLastCalledWith('vod'); + expect(stalkerStore.setSelectedCategory).toHaveBeenLastCalledWith( + 'vod' + ); expect(stalkerStore.setSelectedItem).toHaveBeenLastCalledWith( expect.objectContaining({ id: '1507', @@ -351,6 +356,39 @@ describe('StalkerCollectionDetailComponent', () => { expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled(); expect(fixture.componentInstance.inlinePlayback()).toEqual(playback); }); + + it('does not load VOD playback position when the playlist id is missing', async () => { + const playlistsService = TestBed.inject(PlaylistsService) as { + getPlaylistById: jest.Mock; + }; + playlistsService.getPlaylistById.mockReturnValue( + of({ + ...playlist, + _id: '', + }) + ); + + fixture.componentRef.setInput( + 'item', + buildCollectionItem({ + contentType: 'movie', + categoryId: 'vod', + stalkerItem: { + id: '1702', + title: 'Collection Movie Without Playlist', + category_id: 'vod', + cmd: '/media/file_1702.mpg', + }, + }) + ); + + await settleDetail(fixture); + + expect(playbackPositions.getPlaybackPosition).not.toHaveBeenCalled(); + expect(fixture.componentInstance.selectedVodPlaybackPosition()).toBe( + null + ); + }); }); function buildCollectionItem( 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 5fcf4a9d1..a38f26a68 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 @@ -14,6 +14,7 @@ import { TranslateService } from '@ngx-translate/core'; import { ContentHeroComponent } from 'components'; import { buildStalkerStateItem, + createLogger, PORTAL_EXTERNAL_PLAYBACK, PORTAL_PLAYBACK_POSITIONS, PORTAL_PLAYER, @@ -45,6 +46,7 @@ import { } from 'shared-interfaces'; import { firstValueFrom } from 'rxjs'; import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component'; +import { StalkerVodPlaybackController } from './stalker-vod-playback-controller'; interface StalkerCollectionStateSnapshot { currentPlaylist: Playlist | undefined; @@ -114,6 +116,7 @@ export class StalkerCollectionDetailComponent { private readonly portalPlayer = inject(PORTAL_PLAYER); private readonly snackBar = inject(MatSnackBar); private readonly translateService = inject(TranslateService); + private readonly logger = createLogger('StalkerCollectionDetail'); private readonly originalState = this.captureStoreState(); private readonly favoritesRefresh = createRefreshTrigger(); @@ -145,7 +148,16 @@ export class StalkerCollectionDetailComponent { ); private initRequestId = 0; - private lastInlineSaveTime = 0; + 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', + }); constructor() { effect(() => { @@ -224,50 +236,19 @@ export class StalkerCollectionDetailComponent { currentTime: number; duration: number; }): void { - const playback = this.inlinePlayback(); - if (!playback?.contentInfo) { - return; - } - - const now = Date.now(); - if (now - this.lastInlineSaveTime <= 15000) { - return; - } - - this.lastInlineSaveTime = now; - const position: PlaybackPositionData = { - ...playback.contentInfo, - positionSeconds: Math.floor(event.currentTime), - durationSeconds: Math.floor(event.duration), - }; - - void this.playbackPositions.savePlaybackPosition( - playback.contentInfo.playlistId, - position - ); - this.selectedVodPosition.set(position); + this.vodPlayback.handleInlineTimeUpdate(event); } closeInlinePlayer(): void { - this.inlinePlayback.set(null); - this.lastInlineSaveTime = 0; + this.vodPlayback.closeInlinePlayer(); } showCopyNotification(): void { - this.snackBar.open( - this.translateService.instant('PORTALS.STREAM_URL_COPIED'), - null, - { - duration: 2000, - } - ); + this.vodPlayback.showCopyNotification(); } handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { - void this.portalPlayer.openExternalPlayback( - request.playback, - request.player - ); + this.vodPlayback.handleExternalFallbackRequest(request); } private async prepareDetail( @@ -304,7 +285,9 @@ export class StalkerCollectionDetailComponent { ); this.detailCategoryOverride.set(detailMode.category); - this.stalkerStore.setSelectedContentType(detailMode.selectedContentType); + this.stalkerStore.setSelectedContentType( + detailMode.selectedContentType + ); this.stalkerStore.setSelectedCategory( this.resolveSelectedCategory(item, stalkerItem, detailMode) ); @@ -379,11 +362,8 @@ export class StalkerCollectionDetailComponent { item: UnifiedCollectionItem, stalkerItem: StalkerPortalItem ): StalkerCollectionDetailMode { - const hasEmbeddedSeries = Array.isArray( - (stalkerItem as { series?: unknown[] }).series - ) - ? (stalkerItem as { series?: unknown[] }).series!.length > 0 - : false; + 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 ); @@ -420,7 +400,9 @@ export class StalkerCollectionDetailComponent { return 'vod'; } - return categoryId ?? toStalkerCategoryId(detailMode.selectedContentType); + return ( + categoryId ?? toStalkerCategoryId(detailMode.selectedContentType) + ); } private syncSelectedVodFavorite(): void { @@ -447,58 +429,24 @@ export class StalkerCollectionDetailComponent { thumbnail?: string, startTime?: number ): Promise { - try { - const playback = - startTime === undefined - ? await this.stalkerStore.resolveVodPlayback( - cmd, - title, - thumbnail - ) - : await this.stalkerStore.resolveVodPlayback( - cmd, - title, - thumbnail, - undefined, - undefined, - startTime - ); - - this.lastInlineSaveTime = 0; - if (this.portalPlayer.isEmbeddedPlayer()) { - this.inlinePlayback.set(playback); - return; - } - - this.closeInlinePlayer(); - void this.portalPlayer.openResolvedPlayback(playback, true); - } catch (error) { - const errorMessage = - error instanceof Error && error.message === 'nothing_to_play' - ? this.translateService.instant( - 'PORTALS.CONTENT_NOT_AVAILABLE' - ) - : this.translateService.instant('PORTALS.PLAYBACK_ERROR'); - this.snackBar.open(errorMessage, null, { - duration: 3000, - }); - } + 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 { - if (!Number.isFinite(vodId)) { - this.selectedVodPosition.set(null); - return; - } - - const position = await this.playbackPositions.getPlaybackPosition( - playlistId, - vodId, - 'vod' - ); - this.selectedVodPosition.set(position ?? null); + await this.vodPlayback.loadSelectedVodPosition(playlistId, vodId); } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts index 168f835dd..d2b473625 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts @@ -52,6 +52,7 @@ import { isStalkerSeriesFlag, toggleStalkerVodFavorite, } from '@iptvnator/portal/stalker/data-access'; +import { StalkerVodPlaybackController } from '../stalker-vod-playback-controller'; interface StalkerFilter { key: string; @@ -136,7 +137,16 @@ export class StalkerSearchComponent { readonly selectedVodPlaybackPosition = computed( () => this.selectedVodPosition()?.positionSeconds ?? null ); - private lastInlineSaveTime = 0; + 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 search VOD playback', + }); readonly portalFavorites = createPortalFavoritesResource( this.playlistService, @@ -341,50 +351,19 @@ export class StalkerSearchComponent { currentTime: number; duration: number; }): void { - const playback = this.inlinePlayback(); - if (!playback?.contentInfo) { - return; - } - - const now = Date.now(); - if (now - this.lastInlineSaveTime <= 15000) { - return; - } - - this.lastInlineSaveTime = now; - const position: PlaybackPositionData = { - ...playback.contentInfo, - positionSeconds: Math.floor(event.currentTime), - durationSeconds: Math.floor(event.duration), - }; - - void this.playbackPositions.savePlaybackPosition( - playback.contentInfo.playlistId, - position - ); - this.selectedVodPosition.set(position); + this.vodPlayback.handleInlineTimeUpdate(event); } closeInlinePlayer(): void { - this.inlinePlayback.set(null); - this.lastInlineSaveTime = 0; + this.vodPlayback.closeInlinePlayer(); } showCopyNotification(): void { - this.snackBar.open( - this.translateService.instant('PORTALS.STREAM_URL_COPIED'), - null, - { - duration: 2000, - } - ); + this.vodPlayback.showCopyNotification(); } handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { - void this.portalPlayer.openExternalPlayback( - request.playback, - request.player - ); + this.vodPlayback.handleExternalFallbackRequest(request); } removeFromFavorites(favoriteId: string, onDone?: () => void) { @@ -454,59 +433,24 @@ export class StalkerSearchComponent { thumbnail?: string, startTime?: number ): Promise { - try { - const playback = - startTime === undefined - ? await this.stalkerStore.resolveVodPlayback( - cmd, - title, - thumbnail - ) - : await this.stalkerStore.resolveVodPlayback( - cmd, - title, - thumbnail, - undefined, - undefined, - startTime - ); - - this.lastInlineSaveTime = 0; - if (this.portalPlayer.isEmbeddedPlayer()) { - this.inlinePlayback.set(playback); - return; - } - - this.closeInlinePlayer(); - void this.portalPlayer.openResolvedPlayback(playback, true); - } catch (error) { - this.logger.error('Failed to start search VOD playback', error); - const errorMessage = - error instanceof Error && error.message === 'nothing_to_play' - ? this.translateService.instant( - 'PORTALS.CONTENT_NOT_AVAILABLE' - ) - : this.translateService.instant('PORTALS.PLAYBACK_ERROR'); - this.snackBar.open(errorMessage, null, { - duration: 3000, - }); - } + 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 { - if (!playlistId || !Number.isFinite(vodId)) { - this.selectedVodPosition.set(null); - return; - } - - const position = await this.playbackPositions.getPlaybackPosition( - playlistId, - vodId, - 'vod' - ); - this.selectedVodPosition.set(position ?? null); + await this.vodPlayback.loadSelectedVodPosition(playlistId, vodId); } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 2424a7bbb..ca7ed2d88 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -30,8 +30,10 @@ import { SERIES_QUICK_START_ACTION_KIND, SeriesQuickStartAction, createLogger, + getPositiveInteger, getSeriesQuickStartAction, getStalkerReturnToState, + padEpisodePart, } from '@iptvnator/portal/shared/util'; import { getVodSeriesSeasonKey, @@ -156,14 +158,11 @@ export class StalkerSeriesViewComponent implements OnDestroy { const playlist = this.stalkerStore.currentPlaylist(); if (item && playlist?._id) { const normalizedSeriesId = this.toSeriesId(item.id); - this.logger.debug( - 'Loading positions for series', - { - id: item.id, - seriesId: normalizedSeriesId, - isSeries: item.is_series, - } - ); + this.logger.debug('Loading positions for series', { + id: item.id, + seriesId: normalizedSeriesId, + isSeries: item.is_series, + }); if (!isNaN(normalizedSeriesId)) { void this.loadSeriesPositions( playlist._id, @@ -578,8 +577,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { } catch (error) { this.logger.error('Failed to start inline series playback', error); const errorMessage = - error instanceof Error && - error.message === 'nothing_to_play' + error instanceof Error && error.message === 'nothing_to_play' ? this.translateService.instant( 'PORTALS.CONTENT_NOT_AVAILABLE' ) @@ -654,7 +652,8 @@ export class StalkerSeriesViewComponent implements OnDestroy { const posterUrl = episodeInfo?.movie_image; const seasonNum = Number(episode.season || 1); const episodeNum = episode.episode_num || 1; - const seriesTitle = item.info?.name || this.displayItem()?.info?.name || 'Series'; + const seriesTitle = + item.info?.name || this.displayItem()?.info?.name || 'Series'; const episodeTitle = `${seriesTitle} - S${String(seasonNum).padStart( 2, '0' @@ -686,10 +685,11 @@ export class StalkerSeriesViewComponent implements OnDestroy { playlistId: string, seriesXtreamId: number ): Promise { - const positions = await this.playbackPositions.getSeriesPlaybackPositions( - playlistId, - seriesXtreamId - ); + const positions = + await this.playbackPositions.getSeriesPlaybackPositions( + playlistId, + seriesXtreamId + ); const positionsMap = new Map(); positions.forEach((position) => { positionsMap.set(position.contentXtreamId, position); @@ -774,7 +774,9 @@ export class StalkerSeriesViewComponent implements OnDestroy { return `S${padEpisodePart(seasonNumber)}E01`; } - private updateEpisodePlaybackPosition(position: PlaybackPositionData): void { + private updateEpisodePlaybackPosition( + position: PlaybackPositionData + ): void { const updated = new Map(this.episodePlaybackPositions()); updated.set(position.contentXtreamId, position); this.episodePlaybackPositions.set(updated); @@ -835,11 +837,3 @@ export class StalkerSeriesViewComponent implements OnDestroy { return Math.abs(hash); } } - -function getPositiveInteger(value: number): number | null { - return Number.isInteger(value) && value > 0 ? value : null; -} - -function padEpisodePart(value: number): string { - return value < 10 ? `0${value}` : String(value); -} diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts new file mode 100644 index 000000000..179bc0657 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts @@ -0,0 +1,131 @@ +import type { WritableSignal } from '@angular/core'; +import type { MatSnackBar } from '@angular/material/snack-bar'; +import type { TranslateService } from '@ngx-translate/core'; +import type { + Logger, + PortalPlaybackPositions, + PortalPlayer, +} from '@iptvnator/portal/shared/util'; +import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback'; +import { + PlaybackPositionData, + ResolvedPortalPlayback, +} from 'shared-interfaces'; + +interface StalkerVodPlaybackControllerConfig { + inlinePlayback: WritableSignal; + selectedVodPosition: WritableSignal; + playbackPositions: PortalPlaybackPositions; + portalPlayer: PortalPlayer; + snackBar: MatSnackBar; + translateService: TranslateService; + logger: Logger; + playbackErrorLogMessage: string; +} + +export class StalkerVodPlaybackController { + private lastInlineSaveTime = 0; + + constructor(private readonly config: StalkerVodPlaybackControllerConfig) {} + + async startVodPlayback( + resolvePlayback: () => Promise + ): Promise { + try { + const playback = await resolvePlayback(); + + this.lastInlineSaveTime = 0; + if (this.config.portalPlayer.isEmbeddedPlayer()) { + this.config.inlinePlayback.set(playback); + return; + } + + this.closeInlinePlayer(); + void this.config.portalPlayer.openResolvedPlayback(playback, true); + } catch (error) { + this.config.logger.error( + this.config.playbackErrorLogMessage, + error + ); + const errorMessage = + error instanceof Error && error.message === 'nothing_to_play' + ? this.config.translateService.instant( + 'PORTALS.CONTENT_NOT_AVAILABLE' + ) + : this.config.translateService.instant( + 'PORTALS.PLAYBACK_ERROR' + ); + this.config.snackBar.open(errorMessage, null, { + duration: 3000, + }); + } + } + + async loadSelectedVodPosition( + playlistId: string, + vodId: number + ): Promise { + if (!playlistId || !Number.isFinite(vodId)) { + this.config.selectedVodPosition.set(null); + return; + } + + const position = + await this.config.playbackPositions.getPlaybackPosition( + playlistId, + vodId, + 'vod' + ); + this.config.selectedVodPosition.set(position ?? null); + } + + handleInlineTimeUpdate(event: { + currentTime: number; + duration: number; + }): void { + const playback = this.config.inlinePlayback(); + if (!playback?.contentInfo) { + return; + } + + const now = Date.now(); + if (now - this.lastInlineSaveTime <= 15000) { + return; + } + + this.lastInlineSaveTime = now; + const position: PlaybackPositionData = { + ...playback.contentInfo, + positionSeconds: Math.floor(event.currentTime), + durationSeconds: Math.floor(event.duration), + }; + + void this.config.playbackPositions.savePlaybackPosition( + playback.contentInfo.playlistId, + position + ); + this.config.selectedVodPosition.set(position); + } + + closeInlinePlayer(): void { + this.config.inlinePlayback.set(null); + this.lastInlineSaveTime = 0; + } + + showCopyNotification(): void { + this.config.snackBar.open( + this.config.translateService.instant('PORTALS.STREAM_URL_COPIED'), + null, + { + duration: 2000, + } + ); + } + + handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { + void this.config.portalPlayer.openExternalPlayback( + request.playback, + request.player + ); + } +}