mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
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:
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()"
|
||||
|
||||
+1
@@ -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>();
|
||||
|
||||
+8
-8
@@ -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) =>
|
||||
|
||||
+14
-4
@@ -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
|
||||
|
||||
Reference in new issue
Block a user