diff --git a/.changes/portals-cinematic-vod-details.md b/.changes/portals-cinematic-vod-details.md index 318668e37..bb82f01ec 100644 --- a/.changes/portals-cinematic-vod-details.md +++ b/.changes/portals-cinematic-vod-details.md @@ -4,4 +4,4 @@ area: portals highlight: Cinematic movie and series pages --- -Movie and series pages open on a full-height backdrop with chips, plot and one action row: a light Continue/Play button naming the next episode and the time left, a Trailer modal, and a "…" menu with other sources, external player, copy URL, reset progress and season actions. Cast & crew and Similar follow as rows; without a backdrop the blurred poster fills the stage. +Movie and series pages open on a cinematic backdrop with chips, plot and one action row: a light Continue/Play button naming the next episode and the time left, a Trailer modal, and a "…" menu with other sources, external player, copy URL, reset progress and season actions. Cast & crew and Similar follow as rows. Without a 16:9 backdrop the hero shrinks to its content over the blurred poster. diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 3e3a2295b..23433f05b 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -158,9 +158,13 @@ VOD and series detail screens share `app-portal-detail-shell` and kind label ("Movie · playlist") → title → chips → description (three lines, "More") → resume bar → action row → credits, with the poster bottom-aligned on the left and the backdrop filling the hero behind a two-layer scrim built -from `--app-content-bg`. Without a backdrop, or when the provider sends the -poster as the backdrop, the blurred poster fills the stage. The pane is a -size container (`detail`); the poster hides below 760px of pane width. +from `--app-content-bg`. The hero keeps `min(480px, 60vh)` of stage for a +16:9 backdrop. Without one, or when the provider sends the poster as the +backdrop, the hero is compact (`hero--compact`, sized by its content) over +the blurred poster. The layout is decided once per title, so a backdrop that +TMDB enrichment adds a moment later fills the compact hero instead of +growing it. The pane is a size container (`detail`); the poster hides below +760px of pane width. The pieces are shared and provider-neutral (`libs/ui/components/src/lib/detail-ui/`): `app-meta-chip` (pill; `rating` and `status` variants; facets as projected diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.html b/libs/ui/components/src/lib/content-hero/content-hero.component.html index 6bccf4046..2c0c27a9c 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.html +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.html @@ -1,5 +1,5 @@ -
+
@if (errorMessage()) {
cloud_off diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.scss b/libs/ui/components/src/lib/content-hero/content-hero.component.scss index 629dfd144..ec7392d26 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.scss +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.scss @@ -18,8 +18,16 @@ --text-secondary: var(--app-body-color, #9aa3b2); --text-muted: var(--app-muted-color, #6b7384); --accent-color: var(--app-selection-color, #78adff); - --tag-bg: color-mix(in srgb, var(--app-on-surface, #e6e1e5) 10%, transparent); - --tag-border: color-mix(in srgb, var(--app-on-surface, #e6e1e5) 14%, transparent); + --tag-bg: color-mix( + in srgb, + var(--app-on-surface, #e6e1e5) 10%, + transparent + ); + --tag-border: color-mix( + in srgb, + var(--app-on-surface, #e6e1e5) 14%, + transparent + ); width: 100%; display: block; @@ -40,14 +48,20 @@ display: flex; align-items: flex-end; width: 100%; - // The content column decides the height on a short window; on a tall one - // the hero caps at 72vh so the next block starts inside the first screen. - min-height: min(560px, 72vh); + // Room for a 16:9 backdrop above the content, capped so the episodes or + // cast row still starts inside the first screen of a desktop window. + min-height: min(480px, 60vh); box-sizing: border-box; // `overflow: hidden` clips the scaled blurred backdrop fallback. overflow: hidden; background: var(--surface-bg); + // Only the poster: nothing to show above the content, so the hero is + // sized by its content column over the blurred wash. + &--compact { + min-height: 0; + } + &__backdrop { position: absolute; inset: 0; @@ -110,7 +124,7 @@ width: 100%; max-width: var(--content-max-width); margin: 0 auto; - padding: 72px 32px 24px; + padding: 48px 32px 24px; // Reserved by the detail shell while its sticky Back control shows. padding-inline-start: max(32px, var(--detail-back-lane, 0px)); box-sizing: border-box; @@ -388,7 +402,7 @@ } .hero__content { - padding: 64px 20px 24px; + padding: 40px 20px 24px; padding-inline-start: max(20px, var(--detail-back-lane, 0px)); } diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.spec.ts b/libs/ui/components/src/lib/content-hero/content-hero.component.spec.ts index b71299a4d..01f818f81 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.spec.ts +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.spec.ts @@ -154,7 +154,9 @@ describe('ContentHeroComponent cinematic layout', () => { fixture.componentRef.setInput('posterUrl', 'poster.jpg'); fixture.detectChanges(); expect( - host().querySelector('.hero__backdrop--blurred img[src="poster.jpg"]') + host().querySelector( + '.hero__backdrop--blurred img[src="poster.jpg"]' + ) ).toBeTruthy(); fixture.componentRef.setInput('backdropUrl', 'poster.jpg'); @@ -169,6 +171,40 @@ describe('ContentHeroComponent cinematic layout', () => { ).toBeTruthy(); }); + it('keeps room for a real backdrop and sizes the hero by content without one', () => { + fixture.componentRef.setInput('title', 'Poster only'); + fixture.componentRef.setInput('posterUrl', 'poster.jpg'); + fixture.detectChanges(); + expect(host().querySelector('.hero--compact')).toBeTruthy(); + + fixture.componentRef.setInput('title', 'With backdrop'); + fixture.componentRef.setInput('backdropUrl', 'wide.jpg'); + fixture.detectChanges(); + expect(host().querySelector('.hero--compact')).toBeNull(); + + fixture.componentRef.setInput('title', 'Poster twice'); + fixture.componentRef.setInput('backdropUrl', 'poster.jpg'); + fixture.detectChanges(); + expect(host().querySelector('.hero--compact')).toBeTruthy(); + }); + + it('does not grow when enrichment adds a backdrop to the same title', () => { + fixture.componentRef.setInput('title', 'Late backdrop'); + fixture.componentRef.setInput('posterUrl', 'poster.jpg'); + fixture.detectChanges(); + expect(host().querySelector('.hero--compact')).toBeTruthy(); + + fixture.componentRef.setInput('backdropUrl', 'tmdb-wide.jpg'); + fixture.componentRef.setInput('posterUrl', 'tmdb-poster.jpg'); + fixture.detectChanges(); + expect(host().querySelector('.hero--compact')).toBeTruthy(); + expect( + host().querySelector( + 'img.hero__backdrop-image[src="tmdb-wide.jpg"]' + ) + ).toBeTruthy(); + }); + it('places the actions before the credits slot', () => { fixture.componentRef.setInput('title', 'Black Harbor'); fixture.detectChanges(); diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.ts b/libs/ui/components/src/lib/content-hero/content-hero.component.ts index 2084a665a..86c5126d3 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.ts +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.ts @@ -5,6 +5,7 @@ import { effect, inject, input, + linkedSignal, signal, untracked, viewChild, @@ -17,6 +18,9 @@ import { TranslateModule } from '@ngx-translate/core'; import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader'; import { HeroTrailerBackdropComponent } from '../detail-ui/hero-trailer-backdrop.component'; +/** `stage` keeps room for a 16:9 backdrop; `compact` is sized by the content. */ +export type ContentHeroLayout = 'stage' | 'compact'; + @Component({ selector: 'app-content-hero', standalone: true, @@ -68,7 +72,25 @@ export class ContentHeroComponent { readonly usesPosterBackdrop = computed(() => { const poster = this.posterUrl(); const backdrop = this.backdropUrl(); - return !!poster && (!backdrop || backdrop === poster) && !this.backdropError(); + return ( + !!poster && + (!backdrop || backdrop === poster) && + !this.backdropError() + ); + }); + private readonly hasRealBackdrop = computed(() => { + const backdrop = this.backdropUrl(); + return !!backdrop && backdrop !== this.posterUrl(); + }); + /** + * 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. + */ + readonly layout = linkedSignal({ + source: () => this.title(), + computation: () => + untracked(() => this.hasRealBackdrop()) ? 'stage' : 'compact', }); readonly descriptionEl =