mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:26:43 -08:00
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:
1 parent
ff7b340e87
commit
78314c7fbc
6 files changed
+90
-14
No files matched your search
@@ -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.
|
||||
@@ -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 =
|
||||
|
||||
Reference in new issue
Block a user