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>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-04 10:08:35 +02:00
1 parent 44cec1ea36
commit 7a2e75606b
7 files changed
+548 -454

No files matched your search

@@ -2,6 +2,7 @@ import { Injectable, Signal, computed, inject, signal } from '@angular/core';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { createMovieDownloadSnapshot } from '@iptvnator/portal/shared/util';
import { DownloadsService } from '@iptvnator/services';
import { DialogService } from '@iptvnator/ui/components';
import { resolveXtreamVodPlaybackSource } from '@iptvnator/portal/xtream/data-access';
import {
getXtreamVodInfo,
@@ -54,6 +55,7 @@ export class VodDetailsDownloadsService {
private readonly downloadsService = inject(DownloadsService);
private readonly xtreamStore = inject(XtreamStore);
private readonly translateService = inject(TranslateService);
private readonly dialogService = inject(DialogService);
private routeContentId: Signal<number> = signal(NaN);
@@ -124,6 +126,20 @@ export class VodDetailsDownloadsService {
: null;
});
/** 2πr of the r=15.5 progress-ring circle in its 36×36 viewBox. */
readonly ringCircumference = 2 * Math.PI * 15.5;
/**
* Dash offset that leaves the arc at the real percent — or a fixed
* quarter arc when the total size is unknown and the ring spins instead.
*/
readonly ringOffset = computed(() => {
const percent = this.downloadPercent();
return percent === null
? this.ringCircumference * 0.75
: this.ringCircumference * (1 - percent / 100);
});
/** Cancel whatever download is running or queued for this movie. */
async cancelActive(): Promise<void> {
const item = this.activeDownload();
@@ -132,6 +148,28 @@ export class VodDetailsDownloadsService {
}
}
/**
* A running download is destroyed by one click, so the icon button asks
* first — there is no label left to warn what the click does.
*/
promptCancel(): void {
this.dialogService.openConfirmDialog({
title: this.translateService.instant(
'DOWNLOADS.CANCEL_CONFIRM_TITLE'
),
message: this.translateService.instant(
'DOWNLOADS.CANCEL_CONFIRM_MESSAGE'
),
confirmLabel: this.translateService.instant(
'DOWNLOADS.CANCEL_CONFIRM_TITLE'
),
// "Cancel" next to "Cancel download" would read as the same action.
cancelLabel: this.translateService.instant('CLOSE'),
tone: 'destructive',
onConfirm: () => void this.cancelActive(),
});
}
/** Open the finished file's location in the system file manager. */
async revealDownloaded(): Promise<void> {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
@@ -1,4 +1,9 @@
import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
import { signal } from '@angular/core';
import type {
ExternalPlayerSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import type { ExternalLaunchOwner } from './vod-details-external-launch-owner';
interface OwnedExternalLaunchOptions {
launch: Promise<ExternalPlayerSession | void>;
@@ -63,3 +68,44 @@ function isClosableExternalLaunch(
(session.status === 'error' && session.canClose))
);
}
/**
* The MPV/VLC launch a VOD page made last and the one it still waits on.
*
* What was launched is remembered independently of the controller's active
* source, so a refresh or an overlapping handoff cannot make the exact
* process this page launched look foreign before its teardown has been
* confirmed. Its route owner prevents a reused component from attributing
* that process to a different movie.
*/
export class VodExternalLaunchClaim {
private launchedGeneration = 0;
/** Start generation of the launch still awaiting its player, if any. */
readonly pendingGeneration = signal<number | null>(null);
constructor(private readonly owner: ExternalLaunchOwner) {}
claim(playback: ResolvedPortalPlayback, generation: number): void {
this.owner.set(playback.contentInfo);
this.launchedGeneration = generation;
this.pendingGeneration.set(generation);
}
/** An inline start took over: nothing external is owned or awaited. */
release(): void {
this.owner.clear();
this.pendingGeneration.set(null);
}
clearOwnership(generation: number): void {
if (this.launchedGeneration === generation) {
this.owner.clear();
}
}
clearPending(generation: number): void {
if (this.pendingGeneration() === generation) {
this.pendingGeneration.set(null);
}
}
}
@@ -10,17 +10,20 @@ import {
type RemainingTimeLabel,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import type {
PlaybackPositionData,
TmdbCountryFacet,
TmdbEnrichedCastMember,
TmdbGenreFacet,
XtreamVodInfo,
import {
youtubeEmbedUrl,
type PlaybackPositionData,
type TmdbCountryFacet,
type TmdbEnrichedCastMember,
type TmdbGenreFacet,
type XtreamVodInfo,
} from '@iptvnator/shared/interfaces';
import { SettingsStore } from '@iptvnator/services';
import type { CrossPortalSimilarItem } from '@iptvnator/services';
import {
castMembersFromNames,
splitPeopleNames,
TrailerDialogService,
type DetailActionButtonState,
type SimilarRailItem,
} from '@iptvnator/ui/components';
@@ -54,17 +57,21 @@ interface VodDetailsHeroBindings {
readonly formatPosition: () => string;
readonly similarItems: Signal<readonly SimilarCatalogItem[]>;
readonly similarInPortals: Signal<readonly CrossPortalSimilarItem[]>;
readonly openSimilar: (item: SimilarCatalogItem) => void;
readonly openSimilarInPortals: (item: CrossPortalSimilarItem) => void;
}
/**
* Presentation of the Xtream movie hero: the kind label, the chips, the
* primary button's two lines, the resume bar, the credits and the Similar
* rail, all derived from the route's selected movie.
* primary button's two lines, the resume bar, the credits, the trailer and
* the Similar rail, all derived from the route's selected movie.
*/
@Injectable()
export class VodDetailsHeroPresenter {
private readonly translate = inject(TranslateService);
private readonly xtreamStore = inject(XtreamStore);
private readonly settingsStore = inject(SettingsStore);
private readonly trailerDialog = inject(TrailerDialogService);
private readonly bindings = signal<VodDetailsHeroBindings | null>(null);
bind(bindings: VodDetailsHeroBindings): void {
@@ -208,6 +215,45 @@ export class VodDetailsHeroPresenter {
return [...local, ...crossPortal];
});
readonly trailerEmbedUrl = computed(() =>
youtubeEmbedUrl(this.info()?.youtube_trailer)
);
/** Settings → Playback → Play trailers in details background. */
readonly trailerBackdropUrl = computed(() =>
this.settingsStore.detailTrailerBackdrop?.() === true
? this.trailerEmbedUrl()
: null
);
openTrailer(): void {
const embedUrl = this.trailerEmbedUrl();
const title = this.info()?.name;
if (embedUrl) {
this.trailerDialog.open({ embedUrl, title: title ?? '' });
}
}
openSimilarRailItem(item: SimilarRailItem): void {
const bindings = this.bindings();
const local = bindings
?.similarItems()
.find((candidate) => `c${candidate.id}` === item.key);
if (local) {
bindings?.openSimilar(local);
return;
}
const crossPortal = bindings
?.similarInPortals()
.find(
(candidate) =>
`x${candidate.match.playlistId}-${candidate.match.xtreamId}` ===
item.key
);
if (crossPortal) {
bindings?.openSimilarInPortals(crossPortal);
}
}
private label(label: RemainingTimeLabel | null): string | null {
return label ? this.translate.instant(label.key, label.params) : null;
}
@@ -32,7 +32,6 @@ import {
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
import {
closeRunningExternalSession,
ownsContent,
runningExternalSession,
} from './vod-details-external-session';
import {
@@ -40,13 +39,16 @@ import {
startRouteOwnedPlayback,
} from './vod-details-external-launch-owner';
import { injectXtreamRecentHistory } from '../xtream-recent-history';
import { settleOwnedExternalLaunch } from './vod-details-external-launch';
import {
settleOwnedExternalLaunch,
VodExternalLaunchClaim,
} from './vod-details-external-launch';
import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-presentation';
import { VodDetailsPositionState } from './vod-details-position-state';
import {
hasVodResetTarget,
type VodResetTarget,
} from './vod-details-reset-target';
import { isResumablePosition } from './vod-primary-action-position';
export interface VodDetailsPlaybackBindings {
/** Current vod id resolved from the route */
@@ -83,8 +85,6 @@ export class VodDetailsPlaybackService {
PlaybackPositionRuntimeBridgeService
);
private readonly logger = createLogger('VodDetailsPlayback');
private positionLoadGeneration = 0;
private destroyed = false;
/** Signals bound from the host component via `bind()` */
private readonly bindings = signal<VodDetailsPlaybackBindings | null>(null);
@@ -92,27 +92,23 @@ export class VodDetailsPlaybackService {
() => this.xtreamStore.currentPlaylist()?.id,
() => this.bindings()?.vodId()
);
/** What this page launched externally, see {@link VodExternalLaunchClaim}. */
private readonly externalLaunch = new VodExternalLaunchClaim(
this.externalLaunchOwner
);
/** Stored positions of the page, see {@link VodDetailsPositionState}. */
private readonly positions = new VodDetailsPositionState({
playlistId: () => this.xtreamStore.currentPlaylist()?.id,
contentId: () => this.bindings()?.vodId(),
alternative: () => this.bindings()?.activeSource?.() ?? null,
});
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
/**
* The LAST position seen, whichever copy produced it.
*
* Multi-source can put playback on a copy in another playlist, and this
* follows it — the progress bar and the switch feed both want the stream
* on screen, not the one the route happens to address.
*/
readonly vodPlaybackPosition = signal<PlaybackPositionData | null>(null);
/**
* The ROUTE copy's own row.
*
* Everything that acts on the route's stream — Resume, its label, its
* timecode — has to read this instead. Positions are keyed by (playlist,
* stream), so once an alternative has played, `vodPlaybackPosition` names
* a different film's row entirely and resuming from it would jump the
* route copy to a timecode nobody reached in it.
*/
readonly routePlaybackPosition = signal<PlaybackPositionData | null>(null);
/** The LAST position seen, whichever copy produced it. */
readonly vodPlaybackPosition = this.positions.last;
/** The ROUTE copy's own row: what Resume, its label and its timecode read. */
readonly routePlaybackPosition = this.positions.route;
private readonly externalButton = createExternalPlaybackButtonState({
session: this.externalPlayback.activeSession,
@@ -131,59 +127,27 @@ export class VodDetailsPlaybackService {
readonly externalPrimaryIcon = this.externalButton.primaryIcon;
readonly isExternalLaunchPending = computed(
() =>
this.externalLaunchGeneration() !== null ||
this.externalLaunch.pendingGeneration() !== null ||
this.externalButton.isLaunchPending()
);
readonly isExternalStopAction = this.externalButton.isStopAction;
readonly externalPrimaryButtonState = this.externalButton.buttonState;
/** Mirrors an incoming position into the route's row when it owns it. */
private trackPosition(position: PlaybackPositionData | null): void {
this.vodPlaybackPosition.set(position);
if (this.isRouteContent(position)) {
this.routePlaybackPosition.set(position);
}
}
private isRouteContent(position: PlaybackPositionData | null): boolean {
return (
!!position &&
position.playlistId === this.xtreamStore.currentPlaylist()?.id &&
position.contentXtreamId === this.bindings()?.vodId()
);
}
/** Whether the ROUTE copy has somewhere to resume from. */
readonly hasPlaybackPosition = computed(() =>
isResumablePosition(this.routePlaybackPosition())
);
readonly hasPlaybackPosition = this.positions.hasResumable;
constructor() {
const unsubscribePositionUpdates =
this.playbackPositionBridge.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
// An external player on an ALTERNATIVE reports under
// that playlist's ids; dropping those rewinds a switch.
if (this.ownsContent(data)) {
this.trackPosition(data);
}
}
(data: PlaybackPositionData) => this.positions.trackOwned(data)
) ?? null;
inject(DestroyRef).onDestroy(() => {
this.destroyed = true;
this.positionLoadGeneration++;
this.positions.destroy();
unsubscribePositionUpdates?.();
});
}
private ownsContent(info: Parameters<typeof ownsContent>[0]): boolean {
return ownsContent(info, {
routePlaylistId: this.xtreamStore.currentPlaylist()?.id,
routeContentId: this.bindings()?.vodId(),
alternative: this.bindings()?.activeSource?.() ?? null,
});
}
/** Wires the host component's context signals. Call once at construction. */
bind(bindings: VodDetailsPlaybackBindings): void {
this.bindings.set(bindings);
@@ -285,7 +249,7 @@ export class VodDetailsPlaybackService {
playlistId,
position
),
onSaved: (position) => this.trackPosition(position),
onSaved: (position) => this.positions.track(position),
});
/**
@@ -318,7 +282,7 @@ export class VodDetailsPlaybackService {
const routeIdentity = this.externalLaunchOwner.captureRoute();
this.bindings()?.supersedePendingSwitch();
const generation = ++this.startGeneration;
this.claimExternalLaunch(playback, generation);
this.externalLaunch.claim(playback, generation);
const launch = this.portalPlayer.openExternalPlayback(playback, player);
void this.settleExternalLaunch(
generation,
@@ -334,32 +298,16 @@ export class VodDetailsPlaybackService {
* answer that read started from.
*/
discardPendingPositionLoads(): void {
this.positionLoadGeneration++;
this.positions.discardPendingLoads();
}
/** The route copy's row is in hand; fails closed while a read is in flight. */
readonly positionLoaded = signal(false);
readonly positionLoaded = this.positions.loaded;
async loadPosition(playlistId: string, vodId: number): Promise<void> {
const generation = ++this.positionLoadGeneration;
this.positionLoaded.set(false);
const position = await this.playbackPositions.getPlaybackPosition(
playlistId,
vodId,
'vod'
loadPosition(playlistId: string, vodId: number): Promise<void> {
return this.positions.load(playlistId, vodId, () =>
this.playbackPositions.getPlaybackPosition(playlistId, vodId, 'vod')
);
if (
this.destroyed ||
generation !== this.positionLoadGeneration ||
this.xtreamStore.currentPlaylist()?.id !== playlistId ||
this.bindings()?.vodId() !== vodId
) {
return;
}
this.vodPlaybackPosition.set(position);
this.routePlaybackPosition.set(position);
this.positionLoaded.set(true);
}
/**
@@ -374,18 +322,6 @@ export class VodDetailsPlaybackService {
/** Bumped by every start; only the newest may launch after its close. */
private startGeneration = 0;
/**
* What we last launched externally, remembered independently of the
* controller's active source.
*
* Kept independently of the controller so a refresh or an overlapping
* handoff cannot make the exact process this page launched look foreign
* before its teardown has been confirmed. Its route owner prevents a
* reused component from attributing that process to a different movie.
*/
private launchedExternallyGeneration = 0;
private readonly externalLaunchGeneration = signal<number | null>(null);
/**
* Starts still between the click and `inlinePlayback` / the external
* launch (closing the previous player is a round-trip). The watched
@@ -421,7 +357,10 @@ export class VodDetailsPlaybackService {
isCurrent: () => boolean = () => true,
player?: ExternalPlayerName
): Promise<boolean> {
if (this.externalLaunchGeneration() !== null || this.resetPending()) {
if (
this.externalLaunch.pendingGeneration() !== null ||
this.resetPending()
) {
return false;
}
const runningSession = runningExternalSession(
@@ -497,28 +436,18 @@ export class VodDetailsPlaybackService {
this.positionWriter.reset();
if (!player && this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
this.externalLaunchOwner.clear();
this.externalLaunchGeneration.set(null);
this.externalLaunch.release();
return true;
}
this.closeInlinePlayer();
this.claimExternalLaunch(playback, generation);
this.externalLaunch.claim(playback, generation);
const launch = player
? this.portalPlayer.openExternalPlayback(playback, player)
: this.portalPlayer.openResolvedPlayback(playback, true);
return await this.settleExternalLaunch(generation, isCurrent, launch);
}
private claimExternalLaunch(
playback: ResolvedPortalPlayback,
generation: number
): void {
this.externalLaunchOwner.set(playback.contentInfo);
this.launchedExternallyGeneration = generation;
this.externalLaunchGeneration.set(generation);
}
private async settleExternalLaunch(
generation: number,
isCurrent: () => boolean,
@@ -535,20 +464,9 @@ export class VodDetailsPlaybackService {
),
reportLaunchFailure: (error) =>
this.bindings()?.reportExternalLaunchFailure?.(error),
clearPending: () => this.clearExternalLaunchPending(generation),
clearOwnership: () => this.clearExternalLaunchOwnership(generation),
clearPending: () => this.externalLaunch.clearPending(generation),
clearOwnership: () =>
this.externalLaunch.clearOwnership(generation),
});
}
private clearExternalLaunchOwnership(generation: number): void {
if (this.launchedExternallyGeneration === generation) {
this.externalLaunchOwner.clear();
}
}
private clearExternalLaunchPending(generation: number): void {
if (this.externalLaunchGeneration() === generation) {
this.externalLaunchGeneration.set(null);
}
}
}
@@ -0,0 +1,121 @@
import { computed, signal } from '@angular/core';
import type {
PlaybackPositionData,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
import { ownsContent } from './vod-details-external-session';
import { isResumablePosition } from './vod-primary-action-position';
/** The copies the page's position state belongs to. */
export interface VodDetailsPositionRoute {
readonly playlistId: () => string | undefined;
readonly contentId: () => number | undefined;
/** The alternative currently in use, if it is not the route's own. */
readonly alternative: () => PlayerContentInfo | null;
}
/**
* Stored playback positions of the VOD details page: the last one seen, the
* route copy's own row, and the guarded read that fills both.
*/
export class VodDetailsPositionState {
private loadGeneration = 0;
private destroyed = false;
/**
* The LAST position seen, whichever copy produced it.
*
* Multi-source can put playback on a copy in another playlist, and this
* follows it — the progress bar and the switch feed both want the stream
* on screen, not the one the route happens to address.
*/
readonly last = signal<PlaybackPositionData | null>(null);
/**
* The ROUTE copy's own row.
*
* Everything that acts on the route's stream — Resume, its label, its
* timecode — has to read this instead. Positions are keyed by (playlist,
* stream), so once an alternative has played, `last` names a different
* film's row entirely and resuming from it would jump the route copy to
* a timecode nobody reached in it.
*/
readonly route = signal<PlaybackPositionData | null>(null);
/** Whether the ROUTE copy has somewhere to resume from. */
readonly hasResumable = computed(() => isResumablePosition(this.route()));
/** The route copy's row is in hand; fails closed while a read is in flight. */
readonly loaded = signal(false);
constructor(private readonly routeCopy: VodDetailsPositionRoute) {}
/** Mirrors an incoming position into the route's row when it owns it. */
track(position: PlaybackPositionData | null): void {
this.last.set(position);
if (this.isRouteContent(position)) {
this.route.set(position);
}
}
/**
* Takes a streamed position when the page owns its content. An external
* player on an ALTERNATIVE reports under that playlist's ids; dropping
* those rewinds a switch.
*/
trackOwned(position: PlaybackPositionData): void {
const owned = {
routePlaylistId: this.routeCopy.playlistId(),
routeContentId: this.routeCopy.contentId(),
alternative: this.routeCopy.alternative(),
};
if (ownsContent(position, owned)) {
this.track(position);
}
}
/**
* Retires every stored-position read still in flight. A row written
* since (the manual watched toggle) must not be overwritten by the older
* answer that read started from.
*/
discardPendingLoads(): void {
this.loadGeneration++;
}
/** The page is gone: no read still in flight may land. */
destroy(): void {
this.destroyed = true;
this.loadGeneration++;
}
async load(
playlistId: string,
vodId: number,
read: () => Promise<PlaybackPositionData | null>
): Promise<void> {
const generation = ++this.loadGeneration;
this.loaded.set(false);
const position = await read();
if (
this.destroyed ||
generation !== this.loadGeneration ||
this.routeCopy.playlistId() !== playlistId ||
this.routeCopy.contentId() !== vodId
) {
return;
}
this.last.set(position);
this.route.set(position);
this.loaded.set(true);
}
private isRouteContent(position: PlaybackPositionData | null): boolean {
return (
!!position &&
position.playlistId === this.routeCopy.playlistId() &&
position.contentXtreamId === this.routeCopy.contentId()
);
}
}
@@ -8,13 +8,10 @@ import {
effect,
inject,
signal,
untracked,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatIcon } from '@angular/material/icon';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { ActivatedRoute } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
CastCrewRowComponent,
@@ -24,90 +21,40 @@ import {
DetailIconButtonComponent,
DetailMetaTemplateDirective,
DetailTagsTemplateDirective,
DialogService,
MetaChipComponent,
PortalDetailShellComponent,
SimilarRailComponent,
type SimilarRailItem,
TrailerDialogService,
ViewInPortalActionComponent,
VodMoreMenuComponent,
scrollToCastCrewRow,
} from '@iptvnator/ui/components';
import {
createDiscoverFacetNavigation,
createLogger,
isProviderOnlyDetailState,
} from '@iptvnator/portal/shared/util';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
registerContentMetadataBackfill,
resolveXtreamVodPlaybackSource,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import {
type PlaybackFallbackRequest,
PortalInlinePlayerComponent,
} from '@iptvnator/ui/playback';
import { DownloadsService, SettingsStore } from '@iptvnator/services';
import {
CrossPortalSimilarItem,
CrossPortalSimilarService,
DownloadsService,
SettingsStore,
TmdbEnrichmentService,
} from '@iptvnator/services';
import {
getXtreamVodInfo,
normalizeTitleKeys,
playlistDisplayLabel,
reportsPlaybackFailures,
TmdbEnrichedCastMember,
XtreamCategory,
XtreamVodDetails,
XtreamVodInfo,
XtreamVodStream,
youtubeEmbedUrl,
type ExternalPlayerName,
type PlaybackPositionData,
type VodSourceCandidate,
type VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
import {
SimilarCatalogItem,
matchRecommendationsToCatalog,
} from '../tmdb-similar.util';
import {
buildXtreamVodFallbackViewModel,
hasUsableXtreamVodMetadata,
} from './vod-details-fallback.util';
import { injectXtreamDetailNavigation } from '../xtream-detail-navigation';
import { VodDetailsPlaybackService } from './vod-details-playback.service';
import { VodDetailsMultiSourceUiService } from './vod-details-multi-source-ui.service';
import { VodDetailsDownloadsService } from './vod-details-downloads.service';
import { VodDetailsWatchedService } from './vod-details-watched.service';
import { VodDetailsHeroPresenter } from './vod-details-hero.presenter';
import { VodDetailsMenuService } from './vod-details-menu.service';
import { VodDetailsSelectionService } from './vod-details-selection.service';
import { VodDetailsSimilarService } from './vod-details-similar.service';
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
import { resolveVodMultiSourceMovie } from './vod-multi-source-identity';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
type XtreamVodIdentityItem = XtreamVodDetails & {
readonly id?: number | string;
readonly stream_id?: number | string;
readonly xtream_id?: number | string;
};
function resolveVodIdentity(item: XtreamVodDetails): number | null {
const candidate = item as XtreamVodIdentityItem;
const value =
item.movie_data?.stream_id ??
candidate.xtream_id ??
candidate.stream_id ??
candidate.id;
const id = typeof value === 'string' ? Number(value) : value;
return typeof id === 'number' && Number.isSafeInteger(id) && id > 0
? id
: null;
}
@Component({
templateUrl: './vod-details-route.component.html',
@@ -117,6 +64,7 @@ function resolveVodIdentity(item: XtreamVodDetails): number | null {
],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
VodDetailsSelectionService,
VodDetailsPlaybackService,
VodMultiSourceHostService,
VodDetailsMultiSourceUiService,
@@ -131,7 +79,6 @@ function resolveVodIdentity(item: XtreamVodDetails): number | null {
DetailMetaTemplateDirective,
DetailTagsTemplateDirective,
MatIcon,
MatTooltip,
NgTemplateOutlet,
PortalDetailShellComponent,
ViewInPortalActionComponent,
@@ -151,11 +98,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
private readonly location = inject(Location);
private readonly settingsStore = inject(SettingsStore);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly crossPortalSimilar = inject(CrossPortalSimilarService);
private readonly navigation = injectXtreamDetailNavigation('movie');
private readonly xtreamStore = inject(XtreamStore);
private readonly downloadsService = inject(DownloadsService);
private readonly dialogService = inject(DialogService);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly playback = inject(VodDetailsPlaybackService);
@@ -167,7 +112,8 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
private readonly watched = inject(VodDetailsWatchedService);
readonly hero = inject(VodDetailsHeroPresenter);
readonly menu = inject(VodDetailsMenuService);
private readonly trailerDialog = inject(TrailerDialogService);
/** The movie the route addresses, see {@link VodDetailsSelectionService}. */
private readonly selection = inject(VodDetailsSelectionService);
private readonly logger = createLogger('VodDetailsRoute');
/** `playlistId:vodId` of the last initialized detail view */
private readonly lastInitKey = signal<string | null>(null);
@@ -176,167 +122,22 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
/** The route copy's own row — what Resume acts on. */
readonly routePlaybackPosition = this.playback.routePlaybackPosition;
/**
* Reactive route params: the component is reused when navigating
* between two VOD details (e.g. via the Similar rail), so computeds
* must not read the one-shot snapshot.
*/
private readonly routeParams = toSignal(this.route.params, {
initialValue: this.route.snapshot.params,
});
readonly theme = this.settingsStore.theme;
readonly isElectron = this.downloadsService.isAvailable;
readonly isFavorite = this.xtreamStore.isFavorite;
readonly isWatched = this.watched.isWatched;
readonly canToggleWatched = this.watched.canToggle;
readonly selectedVodId = computed(() =>
Number(this.routeParams()['vodId'])
);
readonly playbackSessionKey = computed(() => {
const sourceId = this.xtreamStore.currentPlaylist()?.id;
const contentId = this.selectedVodId();
return sourceId && Number.isFinite(contentId) && contentId > 0
? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId })
: '';
});
readonly providerOnly = computed(() => {
this.routeParams();
return isProviderOnlyDetailState(window.history.state);
});
readonly selectedItem = computed(() => {
const item =
this.xtreamStore.selectedItem() as unknown as XtreamVodDetails | null;
return item && resolveVodIdentity(item) === this.selectedVodId()
? item
: null;
});
private readonly scopedVodCategories = computed(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
return playlistId &&
this.xtreamStore.vodCategoriesPlaylistId() === playlistId
? this.xtreamStore.vodCategories()
: [];
});
private readonly scopedVodStreams = computed(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
return playlistId &&
this.xtreamStore.vodStreamsPlaylistId() === playlistId
? this.xtreamStore.vodStreams()
: [];
});
readonly selectedCategory = computed<Partial<XtreamCategory> | null>(() => {
const categoryId = this.routeParams()['categoryId'];
if (!categoryId) {
return null;
}
return (
this.scopedVodCategories().find(
(category) =>
String(
(
category as XtreamCategory & {
id?: string | number;
}
).category_id ??
(
category as XtreamCategory & {
id?: string | number;
}
).id
) === String(categoryId)
) ?? null
);
});
readonly selectedCatalogItem = computed<
| (Partial<XtreamVodStream> & {
id?: string | number;
poster_url?: string;
title?: string;
xtream_id?: string | number;
})
| null
>(() => {
const vodId = this.selectedVodId();
if (!Number.isFinite(vodId) || vodId <= 0) {
return null;
}
return (
this.scopedVodStreams().find(
(item) =>
Number(
(
item as XtreamVodStream & {
id?: string | number;
xtream_id?: string | number;
}
).xtream_id ??
(
item as XtreamVodStream & {
id?: string | number;
}
).stream_id ??
(
item as XtreamVodStream & {
id?: string | number;
}
).id
) === vodId
) ?? null
);
});
/** Movie identity for multi-source discovery; null until a title exists */
private readonly multiSourceMovie = computed(() => {
// Electron stores categories under `name`, the live API under
// `category_name` — the same duality the fallback view reads.
const category = this.selectedCategory() as {
name?: string;
category_name?: string;
} | null;
return resolveVodMultiSourceMovie({
playlistId: this.xtreamStore.currentPlaylist()?.id,
// `title` is the alias the Xtream data source actually writes
// (createPlaylist maps name -> title), so reading only `name`
// would fall back to the raw playlist UUID in the sources list.
playlistName:
this.xtreamStore.currentPlaylist()?.name ??
this.xtreamStore.currentPlaylist()?.title,
vodId: this.selectedVodId(),
vodInfo: this.selectedVodInfo(),
catalogItem: this.selectedCatalogItem(),
containerExtension:
this.selectedItem()?.movie_data?.container_extension,
categoryName: category?.name ?? category?.category_name ?? null,
});
});
readonly selectedVodInfo = computed(() => {
const item = this.selectedItem();
return item && hasUsableXtreamVodMetadata(item)
? getXtreamVodInfo(item)
: null;
});
readonly playableVodItem = computed(() => {
const item = this.selectedItem();
return item && resolveXtreamVodPlaybackSource(item) ? item : null;
});
readonly fallbackView = computed(() => {
const item = this.selectedItem();
if (!item || this.selectedVodInfo()) {
return null;
}
return buildXtreamVodFallbackViewModel({
vodDetails: item,
catalogItem: this.selectedCatalogItem(),
category: this.selectedCategory(),
vodId: this.selectedVodId(),
});
});
readonly selectedVodId = this.selection.selectedVodId;
readonly playbackSessionKey = this.selection.playbackSessionKey;
readonly providerOnly = this.selection.providerOnly;
readonly selectedItem = this.selection.selectedItem;
readonly selectedCategory = this.selection.selectedCategory;
readonly selectedCatalogItem = this.selection.selectedCatalogItem;
private readonly multiSourceMovie = this.selection.multiSourceMovie;
readonly selectedVodInfo = this.selection.selectedVodInfo;
readonly playableVodItem = this.selection.playableVodItem;
readonly fallbackView = this.selection.fallbackView;
readonly isLoadingDetails = this.xtreamStore.isLoadingDetails;
readonly detailsError = this.xtreamStore.detailsError;
readonly matchedExternalPlayback = this.playback.matchedExternalPlayback;
@@ -362,38 +163,19 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.isDownloaded() && this.externalPrimaryButtonState() === 'idle'
);
/** 2πr of the r=15.5 progress-ring circle in its 36×36 viewBox. */
readonly downloadRingCircumference = 2 * Math.PI * 15.5;
/**
* Dash offset that leaves the arc at the real percent — or a fixed
* quarter arc when the total size is unknown and the ring spins instead.
*/
readonly downloadRingOffset = computed(() => {
const percent = this.downloadPercent();
return percent === null
? this.downloadRingCircumference * 0.75
: this.downloadRingCircumference * (1 - percent / 100);
});
readonly downloadRingCircumference = this.downloads.ringCircumference;
readonly downloadRingOffset = this.downloads.ringOffset;
/** Drives the heart's brief scale pulse when favoriting toggles. */
readonly favoritePulse = signal(false);
private favoritePulseTimer: ReturnType<typeof setTimeout> | null = null;
readonly trailerEmbedUrl = computed(() =>
youtubeEmbedUrl(this.selectedVodInfo()?.youtube_trailer)
);
/** `playlist:vod`: the hero keys its one-time layout decision on it. */
readonly contentKey = computed(
() =>
`xtream-vod:${this.xtreamStore.currentPlaylist()?.id ?? ''}:${this.selectedVodId()}`
);
/** Settings → Playback → Play trailers in details background. */
readonly trailerBackdropUrl = computed(() =>
this.settingsStore.detailTrailerBackdrop?.() === true
? this.trailerEmbedUrl()
: null
);
readonly trailerEmbedUrl = this.hero.trailerEmbedUrl;
readonly contentKey = this.selection.contentKey;
readonly trailerBackdropUrl = this.hero.trailerBackdropUrl;
readonly scrollToCast = scrollToCastCrewRow;
/** Clickable year/genre/country chips (Discover pages) */
readonly discover = this.navigation.discover;
readonly similarItems = this.similar.similarItems;
readonly similarInPortals = this.similar.similarInPortals;
@@ -511,6 +293,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
formatPosition: () => this.formatPosition(),
similarItems: this.similarItems,
similarInPortals: this.similarInPortals,
openSimilar: (item) => this.navigation.openSimilar(item),
openSimilarInPortals: (item) =>
this.navigation.openSimilarInPortals(item),
});
this.menu.bind({
item: this.playableVodItem,
@@ -538,73 +323,18 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
}
}
openSimilarInPortals(item: CrossPortalSimilarItem): void {
void this.router.navigate(this.crossPortalSimilar.buildLink(item));
}
openSimilar(item: SimilarCatalogItem): void {
void this.router.navigate(['../..', item.categoryId, item.id], {
relativeTo: this.route,
});
}
openSimilarRailItem(item: SimilarRailItem): void {
const local = this.similarItems().find(
(candidate) => `c${candidate.id}` === item.key
);
if (local) {
this.openSimilar(local);
return;
}
const crossPortal = this.similarInPortals().find(
(candidate) =>
`x${candidate.match.playlistId}-${candidate.match.xtreamId}` ===
item.key
);
if (crossPortal) {
this.openSimilarInPortals(crossPortal);
}
this.hero.openSimilarRailItem(item);
}
openTrailer(): void {
const embedUrl = this.trailerEmbedUrl();
const title = this.selectedVodInfo()?.name;
if (embedUrl) {
this.trailerDialog.open({ embedUrl, title: title ?? '' });
}
}
scrollToCast(): void {
document
.getElementById('detail-cast-crew')
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
this.hero.openTrailer();
}
openActor(member: TmdbEnrichedCastMember): void {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId || !member.tmdbPersonId) {
return;
}
void this.router.navigate([
'/workspace/xtreams',
playlistId,
'actor',
member.tmdbPersonId,
]);
this.navigation.openActor(member);
}
/** Clickable year/genre/country chips (Discover pages) */
private readonly tmdbEnrichment = inject(TmdbEnrichmentService);
readonly discover = createDiscoverFacetNavigation(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
// Discover reads its results from TMDB, so a chip must not offer a
// page that enrichment cannot fill
return playlistId && this.tmdbEnrichment.isEnabled()
? { portal: 'xtream', mediaType: 'movie', playlistId }
: null;
});
ngOnDestroy(): void {
if (this.favoritePulseTimer) {
clearTimeout(this.favoritePulseTimer);
@@ -869,26 +599,8 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
return this.downloads.resumePaused();
}
/**
* A running download is destroyed by one click, so the icon button asks
* first — there is no label left to warn what the click does.
*/
promptCancelDownload(): void {
this.dialogService.openConfirmDialog({
title: this.translateService.instant(
'DOWNLOADS.CANCEL_CONFIRM_TITLE'
),
message: this.translateService.instant(
'DOWNLOADS.CANCEL_CONFIRM_MESSAGE'
),
confirmLabel: this.translateService.instant(
'DOWNLOADS.CANCEL_CONFIRM_TITLE'
),
// "Cancel" next to "Cancel download" would read as the same action.
cancelLabel: this.translateService.instant('CLOSE'),
tone: 'destructive',
onConfirm: () => void this.downloads.cancelActive(),
});
this.downloads.promptCancel();
}
revealDownloadedFile(): Promise<void> {
@@ -0,0 +1,213 @@
import { Injectable, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import { isProviderOnlyDetailState } from '@iptvnator/portal/shared/util';
import {
resolveXtreamVodPlaybackSource,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import {
getXtreamVodInfo,
XtreamCategory,
XtreamVodDetails,
XtreamVodStream,
} from '@iptvnator/shared/interfaces';
import {
buildXtreamVodFallbackViewModel,
hasUsableXtreamVodMetadata,
} from './vod-details-fallback.util';
import { resolveVodMultiSourceMovie } from './vod-multi-source-identity';
type XtreamVodIdentityItem = XtreamVodDetails & {
readonly id?: number | string;
readonly stream_id?: number | string;
readonly xtream_id?: number | string;
};
function resolveVodIdentity(item: XtreamVodDetails): number | null {
const candidate = item as XtreamVodIdentityItem;
const value =
item.movie_data?.stream_id ??
candidate.xtream_id ??
candidate.stream_id ??
candidate.id;
const id = typeof value === 'string' ? Number(value) : value;
return typeof id === 'number' && Number.isSafeInteger(id) && id > 0
? id
: null;
}
/**
* What the VOD details route selects: the movie the params address, as the
* store, the loaded catalog and the route's category describe it. Read-only
* derivations of the route and the store, shared by the page and the
* services it binds.
*/
@Injectable()
export class VodDetailsSelectionService {
private readonly route = inject(ActivatedRoute);
private readonly xtreamStore = inject(XtreamStore);
/**
* Reactive route params: the component is reused when navigating
* between two VOD details (e.g. via the Similar rail), so computeds
* must not read the one-shot snapshot.
*/
private readonly routeParams = toSignal(this.route.params, {
initialValue: this.route.snapshot.params,
});
readonly selectedVodId = computed(() =>
Number(this.routeParams()['vodId'])
);
readonly playbackSessionKey = computed(() => {
const sourceId = this.xtreamStore.currentPlaylist()?.id;
const contentId = this.selectedVodId();
return sourceId && Number.isFinite(contentId) && contentId > 0
? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId })
: '';
});
readonly providerOnly = computed(() => {
this.routeParams();
return isProviderOnlyDetailState(window.history.state);
});
readonly selectedItem = computed(() => {
const item =
this.xtreamStore.selectedItem() as unknown as XtreamVodDetails | null;
return item && resolveVodIdentity(item) === this.selectedVodId()
? item
: null;
});
private readonly scopedVodCategories = computed(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
return playlistId &&
this.xtreamStore.vodCategoriesPlaylistId() === playlistId
? this.xtreamStore.vodCategories()
: [];
});
private readonly scopedVodStreams = computed(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
return playlistId &&
this.xtreamStore.vodStreamsPlaylistId() === playlistId
? this.xtreamStore.vodStreams()
: [];
});
readonly selectedCategory = computed<Partial<XtreamCategory> | null>(() => {
const categoryId = this.routeParams()['categoryId'];
if (!categoryId) {
return null;
}
return (
this.scopedVodCategories().find(
(category) =>
String(
(
category as XtreamCategory & {
id?: string | number;
}
).category_id ??
(
category as XtreamCategory & {
id?: string | number;
}
).id
) === String(categoryId)
) ?? null
);
});
readonly selectedCatalogItem = computed<
| (Partial<XtreamVodStream> & {
id?: string | number;
poster_url?: string;
title?: string;
xtream_id?: string | number;
})
| null
>(() => {
const vodId = this.selectedVodId();
if (!Number.isFinite(vodId) || vodId <= 0) {
return null;
}
return (
this.scopedVodStreams().find(
(item) =>
Number(
(
item as XtreamVodStream & {
id?: string | number;
xtream_id?: string | number;
}
).xtream_id ??
(
item as XtreamVodStream & {
id?: string | number;
}
).stream_id ??
(
item as XtreamVodStream & {
id?: string | number;
}
).id
) === vodId
) ?? null
);
});
/** Movie identity for multi-source discovery; null until a title exists */
readonly multiSourceMovie = computed(() => {
// Electron stores categories under `name`, the live API under
// `category_name` — the same duality the fallback view reads.
const category = this.selectedCategory() as {
name?: string;
category_name?: string;
} | null;
return resolveVodMultiSourceMovie({
playlistId: this.xtreamStore.currentPlaylist()?.id,
// `title` is the alias the Xtream data source actually writes
// (createPlaylist maps name -> title), so reading only `name`
// would fall back to the raw playlist UUID in the sources list.
playlistName:
this.xtreamStore.currentPlaylist()?.name ??
this.xtreamStore.currentPlaylist()?.title,
vodId: this.selectedVodId(),
vodInfo: this.selectedVodInfo(),
catalogItem: this.selectedCatalogItem(),
containerExtension:
this.selectedItem()?.movie_data?.container_extension,
categoryName: category?.name ?? category?.category_name ?? null,
});
});
readonly selectedVodInfo = computed(() => {
const item = this.selectedItem();
return item && hasUsableXtreamVodMetadata(item)
? getXtreamVodInfo(item)
: null;
});
readonly playableVodItem = computed(() => {
const item = this.selectedItem();
return item && resolveXtreamVodPlaybackSource(item) ? item : null;
});
readonly fallbackView = computed(() => {
const item = this.selectedItem();
if (!item || this.selectedVodInfo()) {
return null;
}
return buildXtreamVodFallbackViewModel({
vodDetails: item,
catalogItem: this.selectedCatalogItem(),
category: this.selectedCategory(),
vodId: this.selectedVodId(),
});
});
/** `playlist:vod`: the hero keys its one-time layout decision on it. */
readonly contentKey = computed(
() =>
`xtream-vod:${this.xtreamStore.currentPlaylist()?.id ?? ''}:${this.selectedVodId()}`
);
}