From afad80e61b66111cb045853060019932d2867c8a Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 5 Jan 2026 21:54:54 +0100 Subject: [PATCH] feat(ui): add ContentHero component and detail view styles Add a new standalone ContentHero component (template and TS) that provides a cinematic hero section with backdrop, poster, title, description, tags/meta/action slots, and a back button event emitter. The component chooses backdrop or poster as the background, falls back to a default poster image on load error, and exposes inputs for title, description, posterUrl, and backdropUrl. Introduce extensive cinematic styles for the detail view: - define CSS custom properties for light and dark themes, poster size, shadows, and color tokens - implement gradient vignette, layout sizing, and theming via :host and :host-context(.dark-theme) - change container sizing behavior to full-height and hide overflow These changes create a reusable hero UI and themed detail-page styles to support a Netflix-style streaming detail experience. --- .../web/src/app/xtream-tauri/detail-view.scss | 440 ++++++++++++---- libs/ui/components/src/index.ts | 1 + .../content-hero/content-hero.component.html | 72 +++ .../content-hero/content-hero.component.scss | 473 ++++++++++++++++++ .../content-hero/content-hero.component.ts | 24 + 5 files changed, 900 insertions(+), 110 deletions(-) create mode 100644 libs/ui/components/src/lib/content-hero/content-hero.component.html create mode 100644 libs/ui/components/src/lib/content-hero/content-hero.component.scss create mode 100644 libs/ui/components/src/lib/content-hero/content-hero.component.ts diff --git a/apps/web/src/app/xtream-tauri/detail-view.scss b/apps/web/src/app/xtream-tauri/detail-view.scss index fabb0fd35..88895fa10 100644 --- a/apps/web/src/app/xtream-tauri/detail-view.scss +++ b/apps/web/src/app/xtream-tauri/detail-view.scss @@ -1,140 +1,360 @@ +// ============================================================================ +// Netflix-style Detail View - Cinematic Streaming Design +// ============================================================================ + :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: flex; - flex-direction: column; - overflow: auto; + height: 100%; + display: block; + overflow: hidden; + 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); +} + +// ============================================================================ +// Details Section - Content Elements +// ============================================================================ + .details { - h2 { - word-wrap: break-word; - margin-top: 0; - } -} + // Styles for projected content elements -.container { - display: grid; - grid-gap: 20px; - grid-template-columns: minmax(250px, 30%) minmax(300px, 1fr); - grid-template-areas: - 'image details' - 'seasons seasons'; - padding: 20px; - max-width: 100%; - box-sizing: border-box; - height: auto; + &__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); - &::before { - content: ''; - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - background-size: cover; - z-index: -1; - background-repeat: no-repeat; - } -} - -.seasons { - padding: 20px; - - .episodes { - margin-bottom: 10px; - - button { - margin-top: 5px; + &--rating { + color: #ffd700; + border-color: rgba(255, 215, 0, 0.3); } } + + &__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 +// ============================================================================ + +.play-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 28px; + font-size: 1rem; + font-weight: 600; + background: #fff; + color: #141414; + border: none; + border-radius: 4px; + 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; + } +} + +.favorite-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 20px; + font-size: 0.9375rem; + font-weight: 500; + background: rgba(109, 109, 110, 0.7); + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: rgba(109, 109, 110, 0.5); + } + + &--active { + background: rgba(229, 9, 20, 0.8); + + &:hover { + background: rgba(229, 9, 20, 0.6); + } + } + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } +} + +// ============================================================================ +// Content Sections - Below the hero fold +// ============================================================================ + +.content-section { + padding: 20px 0; + width: 100%; + box-sizing: border-box; + + &__title { + font-size: 1.375rem; + font-weight: 600; + margin: 0 0 20px 0; + color: var(--text-primary); + } +} + +// ============================================================================ +// Seasons & Episodes - Inside details section +// ============================================================================ + +.seasons { + margin-top: 24px; + width: 100%; + + .season-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 0; + cursor: pointer; + transition: opacity 0.2s ease; + + &:hover { + opacity: 0.8; + } + + h4 { + margin: 0; + font-size: 1rem; + font-weight: 600; + } + + .expand-icon { + font-size: 0.75rem; + color: var(--text-muted); + } + } + + .episodes { + display: flex; + flex-wrap: wrap; + gap: 10px; + padding: 8px 0 16px; + + button { + margin: 0; + font-weight: 500; + } + } + + .episodes-loading, + .seasons-loading { + display: flex; + align-items: center; + gap: 12px; + padding: 20px 0; + color: var(--text-secondary); + } + + .no-episodes { + color: var(--text-muted); + font-style: italic; + padding: 8px 0; + } +} + +// Seasons loading when inside details +.details .seasons-loading { + display: flex; + align-items: center; + gap: 12px; + padding: 20px 0; + color: var(--text-secondary); + margin-top: 24px; +} + +// Season container when inside details section +.details app-season-container { + display: block; + width: 100%; + margin-top: 24px; + box-sizing: border-box; +} + +// ============================================================================ +// YouTube Trailer Section - Inside details +// ============================================================================ + +.youtube-trailer { + margin-top: 24px; + width: 100%; + + h3 { + font-size: 1.125rem; + font-weight: 600; + margin: 0 0 12px 0; + color: var(--text-primary); + } + + .trailer-wrapper { + position: relative; + width: 100%; + max-width: 560px; + aspect-ratio: 16 / 9; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + + iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: none; + } + } +} + +// ============================================================================ +// Legacy container support (for gradual migration) +// ============================================================================ + +.container { + display: contents; } .image { - grid-area: image; - margin: 0; - border-radius: 12px; - max-width: 100%; - - img { - width: 100%; - height: auto; - border-radius: 12px; - object-fit: cover; - max-width: 450px; - } - - .placeholder-cover { - width: 100%; - aspect-ratio: 2/3; - background-color: #999; - border-radius: 12px; - - &::after { - content: 'No cover'; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - } - } + display: none; // Hidden - now using .poster in hero } -app-season-container { - grid-area: seasons; - width: 100%; -} +// ============================================================================ +// Mobile Responsive - Option A: Keep poster grid layout +// ============================================================================ @media (max-width: 800px) { - .container { - grid-template-columns: 1fr; - grid-template-areas: - 'image' - 'details' - 'seasons'; - padding: 10px; + :host { + --poster-width: 120px; } - .image, - .details { + .action-buttons { width: 100%; - flex: 1 1 100%; - display: flex; - flex-direction: column; - padding: 0; - - img { - max-height: 500px; - object-fit: contain; - border-radius: 0; - } + // This class is in ContentHeroComponent now, but these rules apply to children? + // No, .play-btn is a child of the PROJECTED content. + // So we should target the buttons directly for mobile } - .details { - padding: 0 10px; + .play-btn, + .favorite-btn { + flex: 1; + justify-content: center; + padding: 10px 16px; } .youtube-trailer { - width: 100% !important; + margin-top: 16px; + + .trailer-wrapper { + max-width: 100%; + border-radius: 4px; + } + } + + .seasons { + padding: 16px 0 0; } } -.label { - font-weight: 100; - font-size: 0.9em; - padding: 5px 0; - text-decoration: underline; - opacity: 0.6; -} - -.action-buttons { - margin-top: 10px; -} +// ============================================================================ +// Animations +// ============================================================================ .youtube-trailer { - display: flex; - flex-wrap: wrap; - align-self: center; - width: 100%; - max-width: 650px; - margin: 0 auto; + animation: fadeInUp 0.5s ease-out 0.4s both; } + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +// ============================================================================ +// Utility - keep .label for backwards compatibility +// ============================================================================ + +.label { + font-weight: 400; + font-size: 0.8125rem; + color: var(--text-muted); + text-decoration: none; + padding: 0; + opacity: 1; +} \ No newline at end of file diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index a74ec9623..1ba4a1f71 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -4,6 +4,7 @@ export * from './lib/audio-player/audio-player.component'; export * from './lib/channel-list-container/channel-list-container.component'; export * from './lib/confirm-dialog/confirm-dialog.component'; export * from './lib/confirm-dialog/dialog.service'; +export * from './lib/content-hero/content-hero.component'; export * from './lib/epg-list/epg-item-description/epg-item-description.component'; export * from './lib/epg-list/epg-list-item/epg-list-item.component'; export * from './lib/epg-list/epg-list.component'; 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 new file mode 100644 index 000000000..db95d680b --- /dev/null +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.html @@ -0,0 +1,72 @@ + +
+ + + + +
+ + +
+ + +
+ +
+ @if (posterUrl) { + Poster + } @else { +
No Poster
+ } +
+ + +
+

{{ title }}

+ + +
+ +
+ + + @if (description) { +

+ {{ description }} +

+ } + + +
+ +
+ + +
+ +
+ + + +
+
+
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 new file mode 100644 index 000000000..fbe107b5d --- /dev/null +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.scss @@ -0,0 +1,473 @@ +// ============================================================================ +// Netflix-style Detail View - Cinematic Streaming Design +// ============================================================================ + +: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%; + height: 100%; + display: block; + overflow: hidden; + 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%; + height: 100%; + 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; + 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; + + // 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% + ); + } + } + + // Scrollable content area + &__content { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + gap: 32px; + align-items: flex-start; + padding: 140px 40px 60px; + max-width: var(--content-max-width); + margin: 0 auto; + overflow-y: auto; + overflow-x: hidden; + box-sizing: border-box; + + // Hide scrollbar but keep functionality + scrollbar-width: none; + -ms-overflow-style: none; + &::-webkit-scrollbar { + display: none; + } + } +} + +// ============================================================================ +// 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; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-size: 0.875rem; + box-shadow: var(--poster-shadow); + } +} + +// ============================================================================ +// 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); + } + + &__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: 20px; + max-width: 600px; + + // Truncate long descriptions + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; + } + + &__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: 8px; + padding: 12px 28px; + font-size: 1rem; + font-weight: 600; + background: #fff; + color: #141414; + border: none; + border-radius: 4px; + 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; + } + } + + ::ng-deep .favorite-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 20px; + font-size: 0.9375rem; + font-weight: 500; + background: rgba(109, 109, 110, 0.7); + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: rgba(109, 109, 110, 0.5); + } + + &--active { + background: rgba(229, 9, 20, 0.8); + + &:hover { + background: rgba(229, 9, 20, 0.6); + } + } + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + } +} + + +// ============================================================================ +// 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; + } + + &__meta { + gap: 12px 20px; + } + } + + .action-buttons { + width: 100%; + + ::ng-deep .play-btn, + ::ng-deep .favorite-btn { + flex: 1; + justify-content: center; + padding: 10px 16px; + } + } +} + +// ============================================================================ +// 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; +} 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 new file mode 100644 index 000000000..36bad6e15 --- /dev/null +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.ts @@ -0,0 +1,24 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; + +@Component({ + selector: 'app-content-hero', + standalone: true, + imports: [CommonModule, MatIconModule, MatButtonModule], + templateUrl: './content-hero.component.html', + styleUrls: ['./content-hero.component.scss'], +}) +export class ContentHeroComponent { + @Input() title: string | undefined; + @Input() description: string | undefined; + @Input() posterUrl: string | undefined; + @Input() backdropUrl: string | undefined; + + @Output() backClicked = new EventEmitter(); + + onBack(): void { + this.backClicked.emit(); + } +}