mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
refactor(details): give the detail-page files real max-lines headroom (#1813)
* refactor(xtream): split the series details page into focused services serial-details.component.ts sat at the 400-line max-lines limit and its playback service and spec were close behind. Move cohesive concerns out without changing behavior: - route params, the provider-only flag and the (re)load of the addressed series go to SerialDetailsRouteService; the component still registers the effect, so effect order is unchanged - season descriptions, posters and the TMDB season enrichment go to SerialDetailsSeasonsService - actor, Similar and Discover navigation go to injectXtreamDetailNavigation, shared with the movie page - the watched toggles and the episode playback payload leave SerialDetailsPlaybackService for SerialDetailsWatchToggles and buildSerialEpisodePlayback - the spec's TestBed moves to a harness and the watched-toggle cases to serial-details.season-watch.spec.ts Counted lines: component 400 -> 318, playback service 388 -> 342, component spec 1196 -> 674. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(xtream): move VOD route selection and position state out of the page vod-details-route.component.ts grew from 618 to 741 counted lines and vod-details-playback.service.ts reached 395. Extract without changing behavior: - the route-derived read model (selected movie, category, catalog item, fallback view, multi-source identity, session and content keys) goes to VodDetailsSelectionService; the component keeps the same member names - trailer state and the Similar-rail click move into the hero presenter, the cancel-download prompt and the progress-ring geometry into the downloads service, navigation into injectXtreamDetailNavigation - stored positions (last seen, route row, guarded load) become VodDetailsPositionState and the external-launch bookkeeping becomes VodExternalLaunchClaim - drop the unused MatTooltip import (NG8113) and eight unused imports Counted lines: route component 741 -> 492, playback service 395 -> 344. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(stalker): extract series-view positions and watched toggles stalker-series-view.component.ts grew from 1601 to 1813 counted lines and its spec reached 1198 of 1200. Move code out verbatim: - saved positions, their reconcile and the persist/clear writes go to the component-provided StalkerSeriesPositionsService; the ordering of reads and writes goes to StalkerSeriesPositionQueue - episode, season and series watched toggles go to StalkerSeriesWatchToggleService, the batch core to runStalkerWatchToggleBatch - the lazy VOD season loads go to StalkerVodSeasonEpisodeLoader Every effect stays registered in the component constructor in its original order; template bindings and public member names are unchanged. The spec setup moves to a shared harness and the spillover-prefetch and TMDB season cases to their own specs; the 184 cases of the directory are unchanged. Counted lines: component 1813 -> 1136, component spec 1198 -> 670. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(stalker): extract catalog-detail position and search paging - stalker-catalog-detail.component.ts (397): the stored VOD position and its runtime updates become StalkerCatalogVodPosition, the Play/Resume start becomes startStalkerCatalogVodPlayback - stalker-search.component.ts (776, baselined): the paging resource, the accumulated results and the parental-lock bookkeeping become StalkerSearchPagingController, with pure helpers in stalker-search-results.util.ts. The spec reaches the moved members through component.paging; its cases and assertions are unchanged. Counted lines: catalog detail 397 -> 317, search 776 -> 482. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * 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> * test(web-e2e): move Stalker portal helpers into fixtures stalker.e2e.ts was at 1196 of 1200 counted lines. Move the mock endpoints, scenario MACs and page helpers to stalker-portal.fixture.ts and the embedded-series steps three tests repeated to stalker-embedded-series.fixture.ts. Every test stays in stalker.e2e.ts in the same order, with the same serial mode and OWNED_MACS reset. Counted lines: 1196 -> 981. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: follow the detail-page extractions Name the Stalker watched-toggle and position services that now own the batch and reconcile code, point AUTH_REJECTED_MAC and the scenario MACs at stalker-portal.fixture.ts, and drop two stale statements about components sitting at the max-lines cap. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(xtream): note why the seasons service is injected last Its TMDB enrichment effect keeps the position it had as the first effect of the component constructor only while it is created after the other services' effects. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
51 files changed
+5326
-3850
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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user