fix(portals): fourteenth review round on the cinematic details

- The hero keys its one-time stage/compact decision on a stable content
  identity (provider + id) that every host passes, so a TMDB rename plus
  a late backdrop no longer flips the visible hero.
- An explicit Stalker movie launch joins its host's start generation: it
  supersedes an earlier start and is dropped once a Play, Start Over or
  later launch begins, on the controller, search, collection and catalog
  hosts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 13:14:07 +02:00
1 parent 4e18306d06
commit 0524429fbd
15 files changed
+170 -42

No files matched your search

@@ -1,5 +1,6 @@
<app-portal-detail-shell
[backAvailable]="false"
[contentKey]="'m3u:' + channel().id"
[title]="title()"
[kindLabel]="kindLabel()"
[description]="overview()"
@@ -34,6 +34,7 @@ class StubPortalDetailShellComponent {
readonly description = input<string>();
readonly posterUrl = input<string>();
readonly backdropUrl = input<string>();
readonly contentKey = input<string | null>(null);
readonly progress = input<number | null>(null);
readonly isLoading = input(false);
readonly errorMessage = input<string>();
@@ -49,7 +49,10 @@ import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-serie
import { startStalkerVodDownload } from './stalker-vod-download';
import { createStalkerVodWatchedToggle } from '../stalker-vod-watched-toggle';
import { createStalkerVodDetailActions } from '../stalker-vod-detail-actions';
import {
createStalkerVodDetailActions,
beginTrackedExternalLaunch,
} from '../stalker-vod-detail-actions';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
@Component({
@@ -83,7 +86,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
private readonly downloadsService = inject(DownloadsService);
private readonly logger = createLogger('StalkerCatalogDetail');
private readonly favoritesRefresh = createRefreshTrigger();
private playbackRequestId = 0;
playbackRequestId = 0;
private currentPlaybackOwnerKey = '';
readonly contentType = this.catalog.contentType;
@@ -101,7 +104,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId })
: '';
});
private readonly playbackOwnerKey = computed(() =>
readonly playbackOwnerKey = computed(() =>
JSON.stringify([this.playbackSessionKey(), this.contentType()])
);
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
@@ -116,7 +119,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
* keyed by its owner: a stale resolution for the previous movie must not
* hold the next movie's toggle hostage.
*/
private readonly pendingStart = createPendingPlaybackStart<string>();
readonly pendingStart = createPendingPlaybackStart<string>();
readonly playbackStartPending = computed(() =>
this.pendingStart.isPendingFor(this.playbackOwnerKey())
);
@@ -278,10 +281,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
selectedVodPosition: this.selectedVodPosition,
discardPendingPositionLoad: () => ++this.positionLoadGeneration,
beforeExternalLaunch: () => this.closeInlinePlayer(),
beginPendingStart: () => {
const startId = this.pendingStart.begin(this.playbackOwnerKey());
return () => this.pendingStart.settle(startId);
},
beginPendingStart: () => beginTrackedExternalLaunch(this),
afterProgressReset: (playlistId) =>
void this.catalog.refreshPositions(playlistId),
download: (item) =>
@@ -7,6 +7,7 @@
[posterUrl]="serial.info.movie_image"
[backdropUrl]="serial.info.tmdb_backdrop"
[trailerBackdropUrl]="heroPresenter.trailerBackdropUrl()"
[contentKey]="'stalker-series:' + serial.id"
[playbackActive]="!!inlinePlayback()"
(backClicked)="goBack()"
(closePlayerRequested)="closeInlinePlayer()"
@@ -18,7 +19,10 @@
</app-meta-chip>
}
@if (hero.statusLabel(); as status) {
<app-meta-chip variant="status" data-test-id="series-status-chip">
<app-meta-chip
variant="status"
data-test-id="series-status-chip"
>
{{ status }}
</app-meta-chip>
}
@@ -44,7 +48,9 @@
<app-meta-chip>
@for (genre of hero.genreChips(); track genre.label) {
@if (!$first) {
<span class="meta-chip__sep" aria-hidden="true">·</span>
<span class="meta-chip__sep" aria-hidden="true"
>·</span
>
}
@if (genre.facet; as facet) {
<button
@@ -76,10 +82,14 @@
</app-meta-chip>
}
@if (serial.info.rating_imdb) {
<app-meta-chip variant="rating">★ {{ serial.info.rating_imdb }}</app-meta-chip>
<app-meta-chip variant="rating"
>★ {{ serial.info.rating_imdb }}</app-meta-chip
>
}
@if (serial.info.rating_kinopoisk) {
<app-meta-chip>KP {{ serial.info.rating_kinopoisk }}</app-meta-chip>
<app-meta-chip
>KP {{ serial.info.rating_kinopoisk }}</app-meta-chip
>
}
</ng-template>
@@ -32,7 +32,11 @@ describe('createStalkerVodDetailActions openExternal', () => {
const closeSession = jest.fn().mockResolvedValue(undefined);
const running = signal<ExternalPlayerSession | null>(null);
const settlePendingStart = jest.fn();
const beginPendingStart = jest.fn().mockReturnValue(settlePendingStart);
let launchCurrent = true;
const beginPendingStart = jest.fn().mockReturnValue({
settle: settlePendingStart,
isCurrent: () => launchCurrent,
});
const actions = createStalkerVodDetailActions({
resolvePlayback: resolvePlayback as never,
portalPlayer: { openExternalPlayback },
@@ -54,6 +58,7 @@ describe('createStalkerVodDetailActions openExternal', () => {
closeSession,
beginPendingStart,
settlePendingStart,
supersede: () => (launchCurrent = false),
setRunning: (session: ExternalPlayerSession | null) =>
running.set(session),
resolveLink: () => resolveLink({ streamUrl: 'http://cdn/42.mp4' }),
@@ -180,6 +185,23 @@ describe('createStalkerVodDetailActions openExternal', () => {
expect(t.openExternalPlayback).not.toHaveBeenCalled();
});
it('drops the launch once a newer start superseded it', async () => {
const t = setup(() => 42);
const launch = t.actions.openExternal({
item: MOVIE,
player: 'mpv',
positionSeconds: null,
});
// Play/Resume (or another launch) began while the link resolved.
t.supersede();
t.resolveLink();
await launch;
expect(t.beforeExternalLaunch).not.toHaveBeenCalled();
expect(t.openExternalPlayback).not.toHaveBeenCalled();
expect(t.settlePendingStart).toHaveBeenCalledTimes(1);
});
it('drops the stream once another movie was selected meanwhile', async () => {
let selected = 42;
const t = setup(() => selected);
@@ -15,6 +15,32 @@ import type {
} from '@iptvnator/shared/interfaces';
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
/** A start the explicit launch joins: superseded by, and superseding, every other start of its host. */
export interface PendingExternalLaunch {
settle(): void;
isCurrent(): boolean;
}
/** Builds a `PendingExternalLaunch` on a host that tracks its own request ids. */
export function beginTrackedExternalLaunch(host: {
readonly pendingStart: {
begin(owner: string): number;
settle(startId: number): void;
};
playbackOwnerKey(): string;
playbackRequestId: number;
}): PendingExternalLaunch {
const requestId = ++host.playbackRequestId;
const ownerKey = host.playbackOwnerKey();
const startId = host.pendingStart.begin(ownerKey);
return {
settle: () => host.pendingStart.settle(startId),
isCurrent: () =>
requestId === host.playbackRequestId &&
host.playbackOwnerKey() === ownerKey,
};
}
export interface StalkerVodExternalPlayEvent {
readonly item: VodDetailsItem;
readonly player: ExternalPlayerName;
@@ -51,8 +77,8 @@ interface StalkerVodDetailActionsDeps {
/** Retires a stored-position read in flight, which would restore the row. */
readonly discardPendingPositionLoad?: () => void;
readonly beforeExternalLaunch?: () => void;
/** Marks the movie's start pending while the stream resolves; returns the settle. */
readonly beginPendingStart?: () => () => void;
/** The explicit launch as one of the host's starts: pending while it resolves, dropped once superseded. */
readonly beginPendingStart?: () => PendingExternalLaunch;
readonly afterProgressReset?: (playlistId: string) => void;
readonly snackBar: Pick<MatSnackBar, 'open'>;
readonly translate: Pick<TranslateService, 'instant'>;
@@ -113,7 +139,8 @@ export function createStalkerVodDetailActions(
const stillSelected = () =>
deps.selectedVodId() === vodId &&
deps.playlistId() === playlistId;
const settlePendingStart = deps.beginPendingStart?.();
const pending = deps.beginPendingStart?.();
const superseded = () => pending?.isCurrent() === false;
try {
const playback = await deps.resolvePlayback(
event.item.cmd,
@@ -121,7 +148,7 @@ export function createStalkerVodDetailActions(
event.item.data.info?.movie_image,
event.positionSeconds ?? undefined
);
if (!stillSelected()) {
if (!stillSelected() || superseded()) {
return;
}
const replaced = await replaceOwnedExternalSession(
@@ -132,7 +159,7 @@ export function createStalkerVodDetailActions(
Number(info.contentXtreamId) === vodId,
deps.logError
);
if (!replaced || !stillSelected()) {
if (!replaced || !stillSelected() || superseded()) {
return;
}
deps.beforeExternalLaunch?.();
@@ -147,7 +174,7 @@ export function createStalkerVodDetailActions(
}
} finally {
externalLaunchesInFlight.delete(launchKey);
settlePendingStart?.();
pending?.settle();
}
},
@@ -12,6 +12,7 @@ import {
PlaybackPositionData,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import type { PendingExternalLaunch } from './stalker-vod-detail-actions';
interface StalkerVodPlaybackControllerConfig {
inlinePlayback: WritableSignal<ResolvedPortalPlayback | null>;
@@ -43,12 +44,19 @@ export class StalkerVodPlaybackController {
constructor(private readonly config: StalkerVodPlaybackControllerConfig) {}
/** An explicit launch resolving its stream; the closure settles it. */
beginPendingStart(): () => void {
const startId = this.pendingStart.begin(
this.config.playbackOwnerKey?.()
);
return () => this.pendingStart.settle(startId);
/**
* An explicit launch as one of this controller's starts: it supersedes
* an earlier start and is dropped once a later one begins.
*/
beginPendingStart(): PendingExternalLaunch {
const requestId = ++this.playbackRequestId;
const playbackOwnerKey = this.config.playbackOwnerKey?.();
const startId = this.pendingStart.begin(playbackOwnerKey);
return {
settle: () => this.pendingStart.settle(startId),
isCurrent: () =>
this.isPlaybackRequestCurrent(requestId, playbackOwnerKey),
};
}
async startVodPlayback(
@@ -9,6 +9,7 @@
[posterUrl]="info.cover"
[backdropUrl]="getBackdropUrl(info)"
[trailerBackdropUrl]="heroPresenter.trailerBackdropUrl()"
[contentKey]="'xtream-series:' + item.series_id"
[playbackActive]="!!inlinePlayback()"
(backClicked)="goBack()"
(closePlayerRequested)="closeInlinePlayer()"
@@ -20,13 +21,19 @@
</app-meta-chip>
}
@if (hero.statusLabel(); as status) {
<app-meta-chip variant="status" data-test-id="series-status-chip">
<app-meta-chip
variant="status"
data-test-id="series-status-chip"
>
{{ status }}
</app-meta-chip>
}
@if (hero.year(); as year) {
<app-meta-chip>
@if (info.releaseDate && discover.canOpenYear(info.releaseDate)) {
@if (
info.releaseDate &&
discover.canOpenYear(info.releaseDate)
) {
<button
type="button"
class="meta-chip__facet"
@@ -43,7 +50,9 @@
<app-meta-chip>
@for (genre of hero.genreChips(); track genre.label) {
@if (!$first) {
<span class="meta-chip__sep" aria-hidden="true">·</span>
<span class="meta-chip__sep" aria-hidden="true"
>·</span
>
}
@if (genre.facet; as facet) {
<button
@@ -60,7 +69,9 @@
</app-meta-chip>
}
@if (info.episode_run_time) {
<app-meta-chip>{{ info.episode_run_time }} min/ep</app-meta-chip>
<app-meta-chip
>{{ info.episode_run_time }} min/ep</app-meta-chip
>
}
@for (country of hero.countryChips(); track country.label) {
<app-meta-chip>
@@ -78,7 +89,9 @@
</app-meta-chip>
}
@if (info.rating) {
<app-meta-chip variant="rating">★ {{ info.rating }}</app-meta-chip>
<app-meta-chip variant="rating"
>★ {{ info.rating }}</app-meta-chip
>
}
</ng-template>
@@ -38,8 +38,10 @@
/>
<app-detail-icon-button
[label]="
(isWatched() ? 'XTREAM.MARK_UNWATCHED' : 'XTREAM.MARK_WATCHED')
| translate
(isWatched()
? 'XTREAM.MARK_UNWATCHED'
: 'XTREAM.MARK_WATCHED'
) | translate
"
[icon]="isWatched() ? 'check_circle' : 'check_circle_outline'"
tone="watched"
@@ -173,12 +175,13 @@
@if (multiSource.hasAlternatives()) {
<span class="source-caption__extra">
@if (caption.alternativeCount === 1) {
{{ 'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_ONE' | translate }}
{{
'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_ONE' | translate
}}
} @else {
{{
'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_OTHER'
| translate
: { count: caption.alternativeCount }
| translate: { count: caption.alternativeCount }
}}
}
</span>
@@ -192,7 +195,9 @@
/>
@if (selectedVodInfo()?.rating_kinopoisk; as kinopoisk) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.KINOPOISK_RATING' | translate }}</span>
<span class="label">{{
'XTREAM.KINOPOISK_RATING' | translate
}}</span>
<span class="value">{{ kinopoisk }}</span>
</div>
}
@@ -236,6 +241,7 @@
[backdropUrl]="getBackdropUrl(info)"
[progress]="hero.progress()"
[trailerBackdropUrl]="trailerBackdropUrl()"
[contentKey]="'xtream-vod:' + selectedVodId()"
[playbackActive]="!!inlinePlayback()"
(backClicked)="goBack()"
(closePlayerRequested)="closeInlinePlayer()"
@@ -300,7 +306,9 @@
</app-meta-chip>
}
@if (info.rating_imdb) {
<app-meta-chip variant="rating">★ {{ info.rating_imdb }}</app-meta-chip>
<app-meta-chip variant="rating"
>★ {{ info.rating_imdb }}</app-meta-chip
>
}
@if (isDownloaded()) {
<app-meta-chip variant="status">
@@ -351,7 +359,9 @@
<app-meta-chip>{{ fallback.year }}</app-meta-chip>
}
@if (fallback.rating) {
<app-meta-chip variant="rating">★ {{ fallback.rating }}</app-meta-chip>
<app-meta-chip variant="rating"
>★ {{ fallback.rating }}</app-meta-chip
>
}
@if (fallback.added) {
<app-meta-chip>{{ fallback.added }}</app-meta-chip>
@@ -231,6 +231,24 @@ describe('ContentHeroComponent cinematic layout', () => {
expect(mute?.getAttribute('aria-pressed')).toBe('true');
});
it('keys the layout on the content identity when enrichment renames the title', () => {
fixture.componentRef.setInput('contentKey', 'm3u:42');
fixture.componentRef.setInput('title', 'Channel name');
fixture.componentRef.setInput('posterUrl', 'poster.jpg');
fixture.detectChanges();
expect(host().querySelector('.hero--compact')).toBeTruthy();
// TMDB replaces the title and brings a backdrop: same content.
fixture.componentRef.setInput('title', 'Proper Title (2021)');
fixture.componentRef.setInput('backdropUrl', 'wide.jpg');
fixture.detectChanges();
expect(host().querySelector('.hero--compact')).toBeTruthy();
fixture.componentRef.setInput('contentKey', 'm3u:43');
fixture.detectChanges();
expect(host().querySelector('.hero--compact')).toBeNull();
});
it('places the actions before the credits slot', () => {
fixture.componentRef.setInput('title', 'Black Harbor');
fixture.detectChanges();
@@ -47,6 +47,11 @@ export class ContentHeroComponent {
readonly description = input<string>();
readonly posterUrl = input<string>();
readonly backdropUrl = input<string>();
/**
* Stable identity of the shown title (provider + id). The layout is
* decided once per identity; enrichment may replace the title itself.
*/
readonly contentKey = input<string | null>(null);
/** 0–100 watched share; renders the resume bar above the actions. */
readonly progress = input<number | null>(null);
/** With the setting on, this trailer plays muted behind the details. */
@@ -87,10 +92,11 @@ export class ContentHeroComponent {
/**
* Decided once per title: a backdrop that TMDB enrichment adds a moment
* later fills the compact hero instead of growing it under the user's
* cursor. The title survives the merge; the poster may not.
* cursor. Keyed by `contentKey`, falling back to the title for hosts
* without one.
*/
readonly layout = linkedSignal<string | undefined, ContentHeroLayout>({
source: () => this.title(),
source: () => this.contentKey() ?? this.title(),
computation: () =>
untracked(() => this.hasRealBackdrop()) ? 'stage' : 'compact',
});
@@ -9,6 +9,7 @@
[backdropUrl]="backdropUrl()"
[progress]="progress()"
[trailerBackdropUrl]="trailerBackdropUrl()"
[contentKey]="contentKey()"
[isLoading]="isLoading()"
[errorMessage]="errorMessage()"
>
@@ -73,6 +73,8 @@ export class PortalDetailShellComponent {
readonly progress = input<number | null>(null);
/** Trailer embed to play muted behind the hero, when the setting is on. */
readonly trailerBackdropUrl = input<string | null>(null);
/** Stable identity of the shown title; see `ContentHeroComponent.contentKey`. */
readonly contentKey = input<string | null>(null);
readonly isLoading = input(false);
readonly errorMessage = input<string>();
readonly backLabel = input<string>();
@@ -8,6 +8,7 @@
[backdropUrl]="meta.backdropUrl"
[progress]="hero.progress()"
[trailerBackdropUrl]="trailerBackdropUrl()"
[contentKey]="contentKey()"
[playbackActive]="!!inlinePlayback()"
(backClicked)="goBack()"
(closePlayerRequested)="closeInlinePlayback()"
@@ -68,7 +69,9 @@
</app-meta-chip>
}
@if (meta.ratingImdb) {
<app-meta-chip variant="rating">★ {{ meta.ratingImdb }}</app-meta-chip>
<app-meta-chip variant="rating"
>★ {{ meta.ratingImdb }}</app-meta-chip
>
}
@if (isDownloaded()) {
<app-meta-chip variant="status">
@@ -150,7 +153,9 @@
@if (isDownloaded()) {
@if (externalPrimaryButtonState() === 'idle') {
<app-detail-icon-button
[label]="'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE' | translate"
[label]="
'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE' | translate
"
icon="play_arrow"
testId="vod-play-provider"
(pressed)="onProviderAction()"
@@ -215,6 +215,10 @@ export class VodDetailsComponent {
readonly trailerEmbedUrl = computed(() =>
youtubeEmbedUrl(this.normalizedMeta().youtubeTrailer)
);
/** Provider + id: the hero keys its one-time layout decision on it. */
readonly contentKey = computed(
() => `${this.item().type}:${getVodNumericId(this.item())}`
);
/** Settings → Playback → Play trailers in details background. */
readonly trailerBackdropUrl = computed(() =>
this.settingsStore.detailTrailerBackdrop?.() === true