From 7ae86a82668731e2b70b0d446851fac520a9c94a Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 5 Jan 2026 21:56:54 +0100 Subject: [PATCH] feat(ui): unify season components and handle episode info --- .../season-container.component.html | 130 +++-- .../season-container.component.scss | 470 +++++++++++++++++- .../season-container.component.ts | 19 +- .../season-container.component.html | 126 +++-- .../season-container.component.scss | 41 +- .../season-container.component.ts | 23 +- 6 files changed, 673 insertions(+), 136 deletions(-) diff --git a/apps/web/src/app/xtream-tauri/season-container/season-container.component.html b/apps/web/src/app/xtream-tauri/season-container/season-container.component.html index 0e319aa82..33b5d2be5 100644 --- a/apps/web/src/app/xtream-tauri/season-container/season-container.component.html +++ b/apps/web/src/app/xtream-tauri/season-container/season-container.component.html @@ -1,39 +1,97 @@ -

Seasons and Episodes

-@if (selectedSeason) { - -} -
- @if (!selectedSeason) { - @for (season of seasons() | keyvalue: compareSeasons; track $index) { - - Season {{ season.key }} - +
+
+

Seasons and Episodes

+ @if (selectedSeason) { + } - } @else { - @for (episode of seasons()[selectedSeason]; track $index) { - - @if (episode.info?.movie_image) { - +
+ +
+ @if (!selectedSeason) { + +
+ @for ( + season of seasons() | keyvalue: compareSeasons; + track $index + ) { + } -
- {{ episode.episode_num }}. {{ episode.title }} -
- +
+ } @else { + +
+ @for (episode of seasons()[selectedSeason]; track $index) { + @let episodeInfo = getEpisodeInfo(episode); +
+
+ @if (episodeInfo?.movie_image) { + Episode thumbnail + } @else { +
+ play_circle_outline +
+ } +
+
+ play_arrow +
+
+
+ {{ episode.episode_num }} +
+
+
+

+ {{ episode.episode_num }}. {{ episode.title }} +

+ @if (episodeInfo?.plot) { +

+ {{ episodeInfo.plot }} +

+ } + @if (episodeInfo?.duration) { + + {{ episodeInfo.duration }} + + } +
+
+ } +
} - } -
+
+ diff --git a/apps/web/src/app/xtream-tauri/season-container/season-container.component.scss b/apps/web/src/app/xtream-tauri/season-container/season-container.component.scss index d21c2f189..87198a36d 100644 --- a/apps/web/src/app/xtream-tauri/season-container/season-container.component.scss +++ b/apps/web/src/app/xtream-tauri/season-container/season-container.component.scss @@ -1,41 +1,465 @@ -.seasons-container { +// ============================================================================ +// Netflix-style Seasons & Episodes Component +// ============================================================================ + +:host { + display: block; width: 100%; + + // Default dark theme colors (matches detail-view) + --card-bg: rgba(50, 50, 50, 0.8); + --card-bg-hover: rgba(70, 70, 70, 0.9); + --card-border: rgba(255, 255, 255, 0.15); + --text-primary: #ffffff; + --text-secondary: rgba(255, 255, 255, 0.75); + --text-muted: rgba(255, 255, 255, 0.55); + --accent-color: #e50914; + --accent-hover: #f40612; +} + +// Light theme overrides - only when body does NOT have .dark-theme +:host-context(body:not(.dark-theme)) { + --card-bg: rgba(0, 0, 0, 0.06); + --card-bg-hover: rgba(0, 0, 0, 0.1); + --card-border: rgba(0, 0, 0, 0.12); + --text-primary: #1a1a1a; + --text-secondary: rgba(26, 26, 26, 0.75); + --text-muted: rgba(26, 26, 26, 0.55); +} + +// ============================================================================ +// Section Layout +// ============================================================================ + +.seasons-section { + margin-top: 32px; +} + +.section-header { display: flex; - flex-flow: wrap; + align-items: center; + gap: 16px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.section-title { + font-size: 1.125rem; + font-weight: 600; + color: #fff; // Force white for dark mode + margin: 0; + letter-spacing: -0.01em; +} + +.back-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + color: #fff; // Force white for dark mode + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.3); + } + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } +} + +// Light theme overrides for header +:host-context(body:not(.dark-theme)) { + .section-title { + color: #1a1a1a; + } + .back-btn { + background: rgba(0, 0, 0, 0.06); + border-color: rgba(0, 0, 0, 0.12); + color: #1a1a1a; + + &:hover { + background: rgba(0, 0, 0, 0.1); + } + } +} + +// ============================================================================ +// Seasons Grid +// ============================================================================ + +.seasons-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 10px; } -.season-item, -.episode-item { - min-height: 100px; +.season-card { + position: relative; + display: flex; + flex-direction: column; + align-items: center; justify-content: center; - width: 150px; + padding: 16px 12px; + min-height: 80px; + background: rgba(50, 50, 50, 0.8); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; cursor: pointer; - display: block; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: var(--accent-color); + transform: scaleX(0); + transition: transform 0.25s ease; + } + + &:hover { + background: var(--card-bg-hover); + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3); + + &::before { + transform: scaleX(1); + } + + .season-card__arrow { + opacity: 1; + transform: translateX(0); + } + } + + &__number { + font-size: 1.75rem; + font-weight: 700; + color: #fff; // Force white + line-height: 1; + margin-bottom: 4px; + } + + &__label { + font-size: 0.6875rem; + font-weight: 500; + color: rgba(255, 255, 255, 0.6); // Force light + text-transform: uppercase; + letter-spacing: 0.05em; + } + + &__episodes { + font-size: 0.6875rem; + color: rgba(255, 255, 255, 0.7); // Force light + margin-top: 6px; + } + + &__arrow { + position: absolute; + right: 10px; + top: 50%; + transform: translateX(8px) translateY(-50%); + opacity: 0; + color: var(--accent-color); + transition: all 0.25s ease; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + } } -h3 { - font-weight: normal; +// Light theme overrides for season card +:host-context(body:not(.dark-theme)) { + .season-card { + &__number { + color: #1a1a1a; + } + &__label { + color: rgba(26, 26, 26, 0.6); + } + &__episodes { + color: rgba(26, 26, 26, 0.7); + } + } } -.episode-item { - mat-card-content { - padding: 0; +// ============================================================================ +// Episodes Grid +// ============================================================================ + +.episodes-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; +} + +.episode-card { + display: flex; + flex-direction: column; + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 8px; + overflow: hidden; + cursor: pointer; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + + &:hover { + background: var(--card-bg-hover); + transform: scale(1.02); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4); + + .episode-card__overlay { + opacity: 1; + } + + .episode-card__play { + transform: scale(1); + } + + .episode-card__thumbnail img { + transform: scale(1.05); + } } - mat-card-title { - padding: 10px; - } - - .episode-title { - padding: 5px; - } - - .episode-cover { + &__thumbnail { + position: relative; width: 100%; + aspect-ratio: 16 / 9; + overflow: hidden; + background: linear-gradient(135deg, #2a2a2a 0%, #1a1a1a 100%); + + img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.4s ease; + } + } + + &__placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #3a3a3a 0%, #2a2a2a 100%); + + mat-icon { + font-size: 48px; + width: 48px; + height: 48px; + color: var(--text-muted); + } + } + + &__overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.25s ease; + } + + &__play { + width: 56px; + height: 56px; + border-radius: 50%; + background: var(--accent-color); + display: flex; + align-items: center; + justify-content: center; + transform: scale(0.8); + transition: transform 0.25s ease; + box-shadow: 0 4px 20px rgba(229, 9, 20, 0.4); + + mat-icon { + font-size: 32px; + width: 32px; + height: 32px; + color: #fff; + margin-left: 3px; // Optical centering for play icon + } + } + + &__number { + position: absolute; + bottom: 8px; + left: 8px; + padding: 4px 10px; + background: rgba(0, 0, 0, 0.75); + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + color: #fff; // Always white on dark badge + backdrop-filter: blur(4px); + } + + &__info { + padding: 10px 12px; + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + background: var(--card-bg); // Ensure background for contrast + } + + &__title { + font-size: 0.8125rem; + font-weight: 600; + color: var(--text-primary); + color: #fff; // Force white in dark mode for readability + margin: 0; + line-height: 1.3; + + // Truncate long titles + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + &__plot { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.7); // Force light color + margin: 0; + line-height: 1.4; + + // Truncate plot + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + } + + &__duration { + font-size: 0.6875rem; + color: rgba(255, 255, 255, 0.55); // Force light color + margin-top: auto; } } -.back-to-seasons { - margin-bottom: 5px; +// Light theme text overrides +:host-context(body:not(.dark-theme)) { + .episode-card__title { + color: #1a1a1a; + } + .episode-card__plot { + color: rgba(26, 26, 26, 0.75); + } + .episode-card__duration { + color: rgba(26, 26, 26, 0.55); + } +} + +// ============================================================================ +// Mobile Responsive +// ============================================================================ + +@media (max-width: 600px) { + .seasons-section { + margin-top: 24px; + } + + .section-header { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + + .seasons-grid { + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 8px; + } + + .season-card { + padding: 16px 12px; + min-height: 80px; + + &__number { + font-size: 1.5rem; + } + + &__arrow { + display: none; + } + } + + .episodes-grid { + grid-template-columns: 1fr; + gap: 12px; + } + + .episode-card { + flex-direction: row; + + &__thumbnail { + width: 140px; + flex-shrink: 0; + aspect-ratio: 16 / 10; + } + + &__info { + padding: 12px; + } + + &__title { + font-size: 0.875rem; + } + + &__plot { + -webkit-line-clamp: 1; + } + + &__number { + bottom: 4px; + left: 4px; + padding: 2px 6px; + font-size: 0.625rem; + } + } +} + +// ============================================================================ +// Animations +// ============================================================================ + +@keyframes slideInUp { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.season-card, +.episode-card { + animation: slideInUp 0.4s ease backwards; + + @for $i from 1 through 12 { + &:nth-child(#{$i}) { + animation-delay: #{$i * 0.05}s; + } + } } diff --git a/apps/web/src/app/xtream-tauri/season-container/season-container.component.ts b/apps/web/src/app/xtream-tauri/season-container/season-container.component.ts index b8675228f..706e5be32 100644 --- a/apps/web/src/app/xtream-tauri/season-container/season-container.component.ts +++ b/apps/web/src/app/xtream-tauri/season-container/season-container.component.ts @@ -1,15 +1,13 @@ import { KeyValuePipe } from '@angular/common'; import { Component, EventEmitter, Output, input } from '@angular/core'; -import { MatButton } from '@angular/material/button'; -import { MatCardModule } from '@angular/material/card'; import { MatIcon } from '@angular/material/icon'; -import { XtreamSerieEpisode } from 'shared-interfaces'; +import { XtreamSerieEpisode, XtreamSerieEpisodeInfo } from 'shared-interfaces'; @Component({ selector: 'app-season-container', templateUrl: './season-container.component.html', styleUrls: ['./season-container.component.scss'], - imports: [KeyValuePipe, MatCardModule, MatIcon, MatButton], + imports: [KeyValuePipe, MatIcon], }) export class SeasonContainerComponent { readonly seasons = input.required>(); @@ -29,4 +27,17 @@ export class SeasonContainerComponent { selectEpisode(episode: XtreamSerieEpisode) { this.episodeClicked.emit(episode); } + + /** + * Safely get episode info - returns undefined if info is an empty array + * (Xtream API returns [] when no metadata available instead of null/object) + */ + getEpisodeInfo( + episode: XtreamSerieEpisode + ): XtreamSerieEpisodeInfo | undefined { + if (Array.isArray(episode.info) || !episode.info) { + return undefined; + } + return episode.info; + } } diff --git a/apps/web/src/app/xtream/season-container/season-container.component.html b/apps/web/src/app/xtream/season-container/season-container.component.html index 30d3b2898..0d21fc639 100644 --- a/apps/web/src/app/xtream/season-container/season-container.component.html +++ b/apps/web/src/app/xtream/season-container/season-container.component.html @@ -1,33 +1,97 @@ -

Seasons and episodes

-
- @if (!selectedSeason) { - @for (season of seasons | keyvalue; track season) { - - Season {{ season.key }} - - } - } @else { - -
< Back to seasons
-
- @for (episode of seasons[selectedSeason]; track episode) { - - @if (episode.info.movie_image) { - +
+
+

Seasons and Episodes

+ @if (selectedSeason) { + } -
- {{ episode.episode_num }}. {{ episode.title }} -
- - } - } +
-
+
+ @if (!selectedSeason) { + +
+ @for ( + season of seasons | keyvalue: compareSeasons; + track $index + ) { + + } +
+ } @else { + +
+ @for (episode of seasons[selectedSeason]; track $index) { + @let episodeInfo = getEpisodeInfo(episode); +
+
+ @if (episodeInfo?.movie_image) { + Episode thumbnail + } @else { +
+ play_circle_outline +
+ } +
+
+ play_arrow +
+
+
+ {{ episode.episode_num }} +
+
+
+

+ {{ episode.episode_num }}. {{ episode.title }} +

+ @if (episodeInfo?.plot) { +

+ {{ episodeInfo.plot }} +

+ } + @if (episodeInfo?.duration) { + + {{ episodeInfo.duration }} + + } +
+
+ } +
+ } +
+ diff --git a/apps/web/src/app/xtream/season-container/season-container.component.scss b/apps/web/src/app/xtream/season-container/season-container.component.scss index 659474277..cb206faa1 100644 --- a/apps/web/src/app/xtream/season-container/season-container.component.scss +++ b/apps/web/src/app/xtream/season-container/season-container.component.scss @@ -1,39 +1,2 @@ -:host { - margin: 10px; - display: block; - padding: 20px 0; -} - -.seasons-container { - width: 100%; - display: flex; - flex-flow: wrap; - gap: 10px; -} - -.season-item, -.episode-item { - min-height: 100px; - justify-content: center; - width: 150px; - cursor: pointer; - display: block; -} - -.episode-item { - mat-card-content { - padding: 0; - } - - mat-card-title { - padding: 10px; - } - - .episode-title { - padding: 5px; - } - - .episode-cover { - width: 100%; - } -} +// Import shared Netflix-style from xtream-tauri +@use '../../xtream-tauri/season-container/season-container.component.scss'; diff --git a/apps/web/src/app/xtream/season-container/season-container.component.ts b/apps/web/src/app/xtream/season-container/season-container.component.ts index 3cea4ab34..e7bf01ea2 100644 --- a/apps/web/src/app/xtream/season-container/season-container.component.ts +++ b/apps/web/src/app/xtream/season-container/season-container.component.ts @@ -1,13 +1,13 @@ import { KeyValuePipe } from '@angular/common'; import { Component, EventEmitter, Input, Output } from '@angular/core'; -import { MatCardModule } from '@angular/material/card'; -import { XtreamSerieEpisode } from 'shared-interfaces'; +import { MatIcon } from '@angular/material/icon'; +import { XtreamSerieEpisode, XtreamSerieEpisodeInfo } from 'shared-interfaces'; @Component({ selector: 'app-season-container', templateUrl: './season-container.component.html', styleUrls: ['./season-container.component.scss'], - imports: [KeyValuePipe, MatCardModule], + imports: [KeyValuePipe, MatIcon], }) export class SeasonContainerComponent { @Input({ required: true }) seasons: Record; @@ -16,6 +16,10 @@ export class SeasonContainerComponent { selectedSeason: string; + compareSeasons(a: any, b: any): number { + return Number(a.key) - Number(b.key); + } + selectSeason(seasonKey: string) { this.selectedSeason = seasonKey; } @@ -23,4 +27,17 @@ export class SeasonContainerComponent { selectEpisode(episode: any) { this.episodeClicked.emit(episode); } + + /** + * Safely get episode info - returns undefined if info is an empty array + * (Xtream API returns [] when no metadata available instead of null/object) + */ + getEpisodeInfo( + episode: XtreamSerieEpisode + ): XtreamSerieEpisodeInfo | undefined { + if (Array.isArray(episode.info) || !episode.info) { + return undefined; + } + return episode.info; + } }