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