mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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()}`
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user