refactor(ui): give season-container and vod-details real line headroom

- season-container.component.ts (396): season auto-selection and the
  seasonSelected emission move to createSeasonAutoSelectState, called at
  the same place in the constructor so effect order is unchanged; the
  episode subline becomes buildEpisodeSubline
- vod-details.component.ts (393): the cross-portal Similar loader, the
  provider-only download state and the actor/Similar route helpers move to
  sibling modules

Inputs, outputs, selectors and public members are unchanged.

Counted lines: season container 396 -> 341, vod details 393 -> 337.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-04 10:08:36 +02:00
1 parent d54e6026d7
commit 34ecc5998a
7 files changed
+384 -189

No files matched your search

@@ -0,0 +1,37 @@
import type { TranslateService } from '@ngx-translate/core';
import {
formatDurationLabel,
formatRemainingLabel,
isPortalPlaybackWatched,
} from '@iptvnator/portal/shared/util';
import {
PlaybackPositionData,
XtreamSerieEpisodeInfo,
} from '@iptvnator/shared/interfaces';
import {
episodeRuntimeSeconds,
formatEpisodePositionText,
} from './episode-progress.util';
/**
* The line under an episode card's title, built from the episode's runtime
* and its saved playback position:
* "42 min · 18m left", "42 min · watched", "42 min" — or null.
*/
export function buildEpisodeSubline(
info: XtreamSerieEpisodeInfo | undefined,
position: PlaybackPositionData | undefined,
translate: Pick<TranslateService, 'instant'>
): string | null {
const duration = formatDurationLabel(episodeRuntimeSeconds(info));
const remaining = formatRemainingLabel(position);
const parts = [
duration ? translate.instant(duration.key, duration.params) : null,
isPortalPlaybackWatched(position)
? translate.instant('PORTALS.DETAIL.WATCHED')
: remaining
? translate.instant(remaining.key, remaining.params)
: formatEpisodePositionText(position),
].filter((part): part is string => !!part);
return parts.length ? parts.join(' · ') : null;
}
@@ -0,0 +1,136 @@
import {
type Signal,
type WritableSignal,
computed,
effect,
untracked,
} from '@angular/core';
import {
PlaybackPositionData,
XtreamSerieEpisode,
} from '@iptvnator/shared/interfaces';
import { resolveAutoSelectedSeason } from './season-auto-select.util';
export interface SeasonAutoSelectSources {
/**
* The container's selection (see `SeasonContainerComponent.selectedSeason`).
* Auto-select writes to it; user tab clicks write to it too and stick
* until the auto-select key changes.
*/
readonly selectedSeason: WritableSignal<string | undefined>;
readonly seasons: Signal<Record<string, XtreamSerieEpisode[]>>;
/** Season keys in display order. */
readonly sortedSeasonKeys: Signal<readonly string[]>;
/** Season key of the inline-playing episode, if it is in the loaded set. */
readonly playingSeasonKey: Signal<string | null>;
readonly playbackPositions: Signal<
ReadonlyMap<number, PlaybackPositionData>
>;
readonly positionOf: (
episode: XtreamSerieEpisode
) => PlaybackPositionData | undefined;
/** Stalker lazy-VOD: some seasons' episode lists are not loaded yet. */
readonly hasUnloadedSeasons: Signal<boolean>;
readonly episodeCounts: Signal<Record<string, number>>;
readonly watchedCounts: Signal<Record<string, number>>;
readonly emitSeasonSelected: (seasonKey: string) => void;
}
export interface SeasonAutoSelectState {
/**
* Record that this session toggled watched state itself: the positions
* flip that follows is then not treated as an initial load.
*/
markLocalWatchedMutation(): void;
}
/** Key of the loaded season that holds the episode with this content id. */
export function findSeasonOfEpisode(
seasons: Record<string, XtreamSerieEpisode[]>,
episodeId: number
): string | null {
for (const [key, episodes] of Object.entries(seasons)) {
if (episodes?.some((episode) => Number(episode.id) === episodeId)) {
return key;
}
}
return null;
}
/**
* Season auto-selection and `seasonSelected` emission of
* `SeasonContainerComponent`. The selection re-resolves when the season key
* set changes or when playback positions first arrive; ongoing position
* saves do not reset it — only the empty→loaded transition of the positions
* map does, and even that is ignored once this session toggled watched state
* itself.
*
* Registers two effects, so it must be called in an injection context (the
* container's constructor).
*/
export function createSeasonAutoSelectState(
sources: SeasonAutoSelectSources
): SeasonAutoSelectState {
const autoSelectKey = computed(
() =>
`${sources.sortedSeasonKeys().join('|')}::${
sources.playbackPositions().size > 0 ? '1' : '0'
}`
);
let lastAutoSelectKey: string | null = null;
let lastAutoSelectSeasonSet: string | null = null;
/**
* True once this session toggled watched state itself. From then on an
* empty↔loaded flip of the positions map is the echo of that action, not
* an async initial load — re-resolving on it would yank the user off the
* season they just marked (e.g. all-watched season 1 → jump to season 2).
*/
let hasLocalWatchedMutation = false;
let lastEmittedSeason: string | undefined;
/** Auto-select rules live in season-auto-select.util.ts. */
const resolveAutoSeason = (): string | undefined =>
resolveAutoSelectedSeason({
keys: sources.sortedSeasonKeys(),
playingSeasonKey: sources.playingSeasonKey(),
seasons: sources.seasons(),
positionOf: sources.positionOf,
hasUnloadedSeasons: sources.hasUnloadedSeasons(),
episodeCounts: sources.episodeCounts(),
watchedCounts: sources.watchedCounts(),
});
effect(() => {
const key = autoSelectKey();
if (key === lastAutoSelectKey) {
return;
}
const seasonSet = untracked(() => sources.sortedSeasonKeys().join('|'));
const seasonSetUnchanged = seasonSet === lastAutoSelectSeasonSet;
lastAutoSelectKey = key;
lastAutoSelectSeasonSet = seasonSet;
// A positions-emptiness flip after a local watched toggle keeps
// the current selection; only the async initial positions load
// (or a season-set change) re-resolves the season.
if (seasonSetUnchanged && hasLocalWatchedMutation) {
return;
}
sources.selectedSeason.set(untracked(() => resolveAutoSeason()));
});
// Fire the lazy-load/enrichment hooks for auto-selected seasons too —
// with tabs there is no initial "pick a season" click anymore.
effect(() => {
const selected = sources.selectedSeason();
if (selected && selected !== lastEmittedSeason) {
lastEmittedSeason = selected;
sources.emitSeasonSelected(selected);
}
});
return {
markLocalWatchedMutation: () => {
hasLocalWatchedMutation = true;
},
};
}
@@ -4,12 +4,10 @@ import {
ElementRef,
OnInit,
computed,
effect,
inject,
input,
output,
signal,
untracked,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
@@ -21,8 +19,6 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { type SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
import {
createLogger,
formatDurationLabel,
formatRemainingLabel,
getPortalPlaybackProgressPercent,
isPortalPlaybackInProgress,
isPortalPlaybackWatched,
@@ -38,11 +34,13 @@ import {
EpisodeInfoDialogComponent,
buildEpisodeInfoDialogData,
} from './episode-info-dialog.component';
import { formatEpisodePositionText } from './episode-progress.util';
import { buildEpisodeSubline } from './episode-subline.util';
import {
formatEpisodePositionText,
episodeRuntimeSeconds,
} from './episode-progress.util';
import { resolveAutoSelectedSeason } from './season-auto-select.util';
type SeasonAutoSelectState,
createSeasonAutoSelectState,
findSeasonOfEpisode,
} from './season-auto-select.state';
import { SeasonDownloadPresenter } from './season-download-presenter';
import { SeasonTabsComponent } from './season-tabs.component';
import { SeasonWatchPresenter } from './season-watch-presenter';
@@ -80,7 +78,8 @@ export class SeasonContainerComponent implements OnInit {
private readonly translate = inject(TranslateService);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly logger = createLogger('SeasonContainer');
private lastEmittedSeason: string | undefined;
/** Auto-select + `seasonSelected` emission; wired in the constructor. */
private readonly autoSelect: SeasonAutoSelectState;
readonly downloadPresenter = inject(SeasonDownloadPresenter);
readonly watchPresenter = inject(SeasonWatchPresenter);
@@ -148,9 +147,12 @@ export class SeasonContainerComponent implements OnInit {
});
/** Season key of the inline-playing episode, if it is in the loaded set. */
readonly playingSeasonKey = computed(() =>
this.findSeasonOfEpisode(this.playingEpisodeId())
);
readonly playingSeasonKey = computed(() => {
const episodeId = this.playingEpisodeId();
return episodeId === null
? null
: findSeasonOfEpisode(this.seasons(), episodeId);
});
/**
* Selected season. Auto-resolves when the season key set changes or when
@@ -171,22 +173,6 @@ export class SeasonContainerComponent implements OnInit {
return selected ? (this.seasons()[selected] ?? []) : [];
});
private readonly autoSelectKey = computed(
() =>
`${this.sortedSeasonKeys().join('|')}::${
this.playbackPositions().size > 0 ? '1' : '0'
}`
);
private lastAutoSelectKey: string | null = null;
private lastAutoSelectSeasonSet: string | null = null;
/**
* True once this session toggled watched state itself. From then on an
* empty↔loaded flip of the positions map is the echo of that action, not
* an async initial load — re-resolving on it would yank the user off the
* season they just marked (e.g. all-watched season 1 → jump to season 2).
*/
private hasLocalWatchedMutation = false;
/**
* Show thumbnails in the list view only when episodes have genuinely
* distinct stills (TMDB or per-episode provider art). When every episode
@@ -254,44 +240,29 @@ export class SeasonContainerComponent implements OnInit {
openingEpisodeId: this.openingEpisodeId,
isEpisodeWatched: (episode) => this.isEpisodeWatched(episode),
emitSeasonToggle: (request) => {
this.hasLocalWatchedMutation = true;
this.autoSelect.markLocalWatchedMutation();
this.seasonPlaybackToggleRequested.emit(request);
},
emitSeriesToggle: (request) => {
this.hasLocalWatchedMutation = true;
this.autoSelect.markLocalWatchedMutation();
this.seriesPlaybackToggleRequested.emit(request);
},
});
effect(() => {
const key = this.autoSelectKey();
if (key === this.lastAutoSelectKey) {
return;
}
const seasonSet = untracked(() =>
this.sortedSeasonKeys().join('|')
);
const seasonSetUnchanged =
seasonSet === this.lastAutoSelectSeasonSet;
this.lastAutoSelectKey = key;
this.lastAutoSelectSeasonSet = seasonSet;
// A positions-emptiness flip after a local watched toggle keeps
// the current selection; only the async initial positions load
// (or a season-set change) re-resolves the season.
if (seasonSetUnchanged && this.hasLocalWatchedMutation) {
return;
}
this.selectedSeason.set(untracked(() => this.resolveAutoSeason()));
});
// Fire the lazy-load/enrichment hooks for auto-selected seasons too —
// with tabs there is no initial "pick a season" click anymore.
effect(() => {
const selected = this.selectedSeason();
if (selected && selected !== this.lastEmittedSeason) {
this.lastEmittedSeason = selected;
this.seasonSelected.emit(selected);
}
// Auto-select rules and the effects driving them live in
// season-auto-select.state.ts / season-auto-select.util.ts.
this.autoSelect = createSeasonAutoSelectState({
selectedSeason: this.selectedSeason,
seasons: this.seasons,
sortedSeasonKeys: this.sortedSeasonKeys,
playingSeasonKey: this.playingSeasonKey,
playbackPositions: this.playbackPositions,
positionOf: (episode) => this.getEpisodePosition(episode),
hasUnloadedSeasons: this.hasUnloadedSeasons,
episodeCounts: this.episodeCounts,
watchedCounts: this.watchedCounts,
emitSeasonSelected: (seasonKey) =>
this.seasonSelected.emit(seasonKey),
});
}
@@ -376,7 +347,7 @@ export class SeasonContainerComponent implements OnInit {
this.logger.warn('Cannot toggle watched: no playlist ID');
return;
}
this.hasLocalWatchedMutation = true;
this.autoSelect.markLocalWatchedMutation();
const contentXtreamId = this.getEpisodeContentId(episode);
const currentPosition = this.getEpisodePosition(episode);
@@ -434,21 +405,11 @@ export class SeasonContainerComponent implements OnInit {
/** "42 min · 18m left", "42 min · watched", "42 min" — or null. */
getEpisodeSubline(episode: XtreamSerieEpisode): string | null {
const info = this.getEpisodeInfo(episode);
const duration = formatDurationLabel(episodeRuntimeSeconds(info));
const position = this.playbackPositions().get(Number(episode.id));
const remaining = formatRemainingLabel(position);
const parts = [
duration
? this.translate.instant(duration.key, duration.params)
: null,
this.isEpisodeWatched(episode)
? this.translate.instant('PORTALS.DETAIL.WATCHED')
: remaining
? this.translate.instant(remaining.key, remaining.params)
: this.getEpisodePositionText(episode),
].filter((part): part is string => !!part);
return parts.length ? parts.join(' · ') : null;
return buildEpisodeSubline(
this.getEpisodeInfo(episode),
this.getEpisodePosition(episode),
this.translate
);
}
getEpisodePositionText(episode: XtreamSerieEpisode): string | null {
@@ -464,33 +425,4 @@ export class SeasonContainerComponent implements OnInit {
): PlaybackPositionData | undefined {
return this.playbackPositions().get(this.getEpisodeContentId(episode));
}
private findSeasonOfEpisode(episodeId: number | null): string | null {
if (episodeId === null) {
return null;
}
for (const [key, episodes] of Object.entries(this.seasons())) {
if (
episodes?.some(
(episode) => this.getEpisodeContentId(episode) === episodeId
)
) {
return key;
}
}
return null;
}
/** Auto-select rules live in season-auto-select.util.ts. */
private resolveAutoSeason(): string | undefined {
return resolveAutoSelectedSeason({
keys: this.sortedSeasonKeys(),
playingSeasonKey: this.playingSeasonKey(),
seasons: this.seasons(),
positionOf: (episode) => this.getEpisodePosition(episode),
hasUnloadedSeasons: this.hasUnloadedSeasons(),
episodeCounts: this.episodeCounts(),
watchedCounts: this.watchedCounts(),
});
}
}
@@ -0,0 +1,43 @@
import type { CrossPortalSimilarItem } from '@iptvnator/services';
import type {
TmdbEnrichedCastMember,
VodDetailsItem,
} from '@iptvnator/shared/interfaces';
/**
* Pure helpers resolving where the VOD details page's extras rows (cast &
* crew, Similar rail) lead. The component owns the actual navigation.
*/
/**
* Router commands of the actor page for a cast/crew member, inside the
* item's own portal workspace. Null when the member has no TMDB person id
* (names parsed from provider text): there is no actor page to open.
*/
export function buildVodActorRoute(
item: VodDetailsItem,
member: TmdbEnrichedCastMember
): (string | number)[] | null {
if (!member.tmdbPersonId) {
return null;
}
const basePath =
item.type === 'stalker' ? '/workspace/stalker' : '/workspace/xtreams';
return [basePath, item.playlistId, 'actor', member.tmdbPersonId];
}
/**
* The cross-portal match behind a Similar-rail card. Rail keys are built by
* `buildSimilarRailItems` (vod-details-presentation.ts) as
* `x<playlistId>-<xtreamId>`.
*/
export function findSimilarByRailKey(
items: readonly CrossPortalSimilarItem[],
railKey: string
): CrossPortalSimilarItem | undefined {
return items.find(
(candidate) =>
`x${candidate.match.playlistId}-${candidate.match.xtreamId}` ===
railKey
);
}
@@ -1,12 +1,9 @@
import {
Component,
computed,
effect,
inject,
input,
output,
signal,
untracked,
ChangeDetectionStrategy,
} from '@angular/core';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
@@ -53,9 +50,14 @@ import {
} from '@iptvnator/services';
import { VOD_DETAILS_MENU_ACTION } from './vod-details-presentation';
import { createVodDetailsHeroState } from './vod-details-hero.state';
import {
buildVodActorRoute,
findSimilarByRailKey,
} from './vod-details-navigation.util';
import type { PlaybackFallbackRequest } from '@iptvnator/playback/util';
import { PortalInlinePlayerComponent } from '../portal-inline-player/portal-inline-player.component';
import { createVodDownloadState } from './vod-download-state.util';
import { createVodLocalDownloadState } from './vod-download-state.util';
import { createVodSimilarInPortals } from './vod-similar-in-portals.state';
/**
* Unified VOD details component for both Xtream and Stalker portals.
@@ -231,38 +233,12 @@ export class VodDetailsComponent {
* TMDB recommendations found in the user's OTHER portals (batched DB
* match, Electron only). Loaded async — the section appears when
* resolved; staleness-guarded against item changes in flight.
* Filtered on read: a relock hides matches cached while unlocked.
*/
private readonly similarInPortalsMatched = signal<CrossPortalSimilarItem[]>(
[]
readonly similarInPortals = createVodSimilarInPortals(
this.normalizedMeta,
this.crossPortalSimilar
);
/** Filtered on read: a relock hides matches cached while unlocked. */
readonly similarInPortals = computed(() =>
this.crossPortalSimilar.visible(this.similarInPortalsMatched())
);
private readonly loadSimilarInPortals = effect(() => {
const meta = this.normalizedMeta();
const recommendations = meta.tmdbRecommendations;
untracked(() => {
this.similarInPortalsMatched.set([]);
if (
!recommendations?.length ||
!this.crossPortalSimilar.isAvailable
) {
return;
}
void this.crossPortalSimilar
.matchRecommendations(recommendations, 'movie')
.then((items) => {
if (
this.normalizedMeta().tmdbRecommendations ===
recommendations
) {
this.similarInPortalsMatched.set(items);
}
});
});
});
openSimilarInPortals(item: CrossPortalSimilarItem): void {
void this.router.navigate(this.crossPortalSimilar.buildLink(item));
@@ -276,19 +252,14 @@ export class VodDetailsComponent {
() => (this.playbackPosition() ?? 0) > 0 && !this.isWatched()
);
private readonly downloadState = createVodDownloadState(
this.downloadsService,
this.item
);
readonly isDownloaded = computed(
() => !this.providerOnly() && this.downloadState.isDownloaded()
);
readonly isDownloading = computed(
() => !this.providerOnly() && this.downloadState.isDownloading()
);
readonly isPausedDownload = computed(
() => !this.providerOnly() && this.downloadState.isPausedDownload()
);
private readonly localDownload = createVodLocalDownloadState({
downloadsService: this.downloadsService,
item: this.item,
providerOnly: this.providerOnly,
});
readonly isDownloaded = this.localDownload.isDownloaded;
readonly isDownloading = this.localDownload.isDownloading;
readonly isPausedDownload = this.localDownload.isPausedDownload;
private readonly externalButton = createExternalPlaybackButtonState({
session: this.externalPlayback,
@@ -360,11 +331,7 @@ export class VodDetailsComponent {
readonly scrollToCast = scrollToCastCrewRow;
openSimilarRailItem(item: SimilarRailItem): void {
const match = this.similarInPortals().find(
(candidate) =>
`x${candidate.match.playlistId}-${candidate.match.xtreamId}` ===
item.key
);
const match = findSimilarByRailKey(this.similarInPortals(), item.key);
if (match) {
this.openSimilarInPortals(match);
}
@@ -450,20 +417,10 @@ export class VodDetailsComponent {
/** Handle back navigation - emit event for parent to handle */
openActor(member: TmdbEnrichedCastMember): void {
if (!member.tmdbPersonId) {
return;
const commands = buildVodActorRoute(this.item(), member);
if (commands) {
void this.router.navigate(commands);
}
const item = this.item();
const basePath =
item.type === 'stalker'
? '/workspace/stalker'
: '/workspace/xtreams';
void this.router.navigate([
basePath,
item.playlistId,
'actor',
member.tmdbPersonId,
]);
}
goBack(): void {
@@ -494,13 +451,8 @@ export class VodDetailsComponent {
}
/** Resume the paused download of this VOD */
async resumePausedDownload(): Promise<void> {
const item = this.item();
await this.downloadsService.resumeDownloadByContent(
getVodNumericId(item),
item.playlistId,
'vod'
);
resumePausedDownload(): Promise<void> {
return this.localDownload.resumePausedDownload();
}
onInlineTimeUpdate(event: { currentTime: number; duration: number }): void {
@@ -526,18 +478,7 @@ export class VodDetailsComponent {
}
/** Play from local downloaded file */
async playFromLocal(): Promise<void> {
const item = this.item();
const vodId = getVodNumericId(item);
const filePath = this.downloadsService.getDownloadedFilePath(
vodId,
item.playlistId,
'vod'
);
if (filePath) {
await this.downloadsService.playDownload(filePath);
}
playFromLocal(): Promise<void> {
return this.localDownload.playFromLocal();
}
}
@@ -32,3 +32,58 @@ export function createVodDownloadState(
),
};
}
export interface VodLocalDownloadStateDeps {
readonly downloadsService: DownloadsService;
readonly item: Signal<VodDetailsItem>;
/** Provider detail handoff hides local/download presentation only. */
readonly providerOnly: Signal<boolean>;
}
/**
* The VOD details page's view of its own download: the download-state
* signals (always false in provider-only mode) and the two actions that
* act on the local copy.
*/
export function createVodLocalDownloadState(deps: VodLocalDownloadStateDeps) {
const { downloadsService, item, providerOnly } = deps;
const downloadState = createVodDownloadState(downloadsService, item);
return {
isDownloaded: computed(
() => !providerOnly() && downloadState.isDownloaded()
),
isDownloading: computed(
() => !providerOnly() && downloadState.isDownloading()
),
isPausedDownload: computed(
() => !providerOnly() && downloadState.isPausedDownload()
),
/** Resume the paused download of this VOD */
async resumePausedDownload(): Promise<void> {
const currentItem = item();
await downloadsService.resumeDownloadByContent(
getVodNumericId(currentItem),
currentItem.playlistId,
'vod'
);
},
/** Play from local downloaded file */
async playFromLocal(): Promise<void> {
const currentItem = item();
const vodId = getVodNumericId(currentItem);
const filePath = downloadsService.getDownloadedFilePath(
vodId,
currentItem.playlistId,
'vod'
);
if (filePath) {
await downloadsService.playDownload(filePath);
}
},
};
}
@@ -0,0 +1,51 @@
import {
type Signal,
computed,
effect,
signal,
untracked,
} from '@angular/core';
import type {
CrossPortalSimilarItem,
CrossPortalSimilarService,
} from '@iptvnator/services';
import type { NormalizedVodMeta } from '@iptvnator/shared/interfaces';
/**
* TMDB recommendations found in the user's OTHER portals (batched DB
* match, Electron only). Loaded async — the section appears when
* resolved; staleness-guarded against item changes in flight.
*
* Registers the loading effect, so call it from a field initializer
* (injection context).
*/
export function createVodSimilarInPortals(
normalizedMeta: Signal<NormalizedVodMeta>,
crossPortalSimilar: CrossPortalSimilarService
): Signal<CrossPortalSimilarItem[]> {
const matched = signal<CrossPortalSimilarItem[]>([]);
/** Filtered on read: a relock hides matches cached while unlocked. */
const visible = computed(() => crossPortalSimilar.visible(matched()));
effect(() => {
const meta = normalizedMeta();
const recommendations = meta.tmdbRecommendations;
untracked(() => {
matched.set([]);
if (!recommendations?.length || !crossPortalSimilar.isAvailable) {
return;
}
void crossPortalSimilar
.matchRecommendations(recommendations, 'movie')
.then((items) => {
if (
normalizedMeta().tmdbRecommendations === recommendations
) {
matched.set(items);
}
});
});
});
return visible;
}