refactor(xtream): migrate VOD and serial detail templates to detail shell

Hero chips/meta/actions become *detailTags/*detailMeta/*detailActions
templates; the inline player moves to the full-width [detail-player]
slot; trailer and similar rail move to [detail-extras]. Escape and the
now-playing back button close the inline player back to browse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-04 18:20:25 +02:00
1 parent d2883a9e2b
commit e9e885fc84
6 files changed
+145 -137

No files matched your search

@@ -1,14 +1,16 @@
@let item = selectedItem();
@if (item && item.info) {
@let info = item.info;
<app-content-hero
<app-portal-detail-shell
[title]="info.name"
[description]="info.plot"
[posterUrl]="info.cover"
[backdropUrl]="getBackdropUrl(info)"
[playbackActive]="!!inlinePlayback()"
(backClicked)="goBack()"
(closePlayerRequested)="closeInlinePlayer()"
>
<ng-container hero-tags>
<ng-template detailTags>
@if (info.releaseDate) {
<span class="details__tag">{{
info.releaseDate | slice: 0 : 4
@@ -27,9 +29,9 @@
⭐ {{ info.rating }}
</span>
}
</ng-container>
</ng-template>
<ng-container hero-meta>
<ng-template detailMeta>
@if (info.cast || info.tmdb_cast?.length) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.CAST' | translate }}</span>
@@ -83,9 +85,9 @@
<span class="value">{{ info.director }}</span>
</div>
}
</ng-container>
</ng-template>
<ng-container hero-actions>
<ng-template detailActions>
@if (quickStartAction(); as action) {
<button
class="play-btn"
@@ -122,10 +124,11 @@
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
}
</ng-container>
</ng-template>
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
detail-player
[playback]="playback"
[episodeMetadata]="inlineEpisodeMetadata()"
[seriesNavigation]="inlineSeriesNavigation()"
@@ -142,6 +145,7 @@
}
<app-season-container
detail-episodes
[seasons]="item.episodes"
[seriesId]="item.series_id"
[playlistId]="currentPlaylistId()"
@@ -155,45 +159,47 @@
(seasonSelected)="onSeasonSelected($event)"
/>
@if (similarItems().length) {
<section class="details__similar" data-test-id="tmdb-similar">
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
<div class="details__similar-strip">
@for (similar of similarItems(); track similar.id) {
<button
type="button"
class="details__similar-card"
[title]="similar.title"
(click)="openSimilar(similar)"
>
@if (similar.posterUrl) {
<img
class="details__similar-poster"
[src]="similar.posterUrl"
[alt]="similar.title"
loading="lazy"
/>
} @else {
<span
class="details__similar-poster details__similar-poster--fallback"
>
{{ similar.title.charAt(0) }}
<div detail-extras>
@if (similarItems().length) {
<section class="details__similar" data-test-id="tmdb-similar">
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
<div class="details__similar-strip">
@for (similar of similarItems(); track similar.id) {
<button
type="button"
class="details__similar-card"
[title]="similar.title"
(click)="openSimilar(similar)"
>
@if (similar.posterUrl) {
<img
class="details__similar-poster"
[src]="similar.posterUrl"
[alt]="similar.title"
loading="lazy"
/>
} @else {
<span
class="details__similar-poster details__similar-poster--fallback"
>
{{ similar.title.charAt(0) }}
</span>
}
<span class="details__similar-title">
{{ similar.title }}
</span>
}
<span class="details__similar-title">
{{ similar.title }}
</span>
</button>
}
</div>
</section>
}
</app-content-hero>
</button>
}
</div>
</section>
}
</div>
</app-portal-detail-shell>
} @else if (detailsError()) {
<app-content-hero
<app-portal-detail-shell
[errorMessage]="detailsError() ?? undefined"
(backClicked)="goBack()"
/>
} @else {
<app-content-hero [isLoading]="true" (backClicked)="goBack()" />
<app-portal-detail-shell [isLoading]="true" (backClicked)="goBack()" />
}
@@ -21,21 +21,6 @@ import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { of } from 'rxjs';
import { SerialDetailsComponent } from './serial-details.component';
@Component({
selector: 'app-content-hero',
standalone: true,
template: '<ng-content />',
})
class StubContentHeroComponent {
readonly title = input<string | undefined>(undefined);
readonly description = input<string | undefined>(undefined);
readonly posterUrl = input<string | undefined>(undefined);
readonly backdropUrl = input<string | undefined>(undefined);
readonly isLoading = input(false);
readonly errorMessage = input<string | null>(null);
readonly backClicked = output<void>();
}
@Component({
selector: 'app-season-container',
standalone: true,
@@ -239,7 +224,6 @@ describe('SerialDetailsComponent', () => {
.overrideComponent(SerialDetailsComponent, {
remove: {
imports: [
ContentHeroComponent,
MatIcon,
PortalInlinePlayerComponent,
SeasonContainerComponent,
@@ -248,7 +232,6 @@ describe('SerialDetailsComponent', () => {
},
add: {
imports: [
StubContentHeroComponent,
StubMatIconComponent,
StubPortalInlinePlayerComponent,
StubSeasonContainerComponent,
@@ -334,8 +317,8 @@ describe('SerialDetailsComponent', () => {
expect(() => fixture.detectChanges()).not.toThrow();
const hero = fixture.debugElement.query(
By.directive(StubContentHeroComponent)
).componentInstance as StubContentHeroComponent;
By.directive(ContentHeroComponent)
).componentInstance as ContentHeroComponent;
expect(hero.backdropUrl()).toBeUndefined();
});
@@ -14,7 +14,10 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
ContentHeroComponent,
DetailActionsTemplateDirective,
DetailMetaTemplateDirective,
DetailTagsTemplateDirective,
PortalDetailShellComponent,
SeasonContainerComponent,
SeasonContainerPlaybackToggleRequest,
SeasonContainerXtreamDownloadContext,
@@ -57,8 +60,11 @@ import {
],
providers: [SerialDetailsPlaybackService],
imports: [
ContentHeroComponent,
DetailActionsTemplateDirective,
DetailMetaTemplateDirective,
DetailTagsTemplateDirective,
MatIcon,
PortalDetailShellComponent,
PortalInlinePlayerComponent,
SeasonContainerComponent,
SlicePipe,
@@ -1,13 +1,15 @@
@let item = selectedItem();
@if (selectedVodInfo(); as info) {
<app-content-hero
<app-portal-detail-shell
[title]="info.name || item?.movie_data?.name"
[description]="info.description || info.plot"
[posterUrl]="info.movie_image || info.cover_big"
[backdropUrl]="getBackdropUrl(info)"
[playbackActive]="!!inlinePlayback()"
(backClicked)="goBack()"
(closePlayerRequested)="closeInlinePlayer()"
>
<ng-container hero-tags>
<ng-template detailTags>
@if (info.releasedate) {
<span class="details__tag">{{
info.releasedate | slice: 0 : 4
@@ -27,9 +29,9 @@
@if (info.country) {
<span class="details__tag">{{ info.country }}</span>
}
</ng-container>
</ng-template>
<ng-container hero-meta>
<ng-template detailMeta>
@if (info.actors || info.cast || info.tmdb_cast?.length) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
@@ -93,9 +95,9 @@
<span class="value">{{ info.rating_kinopoisk }}</span>
</div>
}
</ng-container>
</ng-template>
<ng-container hero-actions>
<ng-template detailActions>
@if (hasPlaybackPosition()) {
<button
class="play-btn play-btn--resume"
@@ -195,10 +197,11 @@
</button>
}
}
</ng-container>
</ng-template>
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
detail-player
[playback]="playback"
(timeUpdate)="handleInlineTimeUpdate($event)"
(closed)="closeInlinePlayer()"
@@ -209,70 +212,72 @@
/>
}
@if (trailerEmbedUrl(); as trailerUrl) {
<div class="youtube-trailer">
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
<div class="trailer-wrapper">
<iframe
[src]="trailerUrl | safe"
title="YouTube video player"
allow="
accelerometer;
autoplay;
clipboard-write;
encrypted-media;
gyroscope;
picture-in-picture;
web-share;
"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
></iframe>
<div detail-extras>
@if (trailerEmbedUrl(); as trailerUrl) {
<div class="youtube-trailer">
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
<div class="trailer-wrapper">
<iframe
[src]="trailerUrl | safe"
title="YouTube video player"
allow="
accelerometer;
autoplay;
clipboard-write;
encrypted-media;
gyroscope;
picture-in-picture;
web-share;
"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
></iframe>
</div>
</div>
</div>
}
}
@if (similarItems().length) {
<section class="details__similar" data-test-id="tmdb-similar">
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
<div class="details__similar-strip">
@for (item of similarItems(); track item.id) {
<button
type="button"
class="details__similar-card"
[title]="item.title"
(click)="openSimilar(item)"
>
@if (item.posterUrl) {
<img
class="details__similar-poster"
[src]="item.posterUrl"
[alt]="item.title"
loading="lazy"
/>
} @else {
<span
class="details__similar-poster details__similar-poster--fallback"
>
{{ item.title.charAt(0) }}
@if (similarItems().length) {
<section class="details__similar" data-test-id="tmdb-similar">
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
<div class="details__similar-strip">
@for (item of similarItems(); track item.id) {
<button
type="button"
class="details__similar-card"
[title]="item.title"
(click)="openSimilar(item)"
>
@if (item.posterUrl) {
<img
class="details__similar-poster"
[src]="item.posterUrl"
[alt]="item.title"
loading="lazy"
/>
} @else {
<span
class="details__similar-poster details__similar-poster--fallback"
>
{{ item.title.charAt(0) }}
</span>
}
<span class="details__similar-title">
{{ item.title }}
</span>
}
<span class="details__similar-title">
{{ item.title }}
</span>
</button>
}
</div>
</section>
}
</app-content-hero>
</button>
}
</div>
</section>
}
</div>
</app-portal-detail-shell>
} @else if (fallbackView(); as fallback) {
<app-content-hero
<app-portal-detail-shell
[title]="fallback.title"
[posterUrl]="fallback.posterUrl"
(backClicked)="goBack()"
>
<ng-container hero-tags>
<ng-template detailTags>
<span
class="details__tag details__tag--status"
data-testid="xtream-vod-fallback-status"
@@ -293,9 +298,9 @@
@if (fallback.format) {
<span class="details__tag">{{ fallback.format }}</span>
}
</ng-container>
</ng-template>
<ng-container hero-meta>
<ng-template detailMeta>
<div
class="details__fallback-note"
data-testid="xtream-vod-fallback"
@@ -311,13 +316,13 @@
</span>
</div>
}
</ng-container>
</app-content-hero>
</ng-template>
</app-portal-detail-shell>
} @else if (detailsError()) {
<app-content-hero
<app-portal-detail-shell
[errorMessage]="detailsError() ?? undefined"
(backClicked)="goBack()"
/>
} @else {
<app-content-hero [isLoading]="true" (backClicked)="goBack()" />
<app-portal-detail-shell [isLoading]="true" (backClicked)="goBack()" />
}
@@ -14,7 +14,12 @@ import { MatIcon } from '@angular/material/icon';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { ContentHeroComponent } from '@iptvnator/ui/components';
import {
DetailActionsTemplateDirective,
DetailMetaTemplateDirective,
DetailTagsTemplateDirective,
PortalDetailShellComponent,
} from '@iptvnator/ui/components';
import { SafePipe } from '@iptvnator/pipes';
import { createLogger } from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
@@ -51,8 +56,11 @@ import { VodDetailsPlaybackService } from './vod-details-playback.service';
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [VodDetailsPlaybackService],
imports: [
ContentHeroComponent,
DetailActionsTemplateDirective,
DetailMetaTemplateDirective,
DetailTagsTemplateDirective,
MatIcon,
PortalDetailShellComponent,
SafePipe,
SlicePipe,
TranslateModule,
@@ -11,7 +11,7 @@ import {
untracked,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ContentHeroComponent } from '@iptvnator/ui/components';
import { PortalDetailShellComponent } from '@iptvnator/ui/components';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import {
XtreamPlaylistData,
@@ -35,7 +35,7 @@ interface XtreamCollectionStateSnapshot {
@Component({
selector: 'app-xtream-collection-detail',
imports: [ContentHeroComponent, NgComponentOutlet],
imports: [PortalDetailShellComponent, NgComponentOutlet],
template: `
@if (detailComponent() && detailInjector()) {
<ng-container
@@ -45,7 +45,7 @@ interface XtreamCollectionStateSnapshot {
"
/>
} @else {
<app-content-hero [isLoading]="true" />
<app-portal-detail-shell [isLoading]="true" />
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,