From 7a2e75606bd4fe9cec87a7cbbd3a7f7d9743a55a Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 4 Oct 2026 10:08:35 +0200 Subject: [PATCH] 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 --- .../vod-details-downloads.service.ts | 38 ++ .../vod-details-external-launch.ts | 48 ++- .../vod-details/vod-details-hero.presenter.ts | 62 ++- .../vod-details-playback.service.ts | 162 ++------ .../vod-details/vod-details-position-state.ts | 121 ++++++ .../vod-details-route.component.ts | 358 ++---------------- .../vod-details-selection.service.ts | 213 +++++++++++ 7 files changed, 548 insertions(+), 454 deletions(-) create mode 100644 libs/portal/xtream/feature/src/lib/vod-details/vod-details-position-state.ts create mode 100644 libs/portal/xtream/feature/src/lib/vod-details/vod-details-selection.service.ts diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts index 653897d79..7535070e5 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts @@ -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 = 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 { 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 { const playlistId = this.xtreamStore.currentPlaylist()?.id; diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-launch.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-launch.ts index 7575eee04..233353a99 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-launch.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-launch.ts @@ -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; @@ -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(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); + } + } +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-hero.presenter.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-hero.presenter.ts index 5093b4cdb..6ef9f4c61 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-hero.presenter.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-hero.presenter.ts @@ -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 similarInPortals: Signal; + 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(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; } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts index b43a64d66..8984fe555 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts @@ -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(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(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(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(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[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 { - const generation = ++this.positionLoadGeneration; - this.positionLoaded.set(false); - const position = await this.playbackPositions.getPlaybackPosition( - playlistId, - vodId, - 'vod' + loadPosition(playlistId: string, vodId: number): Promise { + 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(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 { - 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); - } - } } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-position-state.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-position-state.ts new file mode 100644 index 000000000..24571b753 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-position-state.ts @@ -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(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(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 + ): Promise { + 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() + ); + } +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index ffa450009..68b956e63 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -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(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 | 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 & { - 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 | 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 { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-selection.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-selection.service.ts new file mode 100644 index 000000000..cc4458b67 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-selection.service.ts @@ -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 | 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 & { + 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()}` + ); +}