refactor(playback): share series navigation state

This commit is contained in:
4gray committed 2026-06-07 21:50:33 +02:00
1 parent 4e30289293
commit e03dfc9c2a
4 files changed
+131 -122

No files matched your search

@@ -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<SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null>(() =>
this.getInlineEpisodeState()
);
readonly inlineEpisodeMetadata = computed<SeriesEpisodeMetadata | null>(() => {
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<SeriesPlaybackNavigation | null>(() => {
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 {
@@ -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<SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null>(() =>
this.getInlineEpisodeState()
);
readonly inlineEpisodeMetadata = computed<SeriesEpisodeMetadata | null>(() => {
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<SeriesPlaybackNavigation | null>(() => {
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(
@@ -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<void>();
@Output() previousEpisodeRequested = new EventEmitter<void>();
@Output() nextEpisodeRequested = new EventEmitter<void>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
private readonly overlayVisibility = inject(
EmbeddedMpvOverlayVisibilityService
@@ -20,6 +20,22 @@ export interface SeriesPlaybackEpisodeState<TEpisode> {
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<string, readonly TEpisode[]> | 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<TEpisode>): SeriesPlaybackEpisodeState<TEpisode> | 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<TEpisode> | 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<unknown> | 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');