From e03dfc9c2a471e5987f00b7920958378646bcdcb Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 7 Jun 2026 21:50:33 +0200 Subject: [PATCH] refactor(playback): share series navigation state --- .../stalker-series-view.component.ts | 70 +++---------- .../serial-details.component.ts | 77 +++------------ .../embedded-mpv-player.component.ts | 7 +- .../series-playback-navigation.ts | 99 +++++++++++++++++++ 4 files changed, 131 insertions(+), 122 deletions(-) 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 2e6f7fc22..3e324f13a 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 @@ -46,12 +46,12 @@ import { StalkerVodSource, } from '@iptvnator/portal/stalker/data-access'; import { - formatSeriesEpisodeLabel, + getSeriesEpisodeMetadata, + getSeriesPlaybackNavigation, type PlaybackFallbackRequest, PortalInlinePlayerComponent, - type SeriesEpisodeMetadata, + resolveSeriesPlaybackEpisodeState, type SeriesPlaybackEpisodeState, - type SeriesPlaybackNavigation, } from '@iptvnator/ui/playback'; import { DownloadsService, @@ -280,35 +280,12 @@ export class StalkerSeriesViewComponent implements OnDestroy { computed | null>(() => this.getInlineEpisodeState() ); - readonly inlineEpisodeMetadata = computed(() => { - const state = this.inlineEpisodeState(); - if (!state) { - return null; - } - - return { - label: formatSeriesEpisodeLabel( - state.seasonNumber, - state.episodeNumber - ), - seasonNumber: state.seasonNumber, - episodeNumber: state.episodeNumber, - title: state.episode.title, - }; - }); - readonly inlineSeriesNavigation = - computed(() => { - const state = this.inlineEpisodeState(); - if (!state) { - return null; - } - - return { - canPrevious: Boolean(state.previous), - canNext: Boolean(state.next), - autoplayEnabled: true, - }; - }); + readonly inlineEpisodeMetadata = computed(() => + getSeriesEpisodeMetadata(this.inlineEpisodeState()) + ); + readonly inlineSeriesNavigation = computed(() => + getSeriesPlaybackNavigation(this.inlineEpisodeState()) + ); /** * Handles season selection from the container. @@ -628,31 +605,10 @@ export class StalkerSeriesViewComponent implements OnDestroy { return null; } - for (const [seasonKey, episodes] of Object.entries(this.mappedSeasons())) { - const episodeIndex = episodes.findIndex( - (episode) => Number(episode.id) === Number(currentEpisodeId) - ); - if (episodeIndex < 0) { - continue; - } - - const episode = episodes[episodeIndex]; - const seasonNumber = - Number(episode.season) || Number(seasonKey) || 0; - const episodeNumber = - Number(episode.episode_num) || episodeIndex + 1; - - return { - seasonKey, - seasonNumber, - episodeNumber, - episode, - previous: episodes[episodeIndex - 1] ?? null, - next: episodes[episodeIndex + 1] ?? null, - }; - } - - return null; + return resolveSeriesPlaybackEpisodeState({ + episodesBySeason: this.mappedSeasons(), + currentEpisodeId, + }); } ngOnDestroy(): void { diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index 715a419be..ae77bb116 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -26,12 +26,12 @@ import { } from '@iptvnator/portal/shared/util'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { - formatSeriesEpisodeLabel, + getSeriesEpisodeMetadata, + getSeriesPlaybackNavigation, type PlaybackFallbackRequest, PortalInlinePlayerComponent, - type SeriesEpisodeMetadata, + resolveSeriesPlaybackEpisodeState, type SeriesPlaybackEpisodeState, - type SeriesPlaybackNavigation, } from '@iptvnator/ui/playback'; import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services'; import { @@ -118,35 +118,12 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { computed | null>(() => this.getInlineEpisodeState() ); - readonly inlineEpisodeMetadata = computed(() => { - const state = this.inlineEpisodeState(); - if (!state) { - return null; - } - - return { - label: formatSeriesEpisodeLabel( - state.seasonNumber, - state.episodeNumber - ), - seasonNumber: state.seasonNumber, - episodeNumber: state.episodeNumber, - title: state.episode.title, - }; - }); - readonly inlineSeriesNavigation = - computed(() => { - const state = this.inlineEpisodeState(); - if (!state) { - return null; - } - - return { - canPrevious: Boolean(state.previous), - canNext: Boolean(state.next), - autoplayEnabled: true, - }; - }); + readonly inlineEpisodeMetadata = computed(() => + getSeriesEpisodeMetadata(this.inlineEpisodeState()) + ); + readonly inlineSeriesNavigation = computed(() => + getSeriesPlaybackNavigation(this.inlineEpisodeState()) + ); constructor() { effect(() => { @@ -458,36 +435,12 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { return null; } - for (const [seasonKey, episodes] of Object.entries(episodesBySeason)) { - const episodeIndex = episodes.findIndex( - (episode) => Number(episode.id) === Number(currentEpisodeId) - ); - if (episodeIndex < 0) { - continue; - } - - const episode = episodes[episodeIndex]; - const seasonNumber = - Number(episode.season) || - Number(seasonKey) || - playback.contentInfo.seasonNumber || - 0; - const episodeNumber = - Number(episode.episode_num) || - playback.contentInfo.episodeNumber || - episodeIndex + 1; - - return { - seasonKey, - seasonNumber, - episodeNumber, - episode, - previous: episodes[episodeIndex - 1] ?? null, - next: episodes[episodeIndex + 1] ?? null, - }; - } - - return null; + return resolveSeriesPlaybackEpisodeState({ + episodesBySeason, + currentEpisodeId, + fallbackSeasonNumber: playback.contentInfo.seasonNumber, + fallbackEpisodeNumber: playback.contentInfo.episodeNumber, + }); } async handlePlaybackToggleRequested( diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts index 6389f3ac0..cde34d4a1 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts @@ -9,6 +9,7 @@ import { effect, inject, input, + output, signal, untracked, viewChild, @@ -76,9 +77,9 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { currentTime: number; duration: number; }>(); - @Output() playbackEnded = new EventEmitter(); - @Output() previousEpisodeRequested = new EventEmitter(); - @Output() nextEpisodeRequested = new EventEmitter(); + readonly playbackEnded = output(); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); private readonly overlayVisibility = inject( EmbeddedMpvOverlayVisibilityService diff --git a/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation.ts b/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation.ts index 182d64354..790b3adf7 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation.ts @@ -20,6 +20,22 @@ export interface SeriesPlaybackEpisodeState { next: TEpisode | null; } +export interface SeriesPlaybackEpisodeLike { + id: string | number; + season?: string | number; + episode_num?: string | number; + title?: string; +} + +export interface ResolveSeriesPlaybackEpisodeStateOptions< + TEpisode extends SeriesPlaybackEpisodeLike, +> { + episodesBySeason: Record | null | undefined; + currentEpisodeId: string | number | null | undefined; + fallbackSeasonNumber?: number; + fallbackEpisodeNumber?: number; +} + export function formatSeriesEpisodeLabel( seasonNumber: number, episodeNumber: number @@ -27,6 +43,89 @@ export function formatSeriesEpisodeLabel( return `S${padSeriesNumber(seasonNumber)}E${padSeriesNumber(episodeNumber)}`; } +export function resolveSeriesPlaybackEpisodeState< + TEpisode extends SeriesPlaybackEpisodeLike, +>({ + episodesBySeason, + currentEpisodeId, + fallbackSeasonNumber, + fallbackEpisodeNumber, +}: ResolveSeriesPlaybackEpisodeStateOptions): SeriesPlaybackEpisodeState | null { + if ( + !episodesBySeason || + currentEpisodeId === null || + currentEpisodeId === undefined + ) { + return null; + } + + for (const [seasonKey, episodes] of Object.entries(episodesBySeason)) { + const episodeIndex = episodes.findIndex( + (episode) => Number(episode.id) === Number(currentEpisodeId) + ); + if (episodeIndex < 0) { + continue; + } + + const episode = episodes[episodeIndex]; + const seasonNumber = + Number(episode.season) || + Number(seasonKey) || + fallbackSeasonNumber || + 0; + const episodeNumber = + Number(episode.episode_num) || + fallbackEpisodeNumber || + episodeIndex + 1; + + return { + seasonKey, + seasonNumber, + episodeNumber, + episode, + previous: episodes[episodeIndex - 1] ?? null, + next: episodes[episodeIndex + 1] ?? null, + }; + } + + return null; +} + +export function getSeriesEpisodeMetadata< + TEpisode extends SeriesPlaybackEpisodeLike, +>( + state: SeriesPlaybackEpisodeState | null +): SeriesEpisodeMetadata | null { + if (!state) { + return null; + } + + return { + label: formatSeriesEpisodeLabel( + state.seasonNumber, + state.episodeNumber + ), + seasonNumber: state.seasonNumber, + episodeNumber: state.episodeNumber, + title: state.episode.title, + }; +} + +export function getSeriesPlaybackNavigation( + state: SeriesPlaybackEpisodeState | null, + autoplayEnabled = true +): SeriesPlaybackNavigation | null { + if (!state) { + return null; + } + + return { + canPrevious: Boolean(state.previous), + canNext: Boolean(state.next), + autoplayEnabled, + }; +} + function padSeriesNumber(value: number): string { const normalized = Number.isFinite(value) && value > 0 ? value : 0; return String(normalized).padStart(2, '0');