feat(ui): lower the details hero and make it compact without a backdrop

The stage for a 16:9 backdrop is min(480px, 60vh) instead of 560/72vh,
with the content's top padding at 48px, so the episodes or cast row gains
~80px in a 1200×800 window. A title with only a poster (the common case
on real Xtream and Stalker playlists) gets a compact hero sized by its
content over the blurred poster. The layout is decided once per title: a
backdrop that TMDB enrichment adds a moment later fills the compact hero
instead of growing it under the cursor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-02 13:41:05 +02:00
1 parent ff7b340e87
commit 78314c7fbc
6 files changed
+90 -14

No files matched your search

+1 -1
View File
@@ -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.
+7 -3
View File
@@ -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
@@ -1,5 +1,5 @@
<!-- Hero Section with Backdrop -->
<section class="hero">
<section class="hero" [class.hero--compact]="layout() === 'compact'">
@if (errorMessage()) {
<div class="hero__content hero__error">
<mat-icon class="error-icon">cloud_off</mat-icon>
@@ -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));
}
@@ -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();
@@ -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<string | undefined, ContentHeroLayout>({
source: () => this.title(),
computation: () =>
untracked(() => this.hasRealBackdrop()) ? 'stage' : 'compact',
});
readonly descriptionEl =