Files
iptvnator/libs/ui/components/src/lib/content-hero/content-hero.component.scss
T
4grayandClaude Fable 5 0010dd7351 feat(portals): rework the VOD sources popup and action row (#1359)
* feat(portals): rework the VOD sources popup and action row

The Sources popup now anchors above its button and always fits on screen:
the overlay caps it to the space beside the chip, only the source list
scrolls so the header, search, filter chips and footer stay visible, and
it flips below the button when the space above is too small. Filter chips
(All / Available / HD+ / language) compose with the playlist search, and
"Available" runs check-all itself when nothing has been checked yet.

Expanded copy rows no longer repeat the playlist domain: each copy shows
its parsed language chip, the provider's raw stream title, and only the
tags that differ from the parent copy.

Availability checks run at most four at a time and settled verdicts are
remembered per movie and source for ten minutes, so reopening a movie no
longer re-contacts every foreign portal.

Favorites and Download become icon-only buttons with real state: a filled
heart when favorited, and a download icon that turns into a progress ring
and then a checkmark that reveals the finished file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(portals): match the movie detail's icon-only favorite button

The Xtream movie detail's favorite control is now an icon-only button, so
the shared "add this detail to favorites" helpers no longer found it by
class. They are used against series and Stalker details too, which still
render the labeled variant, so they now select by accessible name — the
icon button carries the same label in aria-label.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(portals): keep provider playback for a downloaded movie

Once a movie is downloaded the primary button plays the local file, and
the sources popover only exists when another playlist carries the same
film. The icon-only rework left those as the only two paths, so a
downloaded movie in a single-playlist library had no way at all to stream
the provider's copy — the labeled action that used to do it was gone.

Restores it as an icon button beside the downloaded checkmark, under the
same condition the old one used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 22:27:24 +02:00

634 lines
16 KiB
SCSS

// ============================================================================
// Netflix-style Detail View - Cinematic Streaming Design
// ============================================================================
@use '../../../../styles/detail-view-actions' as detail-actions;
:host {
--poster-width: 220px;
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15);
--content-max-width: 1400px;
// Light theme colors (default - no .dark-theme class on body)
--hero-gradient-start: rgba(255, 250, 245, 0);
--hero-gradient-mid: rgba(255, 250, 245, 0.75);
--hero-gradient-end: rgba(255, 250, 245, 1);
--surface-bg: #fffaf5;
--text-primary: #1a1a1a;
--text-secondary: rgba(26, 26, 26, 0.75);
--text-muted: rgba(26, 26, 26, 0.5);
--accent-color: #b81d24;
--tag-bg: rgba(0, 0, 0, 0.06);
--tag-border: rgba(0, 0, 0, 0.12);
width: 100%;
display: block;
background: var(--surface-bg);
color: var(--text-primary);
}
// Dark theme overrides - cinematic dark mode
:host-context(.dark-theme) {
--hero-gradient-start: rgba(20, 20, 20, 0);
--hero-gradient-mid: rgba(20, 20, 20, 0.7);
--hero-gradient-end: rgba(20, 20, 20, 1);
--surface-bg: #141414;
--text-primary: #fff;
--text-secondary: rgba(255, 255, 255, 0.7);
--text-muted: rgba(255, 255, 255, 0.5);
--accent-color: #e50914;
--tag-bg: rgba(255, 255, 255, 0.1);
--tag-border: rgba(255, 255, 255, 0.2);
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
}
// ============================================================================
// Hero Section - Full container with backdrop
// ============================================================================
.hero {
position: relative;
width: 100%;
// Natural height: the page scroll container lives outside the hero
// (PortalDetailShellComponent). `overflow: hidden` clips the scaled
// blurred backdrop fallback.
overflow: hidden;
background: var(
--surface-bg
); // Solid background prevents any bleed-through
// Floating back button over backdrop
&__back-button {
position: absolute;
top: 16px;
left: 16px;
z-index: 10;
app-region: no-drag;
-webkit-app-region: no-drag;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
border: none;
cursor: pointer;
transition: all 0.2s ease;
color: #fff;
&:hover {
background: rgba(0, 0, 0, 0.7);
transform: scale(1.05);
}
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
}
// Backdrop image layer - fixed at top, never repeats
&__backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: auto; // Only use height, not bottom
height: 60%;
background-size: cover;
background-position: center 20%;
background-repeat: no-repeat;
z-index: 0;
overflow: hidden;
&-image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center 20%;
}
// Fallback blur effect when using poster as backdrop
&--blurred {
filter: blur(40px) saturate(1.3);
transform: scale(1.4);
height: 65%;
}
}
// Gradient overlays for text legibility
&__vignette {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0; // Full height to cover everything
z-index: 1;
pointer-events: none;
background: linear-gradient(
to bottom,
transparent 0%,
transparent 35%,
var(--surface-bg) 55%,
var(--surface-bg) 100%
);
// Top area gradient for text legibility over backdrop
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 60%;
background: linear-gradient(
to top,
var(--hero-gradient-end) 0%,
var(--hero-gradient-mid) 50%,
var(--hero-gradient-start) 100%
);
}
// Side fade for poster area
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
height: 50%;
width: 40%;
background: linear-gradient(
to right,
var(--hero-gradient-mid) 0%,
var(--hero-gradient-start) 100%
);
}
}
// Content area (page scroll lives in the surrounding detail shell)
&__content {
position: relative;
z-index: 2;
display: flex;
gap: 32px;
align-items: flex-start;
padding: 140px 40px 60px;
max-width: var(--content-max-width);
margin: 0 auto;
box-sizing: border-box;
}
// Error state
&__error {
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 16px;
min-height: 50vh;
.error-icon {
font-size: 64px;
width: 64px;
height: 64px;
color: var(--text-muted);
}
.error-title {
font-size: 1.25rem;
font-weight: 500;
color: var(--text-secondary);
margin: 0;
}
}
}
// ============================================================================
// Poster Card - Floating with shadow
// ============================================================================
.poster {
flex-shrink: 0;
width: var(--poster-width);
align-self: flex-start;
&:hover .poster__image {
transform: scale(1.02);
}
&__image {
width: 100%;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: 8px;
box-shadow: var(--poster-shadow);
background: linear-gradient(135deg, #2a2a2a 0%, #1a1a1a 100%);
transition: transform 0.3s ease;
}
&__placeholder {
width: 100%;
aspect-ratio: 2 / 3;
border-radius: 8px;
background: linear-gradient(135deg, #3a3a3a 0%, #2a2a2a 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.8);
box-shadow: var(--poster-shadow);
overflow: hidden;
&-icon {
font-size: 48px;
width: 48px;
height: 48px;
opacity: 0.5;
margin-bottom: 12px;
}
&-text {
font-size: 1rem;
font-weight: 500;
text-align: center;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
overflow-wrap: anywhere;
padding: 16px;
}
}
}
// ============================================================================
// Details Section - Title, metadata, description
// ============================================================================
.details {
flex: 1;
min-width: 0;
padding-bottom: 24px;
&__title {
font-size: clamp(1.75rem, 4vw, 3rem);
font-weight: 700;
line-height: 1.1;
margin: 0 0 16px 0;
letter-spacing: -0.02em;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
&__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
// These styles are for Projected Content, we need to ensure they apply
// using ::ng-deep or just assume the global styles will handle them?
// Actually, since we project content, the styles defined here might NOT apply to the projected content
// unless we use ::ng-deep or the content itself has these styles.
// However, since we are moving the "shell", the container classes (.details__tags) are here.
// The inner items (.details__tag) will be in the projected content.
// So we need ::ng-deep here for .details__tag.
::ng-deep .details__tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
font-size: 0.8125rem;
font-weight: 500;
background: var(--tag-bg);
border: 1px solid var(--tag-border);
border-radius: 4px;
backdrop-filter: blur(8px);
&--rating {
color: #ffd700;
border-color: rgba(255, 215, 0, 0.3);
}
}
&__description {
font-size: 0.9375rem;
line-height: 1.6;
color: var(--text-secondary);
margin-bottom: 6px;
max-width: 600px;
// Truncate long descriptions
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
&--expanded {
display: block;
-webkit-line-clamp: unset;
overflow: visible;
}
}
&__description-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0 0 20px 0;
padding: 4px 0;
background: transparent;
border: 0;
cursor: pointer;
color: var(--text-secondary);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
transition:
color 180ms ease,
transform 180ms ease;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
line-height: 16px;
transition: transform 180ms ease;
}
&:hover {
color: var(--text-primary);
mat-icon {
transform: translateY(2px);
}
}
&[aria-expanded='true']:hover mat-icon {
transform: translateY(-2px);
}
&:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: 2px;
border-radius: 2px;
}
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: 16px 32px;
margin-bottom: 24px;
}
// Similar issue for meta items
::ng-deep .details__meta-item {
font-size: 0.875rem;
.label {
color: var(--text-muted);
font-weight: 400;
margin-bottom: 2px;
text-decoration: none;
display: block;
}
.value {
color: var(--text-primary);
font-weight: 500;
}
}
}
// ============================================================================
// Action Buttons - Play, Favorite
// ============================================================================
.action-buttons {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-top: 0;
// Use ::ng-deep to style the projected buttons
::ng-deep .play-btn {
display: inline-flex;
align-items: center;
gap: 12px;
min-height: 64px;
padding: 10px 24px 10px 18px;
font-size: 1rem;
font-weight: 600;
background: #fff;
color: #141414;
border: none;
border-radius: 16px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.85);
transform: scale(1.02);
}
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
@include detail-actions.play-button-copy;
}
::ng-deep .favorite-btn,
::ng-deep .download-btn,
// The sources chip is a peer secondary action, so it wears the same
// surface, height and radius. Without this it reads as a link floating
// between three solid buttons.
::ng-deep app-vod-sources-chip .sources-chip {
@include detail-actions.secondary-action-button;
}
// Theme-aware surface for the chip only: the shared mixin's white-alpha
// fill vanishes over a bright light-theme hero, and the chip now sits
// beside icon buttons that already wear the tag vars. The chip's own
// pressed state (.sources-chip--open) is more specific and still wins.
::ng-deep app-vod-sources-chip .sources-chip {
background: var(--tag-bg);
border-color: var(--tag-border);
&:hover {
background: var(--tag-border);
}
}
::ng-deep .favorite-btn {
@include detail-actions.favorite-button-state;
}
::ng-deep .download-btn {
@include detail-actions.download-button-state;
}
}
// ============================================================================
// Mobile Responsive - Option A: Keep poster grid layout
// ============================================================================
@media (max-width: 800px) {
:host {
--poster-width: 120px;
}
.hero {
&__backdrop {
height: 45%;
&--blurred {
height: 50%;
}
}
&__vignette {
background: linear-gradient(
to bottom,
transparent 0%,
transparent 25%,
var(--surface-bg) 45%,
var(--surface-bg) 100%
);
&::before {
height: 50%;
}
&::after {
display: none;
}
}
&__content {
padding: 60px 20px 40px;
gap: 16px;
}
}
.poster {
// Mobile poster adjustments - none needed
}
.details {
&__title {
font-size: 1.5rem;
}
&__description {
-webkit-line-clamp: 3;
&--expanded {
-webkit-line-clamp: unset;
}
}
&__meta {
gap: 12px 20px;
}
}
.action-buttons {
width: 100%;
@include detail-actions.projected-mobile-action-buttons;
}
}
// A phone cannot carry the poster and the details side by side: the details
// column drops to ~200px, and the action buttons are then squeezed below
// their own labels ("Play" clipped down to its icon). Stack them instead.
@media (max-width: 640px) {
.hero__content {
flex-direction: column;
align-items: stretch;
padding-inline: 16px;
}
.poster {
width: 140px;
align-self: center;
}
.details__title {
text-align: center;
}
}
// ============================================================================
// Animations
// ============================================================================
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hero__content {
animation: fadeInUp 0.6s ease-out 0.2s both;
}
// Skeleton Loader Overrides
ngx-skeleton-loader {
display: block;
width: 100%;
// Ensure the loader inside poster takes up the correct aspect ratio space
::ng-deep .loader {
display: block;
height: 100%;
}
}
:host-context(.dark-theme) {
ngx-skeleton-loader {
::ng-deep .loader {
background-color: rgba(255, 255, 255, 0.1);
}
}
}
.poster {
// ... existing styles ...
// Ensure poster container has height/ratio for skeleton
min-height: 330px; // Fallback
aspect-ratio: 2 / 3;
}
// After the rule above so it wins the cascade at equal specificity: that
// 330px skeleton fallback is sized for the desktop poster width, while the
// stacked phone hero renders the poster 140px wide — its aspect-ratio height
// is ~210px, and the leftover minimum was pure empty space pushing the title
// and actions ~120px down on every loaded detail page.
@media (max-width: 640px) {
.poster {
min-height: 0;
}
}