diff --git a/libs/ui/components/src/lib/season-container/episode-subline.util.ts b/libs/ui/components/src/lib/season-container/episode-subline.util.ts new file mode 100644 index 000000000..2b73f8b50 --- /dev/null +++ b/libs/ui/components/src/lib/season-container/episode-subline.util.ts @@ -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 +): 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; +} diff --git a/libs/ui/components/src/lib/season-container/season-auto-select.state.ts b/libs/ui/components/src/lib/season-container/season-auto-select.state.ts new file mode 100644 index 000000000..d7a4ce22e --- /dev/null +++ b/libs/ui/components/src/lib/season-container/season-auto-select.state.ts @@ -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; + readonly seasons: Signal>; + /** Season keys in display order. */ + readonly sortedSeasonKeys: Signal; + /** Season key of the inline-playing episode, if it is in the loaded set. */ + readonly playingSeasonKey: Signal; + readonly playbackPositions: Signal< + ReadonlyMap + >; + readonly positionOf: ( + episode: XtreamSerieEpisode + ) => PlaybackPositionData | undefined; + /** Stalker lazy-VOD: some seasons' episode lists are not loaded yet. */ + readonly hasUnloadedSeasons: Signal; + readonly episodeCounts: Signal>; + readonly watchedCounts: Signal>; + 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, + 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; + }, + }; +} diff --git a/libs/ui/components/src/lib/season-container/season-container.component.ts b/libs/ui/components/src/lib/season-container/season-container.component.ts index 26b265c13..c857f5530 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.ts +++ b/libs/ui/components/src/lib/season-container/season-container.component.ts @@ -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); 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(), - }); - } } diff --git a/libs/ui/playback/src/lib/vod-details/vod-details-navigation.util.ts b/libs/ui/playback/src/lib/vod-details/vod-details-navigation.util.ts new file mode 100644 index 000000000..0e5ddea55 --- /dev/null +++ b/libs/ui/playback/src/lib/vod-details/vod-details-navigation.util.ts @@ -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-`. + */ +export function findSimilarByRailKey( + items: readonly CrossPortalSimilarItem[], + railKey: string +): CrossPortalSimilarItem | undefined { + return items.find( + (candidate) => + `x${candidate.match.playlistId}-${candidate.match.xtreamId}` === + railKey + ); +} diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts index a2d6eb523..4d3b658e2 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts @@ -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( - [] + 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 { - const item = this.item(); - await this.downloadsService.resumeDownloadByContent( - getVodNumericId(item), - item.playlistId, - 'vod' - ); + resumePausedDownload(): Promise { + 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 { - 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 { + return this.localDownload.playFromLocal(); } } diff --git a/libs/ui/playback/src/lib/vod-details/vod-download-state.util.ts b/libs/ui/playback/src/lib/vod-details/vod-download-state.util.ts index 7adf76ba5..b17070ec8 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-download-state.util.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-download-state.util.ts @@ -32,3 +32,58 @@ export function createVodDownloadState( ), }; } + +export interface VodLocalDownloadStateDeps { + readonly downloadsService: DownloadsService; + readonly item: Signal; + /** Provider detail handoff hides local/download presentation only. */ + readonly providerOnly: Signal; +} + +/** + * 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 { + const currentItem = item(); + await downloadsService.resumeDownloadByContent( + getVodNumericId(currentItem), + currentItem.playlistId, + 'vod' + ); + }, + + /** Play from local downloaded file */ + async playFromLocal(): Promise { + const currentItem = item(); + const vodId = getVodNumericId(currentItem); + + const filePath = downloadsService.getDownloadedFilePath( + vodId, + currentItem.playlistId, + 'vod' + ); + + if (filePath) { + await downloadsService.playDownload(filePath); + } + }, + }; +} diff --git a/libs/ui/playback/src/lib/vod-details/vod-similar-in-portals.state.ts b/libs/ui/playback/src/lib/vod-details/vod-similar-in-portals.state.ts new file mode 100644 index 000000000..5e3855a89 --- /dev/null +++ b/libs/ui/playback/src/lib/vod-details/vod-similar-in-portals.state.ts @@ -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, + crossPortalSimilar: CrossPortalSimilarService +): Signal { + const matched = signal([]); + /** 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; +}