mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
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:
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,
|
||||
|
||||
Reference in new issue
Block a user